diff --git a/AGENTS-CN.md b/AGENTS-CN.md index ba0b17f81f..9a43707ec3 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -22,6 +22,7 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 | 产品领域 crate | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport 适配层 | `src/crates/transport` | (使用 core 指南) | | API layer | `src/crates/api-layer` | (使用 core 指南) | +| ACP 集成 | `src/crates/acp` | [AGENTS.md](src/crates/acp/AGENTS.md) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | | 桌面应用 | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | | Server | `src/apps/server` | (使用 core 指南) | @@ -114,14 +115,13 @@ await api.invoke('your_command', { request: { ... } }); 任何 `bitfun-core` 拆解、feature 边界、依赖边界或 Rust 构建提速重构, 都必须先阅读 [`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md)。 -该文档定义产品行为不变量、crate 归属目标、禁止依赖方向、feature 安全规则和里程碑验证门禁。 +顶层文档只作为入口;模块级 ownership 细节应放到离代码最近的模块 `AGENTS.md`。 -### Tool 归属护栏 +仓库级拆解规则: -- `src/crates/agent-tools` 拥有轻量 tool contract,以及 generic registry / dynamic-provider container。 -- `src/crates/core/src/agentic/tools` 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、tool exposure / manifest resolution,以及按需工具说明发现(`GetToolSpec`)。 -- `ToolUseContext` 与具体工具实现继续留在 core,直到有已评审的 port/provider 设计和等价测试。 -- Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 +- 不要把 DTO / contract 抽取误判为 runtime owner 已迁移。 +- 产品表面可以有差异;共享稳定 facts 或 ports,不共享 UI、protocol、lifecycle 或平台实现。 +- 迁移 runtime owner 必须有评审过的 port/provider 设计、旧路径兼容、行为等价测试;如果可能改变行为边界,还需要先确认。 ### DeepReview 护栏 diff --git a/AGENTS.md b/AGENTS.md index 5d23ce1896..27e66bbac5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,14 @@ Repository rule: **keep product logic platform-agnostic, then expose it through |---|---|---| | Core (product logic) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | | Extracted core support | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (use core guide) | -| Core owner crates | `src/crates/{services-core,services-integrations,agent-tools,tool-packs}` | (use core guide + decomposition guardrails) | +| Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | +| Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | +| Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | +| Tool pack provider plan | `src/crates/tool-packs` | [AGENTS.md](src/crates/tool-packs/AGENTS.md) | | Product domains | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport adapters | `src/crates/transport` | (use core guide) | | API layer | `src/crates/api-layer` | (use core guide) | +| ACP integration | `src/crates/acp` | [AGENTS.md](src/crates/acp/AGENTS.md) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | | Desktop app | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | | Server | `src/apps/server` | (use core guide) | @@ -114,59 +118,17 @@ await api.invoke('your_command', { request: { ... } }); For any `bitfun-core` decomposition, feature-boundary, dependency-boundary, or Rust build-speed refactor, read [`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) -before editing. The guardrail document defines product-behavior invariants, -crate ownership targets, forbidden dependency directions, feature safety rules, -and milestone verification gates. - -### Tool ownership guardrails - -- `src/crates/agent-tools` owns lightweight tool contracts and the generic - registry / dynamic-provider container. -- `src/crates/core/src/agentic/tools` owns product tool assembly, `dyn Tool` - adaptation, snapshot decoration, tool exposure / manifest resolution, and - on-demand tool spec discovery (`GetToolSpec`) for now. -- Keep `ToolUseContext` and concrete tool implementations in core until a - reviewed port/provider design and equivalence tests exist. -- Tool migrations must preserve expanded/collapsed exposure, prompt-visible - manifests, `ToolUseContext.unlocked_collapsed_tools`, and desktop/MCP/ACP - tool catalog behavior. - -### Latest-main runtime anchors - -- Agent registry migration must preserve mode-scoped subagent availability, - hidden/custom/review grouping, and desktop subagent API semantics. -- DeepResearch report finalization currently relies on the core citation - renumber hook; do not move it without preserving `report.md`, - `citations.md`, `display_map.json`, and rejected-citation handling. -- Workspace/search refactors must preserve remote workspace startup guards, - remote flashgrep fallback, and search preview/context mapping. -- ACP timeout handling and Web operation-diff fallback are product-surface - behavior; share facts through contracts, not UI/protocol implementation. - -### Services/product owner closure - -- Remote-SSH path, session identity, mirror path, and unresolved-session layout - helpers belong in `bitfun-services-integrations`; core may inject - `PathManager` and hold SSH manager / remote FS / terminal assembly. -- MiniApp storage shape belongs in `bitfun-product-domains`; core storage - keeps filesystem IO, worker runtime, `PathManager`, and port adapters until a - reviewed runtime migration exists. -- Remote-connect port baselines live in `bitfun-runtime-ports` and - `bitfun-services-integrations`; tracker state and tracker event reduction - belong in `bitfun-services-integrations`. Remote command/response wire DTOs, - remote model catalog DTOs, poll-response assembly helpers, and model-catalog - poll delta policy also belong there. Pure remote image-context - fallback/preference, restore-target, cancel-decision, and remote file-transfer - size/chunk/name helpers also belong in `bitfun-services-integrations`, while - core still owns the adapter back to `ImageContextData`, dispatcher assembly, - session restore execution, file IO/path resolution, terminal pre-warm, and - product execution routing. Further remote runtime owner migration must - preserve the existing migration snapshots for command/response shape, - restore, active-turn polling, cancel decisions, image context - fallback/preference, tracker fanout, file transfer, and RemoteRelay/Bot queue - policy. - `AgentSubmissionPort` still rejects generic attachments until - image/multimodal equivalence tests and a runtime migration plan are reviewed. +before editing. Keep this file as an entry point; put module-specific ownership +details in the nearest module `AGENTS.md`. + +Repository-level decomposition rules: + +- Do not confuse DTO/contract extraction with runtime owner migration. +- Product surfaces may diverge; share stable facts or ports, not UI, protocol, + lifecycle, or platform implementation. +- Moving runtime ownership requires a reviewed port/provider design, old-path + compatibility, behavior equivalence tests, and explicit confirmation when a + behavior boundary could change. ### DeepReview guardrails diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 02d418f7ba..ef4b2dfbad 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -66,11 +66,11 @@ Rust 编译和链接面。 | `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | done:stream 聚合已独立 | | `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event 与 remote image attachment 契约;不拥有 runtime 实现 | | `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | -| `bitfun-agent-tools` | 轻量 tool DTO / contract、runtime restriction、generic registry / provider container | partial:product manifest、`ToolUseContext`、`GetToolSpec` 和 concrete tools 仍在 core | -| `bitfun-tool-packs` | 由 feature group 隔离的具体工具实现 | target/scaffold:不得声明 concrete tools 已迁移 | +| `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、`GetToolSpec` Tool impl 和 concrete tools 仍在 core;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade 与 readonly/enabled 过滤规则已委托给 `bitfun-agent-tools` | +| `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | | `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle 与 tracker event reduction,dispatcher/product execution 仍在 core | -| `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、storage layout 可迁入;IO、worker、Git/AI service runtime 仍在 core | +| `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、storage/builtin contract 可迁入;IO、worker、built-in asset seeding、Git/AI service runtime 仍在 core | | `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | | `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | done:已在 workspace 顶层 | @@ -125,26 +125,65 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 代码必须面向 port contract,而不是新增对 coordinator 或 manager 的直接依赖。 - Agent runtime 必须通过 ports/providers 依赖 service 行为,不要依赖 concrete 的重集成 crate。 -- 最新主干已把 subagent 可见性做成 mode-scoped registry 行为。迁移 agent registry 或 - subagent definitions 前,必须先保留 mode visibility、hidden/custom/review 分组和 desktop - subagent API 等价测试;在此之前它们仍属于 `bitfun-core` product runtime assembly。 +- 最新主干已把 subagent 可见性做成 mode-scoped registry 行为,并且 CLI `/subagents` + 管理入口也复用该查询 / override 语义;后续又新增 `Multitask` mode、内置 + `GeneralPurpose` subagent、`SubagentSessionLinked` routing 和 `Task.run_in_background` 后台结果投递。迁移 agent + registry、subagent definitions 或 agent scheduler 前,必须先保留 mode visibility、 + hidden/custom/review 分组、desktop subagent API、CLI mode-aware list/config、 + background result running-turn injection 与 idle-session follow-up turn 等价测试;在此之前它们仍属于 + `bitfun-core` product runtime assembly 与各 app surface adapter 的组合边界。 - DeepResearch 现在包含 citation renumber post-turn hook。迁移 agent runtime 或 prompt/report 处理前,必须保留 `report.md` / `citations.md` / `display_map.json` 的 deterministic post-processing 行为; 在此之前该 hook 仍属于 `bitfun-core` agent runtime assembly。 -- 最新主干新增 on-demand tool spec discovery。`ToolExposure`、`GetToolSpec`、 - `manifest_resolver`、collapsed-tool catalog、context-aware tool schema/description - 和 `ToolUseContext.unlocked_collapsed_tools` 暂时属于 `bitfun-core` product tool runtime; - 迁移前必须证明 prompt-visible manifest、expanded/collapsed exposure、unlock state 与 - desktop/MCP/ACP tool catalog 等价。 +- 最新主干新增 on-demand tool spec discovery。`ToolExposure`、`GetToolSpec` 名称、 + collapsed stub、manifest ordering、generic collapsed exposure query、generic contextual + prompt-manifest resolver、generic catalog snapshot provider、ToolCatalogRuntime / GetToolSpec catalog provider / + prompt / schema / assistant-detail rendering / detail JSON 等 provider-neutral 契约可由 `bitfun-agent-tools` + 拥有;但产品 registry snapshot、`dyn Tool` + / `ToolUseContext` adapter、product collapsed-tool catalog、context-aware tool + schema/description 的实际调用、`GetToolSpecTool` Tool impl / `BitFunError` 映射和 + `ToolUseContext.unlocked_collapsed_tools` 仍属于 `bitfun-core` product tool runtime。 + 继续迁移前必须证明 prompt-visible manifest、expanded/collapsed exposure、unlock state + 与 desktop/MCP/ACP tool catalog 等价。 +- 当前 tool runtime 外移的低风险入口是 `StaticToolProvider` / `install_static_provider` + / `ToolRuntimeAssembly` 合约归属 `bitfun-agent-tools`,并让 core 通过 + `runtime_assembly.rs` + `static_providers.rs` + 将内置工具列表收敛为 `core.basic`、`core.agent`、`core.session`、`core.integration` + provider group。 + 这不代表 concrete tools、`ToolUseContext`、product registry snapshot adapter 或 + `GetToolSpecTool` 执行已经迁移。 +- `ToolContextFacts` 只记录 tool call、agent/session/turn、workspace kind/root + 与 runtime restriction 等可移植事实。它不携带 collapsed-tool unlock state、 + `computer_use_host`、workspace services、cancellation token、custom data 或任何 + 可执行 service handle;workspace root 使用 session identity 的 logical path + (remote 为 normalized remote root)。`PortableToolContextProvider` 只是只读 facts + provider 合约,当前由 core `ToolUseContext` 实现;`ToolUseContext` 本体仍归 core 拥有。 - 最新主干的 remote workspace guard 和 search fallback/context 修复提高了 workspace/search 迁移门槛。后续迁移 workspace 或 search runtime 时,必须保留 remote workspace metadata、 startup runtime ensure、remote flashgrep fallback、preview mapping 和 local/remote fallback 语义。 - ACP startup timeout 和 operation diff fallback 属于 ACP/Web product surface 行为;后续只能通过 stable contract 共享事实,不得把 ACP timeout、tool diff fallback 或 Web diff rendering 下沉到 core-types、runtime-ports、agent-tools 等 contract crate。 +- 最新主干的 remote ACP agents config 继续强化 ACP/app adapter owner:remote workspace + 复用 local ACP config,并通过 ACP client manager、remote shell、remote capability store 与 + workspace menu 串联。后续只能把 environment / capability facts 抽成 contract;ACP config + persistence、remote probing 和 workspace surface selection 仍留在 ACP/app surface。 +- 最新主干的 usage/cache 与 OpenAI Responses 修复提高了 AI adapter / stream 迁移门槛。 + `cached_content_token_count` 表示 cache reads / hits,`cache_creation_token_count` 与 + DeepSeek `prompt_cache_hit_tokens` mapping 必须保留为独立语义,不能在 `agent-stream`、 + `session_usage` 或 runtime budget 迁移中重新合并为 total usage。OpenAI Responses / + Codex ChatGPT flat tool schema 是 provider adapter serialization,不应写死进 + `bitfun-agent-tools` 的 provider-neutral manifest contract。 +- 最新 Web 启动优化把 startup trace、deferred background scheduler、narrow tool initializer + 与历史会话 hydrate 放在 web app / Flow Chat surface。后续不能为了“共享启动能力”把 + `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core contract + crate;只能通过 product checks 证明 app 仍可组装。 - 最新 CLI 重构新增大量 TUI、theme、selector、dialog 和 chat-state 代码,但仍位于 `src/apps/cli`。后续 core decomposition 只能通过产品 check 验证 CLI 仍可组装,不应把 CLI presentation 依赖迁入 core-types、runtime-ports 或 agent-tools。 +- 最新 desktop close button 默认最小化到 system tray 是 desktop lifecycle surface 行为; + 后续若调整 desktop app lifecycle / window state,只能用 desktop product check 验证, + 不应把 close/minimize 策略抽入 shared core service。 - Tool framework crate 不得依赖 concrete service implementation。 - 产品 crate 可以通过显式 product feature 组装完整 runtime。 - 后续迁移必须先按风险分层处理: @@ -154,47 +193,105 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 仍由 `product-full` 启用、把只依赖 port 的 helper 迁入 owner crate。 - 当前 `product-domains` 可继续承载 MiniApp runtime search plan、worker install 命令选择、 package.json storage-shape helper、lifecycle / revision helper、host routing / allowlist helper、 - customization metadata / permission diff 等纯决策 / 解析逻辑;实际 runtime detection、worker pool、 + customization metadata / permission diff、built-in MiniApp bundle/hash/marker/source payload + seed-decision contract、seed plan / marker wire helper 等纯决策 / 解析逻辑;实际 runtime detection、worker pool、 storage IO、PathManager、进程执行、host dispatch 执行、customization draft 存储 / 应用与 builtin - asset seeding 仍留在 core product runtime。 + asset include / seeding / marker IO / recompile 仍留在 core product runtime。最新内置 PR Review + MiniApp 依赖 core asset include、user-data seed、customized update runtime 与 source-hash input + lookup;这些不是 `product-domains` runtime owner 已迁移的证据。 - `product-domains` 可以先定义 MiniApp runtime/storage 与 function-agent Git/AI 的 port contract,并承载 function-agent 的纯 prompt / AI response parsing policy;core-owned adapter 只能在不改变执行路径的前提下委托现有 service,并先补等价测试。IO/进程/AI/Git 执行 owner 迁移仍属于后续高风险步骤。 - - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 + - 2026-05-18 product-domain readiness update: MiniApp draft DTO/response shape, + draft/customization storage path shape, import layout / fallback payload + contracts, manager lifecycle state-transition helpers, runtime executable + search-plan helpers, customization draft-apply / built-in update-decline + metadata policy, and Git function-agent diff truncation / commit prompt + preparation now live in `bitfun-product-domains`. The same PR adds + migration-before snapshots for core-owned MiniApp import / sync / recompile / + rollback / dependency state paths and function-agent Git / AI response + boundaries. Core still owns MiniApp filesystem IO, worker process execution, + built-in asset seeding/source-hash input lookup, host-dispatch execution, + `PathManager` integration, and function-agent Git/AI calls. Function-agent + prompt templates, response JSON extraction, and domain error-mapping policy + may move only with focused behavior-equivalence tests. The built-in MiniApp bundle/hash/marker/source payload + seed-decision contract plus seed plan / marker wire helper can live in + `bitfun-product-domains`, but bundled + asset includes, marker IO, customized update runtime, and recompile + orchestration remain core-owned. + - 高风险:`ToolUseContext`、product tool registry / runtime manifest assembly / `GetToolSpec` 执行 owner 化、 MCP concrete tool integration、remote-connect、remote SSH runtime、miniapp / function-agent runtime、 agent registry、`bitfun-core default = []` 或任何产品 crate feature set 调整。 - 高风险项不能作为 P2/P3 普通收尾任务顺带执行,必须先有等价性测试、port/provider 设计、 旧路径兼容策略和用户确认。 -- 为减少 PR 次数,后续 runtime 迁移沿用 5 个主题 PR 的队列约束,每个 PR 仍必须保持单一 - owner 主题:`services-integrations` runtime 收口、MCP runtime/dynamic tools、 - remote-connect runtime、agent tools + `tool-packs` owner 化、`product-domains` - runtime + core facade finalization。PR 2 的 MCP runtime/dynamic tools 已完成;后续不得把 - remote-connect、product tool manifest/exposure owner 化或 product-domain runtime 顺带混入已完成的 MCP PR。 - `bitfun-core default = []` 和 per-product feature matrix 仍是上述 runtime 队列之后的独立评估。 -- 当前批次的 remote-connect runtime 收口以“tracker / wire / pure policy / registry lifecycle 归 - `bitfun-services-integrations`,dispatcher / product execution 显式保留 core-owned”作为可合入闭环。 - 若未来要继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 - `ImageContextData` adapter,必须另起 port/provider 设计和行为等价评审。 -- PR 2 的 MCP 迁移已覆盖 config service orchestration、server process / local-remote - transport lifecycle、resource/prompt adapter、catalog cache、list-changed / reconnect policy、 - dynamic tool descriptor、dynamic tool provider 与 result rendering。`bitfun-core` 保留 - core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade - 和全局 tool registry / manifest 组装;product tool manifest/exposure owner 化仍归后续 tool/provider PR。 -- core MCP facade 当前允许保留窄 adapter 语义:data-dir injection、credential/config store adapter、 - `BitFunError` 映射、legacy facade、product tool wrapper 和 global registry / manifest 接入。 - 如果继续收敛 MCP manager 行为,必须先补 config failure、catalog invalidation、list-changed - 与 dynamic tool manifest 回归测试。 -- 当前 PR3 semantic baseline 已补 config failure、catalog replacement invalidation、沿用既有 list-changed - helper baseline、dynamic manifest order/metadata、tool manifest / `GetToolSpec`、MiniApp storage layout - adapter 等价和 remote search fallback gate;这些都是 behavior-locking tests,不移动 runtime owner。 -- 当前 PR2 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: +- 后续 runtime 迁移以 `docs/plans/core-decomposition-plan.md` 的里程碑表为准,不再按零散 + “剩余 PR 数量”临时拆分。LR1 已闭环为文档/边界/基线校准,不包含 runtime owner + 迁移;后续高风险队列只允许按 H1-H5 的单一 owner 主题推进: + - H1:tool runtime owner 迁移。当前只完成迁移前/迁移中边界收敛:`bitfun-agent-tools` + 承载 provider-neutral tool contract、generic registry/static/dynamic provider、 + contextual manifest resolver、provider-backed tool catalog runtime facade、 + GetToolSpec presentation/schema/static tool surface/detail/ + result assembly / execution-plan helper、provider-backed runtime facade / execution result helper / + Tool-result vector adapter, + generic decorator reference / snapshot-decorator adapter / static-provider runtime assembly + container,以及 generic readonly/enabled registry snapshot filter; + core 仍持有 `ToolUseContext`、`GetToolSpecTool` + Tool impl、collapsed unlock state source、product snapshot wrapper adapter、product registry + snapshot access 与 concrete tools。 + 已合入 PR #803 把 core `Tool` 到 provider-neutral contract 的 adapter 收敛到 + `tool_adapter.rs`,把 product catalog / manifest / GetToolSpec catalog-detail provider + 收敛到 `catalog_provider.rs`;本阶段把 provider-neutral GetToolSpec static tool surface + (name / description / schema / readonly / concurrency / permission / validation / tool-use message)、 + execution plan / result assembly 与 provider-backed execution result helper 收敛到 + `bitfun-agent-tools`;本阶段继续把 provider-backed visible-tools / manifest / readonly + catalog 查询收敛到 `ToolCatalogRuntime`,core 只保留 product registry snapshot、agent + policy、`dyn Tool` / `ToolUseContext` adapter;本阶段继续把 static-provider 安装 assembly 委托到 + `ToolRuntimeAssembly`,并把 product provider group plan 迁入 `bitfun-tool-packs`; + core 只保留 concrete tool materialization、product snapshot wrapper adapter、product provider/context 注入、 + `GetToolSpecTool` Tool impl、unlock state source 和错误映射;本阶段也把 decorator reference contract、generic snapshot decorator adapter、 + GetToolSpec runtime facade、Tool-result vector adapter 与 readonly enabled filtering 的通用规则委托给 + `bitfun-agent-tools`,不改变工具行为。 + - H2:product-domain runtime owner 迁移。MiniApp / function-agent 的纯 DTO/helper/port + facade 已外移;本阶段进一步把 function-agent prompt template、AI response JSON + extraction 与 domain error mapping 策略迁入 `product-domains`。filesystem IO、 + worker process、host dispatch、built-in asset seeding / marker IO 与 Git/AI 调用仍显式 + core-owned。 + - H3:remaining service/runtime owner。remote-connect 当前只迁移 tracker / wire / + pure policy / registry lifecycle;dialog submission、terminal pre-warm、file IO/path + resolution、`ImageContextData` adapter、remote-SSH runtime、agent registry/scheduler + 等必须另起 port/provider 设计和等价评审。 + - H4:facade and boundary finalization。`bitfun-core` 只在 runtime owner 迁移确认后 + 收敛为 legacy facade + product runtime assembly。 + - H5:optional feature/build-benefit evaluation。`bitfun-core default = []`、per-product + feature matrix、依赖版本收敛和构建收益评估只能在 H1-H4 后独立进行。 +- 已完成的 MCP runtime/dynamic tools、remote-connect tracker/wire/pure policy、 + semantic baseline、product-domain port/facade 与 tool contract/helper 外移不得重复规划; + 如果后续发现这些已完成项存在实现错误,应在对应 H 阶段记录问题、风险和修复方案, + 不把行为变更混入当前结构收敛 PR。 +- 已合入的 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: remote-SSH session identity / mirror path / unresolved-session layout 归属 - `bitfun-services-integrations`,MiniApp storage file layout 归属 `bitfun-product-domains`。 + `bitfun-services-integrations`,MiniApp storage/import file layout、fallback payload + 和纯 lifecycle state transition 归属 `bitfun-product-domains`。 core 继续持有 SSH manager、remote FS / terminal、MiniApp filesystem IO、worker runtime、 `PathManager` 注入和兼容 facade;不声明 remote-connect、MiniApp IO、function-agent Git/AI runtime 或 tool runtime 已迁移。 +- 本轮 product-domain runtime preflight PR 已补等价快照和边界锚点:MiniApp + `import_from_path` / `sync_from_fs` / `recompile` / `rollback` / deps state、 + function-agent staged diff snapshot,以及 function-agent AI response JSON extraction 与 + error mapping 的策略等价测试。H2 仅迁移 function-agent prompt/response policy owner; + 后续若继续移动 MiniApp IO/worker 或 Git/AI 调用 runtime owner,必须以这些快照为行为等价基线。 +- 当前 `product-domains` runtime port/facade closure 只迁移 port-backed owner + orchestration:MiniApp 的 deps/restart/recompile/sync/rollback 状态持久化可经 + storage facade 执行,function-agent commit / work-state facade 可基于 Git/AI port + 组装结果。Git commit-message 与 Startchat work-state 产品路径可通过 core-owned Git/AI + adapter 接入该 facade;Startchat 接线必须保留 legacy git-state、git-diff fallback、 + `analyze_git=false` time-info 与 `analyzed_at` 时序。 + core 仍持有 MiniApp filesystem IO、compiler 调度、worker process、host dispatch、 + built-in asset include / seed / marker IO / recompile,以及 function-agent Git/AI service adapter + 和 AI client 调用;`product-domains` 现在承接 function-agent prompt template、AI response JSON + extraction、domain error mapping 与 JSON-to-domain DTO parsing policy。 ## 产品表面边界(Product Surface Boundary) diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 9f27ac36e8..1919c308ae 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -335,7 +335,7 @@ src/crates/ai-adapters # 现有 AI adapter;只接收纯协议 stre src/crates/agent-stream # stream processor 与相关测试,若无法干净放入 ai-adapters src/crates/agent-runtime # session、execution、coordination、agent system src/crates/agent-tools # tool trait、registry、provider contract -src/crates/tool-packs # 具体工具实现,按 feature group 隔离 +src/crates/tool-packs # feature-group 元数据与 provider plan;未来可按 feature group 承载具体工具 src/crates/services-core # config/session/workspace/storage/filesystem/system 等基础服务 src/crates/services-integrations # git/MCP/remote SSH/remote connect 等重集成,按 feature group 隔离 src/crates/product-domains # miniapp、function agents 等产品子域 @@ -488,7 +488,7 @@ pub trait ToolProvider: Send + Sync { 使用原则: - `agent-tools` 只包含 tool trait、context、registry、provider contract。 -- `tool-packs` 拥有具体工具实现,并通过 `git`、`mcp`、`computer-use` 等 feature group 隔离重依赖。 +- `tool-packs` 当前只拥有 feature-group 元数据和 product provider group plan;具体工具实现迁移必须在后续高风险 owner 设计中按单一 feature group 处理。 - 产品完整 runtime 由 assembly 层安装所有 provider,保证产品行为不变。 ### 4.4 Decorator:snapshot 等横切逻辑不侵入 registry @@ -505,7 +505,7 @@ pub trait ToolDecorator: Send + Sync { - snapshot service 不再直接改 registry 内部实现。 - registry 支持 decorator chain。 -- 产品完整 runtime 默认安装同等 snapshot decorator,保持原行为。 +- 产品完整 runtime 默认安装同等 snapshot wrapping,保持原行为。 ### 4.5 Adapter:平台差异留在产品 adapter 层 @@ -531,7 +531,10 @@ pub trait ToolDecorator: Send + Sync { **任务:** -- [ ] 记录依赖和构建基线,生成文件只放 `target/`,不提交。 +- [x] 记录依赖和构建基线,生成文件只放 `target/`,不提交。LR1 已重新生成 + `target/core-decomposition-metadata-baseline.json`、 + `target/core-decomposition-core-duplicates.txt` 和 + `target/core-decomposition-desktop-features.txt`;这些文件只作为本地基线,不提交。 ```powershell cargo metadata --format-version 1 --locked > target/core-decomposition-metadata-baseline.json @@ -721,9 +724,12 @@ serde = { workspace = true } - [x] 把 AI 错误分类和 detail 构造 helper 下沉到 `core-types`,`BitFunError::error_category` / `error_detail` 只做委托。 - [x] 将原本依赖完整 `bitfun-core` 的 AI 错误分类测试迁移到 `bitfun-core-types` 单元测试,作为后续错误边界移动的轻量保护。 - [x] 先拆解 `BitFunError` 的 runtime/network 依赖边界。`reqwest::Error` 已改为字符串承载,`tokio::sync::AcquireError` 已改为调用点显式映射,错误模块不再直接引用这两个类型。 -- [ ] 拆解 `BitFunError` 剩余 concrete error-wrapper 依赖。当前仍保留 `serde_json::Error`、`anyhow::Error` 和相关 `From` 兼容行为,不能直接搬进只依赖 `serde` 的 `core-types`。 -- [ ] 只有当错误类型不再需要 runtime/network 依赖时,才移动 `BitFunError`、`BitFunResult`。 -- [ ] `BitFunError` 移动后保留旧路径 re-export: +- [x] LR1 已复核 `BitFunError` 剩余 concrete error-wrapper 依赖。当前仍保留 + `serde_json::Error`、`anyhow::Error`、`std::io::Error` 和相关 `From` 兼容行为; + 处理决策是继续 core-owned,不在 LR1 字符串化或改变错误边界。 +- [x] `BitFunError`、`BitFunResult` 迁移标记为 deferred:只有当错误类型不再需要 + concrete wrapper,或单独 PR 明确接受 `core-types` 的轻量 error 依赖后才可移动。 +- [x] `BitFunError` 移动后的旧路径 re-export 约束已记录;实际 re-export 只在未来迁移 PR 执行: ```rust pub use bitfun_core_types::errors::{BitFunError, BitFunResult}; @@ -739,9 +745,10 @@ pub use bitfun_core_types::errors::{BitFunError, BitFunResult}; ``` - [x] 已移动第一批 shared DTO/helper,并确认依赖方向为 `bitfun-events -> bitfun-core-types`、`bitfun-core -> bitfun-core-types`。 -- [ ] 逐个移动后续 shared DTO,每移动一个 DTO 都确认依赖方向。 +- [x] LR1 已校准后续 shared DTO 归属:当前没有适合继续批量移动的 DTO。后续只能按 + 单个 owner/单个 DTO 推进,并在移动时确认依赖方向。 -**当前状态:** Plan 3 是部分完成。`ErrorCategory`、`AiErrorDetail` 和第一批纯 helper 已进入 `core-types`;`BitFunError` / `BitFunResult` 迁移、剩余 concrete wrapper 处理和后续 DTO 迁移仍是后续任务。未完成项不阻塞 P1 的安全边界验证,但会阻塞“错误类型完全归属 core-types”的完成声明。 +**当前状态:** Plan 3 是部分完成。`ErrorCategory`、`AiErrorDetail` 和第一批纯 helper 已进入 `core-types`;LR1 已明确 `BitFunError` / `BitFunResult` 继续 core-owned,后续 DTO 不批量移动。未完成迁移不阻塞低风险准备闭环,但会阻塞“错误类型完全归属 core-types”的完成声明。 **验证:** @@ -850,7 +857,9 @@ cargo check --workspace - [x] 用 `ToolDecorator` 注入 registry 注册装饰入口,保留默认 snapshot wrapping 行为。 - [x] 为 `ConfigService` 提供 `ConfigReadPort` adapter,先建立读取边界,不移动 config service。 - [x] 新增 `RuntimeEventEnvelope` / `RuntimeEventSink` 观测事件契约,当前只作为后续 remote runtime 解耦入口,不注册新的运行时事件发布实现。 -- [ ] remote connect / cron / MCP 的 concrete call-site 替换尚未完成;这不是当前第一批 ports adapter 的完成条件,必须在 P2 service 迁移中逐步接入并补 regression。 +- [x] LR1 已复核 remote connect / cron / MCP concrete call-site 状态:MCP runtime 迁移已在 + 后续 PR 闭环;remote-connect dialog submission、cron 调度和剩余 product execution + call-site 继续显式 core-owned,进入 H3 时才按单一 owner 补 port/provider 与 regression。 - [x] 已补 `remote_image` attachment DTO 与 remote-connect image submission request builder 契约;`AgentSubmissionPort` 仍显式拒绝 generic attachments,直到多模态行为等价测试和接入方案单独完成。 - [x] P2 concrete call-site 迁移前,已把 `AgentSubmissionRequest.turn_id` 提升为显式可选 DTO 字段(序列化为 `turnId`);coordinator 兼容期先读显式字段再回退 `metadata["turnId"]`,并补充序列化与 adapter 回归测试。 - [x] P2/P3 tool owner 迁移前,`DynamicToolProvider` 已停止从 `mcp__server__tool` 注册名反推 `provider_id`;MCP wrapper 显式携带 provider metadata,并用特殊 provider id / MCP-like 名称测试证明 provider 身份不依赖注册名格式。 @@ -918,8 +927,11 @@ cargo check --workspace - [x] 新建 `bitfun-services-core`,默认 feature 尽量轻。 - [x] 基础 DTO 从 `bitfun-core-types` 引入。 -- [ ] 与 agent runtime 的调用通过 ports 完成。 -- [ ] `search`、`lsp`、`cron`、`snapshot` 先作为同 crate 内 feature group,不单独拆 crate。 +- [x] LR1 已复核 services-core 与 agent runtime 的调用边界:现有可替换入口通过 + `runtime-ports`/窄 adapter 承载;未完成的 scheduler、agent registry 或执行 runtime + 调用不在 LR1 移动,后续进入 H3 前需单独设计 port/provider。 +- [x] LR1 决策:`search`、`lsp`、`cron`、`snapshot` 继续按同 crate 内 feature group + 处理,不新增独立 crate;真正 runtime owner 迁移必须等 H3 风险评审。 - [x] 已迁移模块的 core 旧路径通过 re-export 保持。 **当前安全迁移状态(2026-05-11):** @@ -968,10 +980,12 @@ product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "f **任务:** - [x] 先迁移 `git`,因为边界相对清晰。 -- [ ] 再迁移 `remote-ssh`,保留 `ssh-remote` 语义。 +- [x] LR1 已复核 `remote-ssh`:当前仅保持 path/session identity 等 contract/helper + 外移;SSH channel、SFTP、remote FS、remote terminal 和 manager assembly 继续 core-owned。 + 若 H3 继续迁移,必须保留 `ssh-remote` 语义并补 remote 等价测试。 - [x] 先迁移 `remote-ssh` 的纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper,runtime manager / fs / terminal 仍保留在 core。 - [x] 迁移 `mcp` 的 PR2 runtime 与 dynamic provider:config service orchestration、server process / transport lifecycle、resource/prompt adapter、catalog cache、list-changed/reconnect policy、dynamic descriptor / provider / result rendering 均归属 `bitfun-services-integrations`。 -- [x] `bitfun-core` 保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade 和全局 tool registry / manifest 组装;product tool manifest/exposure owner 化不混入本 PR。 +- [x] `bitfun-core` 保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade 和全局 tool registry / manifest 组装;product tool runtime manifest / `GetToolSpec` 执行 owner 化不混入本 PR。 - [x] 先迁移 `announcement` 的纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core。 - [x] 先完成 `remote-connect` contract slice:remote chat/image/tool/session wire DTO 与 relay/bot session/submission request builder 由 `bitfun-services-integrations` 拥有,relay/bot session 创建通过 `AgentSubmissionPort`。 - [x] 已补齐 remote runtime 迁移前的第一层 port baseline:`SessionTranscriptReader`、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink` 与 remote image attachment/request DTO;完整 `remote-connect` runtime 仍需后续单独迁移并补 queue/event/image 行为等价测试。 @@ -984,7 +998,7 @@ product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "f **当前安全迁移状态(2026-05-15):** - 已迁移到 `bitfun-services-integrations`:`service::file_watch`,通过 `file-watch` / `product-full` feature 启用,并保持 `core::service::file_watch` 旧路径。 -- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool manifest/exposure owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 +- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool runtime manifest / `GetToolSpec` 执行 owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 - 最新主干的 Deep Review capacity / cost / queue、context profile、evidence ledger、session manifest、stream dedupe、search remote/fallback 与 session rollback persistence 仍属于 core runtime 或对应产品 runtime,不在本轮 `services-integrations` 迁移范围内;如果后续迁移 remote-connect / MCP / search / session,需要先定义运行状态 port 合约和等价测试。 **验证:** @@ -1018,9 +1032,16 @@ cargo check -p bitfun-cli **任务:** - [x] 抽出 tool result、validation、dynamic metadata、runtime restriction、path resolution DTO,以及 generic registry / dynamic provider container 到 `agent-tools`。 -- [ ] 抽出 `Tool` trait 与 `ToolUseContext` 前,先补可移植 tool context / service port 设计;当前不做无端口支撑的行为迁移。 +- [x] 抽出纯 manifest/exposure / GetToolSpec presentation 契约到 `agent-tools`:`ToolExposure`、`GetToolSpec` 名称、纯 manifest policy、collapsed prompt stub、prompt-visible ordering、GetToolSpec prompt description / input schema / validation / assistant-detail rendering / collapsed summary-detail / duplicate-load hint;core 继续拥有 runtime assembly 和执行 owner。 +- [x] 抽出 static tool provider 安装合约到 `agent-tools`,并将 core 内置工具列表收敛到 `static_providers.rs` 的 core-owned provider groups;不迁移 concrete tool implementation。 +- [x] 抽出 `ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实契约,并由 core `ToolUseContext` 提供只读投影;workspace root fact 使用 session identity 的 logical path,remote 场景输出 normalized remote root;不迁移 collapsed unlock state、runtime handles、workspace services 或 cancellation token。 +- [x] 增加 `PortableToolContextProvider` 只读 facts provider 合约,并由 core `ToolUseContext` 兼容实现;该合约不暴露 workspace services、cancellation token、computer-use host 或 collapsed unlock state。 +- [x] LR1 已锁定 tool runtime port/provider 设计前置条件:`PortableToolContextProvider` + 只能提供只读 facts,不能携带 runtime handles、workspace services、cancellation token + 或 collapsed unlock state;`Tool` trait 与 `ToolUseContext` 在 H1 前继续 core-owned。 - [x] `agent-tools` 不依赖任何 concrete service。 -- [ ] 将工具实现迁移到 `tool-packs` crate,并按 feature group 分模块: +- [x] 具体工具实现迁移 deferred 到 H1;LR1 不迁移 concrete tools。未来迁移到 + `tool-packs` 时按 feature group 分模块: - basic file/search/terminal - git - MCP @@ -1028,8 +1049,9 @@ cargo check -p bitfun-cli - computer use - miniapp - cron/task/agent control -- [x] `tool-packs` 默认 feature 为空,产品完整 runtime 启用 `product-full`。 -- [ ] 产品 runtime assembly 注册所有 provider: +- [x] `tool-packs` 默认 feature 为空,产品完整 runtime 启用 `product-full`;当前仅提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据,不注册或迁移任何具体工具。 +- [x] 产品 runtime assembly provider 注册 deferred 到 H1;LR1 继续由 core product tool + runtime 安装 provider: ```rust registry.install_provider(BasicToolProvider::new()); @@ -1037,7 +1059,7 @@ registry.install_provider(GitToolProvider::new(git_service)); registry.install_provider(McpToolProvider::new(mcp_service)); ``` -- [ ] 保持兼容构造函数: +- [x] 兼容构造函数迁移 deferred 到 H1;LR1 继续保持现有 core 旧构造路径: ```rust pub fn create_tool_registry() -> ToolRegistry { @@ -1045,25 +1067,127 @@ pub fn create_tool_registry() -> ToolRegistry { } ``` -- [ ] 增加 registry / manifest 等价性测试:完整产品 registry、expanded/collapsed exposure 与 prompt-visible manifest 和拆分前一致。 -- [ ] 迁移 tool exposure / manifest / `GetToolSpec` 前,补 expanded/collapsed manifest、 +- [x] registry / manifest 迁移前等价基线已在 LR1 复核:现有 + `registry_preserves_builtin_tool_manifest_for_owner_migration`、 + `registry_preserves_readonly_tool_manifest_for_owner_migration`、 + `manifest_snapshot_preserves_collapsed_tool_discovery_contract` 与 + `bitfun-agent-tools` tool contract 测试覆盖当前低风险拆分;H1 迁移前仍需扩展为完整产品 + registry、expanded/collapsed exposure 与 prompt-visible manifest 等价快照。 +- [x] runtime manifest assembly / `GetToolSpec` 执行迁移前的 baseline 已作为 H1 进入条件: + 保留并扩展 expanded/collapsed manifest、 prompt-visible stub、unlock state 和 desktop/MCP/ACP catalog 等价测试。 - -**当前安全迁移状态(2026-05-14):** - -- 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO,以及不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射与路径 containment helper。 -- `bitfun-core::agentic::tools` 现在保留产品完整工具列表、snapshot decorator 组装、旧构造函数、`dyn Tool` 到 generic registry 的适配,以及最新主干新增的 tool exposure / manifest resolution / `GetToolSpec` 按需工具说明发现;dynamic metadata map、tool map、dynamic descriptor assembly 由 `bitfun-agent-tools` 拥有。 -- 已新增 `bitfun-tool-packs` feature scaffold,默认 feature 为空,`product-full` 只聚合 feature,不注册或迁移任何工具实现。 -- 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool manifest、`ToolExposure`、`GetToolSpec` 或 collapsed-tool unlock state;这些仍由 core product tool runtime 负责。 +- [x] H1 解锁契约切片只抽出 `GetToolSpec` 结果到 collapsed 工具名集合的纯收集规则; + `ToolUseContext.unlocked_collapsed_tools`、执行消息解析、runtime manifest assembly 和 + `GetToolSpecTool` 执行仍由 core 拥有。 +- [x] H1 manifest builder 切片只抽出 prompt-visible manifest definition 的纯组装规则: + expanded 工具的 description/schema 仍由 core 按 `ToolUseContext` 获取,collapsed stub + 渲染和排序由 `bitfun-agent-tools` 统一;runtime manifest owner 仍未迁移。 +- [x] H1 catalog/exposure 切片继续抽出 registry snapshot 到 manifest policy input、 + generic collapsed exposure 查询、`GetToolSpec` catalog description 和 detail JSON 的纯规则;core + 仍负责 tool availability、product catalog source、product snapshot wrapper adapter、runtime unlock state 和工具执行。 + +**当前安全迁移状态(2026-05-21):** + +- 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO、`ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实、`PortableToolContextProvider` 只读 facts provider、不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container、`StaticToolProvider` / `install_static_provider` 安装合约、generic decorator reference / snapshot decorator adapter / static-provider `ToolRuntimeAssembly` container、generic readonly/enabled registry snapshot filter、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、registry snapshot 到 manifest policy input 的纯 helper、generic collapsed exposure 查询、`GetToolSpec` load observation 到 collapsed 工具名集合的纯收集 helper、prompt-visible manifest definition 的纯组装 helper、generic contextual prompt-manifest resolver,以及 `GetToolSpec` catalog / detail / static metadata / tool-use message / execution-plan / result assembly 的 provider-neutral 组装 helper、provider-backed execution result helper、runtime facade 和 Tool-result vector adapter。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射、路径 containment helper、`ToolUseContext` 到 facts 的只读投影和 runtime unlock state。 +- `bitfun-core::agentic::tools` 现在保留 `ProductToolRuntimeAssembly` product snapshot wrapper 注入、旧构造函数、`dyn Tool` 到 generic registry / catalog snapshot provider / GetToolSpec catalog provider 的适配、`ToolUseContext` runtime handle / service owner,以及 product registry snapshot access / agent policy / `GetToolSpecTool` Tool impl;core 静态 provider 组顺序和工具名来自 `bitfun-tool-packs` provider plan,core 只负责 concrete tool materialization。dynamic metadata map、tool map、dynamic descriptor assembly、static provider 安装合约、generic decorator reference / snapshot decorator adapter / provider-install assembly、generic readonly/enabled filtering、portable context facts、纯 manifest/exposure 契约、generic catalog snapshot provider、generic contextual prompt-manifest resolver、provider-backed catalog runtime facade、GetToolSpec presentation/schema/detail/static metadata/tool-use message 纯 helper、provider-neutral execution-plan、provider-backed execution result helper、runtime facade、Tool-result vector adapter 和 result assembly helper 由 `bitfun-agent-tools` 拥有。 +- `bitfun-tool-packs` 默认 feature 为空,`product-full` 只聚合 feature;当前提供 `ToolPackFeatureGroup` / `all_feature_groups` / `enabled_feature_groups` 元数据和 `product_tool_provider_group_plan`,不注册或迁移任何工具实现。 +- 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 full product tool runtime assembly、`GetToolSpecTool` Tool impl、collapsed-tool unlock state owner 或 concrete tools;`tool-packs` 也不得拥有 manifest/exposure 契约。`agent-tools` 只允许拥有纯 manifest/exposure helper、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、generic contextual manifest resolver、generic readonly/enabled filter、generic decorator reference / snapshot decorator adapter、GetToolSpec presentation/schema/detail/static metadata/tool-use message/execution-plan/result assembly helper、provider-backed execution result helper / runtime facade / Tool-result vector adapter 和不依赖具体工具的 provider 安装 / runtime assembly 合约,core product tool runtime 继续负责产品 registry snapshot、agent policy、concrete tool materialization、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` adapter、unlock state source 和执行路径。 - boundary check 也已补充 core owner anchor:要求产品工具注册、expanded/collapsed manifest、`GetToolSpec` duplicate-load guard、`ToolUseContext.unlocked_collapsed_tools`、执行管线 gating 与 execution unlock collector 仍保留在 core。后续若迁移这些 owner,必须先更新 port/provider 设计、等价测试与该脚本,而不能只删除 core 侧实现。 -- `Tool` trait、`ToolUseContext` 和具体工具实现仍在 core;它们直接连接 workspace service、snapshot wrapper、computer-use host、cancellation token 与 Deep Review checkpoint hook,继续迁移前必须先确认可移植 tool context / provider port 方案,并补工具清单等价性测试。 +- `Tool` trait、`ToolUseContext` 和具体工具实现仍在 core;它们直接连接 workspace service、snapshot wrapper、computer-use host、cancellation token 与 Deep Review checkpoint hook。`ToolContextFacts` / `PortableToolContextProvider` 只能作为只读事实投影,继续迁移前必须先确认 service port 方案,并补工具清单等价性测试。 - 最新主干新增的 Deep Review shared-context / evidence-ledger checkpoint hook 仍保留在 core 的 `ToolUseContext` 中;在设计独立 tool context / event port 前,不应把 `ToolUseContext` 或 concrete tool implementation 继续外移。 -- 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec`、`manifest_resolver`、collapsed-tool catalog、context-aware `description_with_context` / `input_schema_for_model_with_context`,以及 `ToolUseContext.unlocked_collapsed_tools` 均会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 PR5 product-domain 收尾中顺带执行。 +- 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec` 名称、collapsed prompt stub、manifest ordering、generic collapsed exposure 查询、generic catalog snapshot provider、generic GetToolSpec catalog provider、generic contextual prompt-manifest resolver 与 GetToolSpec presentation/schema/detail 的纯契约已可由 `bitfun-agent-tools` 承载;product registry snapshot、product collapsed-tool catalog source、core `dyn Tool` / `ToolUseContext` adapter、context-aware `description_with_context` / `input_schema_for_model_with_context` 的实际调用、`GetToolSpecTool` Tool impl / `BitFunError` 映射以及 `ToolUseContext.unlocked_collapsed_tools` 仍会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 product-domain runtime 收尾中顺带执行。 +- H1 start(2026-05-19):`StaticToolProviderGroup` 通用容器已迁入 + `bitfun-agent-tools`,core 的 `static_providers.rs` 只负责实例化 concrete tools 并按既有 + provider group 顺序装配。该切片不移动 concrete tool implementation、`ToolUseContext`、 + runtime manifest assembly 或 `GetToolSpec` 执行;provider id、工具顺序与 manifest 快照由 + `bitfun-agent-tools` contract test、core registry snapshot 和 boundary check 共同保护。 +- H1 follow-up(2026-05-19):已补迁移前 baseline 和纯 catalog/manifest helper 外移, + 覆盖完整 collapsed 工具清单、`ToolExecutionContext` 到 core-owned `ToolUseContext` + 的运行时状态传递、`ToolContextFacts` / `PortableToolContextProvider` 不携带 + `unlocked_collapsed_tools`、custom data、cancellation token 或 workspace services 的边界, + 以及显式允许 `GetToolSpec` 时的 runtime insertion 快照。下一步若继续迁移, + 才进入 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` 执行或 concrete + tools 的单一 owner 设计与等价性证明。 +- H2 本轮完成(2026-05-19):已先在 core 内部收敛 product tool runtime assembly owner。 + `ProductToolRuntimeAssembly` 负责安装 core-owned static provider groups 与 product snapshot wrapper adapter, + `ToolRegistry` 只保留对 `bitfun-agent-tools` generic registry 的兼容容器和动态工具入口。 + 该切片仍不迁移 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` 执行、 + product collapsed-tool catalog 或 concrete tool implementation。 +- Tool-runtime H3 start(2026-05-19):先补 tool runtime provider/assembly 等价保护,再评估 runtime + owner 外移。当前新增 custom decorator + provider assembly guard,并锁定 manifest / + `GetToolSpec` unlock surface 的现有顺序与 stub contract;不迁移 `ToolUseContext`、 + runtime manifest assembly、`GetToolSpecTool` 执行、snapshot wrapper implementation 或 concrete + tool implementation。 +- Tool-runtime H4 start(2026-05-19):先锁定 `ToolUseContext` 到 portable facts 的 + runtime-only 字段隔离,避免把 collapsed unlock state、custom data、cancellation token + 或 workspace services 误声明为可外移 facts;boundary check 同步要求该 guard 存在。 + 该切片仍不迁移 `ToolUseContext` owner。 +- Tool-runtime H5 start(2026-05-19):补充 execution 侧 `GetToolSpec` unlock collection guard, + 锁定成功 `GetToolSpec` 结果、collapsed 白名单、去重和非字符串 / 错误 / 非 `GetToolSpec` + 结果过滤语义。该切片仍不迁移 `GetToolSpecTool`、`ToolUseContext`、runtime manifest + assembly 或 concrete tools。 +- Tool-runtime H6 start(2026-05-19):将 context-aware prompt manifest / visible-tools + resolution 的通用算法迁入 `bitfun-agent-tools`,并把 `GetToolSpec` collapsed summary/detail + JSON 的 provider-neutral 组装规则一并迁入;本轮继续补 generic catalog snapshot provider + 与 generic GetToolSpec catalog provider,core 只实现 product registry snapshot adapter / + product GetToolSpec catalog adapter,并保留 product collapsed catalog source 与 + `dyn Tool` / `ToolUseContext` adapter。该切片仍不迁移 concrete tools、`GetToolSpecTool` + 执行、collapsed unlock state 或 snapshot decorator。 +- 已合入 PR #803(2026-05-20):收敛 H1 前的 core product tool owner 边界,而不迁移 runtime owner。 + `tool_adapter.rs` 只承接 core `Tool` 到 `bitfun-agent-tools` provider-neutral contract 的 + adapter;`catalog_provider.rs` 只承接 product registry snapshot、contextual catalog、 + manifest 和 GetToolSpec catalog/detail provider facade;`manifest_resolver` 与 `GetToolSpecTool` + 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 和 assistant result + rendering。该切片仍不迁移 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` + 执行 owner、collapsed unlock state、snapshot decorator 或 concrete tools。 +- H1 execution-plan/result slice(2026-05-20):将 `GetToolSpec` static metadata、 + tool-use message、input extraction、duplicate-load planning、duplicate-load result、detail + result assembly 和 provider-backed execution result helper 迁入 `bitfun-agent-tools`,并用 + contract tests 锁定 JSON shape、assistant XML escaping、no-image result、missing `tool_name` + error、detail error 分类、provider detail lookup、已加载短路语义和工具展示文案。core + `GetToolSpecTool` 继续持有 Tool impl、`ToolUseContext.unlocked_collapsed_tools` 状态来源和 + `BitFunError` 映射;该切片不迁移 + runtime manifest assembly、unlock state owner、snapshot decorator 或 concrete tools。 +- H1 generic runtime assembly slice(2026-05-20):将 static-provider 安装 assembly 的通用 + 顺序与 decorator 应用规则收敛为 `bitfun-agent-tools::ToolRuntimeAssembly`,并从 + `bitfun-agent-tools` 导出 `ToolDecoratorRef` 作为通用 decorator reference contract;core + `ProductToolRuntimeAssembly` 只保留 concrete provider group 来源、product snapshot wrapper adapter 注入和 + 旧路径 `ToolRegistry` wrapper。该切片不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、 + runtime manifest facade、collapsed unlock state、snapshot wrapper implementation 或 concrete + tools。 +- H1 readonly filter slice(2026-05-20):将 registry snapshot 上的 readonly + enabled + 过滤规则收敛为 `bitfun-agent-tools::resolve_readonly_enabled_tools`,core `ToolRegistry` + 只负责读取 product registry snapshot,并通过 `tool_adapter.rs` 投影 `is_readonly` / + `is_enabled`。该切片不改变 readonly 判定、enabled 判定或任何具体工具实现。 +- H1 snapshot decorator port slice(2026-05-20):将 snapshot decorator 的通用 + adapter 收敛为 `bitfun-agent-tools::SnapshotToolDecorator` + `SnapshotToolWrapper`,core + 只保留 `ProductSnapshotToolWrapper` 调用现有 + `wrap_tool_for_snapshot_tracking`。该切片不迁移 snapshot runtime、修改类工具实现、 + `ToolUseContext`、runtime manifest facade、collapsed unlock state 或 `GetToolSpecTool` + Tool impl。 +- H1 GetToolSpec runtime facade slice(2026-05-20):将 provider-backed catalog + description 与 execution result 入口收敛为 `bitfun-agent-tools::GetToolSpecRuntime`; + core `catalog_provider.rs` 只负责 product provider / context / unlock-state 注入, + `GetToolSpecTool` 仍保留 Tool impl、`BitFunError` 映射和 assistant rendering 旧路径兼容。 + 该切片不迁移 `ToolUseContext`、runtime manifest facade、collapsed unlock state owner 或 + concrete tools。 +- H1 tool catalog runtime facade slice(2026-05-20):将 provider-backed visible-tools、 + prompt-visible manifest 和 readonly enabled catalog 查询收敛为 + `bitfun-agent-tools::ToolCatalogRuntime`;core `catalog_provider.rs` 只负责 product registry + snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,`ToolRegistry` + 只通过 product facade 查询 readonly tools。该切片不迁移 `ToolUseContext`、`GetToolSpecTool` + Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 +- H1 GetToolSpec Tool adapter facade slice(2026-05-21):将 `GetToolSpecRuntime::call_results` + 与 core product `resolve_product_get_tool_spec_results` 收敛为同一 provider-backed + result-vector facade,`GetToolSpecTool::call_impl` 只做 product facade 委托和 + `BitFunError` 映射。该切片不迁移 `ToolUseContext`、runtime manifest facade、 + collapsed unlock state owner、assistant rendering 语义或 concrete tools。 **验证:** ```powershell cargo test -p bitfun-agent-tools +cargo test -p bitfun-agent-tools get_tool_spec_contract --test tool_contracts cargo test -p bitfun-tool-packs --features basic cargo check -p bitfun-tool-packs --features product-full cargo check -p bitfun-core --features product-full @@ -1110,19 +1234,32 @@ product-full = ["miniapp", "function-agents"] - [x] miniapp compiler 迁移到 `product-domains::miniapp::compiler`,core 保留原 `miniapp::compiler::compile` 返回 `BitFunResult` 的兼容 wrapper。 - [x] miniapp exporter DTO、runtime detection DTO、runtime search plan、worker install 命令选择与 package.json storage-shape helper 迁移到 `product-domains::miniapp`;core 保留实际 export / runtime detection / worker pool / storage IO 执行逻辑。 -- [ ] miniapp runtime、storage、manager、host dispatch、exporter、builtin 迁移到 `product-domains::miniapp`。 -- [ ] function agents 迁移到 `product-domains::function_agents`。 +- [x] LR1 已完成 MiniApp runtime 迁移前 owner 审视:runtime、storage、manager、host + dispatch、exporter、builtin 中涉及 filesystem IO、worker process、asset seed、marker IO、 + host dispatch execution 或 recompile orchestration 的部分继续 core-owned,actual owner + 迁移 deferred 到 H2。 +- [x] LR1 已完成 function-agent runtime 迁移前 owner 审视:pure DTO/helper/parser/facade + 可由 `product-domains::function_agents` 承载;Git service 与 AI call 继续 core-owned。 + prompt template、JSON extraction 和 domain error mapping 已在 H2 迁入 product-domain policy。 - [x] 已为 miniapp runtime/storage 与 function-agent Git/AI 边界定义迁移前 provider / port contract,并补充 core-owned MiniApp storage/runtime 与 function-agent Git snapshot adapter 等价测试;实际 IO/进程/Git/AI 执行 owner 迁移仍待后续 port/provider 方案确认后推进。 - [x] 已迁移模块的 core 旧路径 re-export。 -- [ ] function agents 依赖 agent runtime port,不直接依赖 service concrete manager。 -- [ ] server/desktop 调用路径保持不变。 +- [x] function-agent agent-runtime port 依赖 deferred 到 H2;LR1 不引入新的 agent runtime + port,也不改变现有 service manager 调用语义。 +- [x] LR1 未改 server/desktop 调用路径;后续 H2/H3 若迁移 runtime owner,必须用现有 + product check 和 focused regression 证明调用路径等价。 **当前安全迁移状态(2026-05-14):** - 已迁移到 `bitfun-product-domains::miniapp`:`types`、`bridge_builder`、`permission_policy`,core 旧路径继续 re-export。 - 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / host dispatch 执行 / customization draft 存储与应用 / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 -- 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 -- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 +- 2026-05-18 update: MiniApp draft manifest/response DTO, draft/customization storage path helpers, import layout / fallback payload contracts, manager lifecycle state-transition helpers, runtime executable search-plan helpers, customization draft-apply metadata policy, and built-in update/decline metadata decisions have been moved to `bitfun-product-domains::miniapp`; core continues to own draft/import filesystem IO, compile orchestration, built-in asset seeding/source-hash lookup, host dispatch execution, `PathManager` integration, worker process execution, and compatibility facades. The current PR also records core-owned MiniApp import / sync / recompile / rollback / dependency-state behavior as migration-before tests, including the existing `sync_from_fs` snapshot boundary. +- 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / prompt template / AI response JSON extraction 与 domain error mapping policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、AI request 与分析运行逻辑仍留在 core。 +- 2026-05-18 update: Git function-agent diff truncation and commit prompt preparation are now owner-crate helpers used by core; at that stage AI client calls, prompt template ownership, JSON extraction, error mapping, and runtime analysis execution remained core-owned. The current focused snapshots for staged-only Git commit diff collection and AI response JSON extraction / error mapping were kept as behavior baselines; prompt/response policy ownership is superseded by the 2026-05-21 H2 update below. +- 2026-05-19 update: `bitfun-product-domains` now owns port-backed MiniApp runtime-state and function-agent runtime facades. Core delegates only MiniApp storage-backed lifecycle persistence through the MiniApp facade; compilation, source reads, storage IO adapter, worker process execution, host dispatch, built-in asset include / seed / marker IO / recompile remain core-owned. The Git commit-message and Startchat work-state product paths now route through the function-agent facade using core-owned Git/AI adapters; Startchat wiring is guarded by focused tests for legacy git-state, no-HEAD git-diff fallback, and `analyze_git=false` time-info, while core keeps the previous post-analysis `analyzed_at` assignment。 +- 2026-05-19 built-in MiniApp contract update: built-in bundle shape, install marker DTO, content-hash helper, source/placeholder/package payload helpers, and seed-decision policy now live in `bitfun-product-domains::miniapp::builtin`; core still owns the bundled asset includes, user-data filesystem IO, marker read/write, customization metadata IO, source-hash input lookup, and recompile orchestration. +- 2026-05-19 follow-up: MiniApp built-in seed artifact / action resolution and install-marker serialize/parse helpers have also moved to `bitfun-product-domains::miniapp::builtin`; core still owns bundled asset includes, user-data filesystem reads/writes, marker read/write calls, local customization metadata IO, source-hash input lookup, timestamp source, and recompile orchestration. +- 2026-05-21 function-agent response-policy update: Git commit-message and Startchat prompt templates, AI response JSON extraction, JSON repair, JSON-string parsers, and domain error mapping now live in `bitfun-product-domains::function_agents`; core still owns AI service calls, Git service adapters, provider acquisition, AI transport errors, and runtime analysis orchestration. +- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization/builtin 纯 contract、MiniApp manager preflight tests、function-agent Git adapter、prompt/response policy helper 必须存在,防止把 port contract 或 response policy 误读成 storage IO、worker process、host dispatch、customization draft runtime、builtin asset seeding runtime 或 Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 **验证:** @@ -1162,9 +1299,11 @@ cargo check -p bitfun-server //! This crate re-exports legacy paths and wires the full BitFun product runtime. ``` -- [ ] `bitfun-core/Cargo.toml` 只保留 facade 和 product assembly 所需依赖;当前仍因 core-owned runtime 保留 concrete runtime 依赖,不在本 PR 强行删减。 +- [x] `bitfun-core/Cargo.toml` 依赖裁剪 deferred 到 H4/H5;LR1 已确认当前仍因 + core-owned runtime 保留 concrete runtime 依赖,不强行删减。 - [x] 旧路径保持 import-compatible。 -- [ ] 只有所有产品 crate 都显式启用完整 runtime 后,才可以在独立 PR 中评估: +- [x] `default = []` / per-product feature matrix 评估 deferred 到 H5;只有所有产品 crate + 都显式启用完整 runtime 且有 feature graph baseline 后,才可以在独立 PR 中评估: ```toml default = [] @@ -1172,7 +1311,7 @@ default = [] **当前收敛状态(2026-05-13):** -- 本轮不把 `remote-ssh` runtime、`remote-connect`、announcement runtime、concrete tool implementations、`ToolUseContext`、product registry / manifest / exposure assembly、miniapp runtime/compiler/builtin、function-agent 运行逻辑声明为已迁移;它们继续作为 `bitfun-core` 的 product runtime assembly 或后续 owner PR 拥有路径。`git` feature group 已外移;`remote-ssh` 目前只外移 contract/type、workspace path/identity helper 与 unresolved-session-key helper;MCP PR2 已外移 config service orchestration、server process / transport lifecycle、adapter 和 dynamic tool/resource/prompt provider;generic tool registry / dynamic descriptor assembly 已由 `bitfun-agent-tools` 拥有,core 只保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade、产品工具列表、tool manifest/exposure 和 snapshot decorator assembly;`announcement` 目前只外移 types contract。 +- 本轮不把 `remote-ssh` runtime、`remote-connect`、announcement runtime、concrete tool implementations、`ToolUseContext`、product registry snapshot / manifest / exposure assembly、miniapp runtime/compiler/builtin、function-agent 运行逻辑声明为已迁移;它们继续作为 `bitfun-core` 的 product runtime assembly 或后续 owner PR 拥有路径。`git` feature group 已外移;`remote-ssh` 目前只外移 contract/type、workspace path/identity helper 与 unresolved-session-key helper;MCP PR2 已外移 config service orchestration、server process / transport lifecycle、adapter 和 dynamic tool/resource/prompt provider;generic tool registry / static provider installation / dynamic descriptor assembly 已由 `bitfun-agent-tools` 拥有,product provider group plan 由 `bitfun-tool-packs` 拥有,core 只保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade、concrete tool materialization、tool manifest/exposure product facade 和 snapshot decorator assembly;`announcement` 目前只外移 types contract。 - 新增 `scripts/check-core-boundaries.mjs`,用于阻止已拆出的 owner crate 反向依赖 `bitfun-core`。该脚本只证明 crate graph 方向,不替代产品等价性测试。 - `default = []` 仍保持为后续独立评估项,本轮不调整默认 feature、构建脚本或 release 脚本。 @@ -1480,7 +1619,7 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts - `bitfun-services-core`:先迁移 config、session、workspace、storage、filesystem、system、session_usage、token_usage 等基础服务,保持旧 core 路径 re-export。 - `bitfun-services-integrations`:按 git、remote-ssh、MCP、remote-connect 顺序迁移重集成;每迁移一个 feature group 都保留产品完整 runtime 等价性。 -- `bitfun-agent-tools` / `bitfun-tool-packs`:拆出 tool trait、context、registry、provider contract,并通过 feature group 承载具体工具实现。 +- `bitfun-agent-tools` / `bitfun-tool-packs`:拆出 tool trait、context、registry、provider contract;`tool-packs` 先承载 feature-group 元数据和 provider plan,具体工具实现仅作为后续按 feature group 外移的目标。 - `bitfun-product-domains`:承接 miniapp 和 function-agent 产品子域,避免继续扩大 `bitfun-core` 的产品职责。 **影响面:** @@ -1532,11 +1671,11 @@ cargo check --workspace - 已完成中等粒度 owner crate 成型的安全部分:`bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`bitfun-tool-packs`、`bitfun-product-domains` 均已加入 workspace。 - 已迁移的模块均由 core facade re-export,未改变产品默认 feature、构建脚本或 release 脚本。 -- Git feature group 已闭环迁移到 `bitfun-services-integrations` 的 `git` feature:DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 均由 integrations owner crate 拥有,并通过 `bitfun-core::service::git::*` 保留旧路径兼容。`GitService` 所需的 Windows `libgit2` system-link 边界挂在该 crate 的 `git` feature 上;`bitfun-core` 仍因未迁移的 remote-connect runtime 保留其它 `git2` 使用。remote-ssh 本轮进一步外移 workspace path/identity 与 unresolved-session-key helper,并用 owner crate contract test 锁定 normalized path、mirror subpath、hostname sanitization、stable id 和 unresolved key 输出;PathManager-backed mirror root、global workspace registry、SSH manager/fs/terminal/runtime 仍留在 core。MCP PR2 已进一步外移 config service orchestration、server process / local-remote transport lifecycle、dynamic tool provider 与 context resource selection helper,core 旧路径继续做兼容 facade、core config store adapter、OAuth 数据目录注入与 `BitFunError` 映射。PR4 已将 generic tool registry / dynamic descriptor assembly 迁入 `bitfun-agent-tools`;core 继续负责产品工具列表、snapshot decorator、`dyn Tool` 适配、tool exposure / manifest resolution 与 `GetToolSpec` 按需工具说明发现。 -- 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、tool exposure / manifest / `GetToolSpec` owner 化、concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 +- Git feature group 已闭环迁移到 `bitfun-services-integrations` 的 `git` feature:DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 均由 integrations owner crate 拥有,并通过 `bitfun-core::service::git::*` 保留旧路径兼容。`GitService` 所需的 Windows `libgit2` system-link 边界挂在该 crate 的 `git` feature 上;`bitfun-core` 仍因未迁移的 remote-connect runtime 保留其它 `git2` 使用。remote-ssh 本轮进一步外移 workspace path/identity 与 unresolved-session-key helper,并用 owner crate contract test 锁定 normalized path、mirror subpath、hostname sanitization、stable id 和 unresolved key 输出;PathManager-backed mirror root、global workspace registry、SSH manager/fs/terminal/runtime 仍留在 core。MCP PR2 已进一步外移 config service orchestration、server process / local-remote transport lifecycle、dynamic tool provider 与 context resource selection helper,core 旧路径继续做兼容 facade、core config store adapter、OAuth 数据目录注入与 `BitFunError` 映射。PR4 已将 generic tool registry / dynamic descriptor assembly 迁入 `bitfun-agent-tools`;后续进一步迁入纯 tool manifest/exposure 契约,本轮再迁入 static provider 安装合约,并把 core 内置工具列表收敛为 core-owned provider groups。当前 tool-runtime H6 已将 generic contextual manifest resolver、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime` 与 GetToolSpec summary/detail/runtime facade 迁入 `bitfun-agent-tools`;core 继续负责 concrete tools、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` 适配、product registry snapshot access、agent policy 与 `GetToolSpec` 执行。 +- 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、runtime manifest assembly / `GetToolSpec` 执行 owner 化、concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 -- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、built-in bundle/hash/marker/source payload seed-decision contract、built-in seed plan / marker wire helper、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / JSON-string parsing helper / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper。H2 进一步将 function-agent prompt template、AI response JSON extraction 与 domain error mapping policy 迁入 product-domain;core 只保留 AI client 调用、Git service adapter、AI transport/provider-acquisition error mapping 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state、built-in asset seeding decision 等价测试与 function-agent response policy 等价快照。PathManager、Git/AI service、builtin asset includes / seed / marker IO / recompile、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 - 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 **后续风险重排(2026-05-13):** @@ -1556,10 +1695,10 @@ cargo check --workspace - 已扩展 `scripts/check-core-boundaries.mjs`,增加 dependency profile / feature graph 静态保护:`core-types` default profile 禁止非 DTO 依赖,`runtime-ports` default profile 禁止 service implementation 依赖,`agent-tools` contract profile 禁止依赖 `bitfun-ai-adapters`,`product-domains` default profile 禁止无条件拉入 `dirs`,`services-integrations` default profile 禁止无条件拉入 feature-gated integration 依赖。 - 已将 `ToolImageAttachment` 提升到 `bitfun-core-types`,并由 `bitfun-ai-adapters`、`bitfun-agent-tools` 和 `bitfun-core::util::types` 保留旧路径兼容;`bitfun-agent-tools` 不再依赖 `bitfun-ai-adapters`。 - 已将 `product-domains` 的 `dirs` 依赖限制到 `miniapp` feature,默认 profile 保持轻量。 -- 已为 `product-domains` 增加 runtime-owner 静态保护,禁止在未确认 port/provider 迁移方案前引入进程启动、具体 Git/AI 服务、网络客户端或平台 API;也已锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool manifest、`GetToolSpec` 或 collapsed-tool unlock state。 +- 已为 `product-domains` 增加 runtime-owner 静态保护,禁止在未确认 port/provider 迁移方案前引入进程启动、具体 Git/AI 服务、网络客户端或平台 API;也已锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool runtime assembly、`GetToolSpecTool` 执行或 collapsed-tool unlock state。 - 已为 core 侧高风险 owner 增加 required-content anchor,覆盖 product tool registry / manifest / `GetToolSpec` / collapsed-tool unlock 流,以及 MiniApp storage/runtime adapter 与 function-agent Git adapter;该检查用于避免“轻量 crate 已抽出”被误解为 runtime owner 已迁移。 -- 已补充 `ToolResult` image attachment、dynamic provider metadata、dynamic descriptor wire shape、runtime restrictions、path resolution contract、generic tool registry descriptor/stale metadata 测试,以及 core 内置 tool registry 清单快照测试;后续迁移 `ToolUseContext`、product registry / manifest assembly 或 concrete tool implementation 前必须保持这些基线。 -- 已将 generic tool registry / dynamic provider descriptor assembly 迁入 `bitfun-agent-tools`;core tool runtime 保留产品完整工具列表、manifest/exposure、snapshot decorator 和 `dyn Tool` 适配,并通过 boundary check 禁止重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 +- 已补充 `ToolResult` image attachment、dynamic provider metadata、dynamic descriptor wire shape、runtime restrictions、path resolution contract、generic tool registry descriptor/stale metadata、static provider 安装,以及 core provider-based 内置 tool registry 清单快照测试;后续迁移 `ToolUseContext`、product registry / manifest assembly 或 concrete tool implementation 前必须保持这些基线。 +- 已将 generic tool registry / static provider installation / dynamic provider descriptor assembly 迁入 `bitfun-agent-tools`,并将 product provider group plan 迁入 `bitfun-tool-packs`;core tool runtime 保留 concrete tool materialization、manifest/exposure product facade、snapshot decorator 和 `dyn Tool` 适配,并通过 boundary check 禁止重新拥有 `IndexMap` 工具容器、dynamic metadata map,或绕过 provider contract 回到散落手工注册。 - PR 1 已开始执行:remote-SSH workspace registry / ambiguous root resolution / legacy state snapshot 已迁入 `bitfun-services-integrations::remote_ssh::RemoteWorkspaceRegistry`,core 仅保留 local assistant path guard 与 SSH manager / file service / terminal manager 组装;announcement state persistence 已迁入 `bitfun-services-integrations::announcement::AnnouncementStateStore`,core 旧 `PathManager` 构造 API 继续委托并映射原错误类型。 - 本批 dependency profile 基线已验证: - `cargo tree -p bitfun-core-types --depth 1 --edges features` 运行时依赖仅显示 `serde`,测试依赖显示 `serde_json`。 @@ -1588,7 +1727,7 @@ P2 后产品表面契约轨道(contract-only): 需要单独审视的高风险项: -- `ToolUseContext`、tool exposure / manifest / `GetToolSpec`、product tool provider assembly、concrete tool implementation 外移。 +- `ToolUseContext`、runtime manifest assembly / `GetToolSpec` 执行、product tool provider assembly、concrete tool implementation 外移。 - MCP concrete tool implementation / product registry / manifest assembly 外移。 - remote-connect、remote-SSH runtime、announcement runtime 外移。 - miniapp runtime/compiler/builtin 与 function-agent 运行逻辑外移。 @@ -1613,17 +1752,61 @@ P2 后产品表面契约轨道(contract-only): **剩余工作压缩为 5 个 PR(2026-05-13):** 1. `services-integrations` runtime 收口:迁移 remote-SSH 中不直接持有 SSH channel / SFTP / terminal handle 的 workspace registry、session mirror 与轻量 runtime helper;继续保留 SSH manager / remote FS / remote terminal 的 core-owned assembly,直到 port/provider 合约明确。`file-watch` 已由 `services-integrations` 拥有,只做 contract 复核;announcement 只迁移不依赖 config service / embedded content / remote fetch 的 state 或 eligibility helper。验收重点是 owner crate contract test、旧路径 facade、boundary check、workspace check/test。 -2. 已完成:MCP runtime 与 dynamic tools:MCP config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider 已归属 `bitfun-services-integrations`;未混入 remote-connect 或 product tool manifest/exposure owner 化。验收重点是 MCP wire shape、auth/config merge、dynamic manifest 快照和 core registry / manifest 集成等价。 +2. 已完成:MCP runtime 与 dynamic tools:MCP config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider 已归属 `bitfun-services-integrations`;未混入 remote-connect 或 product tool runtime manifest / `GetToolSpec` 执行 owner 化。验收重点是 MCP wire shape、auth/config merge、dynamic manifest 快照和 core registry / manifest 集成等价。 - 保留边界:`bitfun-core` 只保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和与全局 tool registry / manifest 的组装调用;配置写入、OAuth、SSE/session 与 registry / manifest 行为不得在本 PR 中改变。 - 后续切片:MCP concrete tool integration / product registry / manifest assembly 继续保留 dynamic provider metadata、工具清单顺序、expanded/collapsed exposure 和 snapshot wrapper 等价测试。 - 文档校正:P2 后补充文档中的 MCP runtime step 已由本 PR2 闭环;后续 MCP 相关工作只保留 concrete tool implementation 迁移或 product registry / manifest assembly,不再重复迁移 config/process/transport lifecycle。 3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、`ImageContextData` adapter、file IO/path resolution、dispatcher/product execution。 - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 `ImageContextData` adapter,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 -4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / dynamic provider container 已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;core tool runtime 保留产品完整工具列表、snapshot decorator、`dyn Tool` 适配、tool exposure / manifest resolution 和 `GetToolSpec` 按需工具说明发现。`ToolUseContext`、tool manifest/exposure 与 concrete tool implementation 按 feature group 外移需要新的 port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 +4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / static-provider / dynamic-provider container、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、`PortableToolContextProvider` 只读 facts provider、纯 manifest/exposure 契约、generic contextual manifest resolver,以及 GetToolSpec presentation/schema/detail/runtime facade helper 已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;`tool-packs` 现在提供 feature-group 元数据和 product provider group plan,但不注册或迁移具体工具;core tool runtime 保留 concrete tool materialization、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` 适配、product registry snapshot access、agent policy 和 `GetToolSpec` 执行。`ToolUseContext`、`GetToolSpec` 执行与 concrete tool implementation 按 feature group 外移需要新的 service port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 5. `product-domains` runtime + core facade finalization:迁移 miniapp runtime/compiler/builtin 与 function-agent 运行逻辑,最后把 `bitfun-core` 收敛为 facade + product runtime assembly;不在本 PR 中修改 `bitfun-core default = []` 或 per-product feature matrix。 `bitfun-core default = []`、per-product feature set、构建矩阵和 release 能力调整仍作为重构完成后的独立评估,不计入上述 5 个 PR。 +**低风险准备 PR 合并锁定(2026-05-19):** + +后续不再把低风险准备工作拆成 4 个小 PR。当前 product-domain owner-helper PR +合入后,所有尚未进入 runtime owner 外移的低风险事项统一合并为 1 个准备 PR:`LR1`。 +`LR1` 之后才开始高风险 runtime 迁移;如果发现 `LR1` 范围不准确,必须先更新本节并说明原因, +不得在开发过程中临时拆碎或扩张 PR 边界。 + +| 序号 | 里程碑 | 必须完成的范围 | 不允许混入 | 退出条件 | +|---|---|---|---|---| +| LR1 | low-risk closure before runtime migration | `BitFunError` 剩余 concrete wrapper 处理决策、后续 shared DTO 归属校准、services-core/services-integrations 悬空 port/call-site 状态复核、tool runtime port/provider 设计与 manifest 等价基线、MiniApp/function-agent runtime 迁移前 owner 审视、P3 facade/boundary 文档与 AGENTS 校准 | `ToolUseContext` / concrete tools / MiniApp IO / function-agent Git/AI / remote-connect dialog 等 runtime owner 外移、`default = []`、feature matrix、构建收益宣传 | 文档中 Plan 3/5/6/7/8/9 的未完成项要么完成,要么显式标为 deferred/core-owned;boundary check、diff check 和对应最小 Rust check 通过 | +| H1 | high-risk tool runtime migration | `ToolUseContext`、runtime manifest assembly / `GetToolSpec` 执行、concrete tool implementation 或 product registry / provider assembly 的单一 owner 迁移 | MiniApp/function-agent runtime、remote-connect runtime、feature matrix | 完整产品 tool registry、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapper 与 Deep Review tool flow 等价可证明 | +| H2 | high-risk product-domain runtime migration | MiniApp runtime/manager/host/exporter/builtin 或 function-agent runtime 的单一 owner 迁移;不能安全外移的路径必须显式保留 core-owned runtime | tool runtime、remote-connect runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;IO/process/Git/AI 边界清晰;MiniApp/function-agent focused regression 通过 | +| H3 | high-risk service/runtime migration, only if still required | remote-connect dialog submission、terminal pre-warm、file IO/path resolution、`ImageContextData` adapter、remote-SSH runtime 或 agent registry/scheduler 等剩余 runtime owner,按单一 owner 独立评审 | tool/product-domain runtime、feature matrix、产品逻辑变更 | 有 port/provider 设计、旧路径兼容和产品等价测试;若决定保留 core-owned runtime,文档必须闭环 | +| H4 | facade and boundary finalization | `bitfun-core` 收敛为 legacy facade + full product runtime assembly;boundary script、AGENTS、architecture/plan docs 与当前代码一致;最终确认没有悬空的“已完成错觉” | 新 runtime 外移、默认 feature 改动 | 文档主线 checklist 闭环;所有 deferred/core-owned 项有明确 owner、测试或后续评估入口 | +| H5 | optional default feature / build-benefit evaluation | 仅在 LR1 与必要的 H1-H4 后评估 `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益 | 任何 runtime owner 迁移或产品逻辑变更 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据;可选择不执行 | + +**H2 closure(2026-05-21):** H2 以 function-agent prompt/response policy 作为单一 owner +迁移主题闭环;MiniApp filesystem IO、worker process、host dispatch、built-in asset seeding / +marker IO / recompile,以及 function-agent Git service / AI client 调用仍显式 core-owned。 +这些路径不是“未注意到”,而是因行为边界和外部副作用风险保留到后续单独评审。 + +本节解释“为什么统计总是 4-5 个”:之前把低风险准备事项拆成 R1-R4,导致每次都看起来还剩 +4 个小 PR;这些准备事项已统一为 `LR1` 并在 2026-05-19 闭环。默认回答必须是: +低风险准备已完成,随后进入高风险 runtime 迁移队列;`H5` 仍是独立可选评估项。 + +**LR1 闭环结果(2026-05-19):** + +- `BitFunError` / `BitFunResult` 不迁移:`serde_json::Error`、`anyhow::Error`、 + `std::io::Error` 与 `From` 兼容仍属于 core-owned error boundary。未来如要移动, + 必须单独选择字符串化 wrapper 或给 `core-types` 引入轻量 error 依赖,并先补兼容测试。 +- 后续 shared DTO 不做批量移动;只能按单个 owner/DTO 在对应 PR 中确认依赖方向。 +- `services-core` / `services-integrations` 的剩余 search、lsp、cron、snapshot、 + remote-connect dialog 与 remote-SSH runtime 均显式 deferred/core-owned;MCP runtime + 已完成的部分不再重复规划。 +- `agent-tools` / `tool-packs` 只承载纯契约、provider metadata、generic/static/dynamic + provider container 与 feature-group scaffold;`ToolUseContext`、runtime manifest + assembly、`GetToolSpec` 执行、collapsed unlock state 和 concrete tools 进入 H1。 +- MiniApp 与 function-agent 的纯 DTO/helper/port facade 已归属 `product-domains`; + H2 已迁移 function-agent prompt template、JSON extraction 与 domain error mapping policy。 + filesystem IO、process/Git/AI 调用、host dispatch、built-in asset seeding/marker IO + 与 recompile orchestration 仍显式保留 core-owned,后续只允许按单一 owner 重新评审。 +- `bitfun-core` 依赖裁剪、`default = []`、per-product feature matrix 与构建收益宣传 + 不属于 LR1;只在 H4/H5 且有 feature graph baseline 后评估。 + ### 里程碑三:facade 收敛、边界强制与可选默认轻量化评估 **覆盖计划:** @@ -1648,16 +1831,21 @@ P2 后产品表面契约轨道(contract-only): - `default = []` 必须是单独 PR,且只在所有产品 crate 显式启用完整 runtime 后评估。 - 不允许把 facade 变成新的业务实现聚合。 -**P3 进入条件与最新主干补充(2026-05-15):** +**P3 进入条件与最新主干补充(2026-05-19):** -- P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / tool exposure / manifest / `GetToolSpec` / concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry/provider container 已在 PR4 中完成低风险外移。 +- P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / runtime manifest assembly / `GetToolSpec` 执行 / concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry / static-provider / dynamic-provider container、generic catalog snapshot provider / GetToolSpec catalog provider、纯 manifest/exposure 契约和 GetToolSpec presentation/schema/detail helper 已在 agent-tools 低风险外移中完成。 - 最近 `origin/main` 的 Deep Review 变更增加了 context profile、evidence ledger、capacity/cost/queue 控制、`deep_review_run_manifest` / `deep_review_cache`、以及 review-team UI orchestration;最新主干还补充了 agent-stream tool-call dedupe、search remote/fallback、session rollback persistence、remote workspace compatibility guard、ACP startup timeout / operation diff fallback 和 companion typewriter。P3 facade 收敛前必须确认这些行为要么仍由 core product runtime assembly 或对应 product surface 拥有,要么已有对应 owner crate + port/provider 合约和等价测试。 -- 最新主干的 mode-scoped subagent visibility 将 `agentic::agents` 重组为 definitions / registry / visibility 边界,并扩展了 desktop subagent API 与 Review Team 可见性测试;后续若迁移 agent registry,不能只做路径 re-export,必须保留 mode 可见性过滤、hidden/custom/review 分组语义和前后端 API contract。 +- 最新主干的 mode-scoped subagent visibility 将 `agentic::agents` 重组为 definitions / registry / visibility 边界,并扩展了 desktop subagent API、CLI `/subagents` mode-aware list/config 与 Review Team 可见性测试;后续又加入 `Multitask` mode、内置 `GeneralPurpose` subagent、`SubagentSessionLinked` routing 和后台 subagent result delivery。后续若迁移 agent registry / subagent definitions / scheduler,不能只做路径 re-export,必须保留 mode 可见性过滤、hidden/custom/review 分组语义、CLI availability override 路径、前后端 API contract、`Task.run_in_background` 的 parent metadata / workspace routing、running-turn injection 与 idle-session follow-up turn 语义。 - 最新主干的 DeepResearch citation renumber hook 是 deterministic post-turn runtime 行为,不是普通 prompt 文案;后续若迁移 agent runtime / report finalization,必须保留 `report.md`、`citations.md`、`display_map.json` 与 REJECTED citation 过滤语义。 -- 最新主干的 on-demand tool spec discovery 将 `ToolExposure`、`manifest_resolver`、`GetToolSpec`、collapsed-tool prompt stub 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移。 +- 最新主干的 on-demand tool spec discovery 将 `manifest_resolver`、`GetToolSpecTool` Tool impl / `BitFunError` 映射、product collapsed-tool catalog 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移;`ToolExposure`、`GetToolSpec` 名称、collapsed-tool prompt stub、generic collapsed exposure 查询、manifest ordering、generic GetToolSpec catalog provider 和 provider-backed result-vector facade 仅作为 provider-neutral 契约保留在 `bitfun-agent-tools`。 - 最新主干的 search result rendering / context handling 与 remote workspace compatibility guard 要求后续 `service::search`、`workspace` 或 remote runtime 迁移保留 startup restored workspace guard、remote runtime ensure、remote flashgrep FilesWithMatches fallback、preview split 和 local/remote fallback contract。 - ACP startup timeout 和 Web file-operation diff fallback 属于 product surface 行为:可以在后续 contract 中记录 operation/diff facts,但不能把 ACP timeout policy 或 Web diff rendering 迁入 core contract crate。 -- 最新主干的 CLI 重构主要新增 TUI/theme/selector/dialog/chat-state 等 app-layer 代码,后续又收敛预置 theme 并补充 desktop companion pet resize / Windows UX;这些当前没有改变 `services-integrations` 的迁移归属。后续若调整 shared crate 边界,必须继续把 `bitfun-cli`、`ratatui`、`crossterm`、`arboard`、`syntect-tui` 等 CLI-only 依赖限制在 app adapter / presentation layer,desktop / web-ui presentation 修复也不应被误判为 core service 迁移前置条件。 +- 最新主干的 ACP agents config 继续把 remote workspace config reuse 放在 ACP/app surface:remote workspaces 复用 local ACP config,ACP client manager / remote shell / remote capability store / workspace menu 共同决定可用 agent。后续只能抽取 environment/capability facts;ACP config persistence、remote probing 和 workspace surface selection 不进入 core contract crate。 +- 最新主干的 usage/cache token 与 OpenAI Responses 修复要求后续 `agent-stream`、`session_usage`、runtime budget 或 tool schema 迁移保留 provider adapter 语义:`cached_content_token_count` 是 cache reads/hits,`cache_creation_token_count` 与 DeepSeek `prompt_cache_hit_tokens` 不得被合并;Responses / Codex ChatGPT flat tool schema 归 AI adapter serialization,不归 provider-neutral tool manifest contract。 +- 最新主干的 Web 启动性能优化新增 startup trace、deferred background scheduler、narrow tool initializer、Monaco warmup 与历史会话非阻塞 hydrate;这些属于 web app / Flow Chat product surface,不是 core service 迁移前置条件。后续只能通过 web product checks 验证,不得把 `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core-types / runtime-ports / agent-tools。 +- 最新主干的 CLI 重构主要新增 TUI/theme/selector/dialog/chat-state 等 app-layer 代码,后续又收敛预置 theme、增加 mode-aware subagent management,并补充 desktop companion pet resize / Windows UX;这些当前没有改变 `services-integrations` 的迁移归属。后续若调整 shared crate 边界,必须继续把 `bitfun-cli`、`ratatui`、`crossterm`、`arboard`、`syntect-tui` 等 CLI-only 依赖限制在 app adapter / presentation layer,desktop / web-ui presentation 修复也不应被误判为 core service 迁移前置条件。 +- 最新 desktop close button 默认最小化到 system tray 属于 desktop lifecycle surface;后续 desktop app lifecycle / window state 调整只能通过 desktop product check 验证,不作为 core service owner 外移前置条件。 +- 最新主干的内置 PR Review MiniApp 通过 core asset include、customization metadata IO、marker IO 与 update marker seed 到用户数据目录;它复用 `product-domains` 的 built-in bundle/hash/marker seed-decision contract,但 builtin asset seeding / customized update runtime / recompile orchestration 仍显式 core-owned,迁移前必须保留这些行为的等价测试。 - P2 后产品表面策略要求“surface divergence, capability convergence”:CLI `/diff`、Desktop 快捷键/面板、Remote card、ACP method 可以映射到同一 capability contract,但不能为了复用把 surface command 或 UI rendering 下沉到 contract crate。 - `ToolUseContext` 的 shared-context / evidence checkpoint hook、`TaskTool` / `CodeReviewTool` 的 Deep Review capacity flow、session manifest/cache persistence、rollback persisted-turn cleanup、search fallback chain 与 stream finish/tool-call contract 不能在 P3 中只通过 re-export 消失;如果外移,需要先补 boundary contract、旧路径兼容和对应 regression。 - P3 的闭环检查应同时覆盖 Rust crate graph 与产品 runtime 行为:边界脚本只证明依赖方向,不能替代 Deep Review、MCP dynamic tools、tool manifest / `GetToolSpec`、remote connect、snapshot wrapping、miniapp/function-agent 的产品等价性验证。 @@ -1667,17 +1855,17 @@ P2 后产品表面契约轨道(contract-only): - 当前分支保持单一主题:在 PR2 owner closure 后补关键语义回归 baseline;不移动 runtime owner,不调整产品表面命令/UI,也不改变 CLI、Desktop、Remote、ACP 的运行语义。 - `core-decomposition-implementation-review.md` 的合理建议已纳入当前护栏:ownership target 必须区分 `done` / `partial` / `target` / `deferred`,`bitfun-core/product-full` 目前只是阶段性 capability guardrail,不是最终 feature matrix;boundary script 是必要下限,不能替代行为级回归。 -- 本次 rebase 到最新 `upstream/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变本轮 guardrail PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface 与 tool runtime 外移的等价性门槛抬高。 +- 本次 rebase 到最新 `gcwing/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、PR #766 ACP config reuse、PR #774 usage/cache 与 Responses schema 修复、PR #776 desktop close-to-tray 默认值、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变当前文档护栏 PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface、AI usage/adapter 与 tool runtime 外移的等价性门槛抬高。 - 质量边界:本阶段证明已拆 owner crate 不依赖回 `bitfun-core`,并新增关键语义 baseline 约束 MCP config failure / catalog replacement invalidation / dynamic manifest、tool manifest / `GetToolSpec` collapsed exposure、MiniApp storage layout adapter 等价和 remote search scan-fallback retry gate;不声明 remote connect、`ToolUseContext`、concrete tool implementation、MiniApp IO / worker runtime 或 function-agent runtime 的外移完成。 - boundary check 已扩展到 `core-types`、`runtime-ports` 和 `agent-tools` 的轻量边界,并覆盖 Cargo inline 依赖和 dependency table 依赖声明,后续不能绕过脚本把重 runtime、concrete service、platform adapter 或 CLI/TUI presentation 依赖带入这些 contract crate。 -- boundary check 现在同时锁定 latest-main owner anchor:mode-scoped subagent availability、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout 和 Web operation diff fallback。后续真正迁移这些 owner 时必须先补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 +- boundary check 现在锁定已纳入脚本的 latest-main owner anchor:mode-scoped subagent availability、`Multitask` / `GeneralPurpose` registration、background subagent delivery、CLI subagent management surface、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout、Web startup/history hydration、Web operation diff fallback 和 built-in MiniApp seed/update path。2026-05-19 新增识别的 remote ACP config reuse、AI usage/cache semantics、Responses flat tool schema adapter boundary 与 desktop close-to-tray surface 先作为后续迁移的复核清单;真正迁移这些 owner 时必须补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 - boundary check 也已锁定 `bitfun-core::service::git`、`bitfun-core::service::remote_ssh::types`、remote-SSH workspace path/identity/unresolved-key helper、MiniApp storage layout、`bitfun-core::service::mcp::{tool_info,tool_name}`、`bitfun-core::service::mcp::protocol::{types,jsonrpc}`、`bitfun-core::service::mcp::config::{location,cursor_format,json_config,service_helpers}`、`bitfun-core::service::mcp::server::config`、`bitfun-core::service::mcp::auth` 和 `bitfun-core::service::announcement::types` 的旧路径 facade-only / 禁止回流状态,并禁止在 `MCPServerProcess` runtime 文件重新定义已外移的 server type/status contract、auth error classifier 和 legacy remote header fallback helper,也禁止在 remote transport 重新实现 Authorization 归一化、client capability 构造和 rmcp result mapping;本轮新增禁止 core registry 重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 - 后续迁移必须拆成可独立审核的提交:先补 port/provider 设计和等价测试;`remote-connect` 完整 runtime、`ToolUseContext` / concrete tool implementation、product-domain runtime 必须一次迁移一个 owner 主题。 - concrete tool implementation 或 product registry / manifest assembly 外移必须先有工具清单和 manifest 等价测试,并保留 dynamic provider metadata;不能把注册名解析、snapshot wrapper 或 runtime restriction 行为改成隐式约定。 - 已新增并扩展内置工具清单基线测试,后续迁移 `ToolUseContext`、concrete tool implementation、tool manifest/exposure 或 product registry / manifest assembly 必须先保持该清单、注册顺序、runtime collection 顺序、expanded/collapsed exposure、`GetToolSpec` unlock state、dynamic provider metadata 顺序和修改类工具 snapshot wrapper 等价,再评估 owner crate 边界。 - miniapp 与 function-agent runtime 外移必须先明确 Git/AI service、PathManager、process execution 和 permission policy 边界;如果需要行为合约变化,必须作为后续单独 PR 并先确认。 - 产品表面 contract 补强必须保持 observational:只记录 surface/thread/environment/permission/artifact facts,不改变 CLI、Desktop、Remote、ACP 的现有交互和运行时语义。 -- 当前 PR3 已补齐关键语义回归 baseline:MCP config failure 作为空配置基线但写入失败继续上抛、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest metadata/order snapshot、tool manifest / `GetToolSpec` snapshot、product-domains pure helper 与 core adapter 等价、remote workspace search fallback focused test。 +- 已合入的 PR3 已补齐关键语义回归 baseline:MCP config failure 作为空配置基线但写入失败继续上抛、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest metadata/order snapshot、tool manifest / `GetToolSpec` snapshot、product-domains pure helper 与 core adapter 等价、remote workspace search fallback focused test。 - 再之后才进入 owner-by-owner baseline:每个 runtime owner 迁移前先列出当前行为、输入输出、feature graph 和验证命令;迁移后先证明行为等价,再考虑删除 legacy path。 - `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益优化仍是后续独立评估项,不与 runtime 外移或构建脚本调整混在同一批提交。 @@ -1719,18 +1907,32 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 5. 已完成:抽取 `bitfun-agent-stream`,迁移 stream processor 测试。 6. 已完成:引入 runtime ports 初始边界;后续在 service 迁移中逐步打断 `service <-> agentic` concrete 循环。 7. 已完成:抽取 `bitfun-services-core`。 -8. 已完成:抽取 `bitfun-services-integrations` 的低风险 feature group 和纯 helper,闭环 `git`、remote-SSH contract/helper、MCP 纯 protocol/config/auth helper;MCP runtime / dynamic provider 已在 PR2 补齐,未把 remote-connect 或 product tool manifest/exposure owner 化顺带迁入。 +8. 已完成:抽取 `bitfun-services-integrations` 的低风险 feature group 和纯 helper,闭环 `git`、remote-SSH contract/helper、MCP 纯 protocol/config/auth helper;MCP runtime / dynamic provider 已在 PR2 补齐,未把 remote-connect 或 product tool runtime manifest / `GetToolSpec` 执行 owner 化顺带迁入。 9. 已完成:前移低风险保护项:dependency profile / feature graph 基线、轻量 contract crate 依赖瘦身、feature group 说明、boundary check 扩展、迁移前快照测试。 10. 已提交:PR 1 `services-integrations` runtime 收口,处理 remote-SSH workspace registry / session mirror helper 和已迁移 file-watch 的 contract 复核;announcement 仅迁移无 config/content/remote fetch 依赖的 helper。 11. 已提交:PR 2 `Services/Product Runtime Owner Closure`,收口 remote-SSH session identity / mirror path / unresolved-session layout 与 MiniApp storage file layout owner;core 保留 `PathManager` 注入、SSH manager、remote FS / terminal、MiniApp filesystem IO 和 worker runtime。 12. 历史已完成:MCP runtime 与 dynamic tools;已迁移 config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider,core 保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade 和 product registry / manifest assembly。 13. P2 后前置轨道:产品表面 contract-only 补强,可在后续 PR 第一组提交中处理;只允许 DTO/port、round-trip/no-op tests 和 boundary check,不实现 CLI/Desktop/Remote/ACP UI 或命令变更。 14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 `bitfun-core` product runtime assembly。 -15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、generic registry / dynamic provider container 已归属 `bitfun-agent-tools`,core 保留产品工具列表、snapshot decorator、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 port/provider 设计。 +15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、portable context facts/provider、generic registry / static provider installation / dynamic provider container 已归属 `bitfun-agent-tools`,`tool-packs` 提供 feature-group scaffold 和 product provider group plan,core 保留 concrete tool materialization、product snapshot wrapper adapter、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 service port/provider 设计。 16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已迁入 `bitfun-services-integrations`。dispatcher / product execution、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 -18. 后续高风险单独审视:`product-domains` runtime + core facade finalization 的剩余 PathManager、process execution、Git/AI service、prompt template、host dispatch 执行与 worker/storage IO owner 迁移;不得与当前 PR2/PR3 混合。 -19. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 +18. 已完成:`product-domains` runtime port/facade closure。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 +19. 已合入:tool runtime owner 迁移前置基线。纯 helper 已从 runtime owner 中剥离到 `bitfun-agent-tools`:`StaticToolProviderGroup`、registry snapshot 到 manifest policy input、generic collapsed exposure 查询、`GetToolSpec` collapsed-load 纯收集规则、prompt-visible manifest definition 组装规则和 `GetToolSpec` catalog/detail helper;完整 collapsed 工具清单、runtime context 传递和 portable facts 边界回归已作为迁移前基线。 +20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntimeAssembly` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 +21. 已合入:provider/assembly equivalence guards。custom decorator、provider install、collapsed catalog、manifest / `GetToolSpec` unlock surface 已有等价保护;这些是 behavior-locking tests,不是 runtime owner 迁移。 +22. 已合入:`ToolUseContext` portable facts owner guard。可外移 facts 与 runtime-only fields 已分界;collapsed unlock state、custom data、workspace services、cancellation token、computer-use host 与 Deep Review checkpoint hook 继续 core-owned。 +23. 已合入:`GetToolSpec` unlock state guard。execution 侧只接受成功的 `GetToolSpec` 结果、只输出 collapsed 白名单工具,并保持去重和过滤语义;`GetToolSpecTool` 执行、runtime manifest assembly、`ToolUseContext` 和 concrete tools 不迁移。 +24. 已合入:contextual manifest owner migration。context-aware prompt manifest / visible-tools resolution 通用算法、generic catalog snapshot provider、generic GetToolSpec catalog provider 和 `GetToolSpec` collapsed summary/detail helper 已迁入 `bitfun-agent-tools`;core 仍保留 product registry snapshot access、product collapsed catalog source、core `Tool` / `ToolUseContext` adapter 与旧路径返回类型。 +25. 已合入:product tool adapter/catalog facade closure。`tool_adapter.rs` 承接 core `Tool` 到 provider-neutral contract 的 adapter,`catalog_provider.rs` 承接 product registry snapshot、contextual catalog、manifest、GetToolSpec catalog-description/detail provider facade;`manifest_resolver` 和 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 与 assistant result rendering。 +26. 已完成:H1 GetToolSpec runtime facade owner slice。`bitfun-agent-tools` 接管 static tool surface(name / description / schema / readonly / concurrency / permission / validation / tool-use message)、input extraction、duplicate-load planning、duplicate-load result、provider-backed detail lookup 到 result assembly 的组装规则和 typed execution error;core 继续持有 `GetToolSpecTool` Tool impl、`ToolUseContext.unlocked_collapsed_tools` 状态来源、product provider/context 注入和 `BitFunError` 映射;该阶段不迁移 runtime manifest assembly、unlock state owner、snapshot decorator 或 concrete tools。 +27. 已完成:H1 generic runtime assembly owner slice。`bitfun-agent-tools` 接管 static-provider 安装 assembly 的通用顺序、decorator reference contract、generic snapshot decorator adapter 与 decorator 应用规则;core `ProductToolRuntimeAssembly` 继续持有 concrete provider group 来源、product snapshot wrapper adapter 注入、旧路径 `ToolRegistry` wrapper、product registry snapshot access 和 dynamic MCP tool entry,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、runtime manifest facade、unlock state owner、snapshot wrapper implementation 或 concrete tools。 +28. 已完成:H1 readonly filter owner slice。`bitfun-agent-tools` 接管 registry snapshot 上 readonly + enabled 过滤的通用规则;core 继续持有 product snapshot access、`dyn Tool` adapter 和各 concrete tool 的 readonly/enabled 判定,不迁移 `ToolUseContext`、runtime manifest facade、`GetToolSpecTool`、snapshot wrapper implementation 或 concrete tools。 +29. 已完成:H1 tool catalog runtime facade slice。`bitfun-agent-tools::ToolCatalogRuntime` 接管 provider-backed visible-tools、prompt-visible manifest 与 readonly enabled catalog 查询入口;core 继续持有 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 +30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `catalog_provider.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 +31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `static_providers.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 +32. H1 闭环状态:工具 runtime 的 provider-neutral contract、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 +33. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index a8fa105063..0b686659cf 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -341,6 +341,14 @@ const forbiddenContentRules = [ regex: /\bpub struct ToolPathResolution\b/, message: 'core tool framework must not redefine ToolPathResolution; use bitfun-agent-tools', }, + { + regex: /\bpub struct ToolContextFacts\b/, + message: 'core tool framework must not redefine ToolContextFacts; use bitfun-agent-tools', + }, + { + regex: /\bpub enum ToolWorkspaceKind\b/, + message: 'core tool framework must not redefine ToolWorkspaceKind; use bitfun-agent-tools', + }, ], }, { @@ -491,6 +499,41 @@ const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', + patterns: [ + { + regex: /\bGitService::get_status\b/, + message: + 'Git function-agent commit generator must use CoreFunctionAgentGitAdapter through FunctionAgentRuntimeFacade', + }, + { + regex: /\bAIAnalysisService::new_with_agent_config\b/, + message: + 'Git function-agent commit generator must use CoreFunctionAgentAiAdapter through FunctionAgentRuntimeFacade', + }, + { + regex: /\bto_string_lossy\b/, + message: + 'Git function-agent commit generator must preserve PathBuf paths when routing through the facade', + }, + ], + }, + { + path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', + patterns: [ + { + regex: /\bAIWorkStateService::new_with_agent_config\b/, + message: + 'Startchat work-state analyzer must use CoreFunctionAgentAiAdapter through FunctionAgentRuntimeFacade', + }, + { + regex: /\bcreate_command\("git"\)/, + message: + 'Startchat work-state analyzer must use CoreFunctionAgentGitAdapter through FunctionAgentRuntimeFacade', + }, + ], + }, { path: 'src/crates/core/src/service/mcp/server/config.rs', patterns: [ @@ -1004,16 +1047,12 @@ const forbiddenContentUnderRules = [ { path: 'src/crates/agent-tools/src', reason: - 'agent-tools must not own product tool manifest/exposure or GetToolSpec runtime without an approved provider migration', + 'agent-tools may own pure tool manifest contracts, but not product manifest runtime or GetToolSpec execution without an approved provider migration', patterns: [ { regex: /\bGetToolSpecTool\b/, message: 'GetToolSpec implementation stays in core product tool runtime', }, - { - regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, - message: 'GetToolSpec manifest insertion stays in core product tool runtime', - }, { regex: /\bmanifest_resolver\b/, message: 'tool manifest resolution stays in core product tool runtime', @@ -1022,10 +1061,6 @@ const forbiddenContentUnderRules = [ regex: /\bunlocked_collapsed_tools\b/, message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', }, - { - regex: /\bToolExposure\b/, - message: 'expanded/collapsed exposure policy stays in core until provider migration', - }, { regex: /\bToolUseContext\b/, message: 'ToolUseContext stays in core until a portable context port is reviewed', @@ -1035,7 +1070,7 @@ const forbiddenContentUnderRules = [ { path: 'src/crates/tool-packs/src', reason: - 'tool-packs must not own product tool manifest/exposure or GetToolSpec runtime without an approved provider migration', + 'tool-packs may own provider group plans, but not product tool manifest/exposure or GetToolSpec runtime', patterns: [ { regex: /\bGetToolSpecTool\b/, @@ -1086,454 +1121,1783 @@ const requiredContentRules = [ ], }, { - path: 'src/crates/core/src/agentic/coordination/coordinator.rs', + path: 'src/crates/agent-tools/src/framework.rs', reason: - 'core must keep current coordinator port adapters and attachment guard until remote runtime migration is reviewed', + 'agent-tools may own pure and generic prompt-visible tool manifest contracts without owning product registry or execution', patterns: [ { - regex: /impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator/, - message: 'missing agent submission port adapter', + regex: /\bpub const GET_TOOL_SPEC_TOOL_NAME\b/, + message: 'missing shared GetToolSpec manifest name contract', }, { - regex: /impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator/, - message: 'missing session transcript reader adapter', + regex: /\bpub enum ToolExposure\b/, + message: 'missing lightweight tool exposure contract', }, { - regex: /impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator/, - message: 'missing turn cancellation port adapter', + regex: /\bpub struct ToolManifestPolicyTool\b/, + message: 'missing pure tool manifest policy input contract', }, { - regex: /impl bitfun_runtime_ports::RemoteControlStatePort for ConversationCoordinator/, - message: 'missing remote control state port adapter', + regex: /\bpub fn resolve_tool_manifest_policy\b/, + message: 'missing pure tool manifest policy resolver', }, { - regex: /agent submission port does not yet accept generic attachments/, - message: 'missing generic attachment guard on agent submission port', + regex: /\bfn default_exposure\b/, + message: 'missing generic tool exposure contract', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/registry.rs', - reason: - 'core must continue owning product tool registry assembly until an approved product-provider migration exists', - patterns: [ { - regex: /\bfn register_all_tools\b/, - message: 'missing product tool registration owner', + regex: /\bpub fn build_tool_manifest_policy_tools\b/, + message: 'missing registry snapshot to manifest policy input helper', }, { - regex: /\bGetToolSpecTool::new\(\)/, - message: 'missing GetToolSpec registration anchor', + regex: /\bpub fn build_collapsed_tool_stub_definition\b/, + message: 'missing collapsed-tool prompt stub contract', }, { - regex: /\bget_collapsed_tool_names\b/, - message: 'missing collapsed-tool catalog owner', + regex: /\bpub enum PromptVisibleToolManifestItem\b/, + message: 'missing prompt-visible manifest item contract', }, { - regex: /\bToolExposure::Collapsed\b/, - message: 'missing collapsed exposure lookup', + regex: /\bpub fn build_prompt_visible_tool_manifest_definitions\b/, + message: 'missing prompt-visible manifest definition builder', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - reason: - 'core must continue owning prompt-visible tool manifest assembly until an approved provider migration exists', - patterns: [ { - regex: /\bpub async fn resolve_tool_manifest\b/, - message: 'missing tool manifest resolver owner', + regex: /\bpub trait ContextualToolManifestItem\b/, + message: 'missing generic contextual manifest item adapter contract', }, { - regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, - message: 'missing GetToolSpec manifest insertion anchor', + regex: /\bpub trait ToolCatalogSnapshotProvider\b/, + message: 'missing generic tool catalog snapshot provider contract', }, { - regex: /\bToolExposure::Collapsed\b/, - message: 'missing collapsed exposure branch', + regex: /\bpub trait GetToolSpecCatalogProvider\b/, + message: 'missing generic GetToolSpec catalog provider contract', }, { - regex: /\bcollapsed_tool_names\b/, - message: 'missing collapsed-tool name tracking', + regex: /\bpub struct ContextualVisibleTools\b/, + message: 'missing generic contextual visible-tools result contract', }, { - regex: /Call `GetToolSpec` first/, - message: 'missing collapsed-tool prompt stub', + regex: /\bpub struct ContextualToolManifest\b/, + message: 'missing generic contextual tool manifest result contract', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', - reason: - 'core must continue owning GetToolSpec runtime until an approved provider migration exists', - patterns: [ { - regex: /\bpub struct GetToolSpecTool\b/, - message: 'missing GetToolSpec owner type', + regex: /\bpub async fn resolve_contextual_visible_tools\b/, + message: 'missing generic contextual visible-tools resolver', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool duplicate-load guard', + regex: /\bpub async fn resolve_contextual_tool_manifest\b/, + message: 'missing generic contextual tool manifest resolver', }, { - regex: /\balready_loaded\b/, - message: 'missing duplicate-load assistant result contract', + regex: /\bpub async fn resolve_contextual_visible_tools_from_provider\b/, + message: 'missing provider-backed contextual visible-tools resolver', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/framework.rs', - reason: - 'core must continue owning ToolUseContext and exposure policy until a portable context port is reviewed', - patterns: [ { - regex: /\bpub enum ToolExposure\b/, - message: 'missing ToolExposure owner type', + regex: /\bpub async fn resolve_contextual_tool_manifest_from_provider\b/, + message: 'missing provider-backed contextual manifest resolver', }, { - regex: /\bpub struct ToolUseContext\b/, - message: 'missing ToolUseContext owner type', + regex: /\bpub async fn build_get_tool_spec_catalog_description_from_provider\b/, + message: 'missing provider-backed GetToolSpec catalog description builder', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock state', + regex: /\bpub async fn resolve_get_tool_spec_detail_from_provider\b/, + message: 'missing provider-backed GetToolSpec detail resolver', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', - reason: - 'core must continue owning collapsed-tool execution gating until manifest/runtime migration is reviewed', - patterns: [ { - regex: /\bfn validate_collapsed_tool_usage\b/, - message: 'missing collapsed-tool execution gate', + regex: /\bpub fn build_get_tool_spec_description\b/, + message: 'missing pure GetToolSpec prompt description contract', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock state propagation', + regex: /\bpub struct GetToolSpecCollapsedToolSummary\b/, + message: 'missing pure GetToolSpec collapsed catalog summary', }, { - regex: /\bGetToolSpec\b/, - message: 'missing GetToolSpec gating contract', + regex: /\bpub struct GetToolSpecDetail\b/, + message: 'missing pure GetToolSpec detail contract', }, - ], - }, - { - path: 'src/crates/core/src/agentic/execution/execution_engine.rs', - reason: - 'core execution must continue carrying collapsed-tool unlock state and DeepResearch post-turn hooks until approved runtime migrations exist', - patterns: [ { - regex: /\bfn collect_unlocked_collapsed_tools\b/, - message: 'missing GetToolSpec result unlock collector', + regex: /\bpub fn summarize_get_tool_spec_collapsed_tools\b/, + message: 'missing pure GetToolSpec collapsed summary helper', }, { - regex: /\bcollapsed_tool_names\b/, - message: 'missing manifest collapsed-tool handoff', + regex: /\bpub async fn resolve_get_tool_spec_detail\b/, + message: 'missing generic GetToolSpec detail resolver', }, { - regex: /\bGetToolSpec\b/, - message: 'missing GetToolSpec execution contract', + regex: /\bpub fn build_get_tool_spec_catalog_description\b/, + message: 'missing pure GetToolSpec catalog description builder', }, { - regex: /\bcitation_renumber\b/, - message: 'missing DeepResearch citation renumber hook', + regex: /\bpub fn get_tool_spec_input_schema\b/, + message: 'missing pure GetToolSpec input schema contract', }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/availability.rs', - reason: - 'core agent registry must continue owning mode-scoped subagent availability until an approved agent-runtime migration exists', - patterns: [ { - regex: /\bpub fn resolve_availability\b/, - message: 'missing mode-scoped subagent availability resolver', + regex: /\bpub fn get_tool_spec_short_description\b/, + message: 'missing pure GetToolSpec short description contract', }, { - regex: /\bpub fn resolve_override_layers\b/, - message: 'missing project/user override layering contract', + regex: /\bpub fn render_get_tool_spec_tool_use_message\b/, + message: 'missing pure GetToolSpec tool-use message renderer', }, { - regex: /\bAgentSubagentOverrideState\b/, - message: 'missing subagent override state contract', + regex: /\bpub fn get_tool_spec_is_readonly\b/, + message: 'missing pure GetToolSpec readonly metadata contract', }, { - regex: /\bSubagentStateReason\b/, - message: 'missing frontend-visible availability reason contract', + regex: /\bpub fn get_tool_spec_is_concurrency_safe\b/, + message: 'missing pure GetToolSpec concurrency metadata contract', }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/registry/types.rs', - reason: - 'core agent registry must continue exposing subagent query and availability DTOs until registry ownership migrates with API equivalence tests', - patterns: [ { - regex: /\bpub struct SubagentQueryContext\b/, - message: 'missing subagent query context', + regex: /\bpub fn get_tool_spec_needs_permissions\b/, + message: 'missing pure GetToolSpec permission metadata contract', }, { - regex: /\bpub enum SubagentListScope\b/, - message: 'missing subagent list scope contract', + regex: /\bpub fn validate_get_tool_spec_input\b/, + message: 'missing pure GetToolSpec input validation contract', }, { - regex: /\bdefault_enabled\b/, - message: 'missing default availability field', + regex: /\bpub fn build_get_tool_spec_assistant_detail\b/, + message: 'missing pure GetToolSpec assistant detail rendering contract', }, { - regex: /\beffective_enabled\b/, - message: 'missing effective availability field', + regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, + message: 'missing pure GetToolSpec duplicate-load result assembly contract', }, { - regex: /\bpub enum SubagentStateReason\b/, - message: 'missing availability reason wire contract', + regex: /\bpub fn build_get_tool_spec_detail_result\b/, + message: 'missing pure GetToolSpec detail result assembly contract', }, - ], - }, - { - path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', - reason: - 'core DeepResearch runtime must continue owning citation renumber post-processing until agent-runtime migration is reviewed', - patterns: [ { - regex: /\bpub async fn run_for_session_workspace\b/, - message: 'missing DeepResearch citation hook entry point', + regex: /\bpub enum GetToolSpecExecutionPlan\b/, + message: 'missing pure GetToolSpec execution plan contract', }, { - regex: /\bpub async fn try_renumber_research_report\b/, - message: 'missing deterministic citation renumber implementation', + regex: /\bpub enum GetToolSpecExecutionError\b/, + message: 'missing pure GetToolSpec execution error contract', }, { - regex: /display_map\.json/, - message: 'missing citation display map sidecar contract', + regex: /\bpub fn resolve_get_tool_spec_execution_plan\b/, + message: 'missing pure GetToolSpec execution plan resolver', }, { - regex: /\bREJECTED\b/, - message: 'missing rejected-citation filtering contract', + regex: /\bpub async fn resolve_get_tool_spec_execution_result_from_provider\b/, + message: 'missing provider-backed GetToolSpec execution result resolver', }, - ], - }, - { - path: 'src/crates/core/src/service/workspace/service.rs', - reason: - 'core workspace runtime must continue owning startup remote-workspace guards until workspace service migration is reviewed', - patterns: [ { - regex: /\bprepare_startup_restored_workspaces\b/, - message: 'missing restored-workspace startup guard', + regex: /\bpub struct GetToolSpecRuntime\b/, + message: 'missing provider-backed GetToolSpec runtime facade', }, { - regex: /\bWorkspaceKind::Remote\b/, - message: 'missing remote workspace branch', + regex: /\bpub async fn call_results\b/, + message: 'missing provider-backed GetToolSpec Tool-result vector adapter facade', }, { - regex: /\bensure_remote_workspace_runtime\b/, - message: 'missing remote workspace runtime ensure call', + regex: /\bpub struct GetToolSpecLoadObservation\b/, + message: 'missing pure GetToolSpec load observation contract', }, { - regex: /\bsshHost\b/, - message: 'missing remote workspace host metadata contract', + regex: /\bpub fn collect_loaded_collapsed_tool_names\b/, + message: 'missing pure collapsed-tool load collection contract', }, - ], - }, - { - path: 'src/crates/core/src/service/search/service.rs', - reason: - 'core search runtime must continue owning local flashgrep fallback and preview mapping until search migration is reviewed', - patterns: [ { - regex: /\bwith_scan_fallback\b/, - message: 'missing flashgrep scan fallback request flag', + regex: /\bpub fn sort_tool_manifest_definitions\b/, + message: 'missing prompt-visible manifest ordering helper', }, { - regex: /\bconvert_hits_to_file_search_results\b/, - message: 'missing hit-to-file-result conversion owner', + regex: /\bpub struct StaticToolProviderGroup\b/, + message: 'missing generic static provider group container', }, { - regex: /\bsplit_preview\b/, - message: 'missing preview split contract', + regex: /\bpub struct ToolRuntimeAssembly\b/, + message: 'missing generic tool runtime assembly owner', }, { - regex: /\bpreview_inside\b/, - message: 'missing preview-inside rendering contract', + regex: /\bpub type ToolDecoratorRef\b/, + message: 'missing generic tool decorator reference contract', }, - ], - }, - { - path: 'src/crates/core/src/service/search/remote.rs', - reason: - 'core remote search runtime must continue owning remote flashgrep fallback/session behavior until search migration is reviewed', - patterns: [ { - regex: /\bremote_workspace_search_service_for_path\b/, - message: 'missing remote workspace search resolver', + regex: /\bpub trait SnapshotToolWrapper\b/, + message: 'missing generic snapshot wrapper port', }, { - regex: /\blookup_remote_connection_with_hint\b/, - message: 'missing preferred remote connection lookup', + regex: /\bpub struct SnapshotToolDecorator\b/, + message: 'missing generic snapshot decorator adapter', }, { - regex: /\ballow_scan_fallback\b/, - message: 'missing remote scan fallback contract', + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static-provider runtime assembly helper', }, { - regex: /\bfallback_query\b/, - message: 'missing FilesWithMatches fallback query', + regex: /\bpub fn is_tool_collapsed\b/, + message: 'missing generic collapsed-tool registry query', }, - ], - }, - { - path: 'src/crates/acp/src/client/manager.rs', - reason: - 'ACP surface runtime must continue owning startup timeout diagnostics until ACP migration is reviewed', - patterns: [ { - regex: /\bCLIENT_STARTUP_TIMEOUT_SECS\b/, - message: 'missing ACP startup timeout duration contract', + regex: /\bpub fn get_collapsed_tool_names\b/, + message: 'missing generic collapsed-tool registry catalog query', }, { - regex: /\bstartup_timeout_error_message\b/, - message: 'missing ACP startup timeout diagnostic formatter', + regex: /\bpub async fn resolve_readonly_enabled_tools\b/, + message: 'missing generic readonly enabled tool filter', }, { - regex: /\bformats_startup_timeout_error_message\b/, - message: 'missing ACP startup timeout regression', + regex: /\bpub struct ToolCatalogRuntime\b/, + message: 'missing provider-backed tool catalog runtime facade', }, ], }, { - path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', + path: 'src/crates/core/src/agentic/coordination/coordinator.rs', reason: - 'web-ui file operation surface must continue owning snapshot-to-local diff fallback until product surface migration is reviewed', + 'core must keep current coordinator port adapters and attachment guard until remote runtime migration is reviewed', patterns: [ { - regex: /\bopenLocalDiff\b/, - message: 'missing local tool diff fallback', + regex: /impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator/, + message: 'missing agent submission port adapter', }, { - regex: /snapshotAPI\.getOperationDiff/, - message: 'missing snapshot operation diff path', + regex: /impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator/, + message: 'missing session transcript reader adapter', }, { - regex: /Snapshot diff unavailable/, - message: 'missing snapshot-unavailable fallback diagnostic', + regex: /impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator/, + message: 'missing turn cancellation port adapter', }, { - regex: /\blocalDiffContent\b/, - message: 'missing local diff content fallback state', + regex: /impl bitfun_runtime_ports::RemoteControlStatePort for ConversationCoordinator/, + message: 'missing remote control state port adapter', + }, + { + regex: /agent submission port does not yet accept generic attachments/, + message: 'missing generic attachment guard on agent submission port', }, ], }, { - path: 'src/crates/core/src/miniapp/storage.rs', + path: 'src/crates/core/src/agentic/tools/registry.rs', reason: - 'core must continue owning MiniApp storage runtime adapter until storage IO migration is reviewed', + 'core registry must stay a compatibility container that delegates product tool runtime assembly through the core owner module', patterns: [ { - regex: /\bimpl MiniAppStoragePort for MiniAppStorage\b/, - message: 'missing MiniApp storage port adapter owner', + regex: /\bfrom_inner\b/, + message: 'missing generic agent-tools registry adapter hook', }, - ], - }, - { - path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', + { + regex: /\bProductToolDecoratorRef\b/, + message: 'missing product decorator ref alias using agent-tools contract', + }, + { + regex: /\bProductToolRuntimeAssembly\b/, + message: 'missing product tool runtime assembly delegation', + }, + { + regex: /\bget_collapsed_tool_names\b/, + message: 'missing collapsed-tool catalog owner', + }, + { + regex: /\bresolve_product_readonly_enabled_tools\b/, + message: 'missing product tool catalog readonly facade delegation', + }, + { + regex: /\bregistry_preserves_collapsed_tool_manifest_for_owner_migration\b/, + message: 'missing collapsed-tool manifest migration baseline', + }, + { + regex: /\binner\.is_tool_collapsed\b/, + message: 'missing collapsed exposure lookup delegation', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/runtime_assembly.rs', + reason: + 'core must keep product tool runtime assembly explicit until ToolUseContext and concrete tool-pack migration are reviewed', + patterns: [ + { + regex: /\bProductToolRuntimeAssembly\b/, + message: 'missing core product tool runtime assembly owner', + }, + { + regex: /\bSnapshotToolDecorator\b/, + message: 'missing generic snapshot decorator injection', + }, + { + regex: /\bProductSnapshotToolWrapper\b/, + message: 'missing core product snapshot wrapper adapter', + }, + { + regex: /\bbuiltin_static_tool_providers\b/, + message: 'missing builtin provider assembly input', + }, + { + regex: /\bToolRuntimeAssembly\b/, + message: 'missing generic agent-tools runtime assembly delegation', + }, + { + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static provider assembly delegation', + }, + { + regex: /\bwrap_tool_for_snapshot_tracking\b/, + message: 'missing snapshot wrapper boundary', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/static_providers.rs', + reason: + 'core materializes concrete tools from the tool-pack provider plan until concrete tools migrate', + patterns: [ + { + regex: /\bbuiltin_static_tool_providers\b/, + message: 'missing builtin static tool provider owner', + }, + { + regex: /\bStaticToolProviderGroup\b/, + message: 'missing generic static provider group contract use', + }, + { + regex: /\bproduct_tool_provider_group_plan\b/, + message: 'missing tool-pack provider group plan delegation', + }, + { + regex: /\bmaterialize_tool\b/, + message: 'missing core concrete tool materialization boundary', + }, + { + regex: /\bGetToolSpecTool::new\(\)/, + message: 'missing GetToolSpec registration anchor', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', + reason: + 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', + patterns: [ + { + regex: /\bimpl ToolRegistryItem for dyn Tool\b/, + message: 'missing core Tool registry adapter', + }, + { + regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, + message: 'missing core ToolUseContext contextual manifest adapter', + }, + { + regex: /\bTool::dynamic_tool_info\b/, + message: 'missing dynamic tool metadata adapter', + }, + { + regex: /\bTool::is_readonly\b/, + message: 'missing readonly metadata adapter', + }, + { + regex: /\bTool::is_enabled\b/, + message: 'missing enabled-state metadata adapter', + }, + { + regex: /\bTool::description_with_context\b/, + message: 'missing context-aware tool description adapter', + }, + { + regex: /\bTool::input_schema_for_model_with_context\b/, + message: 'missing context-aware tool schema adapter', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/catalog_provider.rs', + reason: + 'core must keep the product tool catalog provider explicit until ToolUseContext, manifest assembly, and GetToolSpec execution migration are reviewed', + patterns: [ + { + regex: /\bProductToolCatalogProvider\b/, + message: 'missing core product tool catalog provider owner', + }, + { + regex: /\bimpl ToolCatalogSnapshotProvider for ProductToolCatalogProvider\b/, + message: 'missing core tool catalog snapshot provider implementation', + }, + { + regex: /\bimpl GetToolSpecCatalogProvider for ProductToolCatalogProvider\b/, + message: 'missing core product GetToolSpec catalog provider implementation', + }, + { + regex: /\bget_global_tool_registry\b/, + message: 'missing core product registry snapshot access', + }, + { + regex: /\bget_agent_registry\b/, + message: 'missing core agent policy source for contextual catalog', + }, + { + regex: /\bToolCatalogRuntime\b/, + message: 'missing agent-tools product catalog runtime facade delegation', + }, + { + regex: /\bproduct_tool_catalog_runtime\b/, + message: 'missing product catalog runtime factory', + }, + { + regex: /\bGetToolSpecRuntime\b/, + message: 'missing agent-tools GetToolSpec runtime facade delegation', + }, + { + regex: /\bproduct_get_tool_spec_runtime\b/, + message: 'missing product GetToolSpec runtime factory', + }, + { + regex: /\bresolve_product_tool_manifest\b/, + message: 'missing product manifest facade', + }, + { + regex: /\bresolve_product_readonly_enabled_tools\b/, + message: 'missing product readonly enabled tools facade', + }, + { + regex: /\bresolve_product_get_tool_spec_results\b/, + message: 'missing product GetToolSpec Tool-result vector facade', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing core-owned collapsed-tool unlock state source', + }, + { + regex: /\bproduct_catalog_provider_default_get_tool_spec_catalog_matches_registry\b/, + message: 'missing product catalog provider collapsed catalog regression', + }, + { + regex: /\bGetToolSpec requires agent type context\b/, + message: 'missing contextual GetToolSpec catalog validation boundary', + }, + ], + }, + { + path: 'src/crates/agent-tools/src/framework.rs', + reason: 'agent-tools owns portable tool facts plus generic registry and provider contracts', + patterns: [ + { + regex: /\bpub struct ToolContextFacts\b/, + message: 'missing portable tool context facts contract', + }, + { + regex: /\bpub trait PortableToolContextProvider\b/, + message: 'missing portable tool context provider contract', + }, + { + regex: /\bpub enum ToolWorkspaceKind\b/, + message: 'missing portable workspace kind contract', + }, + { + regex: /\bpub trait StaticToolProvider\b/, + message: 'missing static tool provider contract', + }, + { + regex: /\bpub struct ToolRuntimeAssembly\b/, + message: 'missing generic runtime assembly contract', + }, + { + regex: /\bpub type ToolDecoratorRef\b/, + message: 'missing generic decorator ref contract', + }, + { + regex: /\bpub trait SnapshotToolWrapper\b/, + message: 'missing generic snapshot wrapper contract', + }, + { + regex: /\bpub struct SnapshotToolDecorator\b/, + message: 'missing generic snapshot decorator contract', + }, + { + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static-provider assembly helper', + }, + { + regex: /\bpub fn install_static_provider\b/, + message: 'missing static provider registry installer', + }, + { + regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, + message: 'missing provider-neutral GetToolSpec duplicate-load result helper', + }, + { + regex: /\bpub fn build_get_tool_spec_detail_result\b/, + message: 'missing provider-neutral GetToolSpec detail result helper', + }, + { + regex: /\bpub fn resolve_get_tool_spec_execution_plan\b/, + message: 'missing provider-neutral GetToolSpec execution plan helper', + }, + { + regex: /\bpub async fn resolve_get_tool_spec_execution_result_from_provider\b/, + message: 'missing provider-backed GetToolSpec execution result helper', + }, + { + regex: /\bpub struct GetToolSpecRuntime\b/, + message: 'missing provider-backed GetToolSpec runtime facade', + }, + ], + }, + { + path: 'src/crates/tool-packs/src/lib.rs', + reason: + 'tool-packs must keep its feature-group scaffold explicit without owning concrete tools yet', + patterns: [ + { + regex: /\bpub enum ToolPackFeatureGroup\b/, + message: 'missing tool-pack feature group scaffold', + }, + { + regex: /\bpub fn all_feature_groups\b/, + message: 'missing tool-pack full feature group metadata helper', + }, + { + regex: /\bpub fn enabled_feature_groups\b/, + message: 'missing tool-pack compile-time feature metadata helper', + }, + { + regex: /\bpub struct ToolProviderGroupPlan\b/, + message: 'missing tool-pack provider group plan contract', + }, + { + regex: /\bpub fn product_tool_provider_group_plan\b/, + message: 'missing product tool provider group plan', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', + reason: + 'core must continue owning manifest resolver wrappers while delegating product catalog access and generic manifest assembly', + patterns: [ + { + regex: /\bpub async fn resolve_tool_manifest\b/, + message: 'missing tool manifest resolver owner', + }, + { + regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, + message: 'missing GetToolSpec manifest insertion anchor', + }, + { + regex: /\bresolve_product_visible_tools\b/, + message: 'missing core product visible-tools facade delegation', + }, + { + regex: /\bresolve_product_tool_manifest\b/, + message: 'missing core product manifest facade delegation', + }, + { + regex: /\bcollapsed_tool_names\b/, + message: 'missing collapsed-tool name tracking', + }, + { + regex: /\bmanifest_preserves_explicit_get_tool_spec_runtime_contract\b/, + message: 'missing core GetToolSpec manifest insertion regression', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', + reason: + 'core must continue owning the GetToolSpec Tool adapter and product boundary while delegating generic runtime surface to agent-tools', + patterns: [ + { + regex: /\bpub struct GetToolSpecTool\b/, + message: 'missing GetToolSpec owner type', + }, + { + regex: /\bresolve_product_get_tool_spec_results\b/, + message: 'missing product GetToolSpec Tool-result vector facade delegation', + }, + { + regex: /\bmap_get_tool_spec_execution_error\b/, + message: 'missing core GetToolSpec execution error mapping boundary', + }, + { + regex: /\bbuild_product_get_tool_spec_catalog_description\b/, + message: 'missing core product GetToolSpec catalog facade delegation', + }, + { + regex: /\bproduct_get_tool_spec_runtime\b/, + message: 'missing product GetToolSpec runtime facade delegation', + }, + { + regex: /\bwith_runtime\b/, + message: 'missing core GetToolSpec static surface facade boundary', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/framework.rs', + reason: + 'core must continue owning ToolUseContext while re-exporting pure exposure contracts until a portable context port is reviewed', + patterns: [ + { + regex: /\bToolExposure\b/, + message: 'missing ToolExposure compatibility re-export', + }, + { + regex: /\bpub struct ToolUseContext\b/, + message: 'missing ToolUseContext owner type', + }, + { + regex: /\bto_tool_context_facts\b/, + message: 'missing portable ToolUseContext facts projection', + }, + { + regex: /\btool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated\b/, + message: 'missing portable facts runtime-owner leak guard', + }, + { + regex: /customData/, + message: 'missing custom data runtime-only facts guard', + }, + { + regex: /cancellationToken/, + message: 'missing cancellation token runtime-only facts guard', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock state', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', + reason: + 'core must continue owning collapsed-tool execution gating until manifest/runtime migration is reviewed', + patterns: [ + { + regex: /\bfn validate_collapsed_tool_usage\b/, + message: 'missing collapsed-tool execution gate', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock state propagation', + }, + { + regex: /\bpipeline_preserves_core_owned_tool_context_without_portable_runtime_leak\b/, + message: 'missing ToolUseContext runtime boundary regression', + }, + { + regex: /\bGetToolSpec\b/, + message: 'missing GetToolSpec gating contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/execution/execution_engine.rs', + reason: + 'core execution must continue carrying collapsed-tool unlock state and DeepResearch post-turn hooks until approved runtime migrations exist', + patterns: [ + { + regex: /\bfn collect_unlocked_collapsed_tools\b/, + message: 'missing GetToolSpec result unlock collector', + }, + { + regex: /\bcollect_unlocked_collapsed_tools_dedupes_and_filters_runtime_unlocks\b/, + message: 'missing GetToolSpec unlock filtering regression', + }, + { + regex: /\bcollapsed_tool_names\b/, + message: 'missing manifest collapsed-tool handoff', + }, + { + regex: /\bGetToolSpec\b/, + message: 'missing GetToolSpec execution contract', + }, + { + regex: /\bcitation_renumber\b/, + message: 'missing DeepResearch citation renumber hook', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/availability.rs', + reason: + 'core agent registry must continue owning mode-scoped subagent availability until an approved agent-runtime migration exists', + patterns: [ + { + regex: /\bpub fn resolve_availability\b/, + message: 'missing mode-scoped subagent availability resolver', + }, + { + regex: /\bpub fn resolve_override_layers\b/, + message: 'missing project/user override layering contract', + }, + { + regex: /\bAgentSubagentOverrideState\b/, + message: 'missing subagent override state contract', + }, + { + regex: /\bSubagentStateReason\b/, + message: 'missing frontend-visible availability reason contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/types.rs', + reason: + 'core agent registry must continue exposing subagent query and availability DTOs until registry ownership migrates with API equivalence tests', + patterns: [ + { + regex: /\bpub struct SubagentQueryContext\b/, + message: 'missing subagent query context', + }, + { + regex: /\bpub enum SubagentListScope\b/, + message: 'missing subagent list scope contract', + }, + { + regex: /\bdefault_enabled\b/, + message: 'missing default availability field', + }, + { + regex: /\beffective_enabled\b/, + message: 'missing effective availability field', + }, + { + regex: /\bpub enum SubagentStateReason\b/, + message: 'missing availability reason wire contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/definitions/modes/mod.rs', + reason: + 'core agent mode definitions must continue exposing Multitask mode until an approved agent-runtime migration preserves mode registration semantics', + patterns: [ + { + regex: /\bmod multitask\b/, + message: 'missing Multitask mode module', + }, + { + regex: /\bpub use multitask::MultitaskMode\b/, + message: 'missing Multitask mode export', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/definitions/subagents/mod.rs', + reason: + 'core subagent definitions must continue exposing the built-in GeneralPurpose subagent until registry ownership migration has equivalence coverage', + patterns: [ + { + regex: /\bmod general_purpose\b/, + message: 'missing GeneralPurpose subagent module', + }, + { + regex: /\bpub use general_purpose::GeneralPurposeAgent\b/, + message: 'missing GeneralPurpose subagent export', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/builtin.rs', + reason: + 'core builtin registry must continue registering latest-main mode and subagent defaults until agent registry ownership migrates with API equivalence tests', + patterns: [ + { + regex: /\bbuiltin_agent_specs\(\)/, + message: 'missing builtin agent spec registration source', + }, + { + regex: /"Multitask"\s*=>\s*"auto"/, + message: 'missing Multitask default model mapping', + }, + { + regex: /"GeneralPurpose"\s*=>\s*"fast"/, + message: 'missing GeneralPurpose default model mapping', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', + reason: + 'core Task tool must continue owning background subagent launch semantics until a reviewed agent-runtime port preserves delivery behavior', + patterns: [ + { + regex: /"run_in_background"/, + message: 'missing Task run_in_background schema flag', + }, + { + regex: /\bstart_background_subagent\b/, + message: 'missing background subagent launch path', + }, + { + regex: /\bbackground_task_id\b/, + message: 'missing background task id result contract', + }, + { + regex: /Background subagent/, + message: 'missing assistant-visible background subagent acknowledgement', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler must continue owning background subagent result delivery until running-turn and idle-session routing equivalence tests exist', + patterns: [ + { + regex: /\bdeliver_background_subagent_result\b/, + message: 'missing background subagent delivery entry point', + }, + { + regex: /RoundInjectionKind::BackgroundSubagentResult/, + message: 'missing running-turn background result injection', + }, + { + regex: /RoundInjectionTarget::CurrentRunningTurn/, + message: 'missing current-turn injection target', + }, + { + regex: /DialogTriggerSource::AgentSession/, + message: 'missing idle-session agent-session follow-up turn source', + }, + ], + }, + { + path: 'src/apps/cli/src/ui/startup.rs', + reason: + 'CLI mode-aware subagent management remains an app-layer product surface until agent registry migration has CLI equivalence coverage', + patterns: [ + { + regex: /\bfn show_available_subagent_list\b/, + message: 'missing CLI subagent list surface', + }, + { + regex: /\bfn show_subagent_config_selector\b/, + message: 'missing CLI subagent config surface', + }, + { + regex: /\bget_subagents_for_query\b/, + message: 'missing CLI mode-scoped subagent query', + }, + { + regex: /\bSubagentQueryContext\b/, + message: 'missing CLI subagent query context', + }, + { + regex: /\bupdate_subagent_override\b/, + message: 'missing CLI subagent availability update path', + }, + ], + }, + { + path: 'src/apps/cli/src/ui/subagent_selector.rs', + reason: + 'CLI subagent selector presentation must remain app-layer UI while registry availability semantics stay in core', + patterns: [ + { + regex: /\bpub enum SubagentSelectorAction\b/, + message: 'missing CLI subagent selector action contract', + }, + { + regex: /\bpub fn show_list\b/, + message: 'missing CLI subagent list mode', + }, + { + regex: /\bpub fn show_config\b/, + message: 'missing CLI subagent config mode', + }, + { + regex: /\bdefault_enabled\b/, + message: 'missing CLI default availability display', + }, + { + regex: /\bfn render_subagent_line\b/, + message: 'missing CLI subagent presentation renderer', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', + reason: + 'core DeepResearch runtime must continue owning citation renumber post-processing until agent-runtime migration is reviewed', + patterns: [ + { + regex: /\bpub async fn run_for_session_workspace\b/, + message: 'missing DeepResearch citation hook entry point', + }, + { + regex: /\bpub async fn try_renumber_research_report\b/, + message: 'missing deterministic citation renumber implementation', + }, + { + regex: /display_map\.json/, + message: 'missing citation display map sidecar contract', + }, + { + regex: /\bREJECTED\b/, + message: 'missing rejected-citation filtering contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/workspace/service.rs', + reason: + 'core workspace runtime must continue owning startup remote-workspace guards until workspace service migration is reviewed', + patterns: [ + { + regex: /\bprepare_startup_restored_workspaces\b/, + message: 'missing restored-workspace startup guard', + }, + { + regex: /\bWorkspaceKind::Remote\b/, + message: 'missing remote workspace branch', + }, + { + regex: /\bensure_remote_workspace_runtime\b/, + message: 'missing remote workspace runtime ensure call', + }, + { + regex: /\bsshHost\b/, + message: 'missing remote workspace host metadata contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/search/service.rs', + reason: + 'core search runtime must continue owning local flashgrep fallback and preview mapping until search migration is reviewed', + patterns: [ + { + regex: /\bwith_scan_fallback\b/, + message: 'missing flashgrep scan fallback request flag', + }, + { + regex: /\bconvert_hits_to_file_search_results\b/, + message: 'missing hit-to-file-result conversion owner', + }, + { + regex: /\bsplit_preview\b/, + message: 'missing preview split contract', + }, + { + regex: /\bpreview_inside\b/, + message: 'missing preview-inside rendering contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/search/remote.rs', + reason: + 'core remote search runtime must continue owning remote flashgrep fallback/session behavior until search migration is reviewed', + patterns: [ + { + regex: /\bremote_workspace_search_service_for_path\b/, + message: 'missing remote workspace search resolver', + }, + { + regex: /\blookup_remote_connection_with_hint\b/, + message: 'missing preferred remote connection lookup', + }, + { + regex: /\ballow_scan_fallback\b/, + message: 'missing remote scan fallback contract', + }, + { + regex: /\bfallback_query\b/, + message: 'missing FilesWithMatches fallback query', + }, + ], + }, + { + path: 'src/crates/acp/src/client/manager.rs', + reason: + 'ACP surface runtime must continue owning startup timeout diagnostics until ACP migration is reviewed', + patterns: [ + { + regex: /\bCLIENT_STARTUP_TIMEOUT_SECS\b/, + message: 'missing ACP startup timeout duration contract', + }, + { + regex: /\bstartup_timeout_error_message\b/, + message: 'missing ACP startup timeout diagnostic formatter', + }, + { + regex: /\bformats_startup_timeout_error_message\b/, + message: 'missing ACP startup timeout regression', + }, + ], + }, + { + path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', + reason: + 'web-ui file operation surface must continue owning snapshot-to-local diff fallback until product surface migration is reviewed', + patterns: [ + { + regex: /\bopenLocalDiff\b/, + message: 'missing local tool diff fallback', + }, + { + regex: /snapshotAPI\.getOperationDiff/, + message: 'missing snapshot operation diff path', + }, + { + regex: /Snapshot diff unavailable/, + message: 'missing snapshot-unavailable fallback diagnostic', + }, + { + regex: /\blocalDiffContent\b/, + message: 'missing local diff content fallback state', + }, + ], + }, + { + path: 'src/web-ui/src/main.tsx', + reason: + 'web startup scheduling and trace orchestration remain web product-surface behavior, not core contract runtime', + patterns: [ + { + regex: /\bstartupTrace\b/, + message: 'missing web startup trace surface', + }, + { + regex: /\bbackgroundTaskScheduler\b/, + message: 'missing deferred startup scheduler surface', + }, + { + regex: /\binitializeAllTools\b/, + message: 'missing narrow tool-startup entry integration', + }, + { + regex: /\bafter_render_start\b/, + message: 'missing post-render startup phase', + }, + ], + }, + { + path: 'src/web-ui/src/shared/utils/startupTrace.ts', + reason: + 'web startup trace classification and redaction remain web infrastructure behavior until a telemetry contract is reviewed', + patterns: [ + { + regex: /\bfunction sanitizeTraceData\b/, + message: 'missing startup trace sanitization', + }, + { + regex: /\bexport function isRemoteTraceRequest\b/, + message: 'missing remote request classifier', + }, + { + regex: /\brecordApiCall\b/, + message: 'missing startup API-call trace recorder', + }, + { + regex: /\bflushSummary\b/, + message: 'missing bounded startup summary flush', + }, + { + regex: /\bmarkPhaseAfterAnimationFrames\b/, + message: 'missing frame-delayed startup marker', + }, + ], + }, + { + path: 'src/web-ui/src/shared/utils/backgroundTaskScheduler.ts', + reason: + 'web background startup scheduling remains web infrastructure behavior and must preserve dedupe/cancel semantics', + patterns: [ + { + regex: /\bexport class BackgroundTaskScheduler\b/, + message: 'missing background task scheduler', + }, + { + regex: /\binFlightKey\b/, + message: 'missing in-flight dedupe key', + }, + { + regex: /\bAbortController\b/, + message: 'missing cancellation controller', + }, + { + regex: /\bBackgroundTaskCancelledError\b/, + message: 'missing cancellation error contract', + }, + { + regex: /\bcancelIdle\b/, + message: 'missing idle callback cancellation', + }, + ], + }, + { + path: 'src/web-ui/src/tools/initializeTools.ts', + reason: + 'web tool startup must stay behind a narrow app-layer entry instead of importing product tools through shared contracts', + patterns: [ + { + regex: /\bexport async function initializeAllTools\b/, + message: 'missing narrow tool startup entry', + }, + { + regex: /\binitializeLsp\b/, + message: 'missing LSP startup initializer call', + }, + { + regex: /\binitializeGit\b/, + message: 'missing Git startup initializer call', + }, + { + regex: /does not import every tool/, + message: 'missing narrow startup import guard', + }, + ], + }, + { + path: 'src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts', + reason: + 'Monaco startup warmup remains a deferred web-app optimization, not a core runtime dependency', + patterns: [ + { + regex: /\bexport function scheduleMonacoStartupWarmup\b/, + message: 'missing deferred Monaco warmup entry', + }, + { + regex: /\bbackgroundTaskScheduler\b/, + message: 'missing background scheduler integration', + }, + { + regex: /startup:monaco-warmup/, + message: 'missing Monaco warmup dedupe key', + }, + ], + }, + { + path: 'src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts', + reason: + 'flow-chat history hydration remains web startup/product-surface behavior until a UI equivalence plan exists', + patterns: [ + { + regex: /\bhistorical_session_hydrate_request\b/, + message: 'missing historical session hydrate trace', + }, + { + regex: /Load history in the background/, + message: 'missing non-blocking history load contract', + }, + { + regex: /\bhistoryState: 'ready'/, + message: 'missing history-ready state contract', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/storage.rs', + reason: + 'core must continue owning MiniApp storage runtime adapter until storage IO migration is reviewed', + patterns: [ + { + regex: /\bimpl MiniAppStoragePort for MiniAppStorage\b/, + message: 'missing MiniApp storage port adapter owner', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/builtin/mod.rs', + reason: + 'core must continue owning built-in MiniApp asset includes, seeding IO, marker writes, and recompilation until builtin asset runtime migration is reviewed', + patterns: [ + { + regex: /id: "builtin-pr-review"/, + message: 'missing built-in PR Review MiniApp anchor', + }, + { + regex: /\bBUILTIN_APPS\b/, + message: 'missing built-in MiniApp asset include owner', + }, + { + regex: /\bbuiltin_content_hash\b/, + message: 'missing product-domain built-in MiniApp content hash use', + }, + { + regex: /\bshould_seed_builtin_app\b/, + message: 'missing product-domain built-in MiniApp seed decision use', + }, + { + regex: /\bresolve_builtin_seed_check\b/, + message: 'missing product-domain built-in MiniApp seed check use', + }, + { + regex: /\bresolve_builtin_seed_action\b/, + message: 'missing product-domain built-in MiniApp seed action use', + }, + { + regex: /\bbuiltin_source_files\b/, + message: 'missing product-domain built-in MiniApp source payload use', + }, + { + regex: /\bBUILTIN_PLACEHOLDER_COMPILED_HTML\b/, + message: 'missing product-domain built-in MiniApp placeholder payload use', + }, + { + regex: /\bread_builtin_install_marker\b/, + message: 'missing core-owned built-in MiniApp marker read IO', + }, + { + regex: /\bparse_builtin_install_marker\b/, + message: 'missing product-domain built-in MiniApp marker parse helper use', + }, + { + regex: /\bwrite_builtin_install_marker\b/, + message: 'missing core-owned built-in MiniApp marker write IO', + }, + { + regex: /\bserialize_builtin_install_marker\b/, + message: 'missing product-domain built-in MiniApp marker serialization helper use', + }, + { + regex: /\brecompile\b/, + message: 'missing core-owned built-in MiniApp recompile orchestration', + }, + { + regex: /\bload_customization_metadata\b/, + message: 'missing customized built-in preservation path', + }, + { + regex: /\bavailable_builtin_update\b/, + message: 'missing customized built-in update metadata path', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/host_dispatch.rs', + reason: + 'core must continue owning MiniApp host-dispatch execution until host/runtime migration is reviewed', + patterns: [ + { + regex: /\bpub async fn dispatch_host\b/, + message: 'missing MiniApp host dispatch entry', + }, + { + regex: /\basync fn dispatch_fs\b/, + message: 'missing MiniApp fs host dispatch', + }, + { + regex: /\basync fn dispatch_shell\b/, + message: 'missing MiniApp shell host dispatch', + }, + { + regex: /\bcommand_basename_allowed\b/, + message: 'missing MiniApp shell allowlist policy use', + }, + { + regex: /\bhost_allowed_by_allowlist\b/, + message: 'missing MiniApp net allowlist policy use', + }, + ], + }, + { + path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', + reason: + 'services-integrations remote-ssh owns workspace path/session identity helpers that do not require concrete SSH runtime handles', + patterns: [ + { + regex: /\bpub fn remote_workspace_runtime_root\b/, + message: 'missing remote workspace runtime root helper', + }, + { + regex: /\bpub fn remote_workspace_session_mirror_dir\b/, + message: 'missing remote workspace session mirror helper', + }, + { + regex: /\bpub fn canonicalize_local_workspace_root\b/, + message: 'missing local workspace canonicalization helper', + }, + { + regex: /\bpub fn normalize_local_workspace_root_for_stable_id\b/, + message: 'missing local workspace stable-root helper', + }, + { + regex: /\bpub fn local_workspace_roots_equal\b/, + message: 'missing local workspace equality helper', + }, + { + regex: /\bpub fn unresolved_remote_session_storage_dir\b/, + message: 'missing unresolved remote session storage helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/storage.rs', + reason: + 'product-domains owns MiniApp storage shape contracts while core/adapters keep filesystem IO', + patterns: [ + { + regex: /\bpub struct MiniAppStorageLayout\b/, + message: 'missing MiniApp storage layout contract', + }, + { + regex: /\bpub const META_JSON\b/, + message: 'missing MiniApp metadata filename contract', + }, + { + regex: /\bpub fn source_file_path\b/, + message: 'missing MiniApp source file layout helper', + }, + { + regex: /\bpub fn versions_dir\b/, + message: 'missing MiniApp versions directory layout helper', + }, + { + regex: /\bpub const DRAFT_JSON\b/, + message: 'missing MiniApp draft manifest filename contract', + }, + { + regex: /\bpub const REQUIRED_SOURCE_FILES\b/, + message: 'missing MiniApp import source file list contract', + }, + { + regex: /\bpub const PLACEHOLDER_COMPILED_HTML\b/, + message: 'missing MiniApp placeholder compiled HTML contract', + }, + { + regex: /\bpub struct MiniAppImportLayout\b/, + message: 'missing MiniApp import layout contract', + }, + { + regex: /\bpub fn build_import_fallbacks\b/, + message: 'missing MiniApp import fallback payload helper', + }, + { + regex: /\bpub fn draft_dir\b/, + message: 'missing MiniApp draft directory layout helper', + }, + { + regex: /\bpub fn customization_path\b/, + message: 'missing MiniApp customization metadata path helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/lifecycle.rs', + reason: + 'product-domains owns pure MiniApp lifecycle state transitions while core keeps compile, storage IO, and runtime execution', + patterns: [ + { + regex: /\bpub fn mark_deps_installed_state\b/, + message: 'missing MiniApp deps-installed state helper', + }, + { + regex: /\bpub fn clear_worker_restart_required_state\b/, + message: 'missing MiniApp worker-restart clear state helper', + }, + { + regex: /\bpub fn prepare_rollback_app\b/, + message: 'missing MiniApp rollback state helper', + }, + { + regex: /\bpub fn apply_recompile_result\b/, + message: 'missing MiniApp recompile result state helper', + }, + { + regex: /\bpub fn apply_sync_from_fs_result\b/, + message: 'missing MiniApp sync-from-fs state helper', + }, + { + regex: /\bpub fn apply_import_runtime_state\b/, + message: 'missing MiniApp import runtime state helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/draft.rs', + reason: + 'product-domains owns MiniApp draft DTO and response shape while core keeps draft filesystem IO', + patterns: [ + { + regex: /\bpub struct MiniAppDraftManifest\b/, + message: 'missing MiniApp draft manifest DTO', + }, + { + regex: /\bpub struct MiniAppDraft\b/, + message: 'missing MiniApp draft response DTO', + }, + { + regex: /\bpub fn build_draft_manifest\b/, + message: 'missing MiniApp draft manifest helper', + }, + { + regex: /\bpub fn build_draft_response\b/, + message: 'missing MiniApp draft response helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/runtime.rs', + reason: + 'product-domains owns MiniApp runtime search-plan contracts while core keeps executable lookup and version process execution', + patterns: [ + { + regex: /\bpub fn runtime_lookup_order\b/, + message: 'missing MiniApp runtime lookup order contract', + }, + { + regex: /\bpub fn candidate_executable_path\b/, + message: 'missing MiniApp runtime candidate executable helper', + }, + { + regex: /\bpub fn versioned_executable_candidate\b/, + message: 'missing MiniApp version-manager executable helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/host_routing.rs', + reason: + 'product-domains owns MiniApp host-routing and allowlist string policy while core keeps host execution', + patterns: [ + { + regex: /\bpub fn command_basename_for_allowlist\b/, + message: 'missing MiniApp command basename allowlist helper', + }, + { + regex: /\bpub fn command_basename_allowed\b/, + message: 'missing MiniApp command allowlist policy helper', + }, + { + regex: /\bpub fn host_allowed_by_allowlist\b/, + message: 'missing MiniApp host allowlist policy helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/customization.rs', + reason: + 'product-domains owns MiniApp customization metadata, built-in update policy, and permission-diff contracts while core keeps draft storage/runtime', + patterns: [ + { + regex: /\bpub struct MiniAppCustomizationMetadata\b/, + message: 'missing MiniApp customization metadata contract', + }, + { + regex: /\bpub struct MiniAppDeclinedBuiltinUpdate\b/, + message: 'missing MiniApp declined built-in update contract', + }, + { + regex: /\bpub struct MiniAppPermissionDiff\b/, + message: 'missing MiniApp permission diff contract', + }, + { + regex: /\bpub fn diff_permissions\b/, + message: 'missing MiniApp permission diff helper', + }, + { + regex: /\bpub fn apply_draft_customization_metadata\b/, + message: 'missing MiniApp customization draft-apply helper', + }, + { + regex: /\bpub fn mark_builtin_update_available_metadata\b/, + message: 'missing MiniApp built-in update availability helper', + }, + { + regex: /\bpub fn decline_builtin_update_metadata\b/, + message: 'missing MiniApp built-in update decline helper', + }, + { + regex: /\bpub fn is_current_declined_builtin_update\b/, + message: 'missing MiniApp declined update current-state helper', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/manager.rs', + reason: + 'core MiniApp manager must use product-domain policy/facade helpers while retaining compile, storage IO, and built-in source-hash lookup', + patterns: [ + { + regex: /\bapply_draft_customization_metadata\b/, + message: 'missing product-domain draft customization helper use', + }, + { + regex: /\bmark_builtin_update_available_metadata\b/, + message: 'missing product-domain built-in update availability helper use', + }, + { + regex: /\bdecline_builtin_update_metadata\b/, + message: 'missing product-domain built-in update decline helper use', + }, + { + regex: /\bMiniAppRuntimeFacade\b/, + message: 'missing product-domain MiniApp runtime-state facade use', + }, + { + regex: /\bpersist_sync_from_fs_result_for_app\b/, + message: 'missing product-domain MiniApp sync-from-fs facade delegation', + }, + { + regex: /\bcompile_source\b/, + message: 'missing core-owned MiniApp compile orchestration', + }, + { + regex: /\bREQUIRED_SOURCE_FILES\b/, + message: 'missing product-domain MiniApp import file-shape contract use', + }, + { + regex: /\bMiniAppImportLayout\b/, + message: 'missing product-domain MiniApp import layout helper use', + }, + { + regex: /\bbuild_import_fallbacks\b/, + message: 'missing product-domain MiniApp import fallback helper use', + }, + { + regex: /\bapply_import_runtime_state\b/, + message: 'missing product-domain MiniApp import runtime state helper use', + }, + { + regex: /\bstorage\.load_customization_metadata\b/, + message: 'missing core-owned customization metadata storage IO', + }, + { + regex: /\bruntime_preflight_preserves_recompile_sync_rollback_and_deps_state\b/, + message: 'missing MiniApp manager runtime preflight regression test', + }, + { + regex: /\bimport_from_path_preserves_fallback_files_recompile_and_runtime_state\b/, + message: 'missing MiniApp import runtime preflight regression test', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/ports.rs', + reason: + 'product-domains owns MiniApp runtime-state port facade while core keeps concrete storage IO, compile, worker, and host execution', + patterns: [ + { + regex: /\bpub struct MiniAppRuntimeFacade\b/, + message: 'missing MiniApp runtime-state facade', + }, + { + regex: /\bmark_deps_installed_state\b/, + message: 'missing MiniApp deps-installed state transition in facade', + }, + { + regex: /\bpersist_sync_from_fs_result_for_app\b/, + message: 'missing MiniApp sync-from-fs preloaded snapshot facade path', + }, + ], + }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/ai_service.rs', + reason: + 'core must continue owning Git function-agent AI client calls while product-domains owns prompt and response policy', + patterns: [ + { + regex: /\bprepare_commit_ai_prompt\b/, + message: 'missing product-domain Git function-agent prompt policy use', + }, + { + regex: /\bparse_commit_ai_response\b/, + message: 'missing product-domain Git function-agent response policy use', + }, + { + regex: /\bai_client\s*\.\s*send_message\b/, + message: 'missing core-owned function-agent AI call', + }, + { + regex: /\bAgentError::internal_error\b/, + message: 'missing core-owned function-agent AI transport error mapping', + }, + { + regex: /\bparse_commit_response_preserves_product_domain_response_policy\b/, + message: 'missing Git function-agent AI response boundary regression test', + }, + ], + }, + { + path: 'src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs', + reason: + 'core must continue owning Startchat AI client calls while product-domains owns prompt and response policy', + patterns: [ + { + regex: /\bbuild_work_state_analysis_prompt\b/, + message: 'missing product-domain Startchat prompt policy use', + }, + { + regex: /\bparse_work_state_analysis_response\b/, + message: 'missing product-domain Startchat response policy use', + }, + { + regex: /\bai_client\s*\.\s*send_message\b/, + message: 'missing core-owned Startchat AI call', + }, + { + regex: /\bAgentError::internal_error\b/, + message: 'missing core-owned Startchat AI transport error mapping', + }, + { + regex: /\bparse_complete_analysis_preserves_product_domain_response_policy\b/, + message: 'missing Startchat AI response boundary regression test', + }, + ], + }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', + reason: + 'Git function-agent commit generation must route through the product-domain runtime facade while core keeps concrete adapters', + patterns: [ + { + regex: /\bFunctionAgentRuntimeFacade\b/, + message: 'missing product-domain function-agent runtime facade routing', + }, + { + regex: /\bCoreFunctionAgentGitAdapter\b/, + message: 'missing core-owned Git adapter wiring', + }, + { + regex: /\bCoreFunctionAgentAiAdapter\b/, + message: 'missing core-owned AI adapter wiring', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/builtin.rs', reason: - 'services-integrations remote-ssh owns workspace path/session identity helpers that do not require concrete SSH runtime handles', + 'product-domains owns pure built-in MiniApp bundle, marker, hash, and seed-decision contracts while core keeps asset seeding IO and recompilation', patterns: [ { - regex: /\bpub fn remote_workspace_runtime_root\b/, - message: 'missing remote workspace runtime root helper', + regex: /\bpub struct BuiltinMiniAppBundle\b/, + message: 'missing built-in MiniApp bundle contract', }, { - regex: /\bpub fn remote_workspace_session_mirror_dir\b/, - message: 'missing remote workspace session mirror helper', + regex: /\bpub struct BuiltinInstallMarker\b/, + message: 'missing built-in MiniApp install marker contract', }, { - regex: /\bpub fn canonicalize_local_workspace_root\b/, - message: 'missing local workspace canonicalization helper', + regex: /\bpub const BUILTIN_INSTALL_MARKER\b/, + message: 'missing built-in MiniApp marker filename contract', }, { - regex: /\bpub fn normalize_local_workspace_root_for_stable_id\b/, - message: 'missing local workspace stable-root helper', + regex: /\bpub fn builtin_content_hash\b/, + message: 'missing built-in MiniApp content hash helper', }, { - regex: /\bpub fn local_workspace_roots_equal\b/, - message: 'missing local workspace equality helper', + regex: /\bpub fn should_seed_builtin_app\b/, + message: 'missing built-in MiniApp seed decision helper', }, { - regex: /\bpub fn unresolved_remote_session_storage_dir\b/, - message: 'missing unresolved remote session storage helper', + regex: /\bpub struct BuiltinSeedArtifacts\b/, + message: 'missing built-in MiniApp seed artifacts contract', + }, + { + regex: /\bpub enum BuiltinSeedCheck\b/, + message: 'missing built-in MiniApp seed check contract', + }, + { + regex: /\bpub enum BuiltinSeedAction\b/, + message: 'missing built-in MiniApp seed action contract', + }, + { + regex: /\bpub fn resolve_builtin_seed_check\b/, + message: 'missing built-in MiniApp seed check helper', + }, + { + regex: /\bpub fn resolve_builtin_seed_action\b/, + message: 'missing built-in MiniApp seed action helper', + }, + { + regex: /\bpub fn serialize_builtin_install_marker\b/, + message: 'missing built-in MiniApp marker serialization helper', + }, + { + regex: /\bpub fn parse_builtin_install_marker\b/, + message: 'missing built-in MiniApp marker parse helper', + }, + { + regex: /\bpub fn builtin_source_files\b/, + message: 'missing built-in MiniApp source payload helper', + }, + { + regex: /\bpub const BUILTIN_PLACEHOLDER_COMPILED_HTML\b/, + message: 'missing built-in MiniApp placeholder payload contract', + }, + { + regex: /\bpub fn build_builtin_package_json\b/, + message: 'missing built-in MiniApp package payload helper', }, ], }, { - path: 'src/crates/product-domains/src/miniapp/storage.rs', + path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', reason: - 'product-domains owns MiniApp storage shape contracts while core/adapters keep filesystem IO', + 'Startchat work-state analysis must route through the product-domain runtime facade while core keeps concrete adapters', patterns: [ { - regex: /\bpub struct MiniAppStorageLayout\b/, - message: 'missing MiniApp storage layout contract', - }, - { - regex: /\bpub const META_JSON\b/, - message: 'missing MiniApp metadata filename contract', + regex: /\bFunctionAgentRuntimeFacade\b/, + message: 'missing product-domain function-agent runtime facade routing', }, { - regex: /\bpub fn source_file_path\b/, - message: 'missing MiniApp source file layout helper', + regex: /\bCoreFunctionAgentGitAdapter\b/, + message: 'missing core-owned Git adapter wiring', }, { - regex: /\bpub fn versions_dir\b/, - message: 'missing MiniApp versions directory layout helper', + regex: /\bCoreFunctionAgentAiAdapter\b/, + message: 'missing core-owned AI adapter wiring', }, ], }, { - path: 'src/crates/product-domains/src/miniapp/host_routing.rs', + path: 'src/crates/product-domains/src/function_agents/ports.rs', reason: - 'product-domains owns MiniApp host-routing and allowlist string policy while core keeps host execution', + 'product-domains owns port-backed function-agent facade orchestration while core keeps concrete Git/AI runtime calls', patterns: [ { - regex: /\bpub fn command_basename_for_allowlist\b/, - message: 'missing MiniApp command basename allowlist helper', + regex: /\bpub struct FunctionAgentRuntimeFacade\b/, + message: 'missing function-agent runtime facade', }, { - regex: /\bpub fn command_basename_allowed\b/, - message: 'missing MiniApp command allowlist policy helper', + regex: /\bgenerate_commit_message\b/, + message: 'missing function-agent commit facade orchestration', }, { - regex: /\bpub fn host_allowed_by_allowlist\b/, - message: 'missing MiniApp host allowlist policy helper', + regex: /\banalyze_work_state\b/, + message: 'missing function-agent work-state facade orchestration', + }, + { + regex: /\bgit_work_state_from_snapshot\b/, + message: 'missing Startchat Git snapshot projection helper', + }, + { + regex: /\bStartchatTimeSnapshot\b/, + message: 'missing Startchat time snapshot contract', + }, + { + regex: /\bstartchat_time_snapshot\b/, + message: 'missing Startchat time snapshot port', }, ], }, { - path: 'src/crates/product-domains/src/miniapp/customization.rs', + path: 'src/crates/product-domains/src/function_agents/common.rs', reason: - 'product-domains owns MiniApp customization metadata and permission-diff contracts while core keeps draft storage/runtime', + 'product-domains owns function-agent AI response JSON extraction while core keeps concrete AI clients', patterns: [ { - regex: /\bpub struct MiniAppCustomizationMetadata\b/, - message: 'missing MiniApp customization metadata contract', - }, - { - regex: /\bpub struct MiniAppPermissionDiff\b/, - message: 'missing MiniApp permission diff contract', + regex: /\bfn extract_json_from_ai_response\b/, + message: 'missing function-agent AI response JSON extraction helper', }, { - regex: /\bpub fn diff_permissions\b/, - message: 'missing MiniApp permission diff helper', + regex: /\bfn try_repair_json\b/, + message: 'missing function-agent AI response JSON repair helper', }, ], }, { path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', reason: - 'product-domains owns pure Startchat function-agent parsing policy while core keeps AI calls and error mapping', + 'product-domains owns Startchat function-agent prompt and response policy while core keeps AI calls', patterns: [ + { + regex: /\bpub const WORK_STATE_ANALYSIS_PROMPT\b/, + message: 'missing product-domain Startchat prompt template', + }, + { + regex: /\bpub fn build_work_state_analysis_prompt\b/, + message: 'missing product-domain Startchat prompt builder', + }, { regex: /\bpub struct ParsedCompleteAnalysis\b/, message: 'missing Startchat complete-analysis parse result contract', @@ -1542,17 +2906,80 @@ const requiredContentRules = [ regex: /\bpub fn parse_complete_analysis_value\b/, message: 'missing Startchat complete-analysis value parser', }, + { + regex: /\bpub fn parse_complete_analysis_json\b/, + message: 'missing Startchat complete-analysis JSON parser', + }, + { + regex: /\bpub fn parse_work_state_analysis_response\b/, + message: 'missing Startchat AI response policy', + }, + { + regex: /\bwork_state_ai_response_policy_extracts_json_and_maps_domain_errors\b/, + message: 'missing Startchat AI response policy regression test', + }, ], }, { path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', reason: - 'product-domains owns pure Git function-agent response parsing policy while core keeps AI calls and error mapping', + 'product-domains owns Git function-agent prompt and response policy while core keeps AI calls', patterns: [ + { + regex: /\bpub const COMMIT_MESSAGE_PROMPT\b/, + message: 'missing product-domain Git function-agent prompt template', + }, { regex: /\bpub fn parse_commit_analysis_value\b/, message: 'missing Git function-agent commit analysis value parser', }, + { + regex: /\bpub fn parse_commit_analysis_json\b/, + message: 'missing Git function-agent commit analysis JSON parser', + }, + { + regex: /\bpub fn truncate_diff_for_commit_prompt\b/, + message: 'missing Git function-agent diff truncation helper', + }, + { + regex: /\bpub fn prepare_commit_prompt\b/, + message: 'missing Git function-agent prompt preparation helper', + }, + { + regex: /\bpub fn prepare_commit_ai_prompt\b/, + message: 'missing Git function-agent AI prompt policy', + }, + { + regex: /\bpub fn parse_commit_ai_response\b/, + message: 'missing Git function-agent AI response policy', + }, + { + regex: /\bcommit_ai_response_policy_extracts_json_and_maps_domain_errors\b/, + message: 'missing Git function-agent AI response policy regression test', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/runtime_detect.rs', + reason: + 'core MiniApp runtime detection must use product-domain search-plan helpers while retaining process-backed executable/version checks', + patterns: [ + { + regex: /\bruntime_lookup_order\b/, + message: 'missing product-domain runtime lookup order use', + }, + { + regex: /\bcandidate_executable_path\b/, + message: 'missing product-domain candidate executable helper use', + }, + { + regex: /\bversioned_executable_candidate\b/, + message: 'missing product-domain version-manager executable helper use', + }, + { + regex: /\bget_version\b/, + message: 'missing core-owned version process execution', + }, ], }, { @@ -1569,7 +2996,7 @@ const requiredContentRules = [ { path: 'src/crates/core/src/function_agents/port_adapters.rs', reason: - 'core must continue owning function-agent Git runtime adapter until Git/AI service migration is reviewed', + 'core must continue owning function-agent Git/AI runtime adapters until Git/AI service migration is reviewed', patterns: [ { regex: /\bpub struct CoreFunctionAgentGitAdapter\b/, @@ -1579,6 +3006,22 @@ const requiredContentRules = [ regex: /\bimpl FunctionAgentGitPort for CoreFunctionAgentGitAdapter\b/, message: 'missing function-agent Git port adapter owner', }, + { + regex: /\bpub struct CoreFunctionAgentAiAdapter\b/, + message: 'missing core function-agent AI adapter type', + }, + { + regex: /\bimpl FunctionAgentAiPort for CoreFunctionAgentAiAdapter\b/, + message: 'missing function-agent AI port adapter owner', + }, + { + regex: /\bgit_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate\b/, + message: 'missing function-agent Git snapshot boundary regression test', + }, + { + regex: /\bgit_adapter_startchat_snapshot_preserves_git_state_when_diff_has_no_head\b/, + message: 'missing Startchat Git diff fallback regression test', + }, ], }, ]; @@ -1918,16 +3361,16 @@ function runManifestParserSelfTest() { if (!agentToolsManifestRule) { throw new Error('missing agent-tools manifest-owner boundary rule'); } - const toolManifestContracts = [ + const agentToolsRuntimeForbiddenContracts = [ 'GetToolSpecTool', - 'GET_TOOL_SPEC_TOOL_NAME', 'manifest_resolver', 'unlocked_collapsed_tools', + 'ToolUseContext', ]; const agentToolsManifestRuleText = agentToolsManifestRule.patterns .map((pattern) => pattern.regex.source) .join('\n'); - for (const contract of toolManifestContracts) { + for (const contract of agentToolsRuntimeForbiddenContracts) { if (!agentToolsManifestRuleText.includes(contract)) { throw new Error(`agent-tools manifest boundary rule must forbid: ${contract}`); } @@ -1941,7 +3384,14 @@ function runManifestParserSelfTest() { const toolPacksManifestRuleText = toolPacksManifestRule.patterns .map((pattern) => pattern.regex.source) .join('\n'); - for (const contract of toolManifestContracts) { + const toolPacksManifestContracts = [ + 'GetToolSpecTool', + 'GET_TOOL_SPEC_TOOL_NAME', + 'manifest_resolver', + 'unlocked_collapsed_tools', + 'ToolExposure', + ]; + for (const contract of toolPacksManifestContracts) { if (!toolPacksManifestRuleText.includes(contract)) { throw new Error(`tool-packs manifest boundary rule must forbid: ${contract}`); } @@ -1957,6 +3407,58 @@ function runManifestParserSelfTest() { 'remote_image', ], }, + { + path: 'src/crates/agent-tools/src/framework.rs', + contracts: [ + 'GET_TOOL_SPEC_TOOL_NAME', + 'ToolExposure', + 'ToolManifestPolicyTool', + 'resolve_tool_manifest_policy', + 'default_exposure', + 'build_tool_manifest_policy_tools', + 'build_collapsed_tool_stub_definition', + 'PromptVisibleToolManifestItem', + 'build_prompt_visible_tool_manifest_definitions', + 'ContextualToolManifestItem', + 'ToolCatalogSnapshotProvider', + 'GetToolSpecCatalogProvider', + 'ContextualVisibleTools', + 'ContextualToolManifest', + 'resolve_contextual_visible_tools', + 'resolve_contextual_tool_manifest', + 'resolve_contextual_visible_tools_from_provider', + 'resolve_contextual_tool_manifest_from_provider', + 'build_get_tool_spec_catalog_description_from_provider', + 'resolve_get_tool_spec_detail_from_provider', + 'build_get_tool_spec_description', + 'GetToolSpecCollapsedToolSummary', + 'GetToolSpecDetail', + 'summarize_get_tool_spec_collapsed_tools', + 'resolve_get_tool_spec_detail', + 'build_get_tool_spec_catalog_description', + 'get_tool_spec_input_schema', + 'get_tool_spec_short_description', + 'render_get_tool_spec_tool_use_message', + 'get_tool_spec_is_readonly', + 'get_tool_spec_is_concurrency_safe', + 'get_tool_spec_needs_permissions', + 'validate_get_tool_spec_input', + 'build_get_tool_spec_assistant_detail', + 'build_get_tool_spec_duplicate_load_result', + 'build_get_tool_spec_detail_result', + 'GetToolSpecExecutionPlan', + 'GetToolSpecExecutionError', + 'resolve_get_tool_spec_execution_plan', + 'resolve_get_tool_spec_execution_result_from_provider', + 'GetToolSpecRuntime', + 'call_results', + 'GetToolSpecLoadObservation', + 'collect_loaded_collapsed_tool_names', + 'sort_tool_manifest_definitions', + 'is_tool_collapsed', + 'get_collapsed_tool_names', + ], + }, { path: 'src/crates/core/src/agentic/coordination/coordinator.rs', contracts: [ @@ -2034,19 +3536,134 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/core/src/agentic/tools/registry.rs', - contracts: ['register_all_tools', 'GetToolSpecTool', 'get_collapsed_tool_names'], + contracts: [ + 'from_inner', + 'ProductToolDecoratorRef', + 'ProductToolRuntimeAssembly', + 'get_collapsed_tool_names', + 'resolve_product_readonly_enabled_tools', + ], + }, + { + path: 'src/crates/core/src/agentic/tools/runtime_assembly.rs', + contracts: [ + 'ProductToolRuntimeAssembly', + 'SnapshotToolDecorator', + 'ProductSnapshotToolWrapper', + 'builtin_static_tool_providers', + 'ToolRuntimeAssembly', + 'create_registry_from_static_providers', + 'wrap_tool_for_snapshot_tracking', + ], + }, + { + path: 'src/crates/core/src/agentic/tools/static_providers.rs', + contracts: [ + 'builtin_static_tool_providers', + 'StaticToolProviderGroup', + 'product_tool_provider_group_plan', + 'materialize_tool', + 'GetToolSpecTool', + ], + }, + { + path: 'src/crates/agent-tools/src/framework.rs', + contracts: [ + 'ToolContextFacts', + 'PortableToolContextProvider', + 'ToolWorkspaceKind', + 'StaticToolProvider', + 'StaticToolProviderGroup', + 'ToolRuntimeAssembly', + 'ToolCatalogRuntime', + 'ToolDecoratorRef', + 'SnapshotToolWrapper', + 'SnapshotToolDecorator', + 'create_registry_from_static_providers', + 'install_static_provider', + 'resolve_readonly_enabled_tools', + 'build_get_tool_spec_duplicate_load_result', + 'build_get_tool_spec_detail_result', + 'resolve_get_tool_spec_execution_plan', + 'resolve_get_tool_spec_execution_result_from_provider', + 'GetToolSpecRuntime', + 'call_results', + ], + }, + { + path: 'src/crates/tool-packs/src/lib.rs', + contracts: [ + 'ToolPackFeatureGroup', + 'ToolProviderGroupPlan', + 'all_feature_groups', + 'enabled_feature_groups', + 'product_tool_provider_group_plan', + ], + }, + { + path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', + contracts: [ + 'ToolRegistryItem', + 'ContextualToolManifestItem', + 'Tool::dynamic_tool_info', + 'Tool::is_readonly', + 'Tool::is_enabled', + 'Tool::description_with_context', + 'Tool::input_schema_for_model_with_context', + ], + }, + { + path: 'src/crates/core/src/agentic/tools/catalog_provider.rs', + contracts: [ + 'ProductToolCatalogProvider', + 'ToolCatalogSnapshotProvider', + 'GetToolSpecCatalogProvider', + 'get_global_tool_registry', + 'get_agent_registry', + 'ToolCatalogRuntime', + 'product_tool_catalog_runtime', + 'GetToolSpecRuntime', + 'product_get_tool_spec_runtime', + 'resolve_product_tool_manifest', + 'resolve_product_readonly_enabled_tools', + 'resolve_product_get_tool_spec_results', + 'unlocked_collapsed_tools', + 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', + 'GetToolSpec requires agent type context', + ], }, { path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', - contracts: ['resolve_tool_manifest', 'GET_TOOL_SPEC_TOOL_NAME', 'ToolExposure'], + contracts: [ + 'resolve_tool_manifest', + 'GET_TOOL_SPEC_TOOL_NAME', + 'resolve_product_visible_tools', + 'resolve_product_tool_manifest', + 'collapsed_tool_names', + ], }, { path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', - contracts: ['GetToolSpecTool', 'unlocked_collapsed_tools', 'already_loaded'], + contracts: [ + 'GetToolSpecTool', + 'build_product_get_tool_spec_catalog_description', + 'product_get_tool_spec_runtime', + 'with_runtime', + 'resolve_product_get_tool_spec_results', + 'map_get_tool_spec_execution_error', + ], }, { path: 'src/crates/core/src/agentic/tools/framework.rs', - contracts: ['ToolExposure', 'ToolUseContext', 'unlocked_collapsed_tools'], + contracts: [ + 'ToolExposure', + 'ToolUseContext', + 'to_tool_context_facts', + 'tool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated', + 'customData', + 'cancellationToken', + 'unlocked_collapsed_tools', + ], }, { path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', @@ -2054,7 +3671,13 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/core/src/agentic/execution/execution_engine.rs', - contracts: ['collect_unlocked_collapsed_tools', 'collapsed_tool_names', 'GetToolSpec', 'citation_renumber'], + contracts: [ + 'collect_unlocked_collapsed_tools', + 'collect_unlocked_collapsed_tools_dedupes_and_filters_runtime_unlocks', + 'collapsed_tool_names', + 'GetToolSpec', + 'citation_renumber', + ], }, { path: 'src/crates/core/src/agentic/agents/registry/availability.rs', @@ -2064,6 +3687,51 @@ function runManifestParserSelfTest() { path: 'src/crates/core/src/agentic/agents/registry/types.rs', contracts: ['SubagentQueryContext', 'SubagentListScope', 'default_enabled', 'effective_enabled', 'SubagentStateReason'], }, + { + path: 'src/crates/core/src/agentic/agents/definitions/modes/mod.rs', + contracts: ['mod multitask', 'MultitaskMode'], + }, + { + path: 'src/crates/core/src/agentic/agents/definitions/subagents/mod.rs', + contracts: ['mod general_purpose', 'GeneralPurposeAgent'], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/builtin.rs', + contracts: ['builtin_agent_specs', 'Multitask', 'GeneralPurpose'], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', + contracts: ['run_in_background', 'start_background_subagent', 'background_task_id', 'Background subagent'], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + contracts: [ + 'deliver_background_subagent_result', + 'BackgroundSubagentResult', + 'CurrentRunningTurn', + 'AgentSession', + ], + }, + { + path: 'src/apps/cli/src/ui/startup.rs', + contracts: [ + 'show_available_subagent_list', + 'show_subagent_config_selector', + 'get_subagents_for_query', + 'SubagentQueryContext', + 'update_subagent_override', + ], + }, + { + path: 'src/apps/cli/src/ui/subagent_selector.rs', + contracts: [ + 'SubagentSelectorAction', + 'show_list', + 'show_config', + 'default_enabled', + 'render_subagent_line', + ], + }, { path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', contracts: ['run_for_session_workspace', 'try_renumber_research_report', 'display_map', 'REJECTED'], @@ -2088,17 +3756,109 @@ function runManifestParserSelfTest() { path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', contracts: ['openLocalDiff', 'snapshotAPI\\.getOperationDiff', 'Snapshot diff unavailable', 'localDiffContent'], }, + { + path: 'src/web-ui/src/main.tsx', + contracts: ['startupTrace', 'backgroundTaskScheduler', 'initializeAllTools', 'after_render_start'], + }, + { + path: 'src/web-ui/src/shared/utils/startupTrace.ts', + contracts: [ + 'sanitizeTraceData', + 'isRemoteTraceRequest', + 'recordApiCall', + 'flushSummary', + 'markPhaseAfterAnimationFrames', + ], + }, + { + path: 'src/web-ui/src/shared/utils/backgroundTaskScheduler.ts', + contracts: [ + 'BackgroundTaskScheduler', + 'inFlightKey', + 'AbortController', + 'BackgroundTaskCancelledError', + 'cancelIdle', + ], + }, + { + path: 'src/web-ui/src/tools/initializeTools.ts', + contracts: ['initializeAllTools', 'initializeLsp', 'initializeGit', 'does not import every tool'], + }, + { + path: 'src/web-ui/src/tools/editor/services/MonacoStartupWarmup.ts', + contracts: ['scheduleMonacoStartupWarmup', 'backgroundTaskScheduler', 'startup:monaco-warmup'], + }, + { + path: 'src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts', + contracts: ['historical_session_hydrate_request', 'Load history in the background', "historyState: 'ready'"], + }, { path: 'src/crates/core/src/miniapp/storage.rs', contracts: ['MiniAppStoragePort'], }, + { + path: 'src/crates/core/src/miniapp/builtin/mod.rs', + contracts: [ + 'builtin-pr-review', + 'BUILTIN_APPS', + 'builtin_content_hash', + 'should_seed_builtin_app', + 'resolve_builtin_seed_check', + 'resolve_builtin_seed_action', + 'builtin_source_files', + 'BUILTIN_PLACEHOLDER_COMPILED_HTML', + 'read_builtin_install_marker', + 'parse_builtin_install_marker', + 'write_builtin_install_marker', + 'serialize_builtin_install_marker', + 'recompile', + 'load_customization_metadata', + 'available_builtin_update', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/builtin.rs', + contracts: [ + 'BuiltinMiniAppBundle', + 'BuiltinInstallMarker', + 'BUILTIN_INSTALL_MARKER', + 'builtin_content_hash', + 'should_seed_builtin_app', + 'BuiltinSeedArtifacts', + 'BuiltinSeedCheck', + 'BuiltinSeedAction', + 'resolve_builtin_seed_check', + 'resolve_builtin_seed_action', + 'serialize_builtin_install_marker', + 'parse_builtin_install_marker', + 'builtin_source_files', + 'BUILTIN_PLACEHOLDER_COMPILED_HTML', + 'build_builtin_package_json', + ], + }, + { + path: 'src/crates/core/src/miniapp/host_dispatch.rs', + contracts: [ + 'dispatch_host', + 'dispatch_fs', + 'dispatch_shell', + 'command_basename_allowed', + 'host_allowed_by_allowlist', + ], + }, { path: 'src/crates/core/src/miniapp/js_worker_pool.rs', contracts: ['MiniAppRuntimePort'], }, { path: 'src/crates/core/src/function_agents/port_adapters.rs', - contracts: ['CoreFunctionAgentGitAdapter', 'FunctionAgentGitPort'], + contracts: [ + 'CoreFunctionAgentGitAdapter', + 'FunctionAgentGitPort', + 'CoreFunctionAgentAiAdapter', + 'FunctionAgentAiPort', + 'git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate', + ], }, { path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', @@ -2111,9 +3871,48 @@ function runManifestParserSelfTest() { 'unresolved_remote_session_storage_dir', ], }, + { + path: 'src/crates/product-domains/src/miniapp/ports.rs', + contracts: [ + 'MiniAppRuntimeFacade', + 'mark_deps_installed_state', + 'persist_sync_from_fs_result_for_app', + ], + }, { path: 'src/crates/product-domains/src/miniapp/storage.rs', - contracts: ['MiniAppStorageLayout', 'META_JSON', 'source_file_path', 'versions_dir'], + contracts: [ + 'MiniAppStorageLayout', + 'META_JSON', + 'source_file_path', + 'versions_dir', + 'DRAFT_JSON', + 'draft_dir', + 'customization_path', + 'REQUIRED_SOURCE_FILES', + 'PLACEHOLDER_COMPILED_HTML', + 'MiniAppImportLayout', + 'build_import_fallbacks', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/lifecycle.rs', + contracts: [ + 'mark_deps_installed_state', + 'clear_worker_restart_required_state', + 'prepare_rollback_app', + 'apply_recompile_result', + 'apply_sync_from_fs_result', + 'apply_import_runtime_state', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/draft.rs', + contracts: ['MiniAppDraftManifest', 'MiniAppDraft', 'build_draft_manifest', 'build_draft_response'], + }, + { + path: 'src/crates/product-domains/src/miniapp/runtime.rs', + contracts: ['runtime_lookup_order', 'candidate_executable_path', 'versioned_executable_candidate'], }, { path: 'src/crates/product-domains/src/miniapp/host_routing.rs', @@ -2127,17 +3926,102 @@ function runManifestParserSelfTest() { path: 'src/crates/product-domains/src/miniapp/customization.rs', contracts: [ 'MiniAppCustomizationMetadata', + 'MiniAppDeclinedBuiltinUpdate', 'MiniAppPermissionDiff', 'diff_permissions', + 'apply_draft_customization_metadata', + 'mark_builtin_update_available_metadata', + 'decline_builtin_update_metadata', + 'is_current_declined_builtin_update', + ], + }, + { + path: 'src/crates/core/src/miniapp/manager.rs', + contracts: [ + 'apply_draft_customization_metadata', + 'mark_builtin_update_available_metadata', + 'decline_builtin_update_metadata', + 'storage.load_customization_metadata', + 'MiniAppRuntimeFacade', + 'persist_sync_from_fs_result_for_app', + 'compile_source', + 'REQUIRED_SOURCE_FILES', + 'MiniAppImportLayout', + 'build_import_fallbacks', + 'apply_import_runtime_state', + 'runtime_preflight_preserves_recompile_sync_rollback_and_deps_state', + 'import_from_path_preserves_fallback_files_recompile_and_runtime_state', + ], + }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/ai_service.rs', + contracts: [ + 'prepare_commit_ai_prompt', + 'parse_commit_ai_response', + 'send_message', + 'AgentError::internal_error', + 'parse_commit_response_preserves_product_domain_response_policy', + ], + }, + { + path: 'src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs', + contracts: [ + 'build_work_state_analysis_prompt', + 'parse_work_state_analysis_response', + 'send_message', + 'AgentError::internal_error', + 'parse_complete_analysis_preserves_product_domain_response_policy', ], }, + { + path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', + contracts: [ + 'FunctionAgentRuntimeFacade', + 'CoreFunctionAgentGitAdapter', + 'CoreFunctionAgentAiAdapter', + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/ports.rs', + contracts: [ + 'FunctionAgentRuntimeFacade', + 'generate_commit_message', + 'analyze_work_state', + 'git_work_state_from_snapshot', + 'StartchatTimeSnapshot', + 'startchat_time_snapshot', + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/common.rs', + contracts: ['extract_json_from_ai_response', 'try_repair_json'], + }, { path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', - contracts: ['ParsedCompleteAnalysis', 'parse_complete_analysis_value'], + contracts: [ + 'WORK_STATE_ANALYSIS_PROMPT', + 'build_work_state_analysis_prompt', + 'ParsedCompleteAnalysis', + 'parse_complete_analysis_value', + 'parse_complete_analysis_json', + 'parse_work_state_analysis_response', + ], }, { path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', - contracts: ['parse_commit_analysis_value'], + contracts: [ + 'COMMIT_MESSAGE_PROMPT', + 'parse_commit_analysis_value', + 'parse_commit_analysis_json', + 'truncate_diff_for_commit_prompt', + 'prepare_commit_prompt', + 'prepare_commit_ai_prompt', + 'parse_commit_ai_response', + ], + }, + { + path: 'src/crates/core/src/miniapp/runtime_detect.rs', + contracts: ['runtime_lookup_order', 'candidate_executable_path', 'versioned_executable_candidate', 'get_version'], }, ]; for (const { path, contracts } of requiredContentContracts) { diff --git a/src/apps/cli/build.rs b/src/apps/cli/build.rs index dfde3bb73c..467fbc9ea4 100644 --- a/src/apps/cli/build.rs +++ b/src/apps/cli/build.rs @@ -38,7 +38,10 @@ fn read_prompt_files( if path.is_dir() { read_prompt_files(&path, base_dir, prompts)?; - } else if matches!(path.extension().and_then(|e| e.to_str()), Some("md" | "txt")) { + } else if matches!( + path.extension().and_then(|e| e.to_str()), + Some("md" | "txt") + ) { let content = fs::read_to_string(&path)?; let relative = path .strip_prefix(base_dir)? @@ -67,11 +70,18 @@ fn generate_prompts_code( writeln!(f, "use std::collections::HashMap;")?; writeln!(f, "use once_cell::sync::Lazy;")?; writeln!(f)?; - writeln!(f, "pub static CLI_PROMPTS: Lazy> = Lazy::new(|| {{")?; + writeln!( + f, + "pub static CLI_PROMPTS: Lazy> = Lazy::new(|| {{" + )?; writeln!(f, " let mut m = HashMap::new();")?; for (key, content) in prompts { - writeln!(f, " m.insert(r###\"{}\"###, r###\"{}\"###);", key, content)?; + writeln!( + f, + " m.insert(r###\"{}\"###, r###\"{}\"###);", + key, content + )?; } writeln!(f, " m")?; @@ -79,7 +89,10 @@ fn generate_prompts_code( writeln!(f)?; writeln!(f, "/// Get an embedded CLI prompt by name")?; - writeln!(f, "pub fn get_cli_prompt(name: &str) -> Option<&'static str> {{")?; + writeln!( + f, + "pub fn get_cli_prompt(name: &str) -> Option<&'static str> {{" + )?; writeln!(f, " CLI_PROMPTS.get(name).copied()")?; writeln!(f, "}}")?; diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs index 3b59785f5b..2f81af94f9 100644 --- a/src/apps/cli/src/acp_cli.rs +++ b/src/apps/cli/src/acp_cli.rs @@ -50,7 +50,10 @@ impl ExternalAcpClient { fn config(self) -> AcpClientConfig { let (command, args) = match self { Self::Opencode => ("opencode", vec!["acp"]), - Self::ClaudeCode => ("npx", vec!["--yes", "@zed-industries/claude-code-acp@latest"]), + Self::ClaudeCode => ( + "npx", + vec!["--yes", "@zed-industries/claude-code-acp@latest"], + ), Self::Codex => ("npx", vec!["--yes", "@zed-industries/codex-acp@latest"]), }; AcpClientConfig { @@ -251,10 +254,7 @@ pub async fn list_external_clients() -> Result<()> { } for info in configured.values() { - if !matches!( - info.id.as_str(), - "opencode" | "claude-code" | "codex" - ) { + if !matches!(info.id.as_str(), "opencode" | "claude-code" | "codex") { print_client_info(info); } } @@ -287,7 +287,9 @@ pub async fn doctor_external_clients() -> Result { if has_runnable { println!("At least one external ACP agent is runnable."); } else { - println!("No external ACP agent is runnable yet. Install opencode, Claude Code, or Codex first."); + println!( + "No external ACP agent is runnable yet. Install opencode, Claude Code, or Codex first." + ); } Ok(has_runnable) } @@ -410,7 +412,7 @@ pub async fn run_external_client( if output.trim().is_empty() { println!("External ACP agent completed without text output."); } else { - println!("{}", output); + println!("{}", output); } Ok(()) } @@ -489,7 +491,10 @@ fn update_client_config_json( entry_object.insert("enabled".to_string(), json!(enabled)); if let Some(permission_mode) = permission_mode { - entry_object.insert("permissionMode".to_string(), serde_json::to_value(permission_mode)?); + entry_object.insert( + "permissionMode".to_string(), + serde_json::to_value(permission_mode)?, + ); } Ok(value) @@ -542,7 +547,11 @@ fn print_requirement_probe(probe: &AcpClientRequirementProbe) { } fn print_requirement_item(label: &str, item: &bitfun_acp::client::AcpRequirementProbeItem) { - let installed = if item.installed { "installed" } else { "missing" }; + let installed = if item.installed { + "installed" + } else { + "missing" + }; let mut details = Vec::new(); if let Some(version) = item.version.as_ref().filter(|value| !value.is_empty()) { details.push(format!("version {}", version)); diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index cc79937cae..1e5cbd8074 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -129,7 +129,11 @@ impl CoreAgentAdapter { ); let workspace = self.workspace_path_buf(); - match self.coordinator.restore_session(&workspace, session_id).await { + match self + .coordinator + .restore_session(&workspace, session_id) + .await + { Ok(_) => { tracing::info!("Backend session restored: {}", session_id); Ok(()) @@ -211,7 +215,8 @@ impl Agent for CoreAgentAdapter { session_id, err ); - self.ensure_backend_session_alive(&session_id, agent_type).await?; + self.ensure_backend_session_alive(&session_id, agent_type) + .await?; self.coordinator .start_dialog_turn( session_id, @@ -237,11 +242,7 @@ impl Agent for CoreAgentAdapter { let turn_id_guard = self.current_turn_id.lock().await; if let (Some(session_id), Some(turn_id)) = (&*session_id_guard, &*turn_id_guard) { - tracing::info!( - "Cancelling turn: session={}, turn={}", - session_id, - turn_id - ); + tracing::info!("Cancelling turn: session={}, turn={}", session_id, turn_id); self.coordinator .cancel_dialog_turn(session_id, turn_id) .await?; @@ -276,7 +277,9 @@ impl Agent for CoreAgentAdapter { async fn restore_session(&self, session_id: &str) -> Result<()> { tracing::info!("Restoring session: {}", session_id); let workspace = self.workspace_path_buf(); - self.coordinator.restore_session(&workspace, session_id).await?; + self.coordinator + .restore_session(&workspace, session_id) + .await?; let mut session_id_guard = self.session_id.lock().await; *session_id_guard = Some(session_id.to_string()); @@ -284,7 +287,11 @@ impl Agent for CoreAgentAdapter { Ok(()) } - async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> Result<()> { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<()> { tracing::info!("Confirming tool execution: {}", tool_id); self.coordinator .confirm_tool(tool_id, updated_input) @@ -304,12 +311,16 @@ impl Agent for CoreAgentAdapter { tracing::info!("Submitting user answers for tool: {}", tool_id); use bitfun_core::agentic::tools::user_input_manager::get_user_input_manager; let manager = get_user_input_manager(); - manager.send_answer(tool_id, answers) + manager + .send_answer(tool_id, answers) .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e)) } fn session_id(&self) -> Option { // Try to get session_id without blocking (best effort for sync context) - self.session_id.try_lock().ok().and_then(|guard| guard.clone()) + self.session_id + .try_lock() + .ok() + .and_then(|guard| guard.clone()) } } diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index a5e78f29e8..2eb855591b 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -3,7 +3,6 @@ /// Wraps interaction with bitfun-core's Agentic system. /// The Agent trait provides a thin adapter over ConversationCoordinator. /// Event consumption is done externally (in the chat/exec mode main loops). - pub mod agentic_system; pub mod core_adapter; @@ -31,7 +30,11 @@ pub trait Agent: Send + Sync { async fn restore_session(&self, session_id: &str) -> Result<()>; /// Confirm tool execution (allow once) - async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> Result<()>; + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<()>; /// Reject tool execution async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()>; diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index fd48592bd7..2d83329f6b 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -1,11 +1,10 @@ +use std::collections::HashMap; /// Chat state module /// /// Pure UI rendering state for the chat interface. /// All session lifecycle and persistence is handled by bitfun-core. /// This module only maintains transient state needed for TUI rendering. - use std::time::SystemTime; -use std::collections::HashMap; use bitfun_core::agentic::core::message::{ Message as CoreMessage, MessageContent, MessageRole as CoreMessageRole, @@ -116,18 +115,11 @@ pub struct ToolDisplayState { #[derive(Debug, Clone)] pub enum FlowItem { /// Text content block - Text { - content: String, - is_streaming: bool, - }, + Text { content: String, is_streaming: bool }, /// AI thinking/reasoning block - Thinking { - content: String, - }, + Thinking { content: String }, /// Tool call block - Tool { - tool_state: ToolDisplayState, - }, + Tool { tool_state: ToolDisplayState }, } /// A chat message for UI rendering (converted from core Message + streaming state) @@ -277,7 +269,6 @@ pub struct ChatState { pub metadata: ChatMetadata, // -- Streaming state (transient, not persisted) -- - /// Current turn ID being processed current_turn_id: Option, /// Ordered flow items for the current streaming message. @@ -290,12 +281,10 @@ pub struct ChatState { pub is_processing: bool, // -- Permission state -- - /// Current pending permission prompt (if a tool needs user confirmation) pub permission_prompt: Option, // -- Question state -- - /// Current pending question prompt (if AskUserQuestion tool is waiting for answers) pub question_prompt: Option, } @@ -451,7 +440,9 @@ impl ChatState { pub fn handle_thinking_chunk(&mut self, content: &str) { // Try to append to the last Thinking block // (Thinking usually comes before text, so check the last item) - let appended = if let Some(FlowItem::Thinking { content: existing }) = self.current_flow_items.last_mut() { + let appended = if let Some(FlowItem::Thinking { content: existing }) = + self.current_flow_items.last_mut() + { existing.push_str(content); true } else { @@ -475,19 +466,23 @@ impl ChatState { pub fn handle_tool_event(&mut self, tool_event: &ToolEventData) { match tool_event { ToolEventData::EarlyDetected { tool_id, tool_name } => { - self.insert_or_update_tool(tool_id, |_existing| { - // Should not exist yet, but handle gracefully - }, || ToolDisplayState { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), - parameters: serde_json::Value::Null, - status: ToolDisplayStatus::EarlyDetected, - result: None, - progress_message: None, - duration_ms: None, - metadata: None, - subagent_progress: None, - }); + self.insert_or_update_tool( + tool_id, + |_existing| { + // Should not exist yet, but handle gracefully + }, + || ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + parameters: serde_json::Value::Null, + status: ToolDisplayStatus::EarlyDetected, + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }, + ); self.rebuild_streaming_message(); } @@ -528,8 +523,7 @@ impl ChatState { if !tool.status.is_execution_phase() { tool.status = ToolDisplayStatus::Waiting; } - tool.progress_message = - Some(format!("Waiting for: {:?}", dependencies)); + tool.progress_message = Some(format!("Waiting for: {:?}", dependencies)); }); self.rebuild_streaming_message(); } @@ -543,28 +537,29 @@ impl ChatState { let params_for_update = params.clone(); let params_for_create = params.clone(); let tool_name_clone = tool_name.clone(); - self.insert_or_update_tool(tool_id, |tool| { - tool.status = ToolDisplayStatus::Running; - tool.parameters = params_for_update; - }, || ToolDisplayState { - tool_id: tool_id.clone(), - tool_name: tool_name_clone, - parameters: params_for_create, - status: ToolDisplayStatus::Running, - result: None, - progress_message: None, - duration_ms: None, - metadata: None, - subagent_progress: None, - }); + self.insert_or_update_tool( + tool_id, + |tool| { + tool.status = ToolDisplayStatus::Running; + tool.parameters = params_for_update; + }, + || ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name_clone, + parameters: params_for_create, + status: ToolDisplayStatus::Running, + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }, + ); self.metadata.tool_calls += 1; // Auto-create question prompt for AskUserQuestion tool if tool_name == "AskUserQuestion" { - if let Some(prompt) = QuestionPrompt::from_params( - tool_id.clone(), - params, - ) { + if let Some(prompt) = QuestionPrompt::from_params(tool_id.clone(), params) { self.question_prompt = Some(prompt); } } @@ -588,17 +583,19 @@ impl ChatState { } => { self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::Streaming; - tool.progress_message = - Some(format!("Received {} chunks", chunks_received)); + tool.progress_message = Some(format!("Received {} chunks", chunks_received)); }); self.rebuild_streaming_message(); } - ToolEventData::ConfirmationNeeded { tool_id, tool_name, params } => { + ToolEventData::ConfirmationNeeded { + tool_id, + tool_name, + params, + } => { self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::ConfirmationNeeded; - tool.progress_message = - Some("Waiting for user confirmation".to_string()); + tool.progress_message = Some("Waiting for user confirmation".to_string()); }); // Auto-create permission prompt for user interaction self.permission_prompt = Some(PermissionPrompt::new( @@ -641,16 +638,14 @@ impl ChatState { .. } => { // Prefer result_for_assistant from tool, fallback to extracting from JSON - let result_str = result_for_assistant.clone() + let result_str = result_for_assistant + .clone() .unwrap_or_else(|| extract_fallback_summary(result)); let metadata = result.clone(); let dur = *duration_ms; self.update_tool(tool_id, |tool| { let is_hmos_failed = tool_name == "HmosCompilation" - && result - .get("success") - .and_then(|v| v.as_bool()) - == Some(false); + && result.get("success").and_then(|v| v.as_bool()) == Some(false); tool.status = if is_hmos_failed { ToolDisplayStatus::Failed } else { @@ -667,9 +662,7 @@ impl ChatState { self.rebuild_streaming_message(); } - ToolEventData::Failed { - tool_id, error, .. - } => { + ToolEventData::Failed { tool_id, error, .. } => { let err = error.clone(); self.update_tool(tool_id, |tool| { tool.status = ToolDisplayStatus::Failed; @@ -706,56 +699,68 @@ impl ChatState { /// /// When a subagent emits events (tool started, completed, etc.), we forward /// key information to the parent Task tool so the UI can show real-time progress. - pub fn handle_subagent_event(&mut self, parent_tool_id: &str, event: &bitfun_events::AgenticEvent) { + pub fn handle_subagent_event( + &mut self, + parent_tool_id: &str, + event: &bitfun_events::AgenticEvent, + ) { use bitfun_events::AgenticEvent; match event { - AgenticEvent::ToolEvent { tool_event, .. } => { - match tool_event { - ToolEventData::Started { - tool_name, - params, - .. - } => { - let title = extract_tool_title(tool_name, params); - self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); - progress.tool_count += 1; - progress.current_tool_name = Some(tool_name.clone()); - progress.current_tool_title = title; - }); - self.rebuild_streaming_message(); - } - ToolEventData::Completed { - tool_name, - result_for_assistant, - result: _, - .. - } => { - let summary = result_for_assistant.clone() - .unwrap_or_else(|| tool_name.clone()); - self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); - progress.current_tool_name = Some(tool_name.clone()); - progress.current_tool_title = Some(summary); - }); - self.rebuild_streaming_message(); - } - ToolEventData::Failed { tool_name, error, .. } => { - self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); - progress.current_tool_name = Some(tool_name.clone()); - progress.current_tool_title = Some(format!("Error: {}", truncate_string(error, 60))); - }); - self.rebuild_streaming_message(); - } - _ => {} + AgenticEvent::ToolEvent { tool_event, .. } => match tool_event { + ToolEventData::Started { + tool_name, params, .. + } => { + let title = extract_tool_title(tool_name, params); + self.update_tool(parent_tool_id, |tool| { + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); + progress.tool_count += 1; + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = title; + }); + self.rebuild_streaming_message(); } - } + ToolEventData::Completed { + tool_name, + result_for_assistant, + result: _, + .. + } => { + let summary = result_for_assistant + .clone() + .unwrap_or_else(|| tool_name.clone()); + self.update_tool(parent_tool_id, |tool| { + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = Some(summary); + }); + self.rebuild_streaming_message(); + } + ToolEventData::Failed { + tool_name, error, .. + } => { + self.update_tool(parent_tool_id, |tool| { + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = + Some(format!("Error: {}", truncate_string(error, 60))); + }); + self.rebuild_streaming_message(); + } + _ => {} + }, AgenticEvent::ModelRoundStarted { round_index, .. } => { if *round_index > 0 { self.update_tool(parent_tool_id, |tool| { - let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); + let progress = tool + .subagent_progress + .get_or_insert_with(SubagentProgress::default); progress.current_tool_name = None; progress.current_tool_title = Some(format!("Round {}", round_index + 1)); }); @@ -905,11 +910,7 @@ impl ChatState { /// Update an existing tool in current_flow_items via tool_index. /// No-op if the tool_id is not found (defensive). - fn update_tool( - &mut self, - tool_id: &str, - update_fn: impl FnOnce(&mut ToolDisplayState), - ) { + fn update_tool(&mut self, tool_id: &str, update_fn: impl FnOnce(&mut ToolDisplayState)) { if let Some(&idx) = self.tool_index.get(tool_id) { if let Some(FlowItem::Tool { tool_state }) = self.current_flow_items.get_mut(idx) { update_fn(tool_state); @@ -976,30 +977,42 @@ fn extract_tool_title(tool_name: &str, params: &serde_json::Value) -> Option { - obj.get("path").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) - } - "Bash" => { - obj.get("command").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) - } - "Grep" => { - obj.get("pattern").and_then(|v| v.as_str()).map(|s| truncate_string(s, 40)) - } - "Glob" | "LS" => { - obj.get("glob_pattern") - .or_else(|| obj.get("target_directory")) - .and_then(|v| v.as_str()) - .map(|s| truncate_string(s, 50)) - } - "WebSearch" => { - obj.get("search_term").and_then(|v| v.as_str()).map(|s| truncate_string(s, 40)) - } - "WebFetch" => { - obj.get("url").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) - } + "Read" | "Write" | "Edit" | "Delete" | "GetFileDiff" => obj + .get("path") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), + "Bash" => obj + .get("command") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), + "Grep" => obj + .get("pattern") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 40)), + "Glob" | "LS" => obj + .get("glob_pattern") + .or_else(|| obj.get("target_directory")) + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), + "WebSearch" => obj + .get("search_term") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 40)), + "WebFetch" => obj + .get("url") + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)), _ => { // Generic: try common parameter names - for key in &["path", "file_path", "command", "query", "pattern", "url", "description"] { + for key in &[ + "path", + "file_path", + "command", + "query", + "pattern", + "url", + "description", + ] { if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) { if !s.is_empty() { return Some(truncate_string(s, 50)); diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 9ff936d7c8..71b8f952fd 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -42,11 +42,11 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ }, CommandSpec { name: "/skills", - description: "Browse and execute skills", + description: "List and configure skills", }, CommandSpec { name: "/subagents", - description: "Browse and launch subagents", + description: "List and configure subagents", }, CommandSpec { name: "/mcps", @@ -84,6 +84,10 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ name: "/models", description: "Select model for all modes", }, + CommandSpec { + name: "/theme", + description: "Switch UI theme", + }, CommandSpec { name: "/connect", description: "Add a new AI model configuration", @@ -94,11 +98,11 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ }, CommandSpec { name: "/skills", - description: "Browse and execute skills", + description: "List and configure skills", }, CommandSpec { name: "/subagents", - description: "Browse and launch subagents", + description: "List and configure subagents", }, CommandSpec { name: "/mcps", @@ -118,7 +122,10 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ }, ]; -pub fn match_prefix_in(prefix: &str, commands: &'static [CommandSpec]) -> Vec<&'static CommandSpec> { +pub fn match_prefix_in( + prefix: &str, + commands: &'static [CommandSpec], +) -> Vec<&'static CommandSpec> { if prefix.is_empty() { return Vec::new(); } diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 6fc4f51e81..0740e37a42 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -1,12 +1,11 @@ /// Configuration management module -/// +/// /// CLI uses core's GlobalConfig system directly. /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) - use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::fs; +use std::path::PathBuf; /// CLI configuration (contains only CLI-specific config) /// AI model configuration uses core's GlobalConfig @@ -73,7 +72,7 @@ impl Default for UiConfig { fn default() -> Self { Self { theme: "dark".to_string(), - theme_id: "cursor".to_string(), + theme_id: "bitfun-dark".to_string(), show_tips: true, animation: true, color_scheme: "default".to_string(), @@ -139,14 +138,14 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + Ok(config_dir.join("config.toml")) } /// Load configuration pub fn load() -> Result { let config_path = Self::config_path()?; - + if !config_path.exists() { tracing::info!("Config file not found, using defaults"); let config = Self::default(); @@ -163,7 +162,7 @@ impl CliConfig { /// Save configuration pub fn save(&self) -> Result<()> { let config_path = Self::config_path()?; - + if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } @@ -186,7 +185,7 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + fs::create_dir_all(&config_dir)?; Ok(config_dir) } @@ -198,5 +197,4 @@ impl CliConfig { fs::create_dir_all(&sessions_dir)?; Ok(sessions_dir) } - } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index b28b5e92b8..471ec21473 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,24 +1,23 @@ /// BitFun CLI -/// +/// /// Command-line interface version, supports: /// - Interactive TUI /// - Single command execution /// - Batch task processing - mod acp_cli; -mod config; +mod agent; #[allow(dead_code)] mod chat_state; mod commands; -mod ui; +mod config; mod modes; -mod agent; mod prompts; +mod ui; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use std::sync::OnceLock; use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::OnceLock; use config::CliConfig; use modes::chat::ChatMode; @@ -61,7 +60,7 @@ pub fn get_mcp_service() -> Option<&'static std::sync::Arc, - + /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, @@ -75,39 +74,39 @@ enum Commands { #[arg(short, long, default_value = "agentic")] agent: String, }, - + /// Execute single command Exec { /// User message message: String, - + /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch #[arg(long, num_args = 0..=1, default_missing_value = "-")] output_patch: Option, - + /// Tool execution requires confirmation (default: no confirmation to avoid blocking non-interactive mode) #[arg(long)] confirm: bool, }, - + /// Session management Sessions { #[command(subcommand)] action: SessionAction, }, - + /// Configuration management Config { #[command(subcommand)] action: ConfigAction, }, - + /// Health check Health, @@ -313,10 +312,10 @@ async fn initialize_core_services( Ok(mcp_service) => { let mcp_service = std::sync::Arc::new(mcp_service); MCP_SERVICE.set(mcp_service.clone()).ok(); - + // Mark as in progress get_mcp_init_status().store(1, Ordering::Relaxed); - + // Background async initialization tokio::spawn(async move { let result = mcp_service.server_manager().initialize_all().await; @@ -345,9 +344,7 @@ async fn initialize_core_services( /// Restore original tool confirmation setting async fn restore_tool_confirmation(original: bool) { if let Ok(svc) = bitfun_core::service::config::get_global_config_service().await { - let _ = svc - .set_config("ai.skip_tool_confirmation", original) - .await; + let _ = svc.set_config("ai.skip_tool_confirmation", original).await; } } @@ -366,7 +363,7 @@ async fn shutdown_mcp_servers() { /// Run the full interactive TUI flow: loading screen → startup page → chat async fn run_interactive( - config: CliConfig, + _config: CliConfig, default_agent: String, _workspace_str: String, ) -> Result<()> { @@ -380,8 +377,7 @@ async fn run_interactive( let workspace = setup_workspace(); // 3. Initialize core services - let (agentic_system, original_skip_confirmation) = - initialize_core_services(true).await?; + let (agentic_system, original_skip_confirmation) = initialize_core_services(true).await?; // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( @@ -412,6 +408,7 @@ async fn run_interactive( let agent_type = startup_page.agent_type().to_string(); // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); + let config = startup_page.config().clone(); let mut chat_mode = ChatMode::new(config, agent_type, workspace, &agentic_system); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); @@ -434,7 +431,7 @@ async fn run_interactive( #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - + let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); let is_acp_command = matches!(cli.command, Some(Commands::Acp { .. })); let is_acp_serve = matches!( @@ -453,22 +450,19 @@ async fn main() -> Result<()> { } else { tracing::Level::INFO }; - + if is_tui_mode { use std::fs::OpenOptions; - - let log_dir = CliConfig::config_dir().ok() + + let log_dir = CliConfig::config_dir() + .ok() .map(|d| d.join("logs")) .unwrap_or_else(|| std::env::temp_dir().join("bitfun-cli")); - + std::fs::create_dir_all(&log_dir).ok(); let log_file = log_dir.join("bitfun-cli.log"); - - if let Ok(file) = OpenOptions::new() - .create(true) - .append(true) - .open(log_file) - { + + if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_file) { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(move || file.try_clone().unwrap()) @@ -494,7 +488,7 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - + let config = CliConfig::load().unwrap_or_else(|e| { if !is_tui_mode { eprintln!("Warning: Failed to load config: {}", e); @@ -502,28 +496,33 @@ async fn main() -> Result<()> { } CliConfig::default() }); - + match cli.command { Some(Commands::Chat { agent }) => { // Interactive mode with startup page, scoped to the current directory. run_interactive(config, agent, ".".to_string()).await?; } - - Some(Commands::Exec { message, agent, output_patch, confirm }) => { + + Some(Commands::Exec { + message, + agent, + output_patch, + confirm, + }) => { let workspace_path_resolved = std::env::current_dir().ok(); - + if let Some(ref ws_path) = workspace_path_resolved { tracing::info!("Workspace path set: {:?}", ws_path); } - + let skip_confirmation = !confirm; let (agentic_system, original_skip_confirmation) = initialize_core_services(skip_confirmation).await?; - + let mut exec_mode = ExecMode::new( - config, - message, - agent, + config, + message, + agent, &agentic_system, workspace_path_resolved, output_patch, @@ -535,15 +534,15 @@ async fn main() -> Result<()> { run_result?; } - + Some(Commands::Sessions { action }) => { handle_session_action(action).await?; } - + Some(Commands::Config { action }) => { handle_config_action(action, &config)?; } - + Some(Commands::Health) => { println!("BitFun CLI is running normally"); println!("Version: {}", env!("CARGO_PKG_VERSION")); @@ -598,23 +597,21 @@ async fn main() -> Result<()> { Some(Commands::Acp { action: Some(AcpAction::Clients { action }), - }) => { - match action { - AcpClientsAction::List => acp_cli::list_external_clients().await?, - AcpClientsAction::Doctor => { - if !acp_cli::doctor_external_clients().await? { - std::process::exit(1); - } - } - AcpClientsAction::Enable { client, permission } => { - acp_cli::enable_external_client(client, permission).await?; + }) => match action { + AcpClientsAction::List => acp_cli::list_external_clients().await?, + AcpClientsAction::Doctor => { + if !acp_cli::doctor_external_clients().await? { + std::process::exit(1); } - AcpClientsAction::Disable { client_id } => { - acp_cli::disable_external_client(&client_id).await?; - } - AcpClientsAction::Config => acp_cli::print_external_client_config().await?, } - } + AcpClientsAction::Enable { client, permission } => { + acp_cli::enable_external_client(client, permission).await?; + } + AcpClientsAction::Disable { client_id } => { + acp_cli::disable_external_client(&client_id).await?; + } + AcpClientsAction::Config => acp_cli::print_external_client_config().await?, + }, Some(Commands::Acp { action: @@ -628,7 +625,7 @@ async fn main() -> Result<()> { }) => { acp_cli::run_external_client(client, prompt, workspace, timeout, permission).await?; } - + None => { // Default: interactive TUI with startup page let workspace_str = ".".to_string(); @@ -637,7 +634,7 @@ async fn main() -> Result<()> { run_interactive(config, default_agent, workspace_str).await?; } } - + Ok(()) } @@ -652,8 +649,7 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { .expect("Failed to initialize agentic system"); let coordinator = agentic_system.coordinator.clone(); - let workspace_path = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from(".")); + let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); match action { SessionAction::List => { @@ -675,7 +671,8 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { for (i, info) in sessions.iter().enumerate() { let last_updated = { - let duration = info.last_activity_at + let duration = info + .last_activity_at .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default(); let secs = duration.as_secs() as i64; @@ -728,21 +725,31 @@ async fn handle_session_action(action: SessionAction) -> Result<()> { bitfun_core::agentic::core::message::MessageContent::Text(text) => { text.lines().next().unwrap_or("").to_string() } - bitfun_core::agentic::core::message::MessageContent::Multimodal { text, images } => { + bitfun_core::agentic::core::message::MessageContent::Multimodal { + text, + images, + } => { if text.is_empty() { format!("[{} images]", images.len()) } else { text.lines().next().unwrap_or("").to_string() } } - bitfun_core::agentic::core::message::MessageContent::Mixed { text, tool_calls, .. } => { + bitfun_core::agentic::core::message::MessageContent::Mixed { + text, + tool_calls, + .. + } => { if text.is_empty() { format!("[{} tool calls]", tool_calls.len()) } else { text.lines().next().unwrap_or("").to_string() } } - bitfun_core::agentic::core::message::MessageContent::ToolResult { tool_name, .. } => { + bitfun_core::agentic::core::message::MessageContent::ToolResult { + tool_name, + .. + } => { format!("[Tool result: {}]", tool_name) } }; @@ -786,7 +793,7 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(); println!("Config file: {:?}", CliConfig::config_path()?); } - + ConfigAction::Edit => { let config_path = CliConfig::config_path()?; println!("Config file location: {:?}", config_path); @@ -796,13 +803,13 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(" or"); println!(" code {:?}", config_path); } - + ConfigAction::Reset => { let default_config = CliConfig::default(); default_config.save()?; println!("Reset to default configuration"); } } - + Ok(()) } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 65ec3b1b22..cdd51b94b9 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -9,6 +9,7 @@ use crossterm::event::{ }; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; +use std::collections::HashMap; use std::io; use std::path::PathBuf; use std::sync::Arc; @@ -30,84 +31,27 @@ use crate::ui::permission::PermissionAction; use crate::ui::provider_selector::ProviderSelection; use crate::ui::question::QuestionAction; use crate::ui::session_selector::{SessionAction, SessionItem}; -use crate::ui::skill_selector::SkillItem; -use crate::ui::subagent_selector::SubagentItem; +use crate::ui::skill_selector::{SkillItem, SkillSelectorAction}; +use crate::ui::subagent_selector::{SubagentItem, SubagentSelectorAction}; use crate::ui::theme::{ builtin_theme_ids, builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, Appearance, EffectiveColorScheme, Theme, }; use crate::ui::theme_selector::ThemeItem; use crate::ui::{init_terminal, restore_terminal}; -use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; -use bitfun_core::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_core::agentic::agents::{ + get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, +}; +use bitfun_core::agentic::tools::implementations::skills::{ + mode_overrides::{ + load_project_mode_skills_document_local, save_project_mode_skills_document_local, + set_mode_skill_disabled_in_document, set_user_mode_skill_state, + }, + registry::SkillRegistry, + ModeSkillInfo, SkillInfo, +}; use bitfun_core::service::config::GlobalConfigManager; -fn event_subagent_parent_info( - event: &bitfun_events::AgenticEvent, -) -> Option<&bitfun_events::SubagentParentInfo> { - use bitfun_events::AgenticEvent; - - match event { - AgenticEvent::DialogTurnStarted { - subagent_parent_info, - .. - } - | AgenticEvent::DialogTurnCompleted { - subagent_parent_info, - .. - } - | AgenticEvent::DialogTurnCancelled { - subagent_parent_info, - .. - } - | AgenticEvent::DialogTurnFailed { - subagent_parent_info, - .. - } - | AgenticEvent::ContextCompressionStarted { - subagent_parent_info, - .. - } - | AgenticEvent::ContextCompressionCompleted { - subagent_parent_info, - .. - } - | AgenticEvent::ContextCompressionFailed { - subagent_parent_info, - .. - } - | AgenticEvent::ModelRoundStarted { - subagent_parent_info, - .. - } - | AgenticEvent::ModelRoundCompleted { - subagent_parent_info, - .. - } - | AgenticEvent::TextChunk { - subagent_parent_info, - .. - } - | AgenticEvent::ThinkingChunk { - subagent_parent_info, - .. - } - | AgenticEvent::ToolEvent { - subagent_parent_info, - .. - } - | AgenticEvent::DeepReviewQueueStateChanged { - subagent_parent_info, - .. - } - | AgenticEvent::UserSteeringInjected { - subagent_parent_info, - .. - } => subagent_parent_info.as_ref(), - _ => None, - } -} - /// Keyboard shortcuts help text const KEYBOARD_SHORTCUTS_HELP: &str = "\ Keyboard Shortcuts\n\ @@ -463,6 +407,7 @@ impl ChatMode { let mut exit_reason = ChatExitReason::Quit; let mut should_quit = false; let mut needs_redraw = true; + let mut subagent_parent_tools: HashMap = HashMap::new(); let mut last_spinner_redraw = Instant::now(); let mut pending_resize_at: Option = None; let spinner_redraw_interval = Duration::from_millis(SPINNER_REDRAW_INTERVAL_MS); @@ -548,16 +493,31 @@ impl ChatMode { for envelope in events { let event = &envelope.event; + if let AgenticEvent::SubagentSessionLinked { + session_id: subagent_session_id, + parent_session_id, + parent_tool_call_id, + .. + } = event + { + if parent_session_id == &session_id { + subagent_parent_tools + .insert(subagent_session_id.clone(), parent_tool_call_id.clone()); + } + continue; + } + // Check if this is a subagent event that belongs to our session if event.session_id() != Some(&session_id) { // Check if this event was emitted by a subagent whose parent is in our session - if let Some(parent_info) = event_subagent_parent_info(event) { - if parent_info.session_id == session_id { - // Forward subagent event to the parent Task tool for progress display - chat_state.handle_subagent_event(&parent_info.tool_call_id, event); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } + if let Some(parent_tool_call_id) = event + .session_id() + .and_then(|event_session_id| subagent_parent_tools.get(event_session_id)) + { + // Forward subagent event to the parent Task tool for progress display + chat_state.handle_subagent_event(parent_tool_call_id, event); + chat_view.invalidate_lines_cache(); + needs_redraw = true; } continue; } @@ -1068,10 +1028,9 @@ impl ChatMode { match key.code { KeyCode::Up => chat_view.skill_selector_up(), KeyCode::Down => chat_view.skill_selector_down(), - KeyCode::Enter => { - if let Some(selected) = chat_view.skill_selector_confirm() { - chat_view.hide_skill_selector(); - self.apply_skill_selection(&selected, chat_view); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = chat_view.skill_selector_confirm() { + self.handle_skill_selector_action(action, chat_view, chat_state, rt_handle); } } // Note: Esc is handled globally for navigation back @@ -1084,10 +1043,11 @@ impl ChatMode { match key.code { KeyCode::Up => chat_view.subagent_selector_up(), KeyCode::Down => chat_view.subagent_selector_down(), - KeyCode::Enter => { - if let Some(selected) = chat_view.subagent_selector_confirm() { - chat_view.hide_subagent_selector(); - self.apply_subagent_selection(&selected, chat_view); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = chat_view.subagent_selector_confirm() { + self.handle_subagent_selector_action( + action, chat_view, chat_state, rt_handle, + ); } } // Note: Esc is handled globally for navigation back @@ -1482,7 +1442,16 @@ impl ChatMode { if let Some(selection) = chat_view.provider_selector_handle_mouse(&mouse) { this.handle_provider_selection(selection, chat_view); } - } else if !chat_view.handle_mouse_event(&mouse) { + } else if chat_view.handle_mouse_event(&mouse) { + if let Some(action) = chat_view.take_pending_skill_action() { + this.handle_skill_selector_action(action, chat_view, chat_state, rt_handle); + } + if let Some(action) = chat_view.take_pending_subagent_action() { + this.handle_subagent_selector_action( + action, chat_view, chat_state, rt_handle, + ); + } + } else { match mouse.kind { MouseEventKind::ScrollUp => { let total = chat_view.count_message_lines(chat_state); @@ -2725,7 +2694,11 @@ impl ChatMode { } // Load historical messages from core. - let messages = agent.coordinator().get_messages(&sid).await.unwrap_or_default(); + let messages = agent + .coordinator() + .get_messages(&sid) + .await + .unwrap_or_default(); let state = ChatState::from_core_messages( sid.clone(), @@ -2793,33 +2766,72 @@ impl ChatMode { Ok(()) } - /// Show skill selector popup with all available skills + /// Show skill list/configuration menu. fn show_skill_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + _rt_handle: &tokio::runtime::Handle, + ) { + chat_view.show_skill_menu(); + } + + fn show_available_skill_list( &self, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { let skills = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); rt_handle.block_on(async { let registry = SkillRegistry::global(); - registry.refresh().await; - registry.get_all_skills().await + registry + .get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type)) + .await }) }); if skills.is_empty() { - chat_state.add_system_message("No skills found. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/".to_string()); + chat_state.add_system_message(format!( + "No enabled skills found for agent mode '{}'. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/, or enable built-in skills for this mode.", + self.agent_type + )); + return; + } + + let skill_items: Vec = + skills.into_iter().map(Self::skill_item_from_info).collect(); + + if skill_items.is_empty() { + chat_state.add_system_message("No skills found.".to_string()); return; } + chat_view.show_skill_list(skill_items); + } + + fn show_skill_config_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let skills = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(async { + let registry = SkillRegistry::global(); + registry + .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) + .await + }) + }); + let skill_items: Vec = skills .into_iter() - .map(|s| SkillItem { - name: s.name, - description: s.description, - level: s.level.as_str().to_string(), - }) + .map(Self::skill_item_from_mode_info) .collect(); if skill_items.is_empty() { @@ -2827,7 +2839,32 @@ impl ChatMode { return; } - chat_view.show_skill_selector(skill_items); + chat_view.show_skill_config(skill_items); + } + + fn handle_skill_selector_action( + &self, + action: SkillSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + SkillSelectorAction::ListSkills => { + self.show_available_skill_list(chat_view, chat_state, rt_handle); + } + SkillSelectorAction::ConfigureSkills => { + self.show_skill_config_selector(chat_view, chat_state, rt_handle); + } + SkillSelectorAction::Execute(selected) => { + chat_view.hide_skill_selector(); + self.apply_skill_selection(&selected, chat_view); + } + SkillSelectorAction::Toggle(selected) => { + self.set_skill_enabled(&selected, !selected.enabled, chat_state, rt_handle); + self.show_skill_config_selector(chat_view, chat_state, rt_handle); + } + } } /// Apply skill selection: fill input box with execution command @@ -2835,8 +2872,105 @@ impl ChatMode { chat_view.set_input(&format!("Execute the {} skill.", selected.name)); } - /// Show subagent selector popup with all available subagents + fn set_skill_enabled( + &self, + selected: &SkillItem, + enabled: bool, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let workspace = self.agent.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let skill = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + match skill.level.as_str() { + "user" => { + set_user_mode_skill_state( + &mode_id, + &skill.key, + enabled, + skill.default_enabled, + ) + .await + .map_err(|error| error.to_string())?; + } + "project" => { + let mut document = load_project_mode_skills_document_local(&workspace) + .await + .map_err(|error| error.to_string())?; + set_mode_skill_disabled_in_document( + &mut document, + &mode_id, + &skill.key, + !enabled, + ) + .map_err(|error| error.to_string())?; + save_project_mode_skills_document_local(&workspace, &document) + .await + .map_err(|error| error.to_string())?; + } + other => { + return Err(format!("Unsupported skill level '{}'", other)); + } + } + + Ok(()) + }) + }); + + match result { + Ok(()) => chat_state.add_system_message(format!( + "Skill '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + )), + Err(error) => chat_state.add_system_message(format!( + "Failed to update skill '{}': {}", + selected.name, error + )), + } + } + + fn skill_item_from_info(info: SkillInfo) -> SkillItem { + SkillItem { + key: info.key, + name: info.name, + description: info.description, + level: info.level.as_str().to_string(), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: info.is_shadowed, + } + } + + fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { + SkillItem { + key: info.skill.key, + name: info.skill.name, + description: info.skill.description, + level: info.skill.level.as_str().to_string(), + enabled: info.effective_enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, + is_shadowed: info.skill.is_shadowed, + } + } + + /// Show subagent list/configuration menu. fn show_subagent_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + _rt_handle: &tokio::runtime::Handle, + ) { + chat_view.show_subagent_menu(); + } + + fn show_available_subagent_list( &self, chat_view: &mut ChatView, chat_state: &mut ChatState, @@ -2844,43 +2978,91 @@ impl ChatMode { ) { let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { - let workspace = self.workspace.clone().map(PathBuf::from); - rt_handle.block_on(registry.get_subagents_info(workspace.as_deref())) + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + })) }); if subagents.is_empty() { + chat_state.add_system_message(format!( + "No enabled subagents found for agent mode '{}'.", + self.agent_type + )); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(Self::subagent_item_from_info) + .collect(); + + if subagent_items.is_empty() { chat_state.add_system_message("No subagents found.".to_string()); return; } + chat_view.show_subagent_list(subagent_items); + } + + fn show_subagent_config_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + })) + }); + let subagent_items: Vec = subagents .into_iter() - .map(|s| { - let source = match s.subagent_source { - Some(bitfun_core::agentic::agents::SubAgentSource::Builtin) => { - "builtin".to_string() - } - Some(bitfun_core::agentic::agents::SubAgentSource::Project) => { - "project".to_string() - } - Some(bitfun_core::agentic::agents::SubAgentSource::User) => "user".to_string(), - None => "builtin".to_string(), - }; - SubagentItem { - id: s.id, - name: s.name, - description: s.description, - source, - } - }) + .map(Self::subagent_item_from_info) .collect(); if subagent_items.is_empty() { - chat_state.add_system_message("No enabled subagents found.".to_string()); + chat_state.add_system_message("No subagents found.".to_string()); return; } - chat_view.show_subagent_selector(subagent_items); + chat_view.show_subagent_config(subagent_items); + } + + fn handle_subagent_selector_action( + &self, + action: SubagentSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + SubagentSelectorAction::ListSubagents => { + self.show_available_subagent_list(chat_view, chat_state, rt_handle); + } + SubagentSelectorAction::ConfigureSubagents => { + self.show_subagent_config_selector(chat_view, chat_state, rt_handle); + } + SubagentSelectorAction::Launch(selected) => { + chat_view.hide_subagent_selector(); + self.apply_subagent_selection(&selected, chat_view); + } + SubagentSelectorAction::Toggle(selected) => { + self.set_subagent_enabled(&selected, !selected.enabled, chat_state, rt_handle); + self.show_subagent_config_selector(chat_view, chat_state, rt_handle); + } + } } /// Apply subagent selection: fill input box with launch command @@ -2891,6 +3073,66 @@ impl ChatMode { )); } + fn set_subagent_enabled( + &self, + selected: &SubagentItem, + enabled: bool, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let workspace = self.agent.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let subagent = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + registry + .update_subagent_override( + &mode_id, + &subagent.id, + enabled, + Some(workspace.as_path()), + ) + .await + .map_err(|error| error.to_string()) + }) + }); + + match result { + Ok(()) => chat_state.add_system_message(format!( + "Subagent '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + )), + Err(error) => chat_state.add_system_message(format!( + "Failed to update subagent '{}': {}", + selected.name, error + )), + } + } + + fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { + let source = match info.subagent_source { + Some(SubAgentSource::Builtin) => "builtin", + Some(SubAgentSource::Project) => "project", + Some(SubAgentSource::User) => "user", + None => "builtin", + } + .to_string(); + + SubagentItem { + key: info.key, + id: info.id, + name: info.name, + description: info.description, + source, + enabled: info.effective_enabled, + default_enabled: info.default_enabled, + } + } + /// Send a message to the agent programmatically (used by slash commands like /init) fn send_message_to_agent( &self, @@ -2994,7 +3236,10 @@ impl ChatMode { let result = tokio::task::block_in_place(|| { rt_handle.block_on(async { let workspace_path = agent.workspace_path_buf(); - agent.coordinator().delete_session(&workspace_path, &sid).await + agent + .coordinator() + .delete_session(&workspace_path, &sid) + .await }) }); @@ -3133,8 +3378,7 @@ impl ChatMode { if success { chat_view.set_status(Some(format!("Model added: {}", result.name))); - chat_state.current_model_name = - format!("{} / {}", result.model_name, result.name); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); } else { chat_view.set_status(Some("Failed to add model".to_string())); @@ -3263,8 +3507,7 @@ impl ChatMode { if success { chat_view.set_status(Some(format!("Model updated: {}", result.name))); - chat_state.current_model_name = - format!("{} / {}", result.model_name, result.name); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); tracing::info!("Updated AI model: {}", model_id); } else { chat_view.set_status(Some("Failed to update model".to_string())); diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 5d35f8fb94..1980896e24 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -2,15 +2,15 @@ /// /// Single command execution mode (non-interactive). /// Consumes core events directly from EventQueue. - use anyhow::Result; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use bitfun_events::AgenticEvent; +use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; -use crate::agent::{Agent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; pub struct ExecMode { #[allow(dead_code)] @@ -87,10 +87,14 @@ impl ExecMode { println!("Thinking..."); - let _turn_id = self.agent.send_message(self.message.clone(), &self.agent_type).await?; + let _turn_id = self + .agent + .send_message(self.message.clone(), &self.agent_type) + .await?; // Consume events from EventQueue until turn completes let mut total_tool_calls = 0usize; + let mut subagent_parent_sessions: HashMap = HashMap::new(); loop { // Wait for events (efficient, uses Notify internally) @@ -100,15 +104,25 @@ impl ExecMode { for envelope in events { let event = &envelope.event; + if let AgenticEvent::SubagentSessionLinked { + session_id: subagent_session_id, + parent_session_id, + .. + } = event + { + subagent_parent_sessions + .insert(subagent_session_id.clone(), parent_session_id.clone()); + continue; + } + // Only process events for our session if event.session_id() != Some(&session_id) { // Check if this is a subagent event whose parent is in our session - if let AgenticEvent::ToolEvent { tool_event, subagent_parent_info, .. } = event { - if subagent_parent_info - .as_ref() - .map(|info| info.session_id.as_str()) - == Some(session_id.as_str()) - { + if let AgenticEvent::ToolEvent { tool_event, .. } = event { + let parent_session_id = event.session_id().and_then(|event_session_id| { + subagent_parent_sessions.get(event_session_id) + }); + if parent_session_id.map(String::as_str) == Some(session_id.as_str()) { use bitfun_events::ToolEventData; match tool_event { ToolEventData::Started { tool_name, .. } => { @@ -125,7 +139,9 @@ impl ExecMode { .unwrap_or_else(|| result.to_string()); println!(" [subagent] {} ✓ {}", tool_name, summary); } - ToolEventData::Failed { tool_name, error, .. } => { + ToolEventData::Failed { + tool_name, error, .. + } => { println!(" [subagent] {} ✗ {}", tool_name, error); } _ => {} @@ -184,10 +200,7 @@ impl ExecMode { println!("\n"); println!("Execution complete"); if total_tool_calls > 0 { - println!( - "\nTool call statistics: {} tools invoked", - total_tool_calls - ); + println!("\nTool call statistics: {} tools invoked", total_tool_calls); } // Break out of the event loop self.output_patch_if_needed(); diff --git a/src/apps/cli/src/modes/mod.rs b/src/apps/cli/src/modes/mod.rs index cfdcaa4766..2ce28d6531 100644 --- a/src/apps/cli/src/modes/mod.rs +++ b/src/apps/cli/src/modes/mod.rs @@ -1,4 +1,3 @@ /// Different interaction modes - pub mod chat; pub mod exec; diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index b38d8b4444..9c273481e3 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -2,11 +2,10 @@ /// /// Overlay popup that displays all available agent modes /// and allows the user to select one to switch to. - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -169,7 +168,7 @@ impl AgentSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/chat.rs b/src/apps/cli/src/ui/chat.rs index b426d1bb63..a0b0155f16 100644 --- a/src/apps/cli/src/ui/chat.rs +++ b/src/apps/cli/src/ui/chat.rs @@ -9,4 +9,3 @@ include!("chat/input.rs"); include!("chat/popups.rs"); include!("chat/scroll.rs"); include!("chat/mouse.rs"); - diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index 0f2163c8cf..fb6d7d51fa 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -15,6 +15,14 @@ impl ChatView { self.pending_theme_preview.take() } + pub fn take_pending_skill_action(&mut self) -> Option { + self.pending_skill_action.take() + } + + pub fn take_pending_subagent_action(&mut self) -> Option { + self.pending_subagent_action.take() + } + pub fn handle_mouse_event(&mut self, mouse: &crossterm::event::MouseEvent) -> bool { // Popups take priority when visible if self.model_selector.captures_mouse(mouse) { @@ -35,19 +43,14 @@ impl ChatView { return true; } if self.skill_selector.captures_mouse(mouse) { - if let Some(selected) = self.skill_selector.handle_mouse_event(mouse) { - self.skill_selector.hide(); - self.set_input(&format!("Execute the {} skill.", selected.name)); + if let Some(action) = self.skill_selector.handle_mouse_event(mouse) { + self.pending_skill_action = Some(action); } return true; } if self.subagent_selector.captures_mouse(mouse) { - if let Some(selected) = self.subagent_selector.handle_mouse_event(mouse) { - self.subagent_selector.hide(); - self.set_input(&format!( - "Launch subagent {} to finish task: ", - selected.name - )); + if let Some(action) = self.subagent_selector.handle_mouse_event(mouse) { + self.pending_subagent_action = Some(action); } return true; } @@ -140,7 +143,9 @@ impl ChatView { fn selection_text(&self) -> Option { let (start, end) = self.selection_bounds()?; - if start.line >= self.visible_plain_lines.len() || end.line >= self.visible_plain_lines.len() { + if start.line >= self.visible_plain_lines.len() + || end.line >= self.visible_plain_lines.len() + { return None; } @@ -236,7 +241,8 @@ impl ChatView { let Some((start, end)) = self.selection_bounds() else { return; }; - let dragging = self.selection_mouse_down.is_some() && (self.selection_dragged || start != end); + let dragging = + self.selection_mouse_down.is_some() && (self.selection_dragged || start != end); if !dragging { return; } @@ -247,7 +253,8 @@ impl ChatView { return; } - let style = ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED); + let style = + ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED); for line_idx in start.line..=end.line { if line_idx < list_offset { diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index ec885ebd7e..4fd4c1dab1 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -38,10 +38,7 @@ impl ChatView { self.command_palette.is_visible() } - pub fn command_palette_handle_key( - &mut self, - key: crossterm::event::KeyEvent, - ) -> PaletteAction { + pub fn command_palette_handle_key(&mut self, key: crossterm::event::KeyEvent) -> PaletteAction { self.command_palette.handle_key_event(key) } @@ -52,10 +49,7 @@ impl ChatView { self.command_palette.handle_mouse_event(mouse) } - pub fn command_palette_captures_mouse( - &self, - mouse: &crossterm::event::MouseEvent, - ) -> bool { + pub fn command_palette_captures_mouse(&self, mouse: &crossterm::event::MouseEvent) -> bool { self.command_palette.captures_mouse(mouse) } @@ -170,8 +164,18 @@ impl ChatView { // ============ Skill selector methods ============ - pub fn show_skill_selector(&mut self, skills: Vec) { - self.skill_selector.show(skills); + pub fn show_skill_menu(&mut self) { + self.skill_selector.show_menu(); + self.popup_stack.push(PopupType::SkillSelector); + } + + pub fn show_skill_list(&mut self, skills: Vec) { + self.skill_selector.show_list(skills); + self.popup_stack.push(PopupType::SkillSelector); + } + + pub fn show_skill_config(&mut self, skills: Vec) { + self.skill_selector.show_config(skills); self.popup_stack.push(PopupType::SkillSelector); } @@ -195,14 +199,24 @@ impl ChatView { self.skill_selector.move_down(); } - pub fn skill_selector_confirm(&self) -> Option { + pub fn skill_selector_confirm(&self) -> Option { self.skill_selector.confirm_selection() } // ============ Subagent selector methods ============ - pub fn show_subagent_selector(&mut self, subagents: Vec) { - self.subagent_selector.show(subagents); + pub fn show_subagent_menu(&mut self) { + self.subagent_selector.show_menu(); + self.popup_stack.push(PopupType::SubagentSelector); + } + + pub fn show_subagent_list(&mut self, subagents: Vec) { + self.subagent_selector.show_list(subagents); + self.popup_stack.push(PopupType::SubagentSelector); + } + + pub fn show_subagent_config(&mut self, subagents: Vec) { + self.subagent_selector.show_config(subagents); self.popup_stack.push(PopupType::SubagentSelector); } @@ -226,7 +240,7 @@ impl ChatView { self.subagent_selector.move_down(); } - pub fn subagent_selector_confirm(&self) -> Option { + pub fn subagent_selector_confirm(&self) -> Option { self.subagent_selector.confirm_selection() } @@ -297,10 +311,7 @@ impl ChatView { self.mcp_add_dialog.is_visible() } - pub fn mcp_add_dialog_handle_key( - &mut self, - key: crossterm::event::KeyEvent, - ) -> McpAddAction { + pub fn mcp_add_dialog_handle_key(&mut self, key: crossterm::event::KeyEvent) -> McpAddAction { self.mcp_add_dialog.handle_key_event(key) } @@ -383,10 +394,7 @@ impl ChatView { self.provider_selector.handle_mouse_event(mouse) } - pub fn provider_selector_captures_mouse( - &self, - mouse: &crossterm::event::MouseEvent, - ) -> bool { + pub fn provider_selector_captures_mouse(&self, mouse: &crossterm::event::MouseEvent) -> bool { self.provider_selector.captures_mouse(mouse) } @@ -437,4 +445,3 @@ impl ChatView { self.model_config_form.handle_key_event(key) } } - diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 7c1df10121..309b9af4b6 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -2,6 +2,10 @@ impl ChatView { /// Render interface pub fn render(&mut self, frame: &mut Frame, chat_state: &ChatState) { let size = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(self.theme.background)), + size, + ); // Dynamic input area height: 2 (borders) + visible content lines, capped at 8+2=10 let max_input_content_lines: u16 = 8; @@ -91,7 +95,7 @@ impl ChatView { .style(Style::default().bg(self.theme.background)); let title_style = Style::default() - .fg(ratatui::style::Color::Rgb(147, 51, 234)) + .fg(self.theme.primary) .add_modifier(Modifier::BOLD); let text = vec![Line::from(vec![ @@ -119,6 +123,7 @@ impl ChatView { let block = Block::default() .borders(Borders::ALL) .border_style(self.theme.style(StyleKind::Border)) + .style(Style::default().bg(self.theme.background)) .title(title); let inner = block.inner(area); @@ -152,6 +157,7 @@ impl ChatView { let paragraph = Paragraph::new(welcome) .alignment(Alignment::Center) + .style(Style::default().bg(self.theme.background)) .wrap(Wrap { trim: true }); frame.render_widget(paragraph, inner); diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 869ba459e3..2eb1fe25df 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -8,7 +8,6 @@ use ratatui::{ use std::collections::{HashMap, HashSet, VecDeque}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::text_input::TextInput; use super::agent_selector::{AgentItem, AgentSelectorState}; use super::command_menu::CommandMenuState; use super::command_palette::{CommandPaletteState, PaletteAction}; @@ -21,9 +20,10 @@ use super::permission::render_permission_overlay; use super::provider_selector::{ProviderSelection, ProviderSelectorState}; use super::question::render_question_overlay; use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; -use super::skill_selector::{SkillItem, SkillSelectorState}; -use super::subagent_selector::{SubagentItem, SubagentSelectorState}; -use super::theme::{Theme, StyleKind}; +use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; +use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSelectorState}; +use super::text_input::TextInput; +use super::theme::{StyleKind, Theme}; use super::theme_selector::{ThemeItem, ThemeSelectorState}; use super::widgets::Spinner; use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; @@ -179,14 +179,12 @@ pub struct ChatView { theme_selector: ThemeSelectorState, // -- Tool card expand/collapse state -- - /// Set of collapsed tool IDs (block tools default to expanded; this tracks manually collapsed ones) pub collapsed_tools: HashSet, /// Currently focused block tool ID (for Ctrl+O toggle) pub focused_block_tool: Option, // -- Thinking expand/collapse state -- - /// Set of assistant message IDs whose thinking blocks are collapsed collapsed_thinking: HashSet, /// Tracks which messages have been auto-collapsed (so user re-expands won't be overridden) @@ -195,7 +193,6 @@ pub struct ChatView { thinking_user_overrides: HashSet, // -- Mouse click tracking -- - /// Pending command from mouse click on command menu (consumed by caller) pending_command: Option, /// Pending theme preview selection (consumed by caller) @@ -205,6 +202,10 @@ pub struct ChatView { /// Pending MCP toggle from mouse click (consumed by caller) pending_mcp_toggle: Option, + /// Pending skill selector action from mouse click (consumed by caller) + pending_skill_action: Option, + /// Pending subagent selector action from mouse click (consumed by caller) + pending_subagent_action: Option, /// Info popup message (rendered as overlay, dismissed by any key) info_popup: Option, @@ -236,7 +237,6 @@ pub struct ChatView { pub popup_stack: PopupStack, // -- Render cache state (performance optimization) -- - /// Cached total rendered line count (updated each render frame) cached_total_lines: usize, /// Message count when cache was last updated @@ -280,6 +280,8 @@ impl ChatView { theme_selector: ThemeSelectorState::new(), pending_command: None, pending_mcp_toggle: None, + pending_skill_action: None, + pending_subagent_action: None, pending_theme_preview: None, theme_preview_original: None, info_popup: None, diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index cb052715fd..a755905fda 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -1,9 +1,8 @@ /// Slash command menu rendering and state - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -37,7 +36,12 @@ impl CommandMenuState { self.update_with_commands(input, cursor, COMMAND_SPECS); } - pub fn update_with_commands(&mut self, input: &str, cursor: usize, commands: &'static [CommandSpec]) { + pub fn update_with_commands( + &mut self, + input: &str, + cursor: usize, + commands: &'static [CommandSpec], + ) { if self.suppressed && input == self.last_input { return; } @@ -111,16 +115,20 @@ impl CommandMenuState { return; } - let items: Vec = self.items.iter().map(|spec| { - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); - let desc_style = theme.style(StyleKind::Muted); - let line = Line::from(vec![ - Span::styled(spec.name, name_style), - Span::raw(" - "), - Span::styled(spec.description, desc_style), - ]); - ListItem::new(line) - }).collect(); + let items: Vec = self + .items + .iter() + .map(|spec| { + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + let line = Line::from(vec![ + Span::styled(spec.name, name_style), + Span::raw(" - "), + Span::styled(spec.description, desc_style), + ]); + ListItem::new(line) + }) + .collect(); let desired_height = (items.len() as u16).saturating_add(2); let height = desired_height.min(area.height); @@ -149,7 +157,7 @@ impl CommandMenuState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 0a3d10a79c..61a100e31b 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -3,11 +3,10 @@ /// A centered overlay with a search box at the top and grouped command items below. /// Unlike the slash command menu which appears inline, this is a full-screen centered popup. /// Supports viewport scrolling when content exceeds visible area. - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, Frame, @@ -64,7 +63,7 @@ pub fn default_palette_items() -> Vec { PaletteItem { id: "subagents".into(), label: "Subagents".into(), - description: "Browse and launch subagents".into(), + description: "List and configure subagents".into(), group: "Prompt".into(), }, // Models group @@ -80,6 +79,13 @@ pub fn default_palette_items() -> Vec { description: "Add a new AI model configuration".into(), group: "Models".into(), }, + // Appearance group + PaletteItem { + id: "theme".into(), + label: "Theme".into(), + description: "Switch UI theme".into(), + group: "Appearance".into(), + }, // Agent group PaletteItem { id: "switch_agent".into(), @@ -125,6 +131,12 @@ fn build_suggested_items() -> Vec { description: "Switch agent mode".into(), group: "Suggested".into(), }, + PaletteItem { + id: "theme".into(), + label: "Theme".into(), + description: "Switch UI theme".into(), + group: "Suggested".into(), + }, PaletteItem { id: "new_session".into(), label: "New session".into(), @@ -558,12 +570,18 @@ impl CommandPaletteState { // Row 0: Search box let search_display = if self.search_input.is_empty() { Line::from(vec![ - Span::styled("> ", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled( + "> ", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), Span::styled("Search commands...", theme.style(StyleKind::Muted)), ]) } else { Line::from(vec![ - Span::styled("> ", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled( + "> ", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), Span::raw(&self.search_input), ]) }; @@ -576,9 +594,11 @@ impl CommandPaletteState { frame.render_widget(Paragraph::new(search_display), search_area); // Set cursor position in search box - let cursor_x = inner.x + 2 + self.search_input[..self.char_to_byte(self.search_cursor)] - .chars() - .count() as u16; + let cursor_x = inner.x + + 2 + + self.search_input[..self.char_to_byte(self.search_cursor)] + .chars() + .count() as u16; frame.set_cursor_position((cursor_x, inner.y)); // Separator line @@ -592,7 +612,10 @@ impl CommandPaletteState { height: 1, }; frame.render_widget( - Paragraph::new(Line::from(Span::styled(sep, theme.style(StyleKind::Border)))), + Paragraph::new(Line::from(Span::styled( + sep, + theme.style(StyleKind::Border), + ))), sep_area, ); } @@ -631,12 +654,10 @@ impl CommandPaletteState { match row { PaletteRow::GroupHeader(name) => { - let header_line = Line::from(vec![ - Span::styled( - format!(" {}", name.to_uppercase()), - theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), - ), - ]); + let header_line = Line::from(vec![Span::styled( + format!(" {}", name.to_uppercase()), + theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), + )]); frame.render_widget(Paragraph::new(header_line), row_area); } PaletteRow::Item(sel_idx) => { @@ -647,14 +668,16 @@ impl CommandPaletteState { let label_style = if is_selected { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Primary) }; let desc_style = if is_selected { - Style::default().bg(theme.primary).fg(Color::White) + Style::default() + .bg(theme.primary) + .fg(theme.selection_foreground()) } else { theme.style(StyleKind::Muted) }; @@ -699,12 +722,10 @@ impl CommandPaletteState { height: 1, }; - let mut hints = vec![ - Span::styled( - " \u{2191}\u{2193} Navigate Enter Select Esc Cancel", - theme.style(StyleKind::Muted), - ), - ]; + let mut hints = vec![Span::styled( + " \u{2191}\u{2193} Navigate Enter Select Esc Cancel", + theme.style(StyleKind::Muted), + )]; if has_more_above || has_more_below { let indicator = if has_more_above && has_more_below { @@ -714,10 +735,7 @@ impl CommandPaletteState { } else { " \u{2193}" }; - hints.push(Span::styled( - indicator, - theme.style(StyleKind::Warning), - )); + hints.push(Span::styled(indicator, theme.style(StyleKind::Warning))); } let hint_line = Line::from(hints); diff --git a/src/apps/cli/src/ui/diff_render.rs b/src/apps/cli/src/ui/diff_render.rs index 6f42517cc5..d767928a7a 100644 --- a/src/apps/cli/src/ui/diff_render.rs +++ b/src/apps/cli/src/ui/diff_render.rs @@ -2,12 +2,11 @@ /// /// Uses the `similar` crate to compute line-level diffs and renders them /// with line numbers, hunk headers, and full-line background colors. - use ratatui::text::{Line, Span}; use similar::{ChangeTag, TextDiff}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; /// Diff view mode #[derive(Debug, Clone, Copy, PartialEq)] @@ -278,7 +277,10 @@ fn render_unified<'a>( for (visual_idx, segment) in wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -462,7 +464,10 @@ fn render_split<'a>( for (row_idx, segment) in wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -512,7 +517,10 @@ fn render_split<'a>( for row_idx in 0..row_count { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -562,7 +570,10 @@ fn render_split<'a>( for (row_idx, old_piece) in old_wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; @@ -603,7 +614,10 @@ fn render_split<'a>( for (row_idx, new_piece) in new_wrapped.iter().enumerate() { if shown >= max_lines { lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + format!( + "\u{2026} ({} more changes)", + entries.len().saturating_sub(i) + ), theme.style(StyleKind::Muted), ))); return lines; diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index 8c2f0f600a..bf10b9f981 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -1,16 +1,15 @@ /// Markdown rendering utilities - use std::collections::HashMap; use std::hash::{Hash, Hasher}; -use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd, HeadingLevel}; +use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, }; -use unicode_width::{UnicodeWidthStr, UnicodeWidthChar}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; /// Markdown renderer with built-in cache for parsed results. /// Avoids re-parsing the same markdown content on every frame. @@ -23,7 +22,10 @@ pub struct MarkdownRenderer { impl MarkdownRenderer { pub fn new(theme: Theme) -> Self { - Self { theme, cache: HashMap::new() } + Self { + theme, + cache: HashMap::new(), + } } /// Compute a cache key from content and width @@ -49,21 +51,21 @@ impl MarkdownRenderer { pub fn clear_cache(&mut self) { self.cache.clear(); } - + pub fn render(&self, markdown: &str, width: usize) -> Vec> { let mut lines = Vec::new(); let mut current_line_spans: Vec> = Vec::new(); - + // Style stack let mut style_stack: Vec = Vec::new(); let mut list_level: usize = 0; let mut in_code_block = false; let mut code_block_lang = String::new(); let wrap_width = if width > 0 { width } else { 80 }; - + // Table state let mut table_state = TableState::new(); - + // Flush current_line_spans into `lines`, wrapping at `wrap_width`. // Headings skip wrapping. let flush_with_wrap = |spans: &mut Vec>, @@ -83,106 +85,106 @@ impl MarkdownRenderer { let options = Options::all(); let parser = Parser::new_ext(markdown, options); - + for event in parser { match event { - Event::Start(tag) => { - match tag { - Tag::Heading { level, .. } => { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - lines.push(Line::from("")); - - let prefix = match level { - HeadingLevel::H1 => "# ", - HeadingLevel::H2 => "## ", - HeadingLevel::H3 => "### ", - HeadingLevel::H4 => "#### ", - HeadingLevel::H5 => "##### ", - HeadingLevel::H6 => "###### ", - }; - current_line_spans.push(Span::styled( - prefix.to_string(), - self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) - )); - - style_stack.push(StyleModifier::Heading); - } - Tag::Paragraph => {} - Tag::BlockQuote(_) => { - current_line_spans.push(Span::styled( - "\u{2502} ".to_string(), - self.theme.style(StyleKind::Muted) - )); - style_stack.push(StyleModifier::Quote); - } - Tag::CodeBlock(kind) => { - in_code_block = true; - if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind { - code_block_lang = lang.to_string(); - } - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - lines.push(Line::from("")); - - if !code_block_lang.is_empty() { - current_line_spans.push(Span::styled( - format!("```{}", code_block_lang), - self.theme.style(StyleKind::Muted) - )); - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } - } - Tag::List(_) => { - list_level += 1; - if list_level == 1 { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - } - } - Tag::Item => { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - let indent = " ".repeat(list_level.saturating_sub(1)); - current_line_spans.push(Span::raw(indent)); - current_line_spans.push(Span::styled( - "\u{2022} ".to_string(), - self.theme.style(StyleKind::Primary) - )); - } - Tag::Strong => { - style_stack.push(StyleModifier::Bold); - } - Tag::Emphasis => { - style_stack.push(StyleModifier::Italic); - } - Tag::Link { .. } => { - style_stack.push(StyleModifier::Link); + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + + let prefix = match level { + HeadingLevel::H1 => "# ", + HeadingLevel::H2 => "## ", + HeadingLevel::H3 => "### ", + HeadingLevel::H4 => "#### ", + HeadingLevel::H5 => "##### ", + HeadingLevel::H6 => "###### ", + }; + current_line_spans.push(Span::styled( + prefix.to_string(), + self.theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), + )); + + style_stack.push(StyleModifier::Heading); + } + Tag::Paragraph => {} + Tag::BlockQuote(_) => { + current_line_spans.push(Span::styled( + "\u{2502} ".to_string(), + self.theme.style(StyleKind::Muted), + )); + style_stack.push(StyleModifier::Quote); + } + Tag::CodeBlock(kind) => { + in_code_block = true; + if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind { + code_block_lang = lang.to_string(); } - Tag::Image { .. } => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + + if !code_block_lang.is_empty() { current_line_spans.push(Span::styled( - "[Image]".to_string(), - self.theme.style(StyleKind::Info) + format!("```{}", code_block_lang), + self.theme.style(StyleKind::Muted), )); + lines.push(Line::from(std::mem::take(&mut current_line_spans))); } - Tag::Table(alignment) => { + } + Tag::List(_) => { + list_level += 1; + if list_level == 1 { flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - lines.push(Line::from("")); - table_state.start_table(alignment.into_iter().collect()); - } - Tag::TableHead => { - table_state.is_header = true; - table_state.start_row(); - } - Tag::TableRow => { - table_state.start_row(); } - Tag::TableCell => { - table_state.start_cell(); - if table_state.is_header { - style_stack.push(StyleModifier::TableHeader); - } + } + Tag::Item => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + let indent = " ".repeat(list_level.saturating_sub(1)); + current_line_spans.push(Span::raw(indent)); + current_line_spans.push(Span::styled( + "\u{2022} ".to_string(), + self.theme.style(StyleKind::Primary), + )); + } + Tag::Strong => { + style_stack.push(StyleModifier::Bold); + } + Tag::Emphasis => { + style_stack.push(StyleModifier::Italic); + } + Tag::Link { .. } => { + style_stack.push(StyleModifier::Link); + } + Tag::Image { .. } => { + current_line_spans.push(Span::styled( + "[Image]".to_string(), + self.theme.style(StyleKind::Info), + )); + } + Tag::Table(alignment) => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + table_state.start_table(alignment.into_iter().collect()); + } + Tag::TableHead => { + table_state.is_header = true; + table_state.start_row(); + } + Tag::TableRow => { + table_state.start_row(); + } + Tag::TableCell => { + table_state.start_cell(); + if table_state.is_header { + style_stack.push(StyleModifier::TableHeader); } - _ => {} } - } - + _ => {} + }, + Event::End(tag_end) => { match tag_end { TagEnd::Heading(_) => { @@ -194,7 +196,12 @@ impl MarkdownRenderer { } TagEnd::Paragraph => { if !in_code_block && !table_state.in_table { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + flush_with_wrap( + &mut current_line_spans, + &mut lines, + wrap_width, + true, + ); lines.push(Line::from("")); } } @@ -212,7 +219,7 @@ impl MarkdownRenderer { if !code_block_lang.is_empty() { lines.push(Line::from(Span::styled( "```".to_string(), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); code_block_lang.clear(); } @@ -221,7 +228,12 @@ impl MarkdownRenderer { TagEnd::List(_) => { list_level = list_level.saturating_sub(1); if list_level == 0 { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + flush_with_wrap( + &mut current_line_spans, + &mut lines, + wrap_width, + true, + ); lines.push(Line::from("")); } } @@ -265,10 +277,10 @@ impl MarkdownRenderer { _ => {} } } - + Event::Text(text) => { let style = self.compute_style(&style_stack, in_code_block); - + if in_code_block { for line in text.lines() { // Code blocks preserve style but still wrap to viewport width. @@ -285,82 +297,90 @@ impl MarkdownRenderer { current_line_spans.push(Span::styled(text.to_string(), style)); } } - + Event::Code(code) => { - let span = Span::styled( - format!("`{}`", code), - self.theme.style(StyleKind::Success) - ); + let span = + Span::styled(format!("`{}`", code), self.theme.style(StyleKind::Success)); if table_state.in_cell { table_state.push_span(span); } else { current_line_spans.push(span); } } - + Event::SoftBreak | Event::HardBreak => { if !in_code_block && !table_state.in_table { flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } } - + Event::Rule => { let rule_w = wrap_width.min(60); lines.push(Line::from(Span::styled( "\u{2500}".repeat(rule_w), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); } - + _ => {} } } - + // Process remaining spans with wrapping if !current_line_spans.is_empty() { wrap_spans_to_lines(current_line_spans, wrap_width, &mut lines); } - + // Remove trailing empty lines - while lines.last().map_or(false, |line| line.spans.is_empty() || - (line.spans.len() == 1 && line.spans[0].content.is_empty())) { + while lines.last().map_or(false, |line| { + line.spans.is_empty() || (line.spans.len() == 1 && line.spans[0].content.is_empty()) + }) { lines.pop(); } - + lines } - + fn compute_style(&self, stack: &[StyleModifier], in_code_block: bool) -> Style { let mut style = Style::default(); - + if in_code_block { return self.theme.style(StyleKind::Success); } - + for modifier in stack { style = match modifier { StyleModifier::Bold => style.add_modifier(Modifier::BOLD), StyleModifier::Italic => style.add_modifier(Modifier::ITALIC), - StyleModifier::Heading => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + StyleModifier::Heading => self + .theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), StyleModifier::Quote => style.fg(self.theme.muted), - StyleModifier::Link => self.theme.style(StyleKind::Info).add_modifier(Modifier::UNDERLINED), - StyleModifier::TableHeader => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + StyleModifier::Link => self + .theme + .style(StyleKind::Info) + .add_modifier(Modifier::UNDERLINED), + StyleModifier::TableHeader => self + .theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), }; } - + style } - + pub fn has_markdown_syntax(text: &str) -> bool { - text.contains("**") || - text.contains("__") || - text.contains("*") || - text.contains("_") || - text.contains("`") || - text.contains("#") || - text.contains("[") || - text.contains(">") || - text.contains("```") + text.contains("**") + || text.contains("__") + || text.contains("*") + || text.contains("_") + || text.contains("`") + || text.contains("#") + || text.contains("[") + || text.contains(">") + || text.contains("```") } } @@ -373,7 +393,10 @@ fn wrap_spans_to_lines(spans: Vec>, max_width: usize, out: &mut Ve return; } - let total_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + let total_width: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); if total_width <= max_width { out.push(Line::from(spans)); return; @@ -723,4 +746,4 @@ impl TableState { lines.push(build_border('\u{2514}', '\u{2534}', '\u{2518}')); lines } -} \ No newline at end of file +} diff --git a/src/apps/cli/src/ui/mcp_add_dialog.rs b/src/apps/cli/src/ui/mcp_add_dialog.rs index 4183d1c79e..760a461bfa 100644 --- a/src/apps/cli/src/ui/mcp_add_dialog.rs +++ b/src/apps/cli/src/ui/mcp_add_dialog.rs @@ -5,7 +5,6 @@ /// Step 3: Enter command (local) or URL (remote) /// /// Enter advances to next step, Esc goes back or cancels. - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::Rect, @@ -23,10 +22,7 @@ pub enum McpAddAction { /// No action, dialog consumed the key None, /// User completed all steps — returns name + generated JSON config string - Confirm { - name: String, - config_json: String, - }, + Confirm { name: String, config_json: String }, /// User cancelled (Esc on step 1) Cancel, } @@ -295,7 +291,8 @@ impl McpAddDialogState { format!(r#"{{"type":"stdio","command":"{}"}}"#, value) } else { let cmd = parts[0]; - let args: Vec = parts[1..].iter().map(|s| format!("\"{}\"", s)).collect(); + let args: Vec = + parts[1..].iter().map(|s| format!("\"{}\"", s)).collect(); format!( r#"{{"type":"stdio","command":"{}","args":[{}]}}"#, cmd, @@ -378,7 +375,12 @@ impl McpAddDialogState { // ── Render completed steps as read-only summary ── if self.step == Step::ServerType || self.step == Step::CommandOrUrl { - let name_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let name_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let name_line = Line::from(vec![ Span::styled("\u{2713} ", theme.style(StyleKind::Success)), Span::styled("Name: ", theme.style(StyleKind::Muted)), @@ -392,11 +394,16 @@ impl McpAddDialogState { } if self.step == Step::CommandOrUrl { - let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; - let type_label = match self.server_type { - ServerType::Local => "local (stdio)", - ServerType::Remote => "remote (streamable-http)", - }; + let type_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; + let type_label = match self.server_type { + ServerType::Local => "local (stdio)", + ServerType::Remote => "remote (streamable-http)", + }; let type_line = Line::from(vec![ Span::styled("\u{2713} ", theme.style(StyleKind::Success)), Span::styled("Type: ", theme.style(StyleKind::Muted)), @@ -414,7 +421,12 @@ impl McpAddDialogState { match self.step { Step::Name => { // Prompt - let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let prompt_line = Line::from(Span::styled( "Enter MCP server name (used as identifier):", theme.style(StyleKind::Info), @@ -423,7 +435,12 @@ impl McpAddDialogState { row += 1; // Input - let input_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let input_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let line = render_input_line( " > ", &self.name_buf, @@ -437,7 +454,12 @@ impl McpAddDialogState { } Step::ServerType => { // Prompt - let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let prompt_line = Line::from(Span::styled( "Select MCP server type:", theme.style(StyleKind::Info), @@ -446,14 +468,25 @@ impl McpAddDialogState { row += 1; // Selector - let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let type_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let local_style = if self.server_type == ServerType::Local { - Style::default().fg(Color::White).bg(theme.primary).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme.selection_foreground()) + .bg(theme.primary) + .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Muted) }; let remote_style = if self.server_type == ServerType::Remote { - Style::default().fg(Color::White).bg(theme.primary).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme.selection_foreground()) + .bg(theme.primary) + .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Muted) }; @@ -468,7 +501,12 @@ impl McpAddDialogState { } Step::CommandOrUrl => { // Prompt - let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let (prompt_text, placeholder) = match self.server_type { ServerType::Local => ( "Enter command to run the MCP server:", @@ -479,15 +517,18 @@ impl McpAddDialogState { "https://example.com/mcp", ), }; - let prompt_line = Line::from(Span::styled( - prompt_text, - theme.style(StyleKind::Info), - )); + let prompt_line = + Line::from(Span::styled(prompt_text, theme.style(StyleKind::Info))); frame.render_widget(Paragraph::new(prompt_line), prompt_area); row += 1; // Input - let input_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let input_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let line = render_input_line( " > ", &self.value_buf, @@ -505,8 +546,14 @@ impl McpAddDialogState { if let Some(ref err) = self.error { if inner.y + row < inner.y + inner.height { - let err_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; - let err_line = Line::from(Span::styled(err.as_str(), theme.style(StyleKind::Error))); + let err_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; + let err_line = + Line::from(Span::styled(err.as_str(), theme.style(StyleKind::Error))); frame.render_widget(Paragraph::new(err_line), err_area); row += 1; } @@ -515,7 +562,12 @@ impl McpAddDialogState { // ── Hint line ── if inner.y + row < inner.y + inner.height { - let hint_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let hint_area = Rect { + x: inner.x, + y: inner.y + row, + width: inner.width, + height: 1, + }; let hint_text = match self.step { Step::Name => "Enter: Next Esc: Cancel", Step::ServerType => "\u{2190}\u{2192}/Tab: Switch Enter: Next Esc: Back", @@ -573,11 +625,17 @@ fn render_input_line<'a>( // Empty buffer: show placeholder with cursor at start if buffer.is_empty() { - let placeholder_display: String = placeholder.chars().take(field_width.saturating_sub(1)).collect(); + let placeholder_display: String = placeholder + .chars() + .take(field_width.saturating_sub(1)) + .collect(); return Line::from(vec![ Span::styled(label, label_style), Span::styled(" ", Style::default().fg(Color::Black).bg(Color::White)), - Span::styled(placeholder_display, theme.style(StyleKind::Muted).add_modifier(Modifier::DIM)), + Span::styled( + placeholder_display, + theme.style(StyleKind::Muted).add_modifier(Modifier::DIM), + ), ]); } @@ -599,7 +657,11 @@ fn render_input_line<'a>( let cursor_char: String = visible_chars.chars().skip(cursor_in_view).take(1).collect(); let after: String = visible_chars.chars().skip(cursor_in_view + 1).collect(); - let cursor_display = if cursor_char.is_empty() { " ".to_string() } else { cursor_char }; + let cursor_display = if cursor_char.is_empty() { + " ".to_string() + } else { + cursor_char + }; let has_more_left = scroll > 0; let has_more_right = scroll + field_width < total_chars; @@ -614,7 +676,10 @@ fn render_input_line<'a>( spans.push(Span::styled(before, text_style)); } - spans.push(Span::styled(cursor_display, Style::default().fg(Color::Black).bg(Color::White))); + spans.push(Span::styled( + cursor_display, + Style::default().fg(Color::Black).bg(Color::White), + )); if has_more_right { let after_len = after.chars().count(); diff --git a/src/apps/cli/src/ui/mcp_selector.rs b/src/apps/cli/src/ui/mcp_selector.rs index dadefbe69d..2d980991e6 100644 --- a/src/apps/cli/src/ui/mcp_selector.rs +++ b/src/apps/cli/src/ui/mcp_selector.rs @@ -8,11 +8,10 @@ /// - Space key toggles server on/off /// - Enter key also toggles /// - Status indicators: ✓ Connected (green), ○ Stopped (gray), ✗ Failed (red), ⋯ Loading (yellow) - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -84,7 +83,8 @@ impl McpSelectorState { // Preserve selection if possible if let Some(idx) = selected_idx { if idx >= self.items.len() { - self.list_state.select(Some(self.items.len().saturating_sub(1))); + self.list_state + .select(Some(self.items.len().saturating_sub(1))); } } } @@ -159,7 +159,9 @@ impl McpSelectorState { let popup_width = area.width.saturating_sub(4).min(72); // +5 for border(2) + title(1) + hint(1) + padding(1) - let popup_height = (self.items.len() as u16 + 5).min(area.height.saturating_sub(2)).max(6); + let popup_height = (self.items.len() as u16 + 5) + .min(area.height.saturating_sub(2)) + .max(6); if popup_height < 5 || popup_width < 30 { self.last_area = None; return; @@ -185,7 +187,9 @@ impl McpSelectorState { .iter() .map(|item| { let is_loading = loading_id.as_ref().map_or(false, |id| id == &item.id); - let is_confirm_delete = confirm_delete_id.as_ref().map_or(false, |id| id == &item.id); + let is_confirm_delete = confirm_delete_id + .as_ref() + .map_or(false, |id| id == &item.id); // If this item is pending delete confirmation, show special style if is_confirm_delete { @@ -290,7 +294,7 @@ impl McpSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index e4a50a7116..6f629c57fd 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -1,35 +1,34 @@ /// TUI interface module -/// +/// /// Build terminal user interface using ratatui - pub mod agent_selector; pub mod chat; pub mod command_menu; pub mod command_palette; -pub mod model_config_form; +pub mod diff_render; +pub mod markdown; pub mod mcp_add_dialog; pub mod mcp_selector; +pub mod model_config_form; pub mod model_selector; +pub mod permission; pub mod provider_selector; +pub mod question; pub mod session_selector; pub mod skill_selector; +pub mod startup; +pub mod string_utils; pub mod subagent_selector; +pub mod syntax_highlight; +pub mod text_input; pub mod theme; pub mod theme_selector; -pub mod text_input; -pub mod widgets; -pub mod startup; pub mod tool_cards; -pub mod string_utils; -pub mod markdown; -pub mod diff_render; -pub mod permission; -pub mod question; -pub mod syntax_highlight; +pub mod widgets; use anyhow::Result; use crossterm::{ - event::{EnableMouseCapture, DisableMouseCapture, EnableBracketedPaste, DisableBracketedPaste}, + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -47,7 +46,12 @@ use std::io; pub fn init_terminal() -> Result>> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; Ok(terminal) @@ -56,13 +60,21 @@ pub fn init_terminal() -> Result>> { /// Restore terminal pub fn restore_terminal(mut terminal: Terminal>) -> Result<()> { disable_raw_mode()?; - execute!(terminal.backend_mut(), DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen)?; + execute!( + terminal.backend_mut(), + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + )?; terminal.show_cursor()?; Ok(()) } /// Render a loading/status message on the terminal (stays in alternate screen) -pub fn render_loading(terminal: &mut Terminal>, message: &str) -> Result<()> { +pub fn render_loading( + terminal: &mut Terminal>, + message: &str, +) -> Result<()> { let msg = message.to_string(); terminal.draw(|frame| { let area = frame.area(); @@ -75,14 +87,12 @@ pub fn render_loading(terminal: &mut Terminal>, mes ]) .split(area); - let text = vec![ - Line::from(Span::styled( - msg, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - ]; + let text = vec![Line::from(Span::styled( + msg, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))]; let paragraph = Paragraph::new(text).alignment(Alignment::Center); frame.render_widget(paragraph, chunks[1]); diff --git a/src/apps/cli/src/ui/model_config_form.rs b/src/apps/cli/src/ui/model_config_form.rs index 4e84e48ce6..401ae302c9 100644 --- a/src/apps/cli/src/ui/model_config_form.rs +++ b/src/apps/cli/src/ui/model_config_form.rs @@ -850,7 +850,7 @@ impl ModelConfigFormState { let style = if selected && is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else if selected { theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) @@ -881,7 +881,7 @@ impl ModelConfigFormState { let style = if selected && is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else if selected { theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) @@ -919,7 +919,7 @@ impl ModelConfigFormState { if is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { Style::default() @@ -933,7 +933,7 @@ impl ModelConfigFormState { if is_active { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Muted) diff --git a/src/apps/cli/src/ui/model_selector.rs b/src/apps/cli/src/ui/model_selector.rs index 23f2464d21..aef051d9aa 100644 --- a/src/apps/cli/src/ui/model_selector.rs +++ b/src/apps/cli/src/ui/model_selector.rs @@ -2,11 +2,10 @@ /// /// Full-screen overlay popup that displays all available models /// and allows the user to select one. - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, Frame, @@ -178,7 +177,7 @@ impl ModelSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); @@ -194,12 +193,10 @@ impl ModelSelectorState { height: 1.min(area.y + area.height - popup_area.y - popup_area.height), }; if hint_area.height > 0 { - let hint = Paragraph::new(Line::from(vec![ - Span::styled( - " Selecting a model will apply to all modes ", - theme.style(StyleKind::Info), - ), - ])) + let hint = Paragraph::new(Line::from(vec![Span::styled( + " Selecting a model will apply to all modes ", + theme.style(StyleKind::Info), + )])) .alignment(Alignment::Center); frame.render_widget(hint, hint_area); } diff --git a/src/apps/cli/src/ui/permission.rs b/src/apps/cli/src/ui/permission.rs index f8e09461e7..449d81e630 100644 --- a/src/apps/cli/src/ui/permission.rs +++ b/src/apps/cli/src/ui/permission.rs @@ -5,7 +5,6 @@ /// - Allow once: execute this tool call only /// - Allow always: auto-approve this tool type for the session /// - Reject: deny execution (optionally with a reason) - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -15,8 +14,8 @@ use ratatui::{ Frame, }; -use super::theme::{Theme, StyleKind, tool_icon}; use super::string_utils::truncate_str; +use super::theme::{tool_icon, StyleKind, Theme}; // ============ Data Types ============ @@ -123,8 +122,10 @@ impl PermissionPrompt { fn handle_confirm_always_key(&mut self, key: KeyEvent) -> PermissionAction { match (key.code, key.modifiers) { - (KeyCode::Left, _) | (KeyCode::Right, _) - | (KeyCode::Char('h'), KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + (KeyCode::Left, _) + | (KeyCode::Right, _) + | (KeyCode::Char('h'), KeyModifiers::NONE) + | (KeyCode::Char('l'), KeyModifiers::NONE) => { self.selected_option = if self.selected_option == 0 { 1 } else { 0 }; PermissionAction::None } @@ -197,12 +198,7 @@ pub fn render_permission_overlay( } /// Render the main permission prompt (Allow once / Allow always / Reject) -fn render_permission_main( - frame: &mut Frame, - prompt: &PermissionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_permission_main(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { // Calculate overlay height based on content let overlay_height = 8u16.min(area.height.saturating_sub(2)); let overlay_area = Rect { @@ -237,7 +233,12 @@ fn render_permission_main( let mut lines = vec![ Line::from(vec![ Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), // △ - Span::styled("Permission required", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)), + Span::styled( + "Permission required", + Style::default() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(""), ]; @@ -250,8 +251,7 @@ fn render_permission_main( Span::styled(detail, Style::default()), ])); - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: true }); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); frame.render_widget(paragraph, inner); // Button bar @@ -266,12 +266,7 @@ fn render_permission_main( } /// Render the "Confirm Always" stage -fn render_confirm_always( - frame: &mut Frame, - prompt: &PermissionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_confirm_always(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { let overlay_height = 6u16.min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, @@ -284,10 +279,7 @@ fn render_confirm_always( let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(2), - Constraint::Length(2), - ]) + .constraints([Constraint::Min(2), Constraint::Length(2)]) .split(overlay_area); let content_block = Block::default() @@ -301,11 +293,19 @@ fn render_confirm_always( let lines = vec![ Line::from(vec![ Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), - Span::styled("Always allow", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)), + Span::styled( + "Always allow", + Style::default() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(""), Line::from(Span::styled( - format!("This will auto-approve '{}' tool calls for this session.", prompt.tool_name), + format!( + "This will auto-approve '{}' tool calls for this session.", + prompt.tool_name + ), theme.style(StyleKind::Muted), )), ]; @@ -324,12 +324,7 @@ fn render_confirm_always( } /// Render the "Reject with reason" stage -fn render_reject_reason( - frame: &mut Frame, - prompt: &PermissionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_reject_reason(frame: &mut Frame, prompt: &PermissionPrompt, theme: &Theme, area: Rect) { let overlay_height = 7u16.min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, @@ -342,10 +337,7 @@ fn render_reject_reason( let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), - Constraint::Length(2), - ]) + .constraints([Constraint::Min(3), Constraint::Length(2)]) .split(overlay_area); let content_block = Block::default() @@ -365,7 +357,12 @@ fn render_reject_reason( let lines = vec![ Line::from(vec![ Span::styled("\u{25b3} ", theme.style(StyleKind::Error)), - Span::styled("Reject permission", Style::default().fg(theme.error).add_modifier(Modifier::BOLD)), + Span::styled( + "Reject permission", + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(""), Line::from(Span::styled( @@ -386,8 +383,7 @@ fn render_reject_reason( frame.render_widget(paragraph, inner); // Bottom hint bar - let hint_block = Block::default() - .style(Style::default().bg(theme.background_element)); + let hint_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(hint_block, chunks[1]); let hint = Paragraph::new(Line::from(vec![ @@ -410,8 +406,7 @@ fn render_button_bar( selected: usize, hint_text: &str, ) { - let bar_block = Block::default() - .style(Style::default().bg(theme.background_element)); + let bar_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(bar_block, area); // Build button spans @@ -448,8 +443,7 @@ fn render_button_bar( } let line = Line::from(spans); - let paragraph = Paragraph::new(line) - .style(Style::default().bg(theme.background_element)); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme.background_element)); frame.render_widget(paragraph, area); } @@ -457,42 +451,53 @@ fn render_button_bar( fn build_tool_detail(prompt: &PermissionPrompt) -> String { match prompt.tool_name.as_str() { "Bash" | "bash_tool" | "run_terminal_cmd" => { - let cmd = prompt.params.get("command") + let cmd = prompt + .params + .get("command") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - let desc = prompt.params.get("description") - .and_then(|v| v.as_str()); + let desc = prompt.params.get("description").and_then(|v| v.as_str()); match desc { Some(d) => format!("{}\n$ {}", d, cmd), None => format!("$ {}", cmd), } } "Edit" | "search_replace" => { - let path = prompt.params.get("file_path") + let path = prompt + .params + .get("file_path") .or_else(|| prompt.params.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("Edit {}", path) } "Write" | "write_file" | "write_file_tool" => { - let path = prompt.params.get("file_path") + let path = prompt + .params + .get("file_path") .or_else(|| prompt.params.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("Write {}", path) } "Delete" => { - let path = prompt.params.get("file_path") + let path = prompt + .params + .get("file_path") .or_else(|| prompt.params.get("path")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("Delete {}", path) } "Task" => { - let desc = prompt.params.get("description") + let desc = prompt + .params + .get("description") .and_then(|v| v.as_str()) .unwrap_or("Task"); - let subagent = prompt.params.get("subagent_type") + let subagent = prompt + .params + .get("subagent_type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); format!("{} Task: {}", subagent, desc) @@ -512,7 +517,15 @@ fn build_tool_detail(prompt: &PermissionPrompt) -> String { /// Extract the first meaningful string parameter from JSON fn extract_first_param(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority = ["command", "path", "file_path", "query", "pattern", "url", "description"]; + let priority = [ + "command", + "path", + "file_path", + "query", + "pattern", + "url", + "description", + ]; for key in &priority { if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { return v.to_string(); diff --git a/src/apps/cli/src/ui/provider_selector.rs b/src/apps/cli/src/ui/provider_selector.rs index d4e1920e8c..a5a04fdc1a 100644 --- a/src/apps/cli/src/ui/provider_selector.rs +++ b/src/apps/cli/src/ui/provider_selector.rs @@ -8,7 +8,7 @@ use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, Frame, @@ -490,14 +490,16 @@ impl ProviderSelectorState { let label_style = if is_selected { Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD) } else { theme.style(StyleKind::Primary) }; let desc_style = if is_selected { - Style::default().bg(theme.primary).fg(Color::White) + Style::default() + .bg(theme.primary) + .fg(theme.selection_foreground()) } else { theme.style(StyleKind::Muted) }; diff --git a/src/apps/cli/src/ui/question.rs b/src/apps/cli/src/ui/question.rs index e37d2b2330..75aa660a6a 100644 --- a/src/apps/cli/src/ui/question.rs +++ b/src/apps/cli/src/ui/question.rs @@ -7,7 +7,6 @@ /// - Multiple questions: Tab/Shift+Tab to switch, Confirm page at the end /// - Custom "Other" input: type your own answer /// - Number shortcuts: 1-9 to quick-pick - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -17,7 +16,7 @@ use ratatui::{ Frame, }; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; // ============ Data Types ============ @@ -72,21 +71,35 @@ impl QuestionPrompt { let mut questions = Vec::new(); for q in questions_val { - let question = q.get("question").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); - let header = q.get("header").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); - let multi_select = q.get("multiSelect").and_then(|v| v.as_bool()) + let question = q + .get("question") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let header = q + .get("header") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let multi_select = q + .get("multiSelect") + .and_then(|v| v.as_bool()) .or_else(|| q.get("multi_select").and_then(|v| v.as_bool())) .unwrap_or(false); let mut options = Vec::new(); if let Some(opts) = q.get("options").and_then(|v| v.as_array()) { for opt in opts { - let label = opt.get("label").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); - let description = opt.get("description").and_then(|v| v.as_str()) - .unwrap_or("").to_string(); + let label = opt + .get("label") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let description = opt + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); options.push(QuestionOption { label, description }); } } @@ -164,19 +177,25 @@ impl QuestionPrompt { let q = &self.questions[i]; // Replace "Other" with actual custom input let custom = &self.custom_inputs[i]; - let processed: Vec = answer_list.iter().map(|a| { - if a == "Other" && !custom.is_empty() { - custom.clone() - } else { - a.clone() - } - }).collect(); + let processed: Vec = answer_list + .iter() + .map(|a| { + if a == "Other" && !custom.is_empty() { + custom.clone() + } else { + a.clone() + } + }) + .collect(); if q.multi_select { map.insert( i.to_string(), serde_json::Value::Array( - processed.into_iter().map(serde_json::Value::String).collect(), + processed + .into_iter() + .map(serde_json::Value::String) + .collect(), ), ); } else { @@ -272,12 +291,8 @@ impl QuestionPrompt { /// Handle keys on the confirm/review page fn handle_confirm_key(&mut self, key: KeyEvent) -> QuestionAction { match (key.code, key.modifiers) { - (KeyCode::Enter, _) => { - QuestionAction::Submit(self.build_answers_payload()) - } - (KeyCode::Esc, _) => { - QuestionAction::Reject - } + (KeyCode::Enter, _) => QuestionAction::Submit(self.build_answers_payload()), + (KeyCode::Esc, _) => QuestionAction::Reject, // Navigate back to questions (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { self.current_tab = self.questions.len().saturating_sub(1); @@ -353,9 +368,7 @@ impl QuestionPrompt { } // Select / toggle - (KeyCode::Enter, _) => { - self.select_current_option() - } + (KeyCode::Enter, _) => self.select_current_option(), // Number shortcuts (1-9) (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() && c != '0' => { @@ -432,12 +445,7 @@ pub fn render_question_overlay( } /// Render a single question page with options -fn render_question_page( - frame: &mut Frame, - prompt: &QuestionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_question_page(frame: &mut Frame, prompt: &QuestionPrompt, theme: &Theme, area: Rect) { let q = match prompt.current_question() { Some(q) => q, None => return, @@ -445,7 +453,9 @@ fn render_question_page( // Calculate overlay height: header(1) + question(2) + options + other + hint(2) + padding let options_count = q.options.len() + 1; // +1 for "Other" - let description_lines: usize = q.options.iter() + let description_lines: usize = q + .options + .iter() .map(|o| if o.description.is_empty() { 0 } else { 1 }) .sum(); let tab_line = if prompt.tab_count() > 1 { 2 } else { 0 }; @@ -494,16 +504,18 @@ fn render_question_page( tab_spans.push(Span::styled( format!(" {} ", qd.header), Style::default() - .fg(theme.background) + .fg(theme.selection_foreground()) .bg(theme.primary) .add_modifier(Modifier::BOLD), )); } else { tab_spans.push(Span::styled( format!(" {} ", qd.header), - Style::default().fg( - if is_answered { theme.success } else { theme.muted } - ), + Style::default().fg(if is_answered { + theme.success + } else { + theme.muted + }), )); } } @@ -514,7 +526,7 @@ fn render_question_page( tab_spans.push(Span::styled( " Confirm ", Style::default() - .fg(theme.background) + .fg(theme.selection_foreground()) .bg(theme.primary) .add_modifier(Modifier::BOLD), )); @@ -526,7 +538,11 @@ fn render_question_page( } // Question text - let multi_hint = if q.multi_select { " (select all that apply)" } else { "" }; + let multi_hint = if q.multi_select { + " (select all that apply)" + } else { + "" + }; lines.push(Line::from(Span::styled( format!("{}{}", q.question, multi_hint), Style::default().add_modifier(Modifier::BOLD), @@ -553,9 +569,17 @@ fn render_question_page( }; let marker = if q.multi_select { - if is_picked { "[\u{2713}]" } else { "[ ]" } + if is_picked { + "[\u{2713}]" + } else { + "[ ]" + } } else { - if is_picked { "(\u{2022})" } else { "( )" } + if is_picked { + "(\u{2022})" + } else { + "( )" + } }; lines.push(Line::from(vec![ @@ -592,15 +616,27 @@ fn render_question_page( }; let other_marker = if q.multi_select { - if is_other_picked { "[\u{2713}]" } else { "[ ]" } + if is_other_picked { + "[\u{2713}]" + } else { + "[ ]" + } } else { - if is_other_picked { "(\u{2022})" } else { "( )" } + if is_other_picked { + "(\u{2022})" + } else { + "( )" + } }; lines.push(Line::from(vec![ Span::styled( format!("{}. ", other_idx + 1), - if is_other_active { theme.style(StyleKind::Primary) } else { theme.style(StyleKind::Muted) }, + if is_other_active { + theme.style(StyleKind::Primary) + } else { + theme.style(StyleKind::Muted) + }, ), Span::styled(format!("{} ", other_marker), other_style), Span::styled("Type your own answer", other_style), @@ -639,15 +675,11 @@ fn render_question_page( } /// Render the confirm/review page (multi-question) -fn render_confirm_page( - frame: &mut Frame, - prompt: &QuestionPrompt, - theme: &Theme, - area: Rect, -) { +fn render_confirm_page(frame: &mut Frame, prompt: &QuestionPrompt, theme: &Theme, area: Rect) { let content_height = 3 + prompt.questions.len(); // title + blank + questions + padding let tab_line = 2; - let overlay_height = ((content_height + tab_line) as u16 + 4).min(area.height.saturating_sub(2)); + let overlay_height = + ((content_height + tab_line) as u16 + 4).min(area.height.saturating_sub(2)); let overlay_area = Rect { x: area.x, @@ -660,10 +692,7 @@ fn render_confirm_page( let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), - Constraint::Length(2), - ]) + .constraints([Constraint::Min(3), Constraint::Length(2)]) .split(overlay_area); let content_block = Block::default() @@ -685,14 +714,18 @@ fn render_confirm_page( let is_answered = !prompt.answers[i].is_empty(); tab_spans.push(Span::styled( format!(" {} ", qd.header), - Style::default().fg(if is_answered { theme.success } else { theme.muted }), + Style::default().fg(if is_answered { + theme.success + } else { + theme.muted + }), )); } tab_spans.push(Span::raw(" ")); tab_spans.push(Span::styled( " Confirm ", Style::default() - .fg(theme.background) + .fg(theme.selection_foreground()) .bg(theme.primary) .add_modifier(Modifier::BOLD), )); @@ -711,13 +744,16 @@ fn render_confirm_page( let answer_list = &prompt.answers[i]; let custom = &prompt.custom_inputs[i]; - let display_answers: Vec = answer_list.iter().map(|a| { - if a == "Other" && !custom.is_empty() { - custom.clone() - } else { - a.clone() - } - }).collect(); + let display_answers: Vec = answer_list + .iter() + .map(|a| { + if a == "Other" && !custom.is_empty() { + custom.clone() + } else { + a.clone() + } + }) + .collect(); let answered = !display_answers.is_empty(); let value_text = if answered { @@ -730,7 +766,11 @@ fn render_confirm_page( Span::styled(format!("{}: ", q.header), theme.style(StyleKind::Muted)), Span::styled( value_text, - if answered { Style::default() } else { theme.style(StyleKind::Error) }, + if answered { + Style::default() + } else { + theme.style(StyleKind::Error) + }, ), ])); } @@ -739,8 +779,7 @@ fn render_confirm_page( frame.render_widget(paragraph, inner); // Hint bar - let hint_block = Block::default() - .style(Style::default().bg(theme.background_element)); + let hint_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(hint_block, chunks[1]); let hint = Paragraph::new(Line::from(vec![ @@ -757,14 +796,8 @@ fn render_confirm_page( } /// Render the hint bar for question pages -fn render_question_hint_bar( - frame: &mut Frame, - area: Rect, - theme: &Theme, - prompt: &QuestionPrompt, -) { - let hint_block = Block::default() - .style(Style::default().bg(theme.background_element)); +fn render_question_hint_bar(frame: &mut Frame, area: Rect, theme: &Theme, prompt: &QuestionPrompt) { + let hint_block = Block::default().style(Style::default().bg(theme.background_element)); frame.render_widget(hint_block, area); let mut spans = vec![Span::raw(" ")]; @@ -800,7 +833,6 @@ fn render_question_hint_bar( } let line = Line::from(spans); - let paragraph = Paragraph::new(line) - .style(Style::default().bg(theme.background_element)); + let paragraph = Paragraph::new(line).style(Style::default().bg(theme.background_element)); frame.render_widget(paragraph, area); } diff --git a/src/apps/cli/src/ui/session_selector.rs b/src/apps/cli/src/ui/session_selector.rs index 79d1cf1639..b4f69b3fe6 100644 --- a/src/apps/cli/src/ui/session_selector.rs +++ b/src/apps/cli/src/ui/session_selector.rs @@ -3,7 +3,6 @@ /// Overlay popup that displays all available sessions /// and allows the user to select one to switch to. /// Supports switching and deleting current-project sessions. - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, @@ -307,7 +306,10 @@ impl SessionSelectorState { spans.push(Span::styled(format!(" [{}]", short_ws), workspace_style)); } - spans.push(Span::styled(format!(" {}", session.last_activity), time_style)); + spans.push(Span::styled( + format!(" {}", session.last_activity), + time_style, + )); let line = Line::from(spans); ListItem::new(line) @@ -326,7 +328,7 @@ impl SessionSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/skill_selector.rs b/src/apps/cli/src/ui/skill_selector.rs index f25b507b1f..ac97bc2b37 100644 --- a/src/apps/cli/src/ui/skill_selector.rs +++ b/src/apps/cli/src/ui/skill_selector.rs @@ -5,7 +5,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -13,12 +13,32 @@ use ratatui::{ use crate::ui::theme::{StyleKind, Theme}; -/// A skill item for display in the selector +/// A skill item for display in the selector. #[derive(Debug, Clone)] pub struct SkillItem { + pub key: String, pub name: String, pub description: String, pub level: String, // "project" or "user" + pub enabled: bool, + pub selected_for_runtime: bool, + pub default_enabled: bool, + pub is_shadowed: bool, +} + +#[derive(Debug, Clone)] +pub enum SkillSelectorAction { + ListSkills, + ConfigureSkills, + Execute(SkillItem), + Toggle(SkillItem), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillSelectorScreen { + Menu, + List, + Configure, } /// Skill selector popup state @@ -27,6 +47,7 @@ pub struct SkillSelectorState { list_state: ListState, visible: bool, last_area: Option, + screen: SkillSelectorScreen, } impl SkillSelectorState { @@ -36,20 +57,54 @@ impl SkillSelectorState { list_state: ListState::default(), visible: false, last_area: None, + screen: SkillSelectorScreen::Menu, } } - /// Show the skill selector with given skill list - pub fn show(&mut self, skills: Vec) { + pub fn show_menu(&mut self) { + self.items.clear(); + self.screen = SkillSelectorScreen::Menu; + self.list_state.select(Some(0)); + self.visible = true; + } + + /// Show the current mode's runtime-visible skills. + pub fn show_list(&mut self, skills: Vec) { if skills.is_empty() { return; } self.items = skills; + self.screen = SkillSelectorScreen::List; self.list_state.select(Some(0)); self.visible = true; } + /// Show all discovered skills with mode-specific enablement checkboxes. + pub fn show_config(&mut self, skills: Vec) { + if skills.is_empty() { + return; + } + + let selected_key = if self.screen == SkillSelectorScreen::Configure { + self.list_state + .selected() + .and_then(|index| self.items.get(index)) + .map(|item| item.key.clone()) + } else { + None + }; + let selected_index = self.list_state.selected().unwrap_or(0); + let next_index = selected_key + .and_then(|key| skills.iter().position(|item| item.key == key)) + .unwrap_or_else(|| selected_index.min(skills.len().saturating_sub(1))); + + self.items = skills; + self.screen = SkillSelectorScreen::Configure; + self.list_state.select(Some(next_index)); + self.visible = true; + } + pub fn hide(&mut self) { self.visible = false; // Note: we don't clear items here to support back navigation @@ -58,7 +113,7 @@ impl SkillSelectorState { /// Reshow the skill selector (for back navigation) pub fn reshow(&mut self) { - if !self.items.is_empty() { + if self.screen == SkillSelectorScreen::Menu || !self.items.is_empty() { self.visible = true; } } @@ -68,7 +123,7 @@ impl SkillSelectorState { } pub fn move_up(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); @@ -77,32 +132,55 @@ impl SkillSelectorState { } pub fn move_down(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); - let next = (selected + 1).min(self.items.len().saturating_sub(1)); + let next = (selected + 1).min(self.len().saturating_sub(1)); self.list_state.select(Some(next)); } - /// Get the selected skill item - pub fn confirm_selection(&self) -> Option { + /// Get the selected action. + pub fn confirm_selection(&self) -> Option { if !self.visible { return None; } let idx = self.list_state.selected()?; - self.items.get(idx).cloned() + match self.screen { + SkillSelectorScreen::Menu => match idx { + 0 => Some(SkillSelectorAction::ListSkills), + 1 => Some(SkillSelectorAction::ConfigureSkills), + _ => None, + }, + SkillSelectorScreen::List => self + .items + .get(idx) + .cloned() + .map(SkillSelectorAction::Execute), + SkillSelectorScreen::Configure => self + .items + .get(idx) + .cloned() + .map(SkillSelectorAction::Toggle), + } + } + + fn len(&self) -> usize { + match self.screen { + SkillSelectorScreen::Menu => 2, + SkillSelectorScreen::List | SkillSelectorScreen::Configure => self.items.len(), + } } /// Render the skill selector popup as an overlay pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { self.last_area = None; return; } - let popup_width = area.width.saturating_sub(4).min(70); - let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + let popup_width = area.width.saturating_sub(4).min(92); + let popup_height = (self.len() as u16 + 4).min(area.height.saturating_sub(2)); if popup_height < 5 || popup_width < 20 { self.last_area = None; return; @@ -119,38 +197,18 @@ impl SkillSelectorState { }; self.last_area = Some(popup_area); - let list_items: Vec = self - .items - .iter() - .map(|skill| { - let level_marker = match skill.level.as_str() { - "project" => "P", - "user" => "U", - _ => "?", - }; - let level_style = match skill.level.as_str() { - "project" => theme.style(StyleKind::Info), - _ => theme.style(StyleKind::Muted), - }; - - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); - let desc_style = theme.style(StyleKind::Muted); - - let line = Line::from(vec![ - Span::styled(format!("[{}] ", level_marker), level_style), - Span::styled(&skill.name, name_style), - Span::raw(" "), - Span::styled(&skill.description, desc_style), - ]); - ListItem::new(line) - }) - .collect(); + let list_items = self.render_items(theme); + let title = match self.screen { + SkillSelectorScreen::Menu => " Skills ", + SkillSelectorScreen::List => " List Skills (current mode) ", + SkillSelectorScreen::Configure => " Enable/Disable Skills (Space/Enter Toggle) ", + }; let block = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) .style(Style::default().bg(theme.background)) - .title(" Select Skill (↑↓ Navigate, Enter Select, Esc Cancel) "); + .title(title); let list = List::new(list_items) .block(block) @@ -158,7 +216,7 @@ impl SkillSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); @@ -167,7 +225,7 @@ impl SkillSelectorState { } /// Handle mouse events - pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { if !self.visible { return None; } @@ -229,10 +287,99 @@ impl SkillSelectorState { let offset = self.list_state.offset(); let index = (row - inner_y) as usize + offset; - if index >= self.items.len() { + if index >= self.len() { return None; } Some(index) } + + fn render_items(&self, theme: &Theme) -> Vec> { + match self.screen { + SkillSelectorScreen::Menu => vec![ + ListItem::new(Line::from(vec![ + Span::styled( + "List skills", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Show skills available to the current mode", + theme.style(StyleKind::Muted), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled( + "Enable/disable skills", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Toggle all discovered skills for this mode", + theme.style(StyleKind::Muted), + ), + ])), + ], + SkillSelectorScreen::List => self + .items + .iter() + .map(|skill| self.render_skill_line(skill, theme, false)) + .collect(), + SkillSelectorScreen::Configure => self + .items + .iter() + .map(|skill| self.render_skill_line(skill, theme, true)) + .collect(), + } + } + + fn render_skill_line( + &self, + skill: &SkillItem, + theme: &Theme, + include_checkbox: bool, + ) -> ListItem<'static> { + let level_marker = match skill.level.as_str() { + "project" => "P", + "user" => "U", + _ => "?", + }; + let level_style = match skill.level.as_str() { + "project" => theme.style(StyleKind::Info), + _ => theme.style(StyleKind::Muted), + }; + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + let status = if skill.is_shadowed { + " shadowed" + } else if include_checkbox && skill.enabled && !skill.selected_for_runtime { + " enabled" + } else { + "" + }; + + let mut spans = Vec::new(); + if include_checkbox { + spans.push(Span::styled( + if skill.enabled { "[x] " } else { "[ ] " }, + if skill.enabled { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }, + )); + } + spans.push(Span::styled(format!("[{}] ", level_marker), level_style)); + spans.push(Span::styled(skill.name.clone(), name_style)); + if !status.is_empty() { + spans.push(Span::styled( + status.to_string(), + theme.style(StyleKind::Muted), + )); + } + spans.push(Span::raw(" ")); + spans.push(Span::styled(skill.description.clone(), desc_style)); + + ListItem::new(Line::from(spans)) + } } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index d92f75f3a4..7aecf1b881 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1,3 +1,20 @@ +use super::agent_selector::{AgentItem, AgentSelectorState}; +use super::command_menu::CommandMenuState; +use super::command_palette::{CommandPaletteState, PaletteAction}; +use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; +use super::model_selector::{ModelItem, ModelSelectorState}; +use super::provider_selector::{ProviderSelection, ProviderSelectorState}; +use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; +use super::skill_selector::{SkillItem, SkillSelectorAction, SkillSelectorState}; +use super::subagent_selector::{SubagentItem, SubagentSelectorAction, SubagentSelectorState}; +use super::text_input::{TextInput, TextInputStyle}; +use super::theme::{ + builtin_theme_ids, builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, + Appearance, EffectiveColorScheme, Theme, +}; +use super::theme_selector::{ThemeItem, ThemeSelectorState}; +use crate::commands::STARTUP_COMMAND_SPECS; +use crate::config::CliConfig; /// Startup page module /// /// Full-featured startup page with: @@ -5,39 +22,31 @@ /// - Slash command menu with real execution /// - Model/Agent/Session/Skill/Subagent selector popups /// - Random tips - use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, - widgets::Paragraph, + widgets::{Block, Paragraph}, Frame, Terminal, }; use std::sync::Arc; use std::time::Duration; -use super::text_input::{TextInput, TextInputStyle}; -use super::command_menu::CommandMenuState; -use super::command_palette::{CommandPaletteState, PaletteAction}; -use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; -use super::model_selector::{ModelItem, ModelSelectorState}; -use super::provider_selector::{ProviderSelection, ProviderSelectorState}; -use super::agent_selector::{AgentItem, AgentSelectorState}; -use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; -use super::skill_selector::{SkillItem, SkillSelectorState}; -use super::subagent_selector::{SubagentItem, SubagentSelectorState}; -use super::theme::{ - builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, EffectiveColorScheme, - Theme, -}; -use crate::commands::STARTUP_COMMAND_SPECS; -use crate::config::CliConfig; +use bitfun_core::agentic::agents::{ + get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, +}; use bitfun_core::agentic::coordination::ConversationCoordinator; -use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; -use bitfun_core::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_core::agentic::tools::implementations::skills::{ + mode_overrides::{ + load_project_mode_skills_document_local, save_project_mode_skills_document_local, + set_mode_skill_disabled_in_document, set_user_mode_skill_state, + }, + registry::SkillRegistry, + ModeSkillInfo, SkillInfo, +}; use bitfun_core::service::config::GlobalConfigManager; /// Types of popups that can be shown on the startup page @@ -49,6 +58,7 @@ pub enum PopupType { SessionSelector, SkillSelector, SubagentSelector, + ThemeSelector, ProviderSelector, ModelConfigForm, } @@ -125,6 +135,7 @@ const TIPS: &[&str] = &[ "Use /sessions to list and continue previous conversations", "Press Ctrl+O to expand/collapse tool output", "Use /skills to browse and execute available skills", + "Use /theme to switch the CLI theme", "Use /acp to copy editor setup commands for ACP hosts", "Press Up/Down to cycle through input history", "Use /new to start a fresh conversation session", @@ -136,6 +147,8 @@ pub struct StartupPage { text_input: TextInput, /// Theme theme: Theme, + /// CLI config, including persisted theme preference. + config: CliConfig, /// Current tip text tip: &'static str, @@ -151,8 +164,10 @@ pub struct StartupPage { session_selector: SessionSelectorState, skill_selector: SkillSelectorState, subagent_selector: SubagentSelectorState, + theme_selector: ThemeSelectorState, provider_selector: ProviderSelectorState, model_config_form: ModelConfigFormState, + theme_preview_original: Option, // ── System context ── coordinator: Arc, @@ -214,6 +229,7 @@ impl StartupPage { let mut page = Self { text_input: TextInput::new(), theme, + config, tip: TIPS[tip_index], command_menu: CommandMenuState::new(), command_palette: CommandPaletteState::new(), @@ -222,8 +238,10 @@ impl StartupPage { session_selector: SessionSelectorState::new(), skill_selector: SkillSelectorState::new(), subagent_selector: SubagentSelectorState::new(), + theme_selector: ThemeSelectorState::new(), provider_selector: ProviderSelectorState::new(), model_config_form: ModelConfigFormState::new(), + theme_preview_original: None, coordinator, agent_type: default_agent, model_display_name: String::new(), @@ -258,6 +276,11 @@ impl StartupPage { } } + /// Get the current CLI config after startup-page edits. + pub fn config(&self) -> &CliConfig { + &self.config + } + fn workspace_path_buf(&self) -> std::path::PathBuf { self.workspace() .map(std::path::PathBuf::from) @@ -273,6 +296,7 @@ impl StartupPage { || self.session_selector.is_visible() || self.skill_selector.is_visible() || self.subagent_selector.is_visible() + || self.theme_selector.is_visible() || self.provider_selector.is_visible() || self.model_config_form.is_visible() } @@ -337,7 +361,10 @@ impl StartupPage { } else { for ev in events { match ev { - Event::Key(key) if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat => { + Event::Key(key) + if key.kind == KeyEventKind::Press + || key.kind == KeyEventKind::Repeat => + { if let Some(result) = self.handle_key(key) { return Ok(result); } @@ -365,6 +392,11 @@ impl StartupPage { if let PaletteAction::Execute(id) = action { let _ = self.handle_palette_action(&id); } + } else if self.theme_selector.captures_mouse(&mouse) { + self.theme_selector.handle_mouse_event(&mouse); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } } else if self.provider_selector.captures_mouse(&mouse) { if let Some(selection) = self.provider_selector.handle_mouse_event(&mouse) { self.handle_provider_selection(selection); @@ -388,6 +420,10 @@ impl StartupPage { fn render(&mut self, frame: &mut Frame) { let size = frame.area(); + frame.render_widget( + Block::default().style(Style::default().bg(self.theme.background)), + size, + ); let chunks = Layout::default() .direction(Direction::Vertical) @@ -418,6 +454,7 @@ impl StartupPage { self.session_selector.render(frame, size, &self.theme); self.skill_selector.render(frame, size, &self.theme); self.subagent_selector.render(frame, size, &self.theme); + self.theme_selector.render(frame, size, &self.theme); self.provider_selector.render(frame, size, &self.theme); self.model_config_form.render_mut(frame, size, &self.theme); @@ -436,19 +473,21 @@ impl StartupPage { // Dynamic input height: content lines (1..6) + 2 (padding top + agent label row) + 1 (gap) let input_content_width = max_width.saturating_sub(2 + 4); // left bar(2) + inner padding(4) - let visual_lines = self.text_input.visual_line_count_with_prefix(input_content_width, 0) as u16; + let visual_lines = + self.text_input + .visual_line_count_with_prefix(input_content_width, 0) as u16; let content_lines = visual_lines.max(1).min(6); let input_box_height = content_lines + 3; // +1 top padding, +1 gap, +1 agent label let v_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(20), // top space - Constraint::Length(12), // logo - Constraint::Length(1), // gap + Constraint::Percentage(20), // top space + Constraint::Length(12), // logo + Constraint::Length(1), // gap Constraint::Length(input_box_height), // input box - Constraint::Length(2), // gap + tip/status - Constraint::Min(1), // bottom space + Constraint::Length(2), // gap + tip/status + Constraint::Min(1), // bottom space ]) .split(area); @@ -479,13 +518,14 @@ impl StartupPage { fn render_input(&mut self, frame: &mut Frame, area: Rect) { let highlight_color = self.theme.primary; + let input_bg = self.input_background(); // Split: 2 cols for left bar, rest for content let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(2), // left bar - Constraint::Min(1), // content + Constraint::Min(1), // content ]) .split(area); @@ -494,21 +534,19 @@ impl StartupPage { .map(|_| { Line::from(Span::styled( " ┃", - Style::default().fg(highlight_color), + Style::default().fg(highlight_color).bg(input_bg), )) }) .collect(); - let bar = Paragraph::new(bar_lines); + let bar = Paragraph::new(bar_lines).style(Style::default().bg(input_bg)); frame.render_widget(bar, h_chunks[0]); // Content area with background let content_area = h_chunks[1]; // Fill background - let bg = Paragraph::new( - vec![Line::from(""); content_area.height as usize], - ) - .style(Style::default().bg(self.theme.background_element)); + let bg = Paragraph::new(vec![Line::from(""); content_area.height as usize]) + .style(Style::default().bg(input_bg)); frame.render_widget(bg, content_area); // Inner content with padding @@ -534,8 +572,8 @@ impl StartupPage { first_line_prefix: "", continuation_prefix: "", placeholder: "Ask anything... or type / for commands".to_string(), - text_style: Style::default().fg(Color::White), - placeholder_style: Style::default().fg(self.theme.muted), + text_style: Style::default().fg(self.theme.command_text).bg(input_bg), + placeholder_style: Style::default().fg(self.theme.muted).bg(input_bg), }; self.text_input.render(frame, text_area, &style, true); @@ -546,10 +584,7 @@ impl StartupPage { Style::default().fg(highlight_color), )]; if !self.model_display_name.is_empty() { - spans.push(Span::styled( - " | ", - Style::default().fg(self.theme.muted), - )); + spans.push(Span::styled(" | ", Style::default().fg(self.theme.muted))); spans.push(Span::styled( &self.model_display_name, Style::default().fg(self.theme.muted), @@ -566,6 +601,10 @@ impl StartupPage { } } + fn input_background(&self) -> ratatui::style::Color { + self.theme.input_background + } + fn render_tip_or_status(&self, frame: &mut Frame, area: Rect) { let line = if let Some(ref status) = self.status { Line::from(vec![ @@ -605,7 +644,10 @@ impl StartupPage { // Right: MCP status | version let right = Paragraph::new(Line::from(vec![ Span::styled(&mcp_status, Style::default().fg(mcp_color)), - Span::styled(format!(" | {} ", version), Style::default().fg(self.theme.muted)), + Span::styled( + format!(" | {} ", version), + Style::default().fg(self.theme.muted), + ), ])) .alignment(Alignment::Right); frame.render_widget(right, area); @@ -627,12 +669,12 @@ impl StartupPage { ]; let colors = [ - Color::Rgb(255, 0, 100), - Color::Rgb(255, 100, 0), - Color::Rgb(255, 200, 0), - Color::Rgb(100, 255, 0), - Color::Rgb(0, 255, 200), - Color::Rgb(100, 100, 255), + self.theme.primary, + self.theme.info, + self.theme.success, + self.theme.warning, + self.theme.error, + self.theme.muted, ]; for (i, line) in logo.iter().enumerate() { @@ -653,11 +695,11 @@ impl StartupPage { ]; let colors = [ - Color::Cyan, - Color::Blue, - Color::Magenta, - Color::Red, - Color::Yellow, + self.theme.primary, + self.theme.info, + self.theme.success, + self.theme.warning, + self.theme.error, ]; for (i, line) in logo.iter().enumerate() { @@ -674,14 +716,14 @@ impl StartupPage { lines.push(Line::from(Span::styled( "AI agent-driven command-line programming assistant", Style::default() - .fg(Color::Gray) + .fg(self.theme.muted) .add_modifier(Modifier::ITALIC), ))); let version = format!("v{}", env!("CARGO_PKG_VERSION")); lines.push(Line::from(Span::styled( version, - Style::default().fg(Color::DarkGray), + Style::default().fg(self.theme.muted), ))); let paragraph = Paragraph::new(lines).alignment(Alignment::Center); @@ -717,6 +759,32 @@ impl StartupPage { // ── Selector popups intercept all keys when active ── + if self.theme_selector.is_visible() { + match key.code { + KeyCode::Up => { + self.theme_selector.move_up(); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } + } + KeyCode::Down => { + self.theme_selector.move_down(); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } + } + KeyCode::Enter => { + if let Some(selected) = self.theme_selector.confirm_selection() { + self.theme_selector.hide(); + self.apply_theme_selection(&selected); + } + } + KeyCode::Esc => self.navigate_back(), + _ => {} + } + return None; + } + if self.model_selector.is_visible() { match key.code { KeyCode::Up => self.model_selector.move_up(), @@ -776,10 +844,9 @@ impl StartupPage { match key.code { KeyCode::Up => self.skill_selector.move_up(), KeyCode::Down => self.skill_selector.move_down(), - KeyCode::Enter => { - if let Some(selected) = self.skill_selector.confirm_selection() { - self.skill_selector.hide(); - self.set_input(&format!("Execute the {} skill.", selected.name)); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = self.skill_selector.confirm_selection() { + self.handle_skill_selector_action(action); } } KeyCode::Esc => self.navigate_back(), @@ -792,10 +859,9 @@ impl StartupPage { match key.code { KeyCode::Up => self.subagent_selector.move_up(), KeyCode::Down => self.subagent_selector.move_down(), - KeyCode::Enter => { - if let Some(selected) = self.subagent_selector.confirm_selection() { - self.subagent_selector.hide(); - self.set_input(&format!("Launch subagent {} to finish task: ", selected.name)); + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = self.subagent_selector.confirm_selection() { + self.handle_subagent_selector_action(action); } } KeyCode::Esc => self.navigate_back(), @@ -866,7 +932,8 @@ impl StartupPage { } KeyCode::Esc => { self.text_input.clear(); - self.command_menu.update_with_commands("", 0, STARTUP_COMMAND_SPECS); + self.command_menu + .update_with_commands("", 0, STARTUP_COMMAND_SPECS); return None; } _ => { @@ -996,6 +1063,10 @@ impl StartupPage { self.push_current_popup_to_stack(); self.provider_selector.show(); } + // Appearance group + "theme" => { + self.show_theme_selector(); + } // Agent group "switch_agent" => { self.show_agent_selector(); @@ -1041,6 +1112,9 @@ impl StartupPage { "/models" => { self.show_model_selector(); } + "/theme" => { + self.show_theme_selector(); + } "/connect" => { self.push_current_popup_to_stack(); self.provider_selector.show(); @@ -1065,20 +1139,21 @@ impl StartupPage { prompt: Some("/acp".to_string()), }); } - "/init" => { - match crate::prompts::get_cli_prompt("init") { - Some(prompt) => { - return Some(StartupResult::NewSession { - prompt: Some(prompt.to_string()), - }); - } - None => { - self.status = Some("Init prompt not found".to_string()); - } + "/init" => match crate::prompts::get_cli_prompt("init") { + Some(prompt) => { + return Some(StartupResult::NewSession { + prompt: Some(prompt.to_string()), + }); } - } + None => { + self.status = Some("Init prompt not found".to_string()); + } + }, _ => { - self.status = Some(format!("Unknown command: {}. Type /help for available commands.", cmd)); + self.status = Some(format!( + "Unknown command: {}. Type /help for available commands.", + cmd + )); } } @@ -1107,6 +1182,9 @@ impl StartupPage { } else if self.subagent_selector.is_visible() { self.popup_stack.push(PopupType::SubagentSelector); self.subagent_selector.hide(); + } else if self.theme_selector.is_visible() { + self.popup_stack.push(PopupType::ThemeSelector); + self.theme_selector.hide(); } else if self.provider_selector.is_visible() { self.popup_stack.push(PopupType::ProviderSelector); self.provider_selector.hide(); @@ -1167,9 +1245,8 @@ impl StartupPage { let result = tokio::task::block_in_place(|| { let workspace_path = self.workspace_path_buf(); - tokio::runtime::Handle::current().block_on(async { - coordinator.delete_session(&workspace_path, &sid).await - }) + tokio::runtime::Handle::current() + .block_on(async { coordinator.delete_session(&workspace_path, &sid).await }) }); match result { @@ -1320,7 +1397,9 @@ impl StartupPage { enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, skip_ssl_verify: result.skip_ssl_verify, custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() || result.custom_headers_mode == "merge" { + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { None } else { Some(result.custom_headers_mode.clone()) @@ -1330,8 +1409,7 @@ impl StartupPage { }; let result_name = result.name.clone(); - let result_model_display = - format!("{} / {}", result.model_name, result.name); + let result_model_display = format!("{} / {}", result.model_name, result.name); let success = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1418,10 +1496,13 @@ impl StartupPage { enable_thinking: model.enable_thinking_process, support_preserved_thinking: model.inline_think_in_text, skip_ssl_verify: model.skip_ssl_verify, - custom_headers: model.custom_headers + custom_headers: model + .custom_headers .map(|h| serde_json::to_string(&h).unwrap_or_default()) .unwrap_or_default(), - custom_headers_mode: model.custom_headers_mode.unwrap_or_else(|| "merge".to_string()), + custom_headers_mode: model + .custom_headers_mode + .unwrap_or_else(|| "merge".to_string()), custom_request_body: model.custom_request_body.unwrap_or_default(), }; self.model_config_form.show_for_edit(&model.id, &form_data); @@ -1465,7 +1546,9 @@ impl StartupPage { enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, skip_ssl_verify: result.skip_ssl_verify, custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() || result.custom_headers_mode == "merge" { + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { None } else { Some(result.custom_headers_mode.clone()) @@ -1475,8 +1558,7 @@ impl StartupPage { }; let result_name = result.name.clone(); - let result_model_display = - format!("{} / {}", result.model_name, result.name); + let result_model_display = format!("{} / {}", result.model_name, result.name); let success = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1488,7 +1570,10 @@ impl StartupPage { } }; - if let Err(e) = config_service.update_ai_model(&model_id, model_config).await { + if let Err(e) = config_service + .update_ai_model(&model_id, model_config) + .await + { tracing::error!("Failed to update AI model: {}", e); return false; } @@ -1524,7 +1609,8 @@ impl StartupPage { }) .collect(); - self.agent_selector.show(agent_items, Some(self.agent_type.clone())); + self.agent_selector + .show(agent_items, Some(self.agent_type.clone())); } fn apply_agent_selection(&mut self, selected: &AgentItem) { @@ -1536,29 +1622,164 @@ impl StartupPage { } } + fn show_theme_selector(&mut self) { + let themes = self.list_available_themes(); + if themes.is_empty() { + self.status = Some("No themes available.".to_string()); + return; + } + + self.push_current_popup_to_stack(); + self.begin_theme_preview(); + self.theme_selector + .show(themes, Some(self.config.ui.theme_id.clone())); + if let Some(selected) = self.theme_selector.selected_item().cloned() { + self.preview_theme_selection(&selected); + } + } + + fn list_available_themes(&self) -> Vec { + let mut themes: Vec = builtin_theme_ids() + .into_iter() + .map(|id| ThemeItem { id }) + .collect(); + + themes.sort_by(|a, b| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase())); + themes.dedup_by(|a, b| a.id == b.id); + themes + } + + fn current_base_theme(&self) -> (Theme, Appearance, EffectiveColorScheme) { + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + + (base, appearance, scheme) + } + + fn resolve_theme_by_id( + &self, + base: Theme, + appearance: Appearance, + scheme: EffectiveColorScheme, + id: &str, + ) -> Theme { + if scheme == EffectiveColorScheme::Monochrome { + return Theme::monochrome(); + } + + let id = id.trim(); + if id.is_empty() { + return base; + } + + if let Some(json) = builtin_theme_json(id) { + return base + .apply_opencode_theme_json(json, appearance) + .unwrap_or(base) + .with_effective_scheme(scheme); + } + + base + } + + fn begin_theme_preview(&mut self) { + if self.theme_preview_original.is_none() { + self.theme_preview_original = Some(self.theme.clone()); + } + } + + fn cancel_theme_preview(&mut self) { + if let Some(original) = self.theme_preview_original.take() { + self.theme = original; + } + } + + fn preview_theme_selection(&mut self, theme: &ThemeItem) { + self.begin_theme_preview(); + let (base, appearance, scheme) = self.current_base_theme(); + self.theme = self.resolve_theme_by_id(base, appearance, scheme, &theme.id); + self.status = Some(format!( + "Preview theme: {} (Enter apply, Esc cancel)", + theme.id + )); + } + + fn apply_theme_selection(&mut self, theme: &ThemeItem) { + let (base, appearance, scheme) = self.current_base_theme(); + self.config.ui.theme_id = theme.id.clone(); + + match self.config.save() { + Ok(()) => { + self.status = Some(format!("Theme set to: {}", theme.id)); + } + Err(e) => { + self.status = Some(format!("Failed to save config: {}", e)); + } + } + + self.theme = self.resolve_theme_by_id(base, appearance, scheme, &theme.id); + self.theme_preview_original = None; + } + fn show_skill_selector(&mut self) { self.push_current_popup_to_stack(); + self.skill_selector.show_menu(); + } + fn show_available_skill_list(&mut self) { let skills = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + let agent_type = self.agent_type.clone(); tokio::runtime::Handle::current().block_on(async { let registry = SkillRegistry::global(); - registry.refresh().await; - registry.get_all_skills().await + registry + .get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type)) + .await }) }); if skills.is_empty() { + self.status = Some(format!( + "No enabled skills found for agent mode '{}'.", + self.agent_type + )); + return; + } + + let skill_items: Vec = + skills.into_iter().map(Self::skill_item_from_info).collect(); + + if skill_items.is_empty() { self.status = Some("No skills found.".to_string()); return; } + self.skill_selector.show_list(skill_items); + } + + fn show_skill_config_selector(&mut self) { + let skills = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + tokio::runtime::Handle::current().block_on(async { + let registry = SkillRegistry::global(); + registry + .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) + .await + }) + }); + let skill_items: Vec = skills .into_iter() - .map(|s| SkillItem { - name: s.name, - description: s.description, - level: s.level.as_str().to_string(), - }) + .map(Self::skill_item_from_mode_info) .collect(); if skill_items.is_empty() { @@ -1566,40 +1787,162 @@ impl StartupPage { return; } - self.skill_selector.show(skill_items); + self.skill_selector.show_config(skill_items); + } + + fn handle_skill_selector_action(&mut self, action: SkillSelectorAction) { + match action { + SkillSelectorAction::ListSkills => self.show_available_skill_list(), + SkillSelectorAction::ConfigureSkills => self.show_skill_config_selector(), + SkillSelectorAction::Execute(selected) => { + self.skill_selector.hide(); + self.set_input(&format!("Execute the {} skill.", selected.name)); + } + SkillSelectorAction::Toggle(selected) => { + self.set_skill_enabled(&selected, !selected.enabled); + self.show_skill_config_selector(); + } + } + } + + fn set_skill_enabled(&mut self, selected: &SkillItem, enabled: bool) { + let workspace = self.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let skill = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + match skill.level.as_str() { + "user" => { + set_user_mode_skill_state( + &mode_id, + &skill.key, + enabled, + skill.default_enabled, + ) + .await + .map_err(|error| error.to_string())?; + } + "project" => { + let mut document = load_project_mode_skills_document_local(&workspace) + .await + .map_err(|error| error.to_string())?; + set_mode_skill_disabled_in_document( + &mut document, + &mode_id, + &skill.key, + !enabled, + ) + .map_err(|error| error.to_string())?; + save_project_mode_skills_document_local(&workspace, &document) + .await + .map_err(|error| error.to_string())?; + } + other => { + return Err(format!("Unsupported skill level '{}'", other)); + } + } + + Ok(()) + }) + }); + + self.status = Some(match result { + Ok(()) => format!( + "Skill '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + ), + Err(error) => format!("Failed to update skill '{}': {}", selected.name, error), + }); + } + + fn skill_item_from_info(info: SkillInfo) -> SkillItem { + SkillItem { + key: info.key, + name: info.name, + description: info.description, + level: info.level.as_str().to_string(), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: info.is_shadowed, + } + } + + fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { + SkillItem { + key: info.skill.key, + name: info.skill.name, + description: info.skill.description, + level: info.skill.level.as_str().to_string(), + enabled: info.effective_enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, + is_shadowed: info.skill.is_shadowed, + } } fn show_subagent_selector(&mut self) { self.push_current_popup_to_stack(); + self.subagent_selector.show_menu(); + } + fn show_available_subagent_list(&mut self) { let registry = get_agent_registry(); let subagents = tokio::task::block_in_place(|| { let workspace = self.workspace_path_buf(); - tokio::runtime::Handle::current() - .block_on(registry.get_subagents_info(Some(workspace.as_path()))) + let agent_type = self.agent_type.clone(); + tokio::runtime::Handle::current().block_on(registry.get_subagents_for_query( + &SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + }, + )) }); if subagents.is_empty() { + self.status = Some(format!( + "No enabled subagents found for agent mode '{}'.", + self.agent_type + )); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(Self::subagent_item_from_info) + .collect(); + + if subagent_items.is_empty() { self.status = Some("No subagents found.".to_string()); return; } + self.subagent_selector.show_list(subagent_items); + } + + fn show_subagent_config_selector(&mut self) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + tokio::runtime::Handle::current().block_on(registry.get_subagents_for_query( + &SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + }, + )) + }); + let subagent_items: Vec = subagents .into_iter() - .map(|s| { - let source = match s.subagent_source { - Some(bitfun_core::agentic::agents::SubAgentSource::Builtin) => "builtin".to_string(), - Some(bitfun_core::agentic::agents::SubAgentSource::Project) => "project".to_string(), - Some(bitfun_core::agentic::agents::SubAgentSource::User) => "user".to_string(), - None => "builtin".to_string(), - }; - SubagentItem { - id: s.id, - name: s.name, - description: s.description, - source, - } - }) + .map(Self::subagent_item_from_info) .collect(); if subagent_items.is_empty() { @@ -1607,7 +1950,76 @@ impl StartupPage { return; } - self.subagent_selector.show(subagent_items); + self.subagent_selector.show_config(subagent_items); + } + + fn handle_subagent_selector_action(&mut self, action: SubagentSelectorAction) { + match action { + SubagentSelectorAction::ListSubagents => self.show_available_subagent_list(), + SubagentSelectorAction::ConfigureSubagents => self.show_subagent_config_selector(), + SubagentSelectorAction::Launch(selected) => { + self.subagent_selector.hide(); + self.set_input(&format!( + "Launch subagent {} to finish task: ", + selected.name + )); + } + SubagentSelectorAction::Toggle(selected) => { + self.set_subagent_enabled(&selected, !selected.enabled); + self.show_subagent_config_selector(); + } + } + } + + fn set_subagent_enabled(&mut self, selected: &SubagentItem, enabled: bool) { + let registry = get_agent_registry(); + let workspace = self.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let subagent = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + registry + .update_subagent_override( + &mode_id, + &subagent.id, + enabled, + Some(workspace.as_path()), + ) + .await + .map_err(|error| error.to_string()) + }) + }); + + self.status = Some(match result { + Ok(()) => format!( + "Subagent '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + ), + Err(error) => format!("Failed to update subagent '{}': {}", selected.name, error), + }); + } + + fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { + let source = match info.subagent_source { + Some(SubAgentSource::Builtin) => "builtin", + Some(SubAgentSource::Project) => "project", + Some(SubAgentSource::User) => "user", + None => "builtin", + } + .to_string(); + + SubagentItem { + key: info.key, + id: info.id, + name: info.name, + description: info.description, + source, + enabled: info.effective_enabled, + default_enabled: info.default_enabled, + } } // ======================== Helpers ======================== @@ -1627,6 +2039,9 @@ impl StartupPage { self.skill_selector.hide(); } else if self.subagent_selector.is_visible() { self.subagent_selector.hide(); + } else if self.theme_selector.is_visible() { + self.theme_selector.hide(); + self.cancel_theme_preview(); } else if self.provider_selector.is_visible() { self.provider_selector.hide(); } else if self.model_config_form.is_visible() { @@ -1642,6 +2057,7 @@ impl StartupPage { PopupType::SessionSelector => self.session_selector.reshow(), PopupType::SkillSelector => self.skill_selector.reshow(), PopupType::SubagentSelector => self.subagent_selector.reshow(), + PopupType::ThemeSelector => self.theme_selector.reshow(), PopupType::ProviderSelector => self.provider_selector.reshow(), PopupType::ModelConfigForm => self.model_config_form.reshow(), } @@ -1656,6 +2072,8 @@ impl StartupPage { self.session_selector.hide(); self.skill_selector.hide(); self.subagent_selector.hide(); + self.theme_selector.hide(); + self.cancel_theme_preview(); self.provider_selector.hide(); self.model_config_form.hide(); self.popup_stack.clear(); diff --git a/src/apps/cli/src/ui/string_utils.rs b/src/apps/cli/src/ui/string_utils.rs index 1802b02ce4..8f8548d4e7 100644 --- a/src/apps/cli/src/ui/string_utils.rs +++ b/src/apps/cli/src/ui/string_utils.rs @@ -1,24 +1,23 @@ /// String processing utilities - use unicode_width::UnicodeWidthChar; /// Safely truncate string to specified byte length pub fn truncate_str(s: &str, max_bytes: usize) -> String { let first_line = s.lines().next().unwrap_or(""); - + if first_line.len() <= max_bytes { return first_line.to_string(); } - + let mut boundary = max_bytes; while boundary > 0 && !first_line.is_char_boundary(boundary) { boundary -= 1; } - + if boundary == 0 { return String::new(); } - + format!("{}...", &first_line[..boundary]) } @@ -118,4 +117,3 @@ pub fn wrap_to_display_width(s: &str, max_width: usize) -> Vec { lines } - diff --git a/src/apps/cli/src/ui/subagent_selector.rs b/src/apps/cli/src/ui/subagent_selector.rs index f984658229..55720b6861 100644 --- a/src/apps/cli/src/ui/subagent_selector.rs +++ b/src/apps/cli/src/ui/subagent_selector.rs @@ -5,7 +5,7 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -17,10 +17,28 @@ use crate::ui::theme::{StyleKind, Theme}; #[derive(Debug, Clone)] #[allow(dead_code)] pub struct SubagentItem { + pub key: String, pub id: String, pub name: String, pub description: String, pub source: String, // "builtin", "project", or "user" + pub enabled: bool, + pub default_enabled: bool, +} + +#[derive(Debug, Clone)] +pub enum SubagentSelectorAction { + ListSubagents, + ConfigureSubagents, + Launch(SubagentItem), + Toggle(SubagentItem), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubagentSelectorScreen { + Menu, + List, + Configure, } /// Subagent selector popup state @@ -29,6 +47,7 @@ pub struct SubagentSelectorState { list_state: ListState, visible: bool, last_area: Option, + screen: SubagentSelectorScreen, } impl SubagentSelectorState { @@ -38,20 +57,54 @@ impl SubagentSelectorState { list_state: ListState::default(), visible: false, last_area: None, + screen: SubagentSelectorScreen::Menu, } } - /// Show the subagent selector with given subagent list - pub fn show(&mut self, subagents: Vec) { + pub fn show_menu(&mut self) { + self.items.clear(); + self.screen = SubagentSelectorScreen::Menu; + self.list_state.select(Some(0)); + self.visible = true; + } + + /// Show subagents available to the current parent mode. + pub fn show_list(&mut self, subagents: Vec) { if subagents.is_empty() { return; } self.items = subagents; + self.screen = SubagentSelectorScreen::List; self.list_state.select(Some(0)); self.visible = true; } + /// Show all discovered subagents with mode-specific enablement checkboxes. + pub fn show_config(&mut self, subagents: Vec) { + if subagents.is_empty() { + return; + } + + let selected_key = if self.screen == SubagentSelectorScreen::Configure { + self.list_state + .selected() + .and_then(|index| self.items.get(index)) + .map(|item| item.key.clone()) + } else { + None + }; + let selected_index = self.list_state.selected().unwrap_or(0); + let next_index = selected_key + .and_then(|key| subagents.iter().position(|item| item.key == key)) + .unwrap_or_else(|| selected_index.min(subagents.len().saturating_sub(1))); + + self.items = subagents; + self.screen = SubagentSelectorScreen::Configure; + self.list_state.select(Some(next_index)); + self.visible = true; + } + pub fn hide(&mut self) { self.visible = false; // Note: we don't clear items here to support back navigation @@ -60,7 +113,7 @@ impl SubagentSelectorState { /// Reshow the subagent selector (for back navigation) pub fn reshow(&mut self) { - if !self.items.is_empty() { + if self.screen == SubagentSelectorScreen::Menu || !self.items.is_empty() { self.visible = true; } } @@ -70,7 +123,7 @@ impl SubagentSelectorState { } pub fn move_up(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); @@ -79,32 +132,55 @@ impl SubagentSelectorState { } pub fn move_down(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); - let next = (selected + 1).min(self.items.len().saturating_sub(1)); + let next = (selected + 1).min(self.len().saturating_sub(1)); self.list_state.select(Some(next)); } - /// Get the selected subagent item - pub fn confirm_selection(&self) -> Option { + /// Get the selected action. + pub fn confirm_selection(&self) -> Option { if !self.visible { return None; } let idx = self.list_state.selected()?; - self.items.get(idx).cloned() + match self.screen { + SubagentSelectorScreen::Menu => match idx { + 0 => Some(SubagentSelectorAction::ListSubagents), + 1 => Some(SubagentSelectorAction::ConfigureSubagents), + _ => None, + }, + SubagentSelectorScreen::List => self + .items + .get(idx) + .cloned() + .map(SubagentSelectorAction::Launch), + SubagentSelectorScreen::Configure => self + .items + .get(idx) + .cloned() + .map(SubagentSelectorAction::Toggle), + } + } + + fn len(&self) -> usize { + match self.screen { + SubagentSelectorScreen::Menu => 2, + SubagentSelectorScreen::List | SubagentSelectorScreen::Configure => self.items.len(), + } } /// Render the subagent selector popup as an overlay pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { self.last_area = None; return; } - let popup_width = area.width.saturating_sub(4).min(70); - let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + let popup_width = area.width.saturating_sub(4).min(92); + let popup_height = (self.len() as u16 + 4).min(area.height.saturating_sub(2)); if popup_height < 5 || popup_width < 20 { self.last_area = None; return; @@ -121,40 +197,18 @@ impl SubagentSelectorState { }; self.last_area = Some(popup_area); - let list_items: Vec = self - .items - .iter() - .map(|subagent| { - let source_marker = match subagent.source.as_str() { - "builtin" => "B", - "project" => "P", - "user" => "U", - _ => "?", - }; - let source_style = match subagent.source.as_str() { - "builtin" => theme.style(StyleKind::Success), - "project" => theme.style(StyleKind::Info), - _ => theme.style(StyleKind::Muted), - }; - - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); - let desc_style = theme.style(StyleKind::Muted); - - let line = Line::from(vec![ - Span::styled(format!("[{}] ", source_marker), source_style), - Span::styled(&subagent.name, name_style), - Span::raw(" "), - Span::styled(&subagent.description, desc_style), - ]); - ListItem::new(line) - }) - .collect(); + let list_items = self.render_items(theme); + let title = match self.screen { + SubagentSelectorScreen::Menu => " Subagents ", + SubagentSelectorScreen::List => " List Subagents (current mode) ", + SubagentSelectorScreen::Configure => " Enable/Disable Subagents (Space/Enter Toggle) ", + }; let block = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) .style(Style::default().bg(theme.background)) - .title(" Select Subagent (↑↓ Navigate, Enter Select, Esc Cancel) "); + .title(title); let list = List::new(list_items) .block(block) @@ -162,7 +216,7 @@ impl SubagentSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); @@ -171,7 +225,7 @@ impl SubagentSelectorState { } /// Handle mouse events - pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { if !self.visible { return None; } @@ -233,10 +287,88 @@ impl SubagentSelectorState { let offset = self.list_state.offset(); let index = (row - inner_y) as usize + offset; - if index >= self.items.len() { + if index >= self.len() { return None; } Some(index) } + + fn render_items(&self, theme: &Theme) -> Vec> { + match self.screen { + SubagentSelectorScreen::Menu => vec![ + ListItem::new(Line::from(vec![ + Span::styled( + "List subagents", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Show subagents available to the current mode", + theme.style(StyleKind::Muted), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled( + "Enable/disable subagents", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Toggle all discovered subagents for this mode", + theme.style(StyleKind::Muted), + ), + ])), + ], + SubagentSelectorScreen::List => self + .items + .iter() + .map(|subagent| self.render_subagent_line(subagent, theme, false)) + .collect(), + SubagentSelectorScreen::Configure => self + .items + .iter() + .map(|subagent| self.render_subagent_line(subagent, theme, true)) + .collect(), + } + } + + fn render_subagent_line( + &self, + subagent: &SubagentItem, + theme: &Theme, + include_checkbox: bool, + ) -> ListItem<'static> { + let source_marker = match subagent.source.as_str() { + "builtin" => "B", + "project" => "P", + "user" => "U", + _ => "?", + }; + let source_style = match subagent.source.as_str() { + "builtin" => theme.style(StyleKind::Success), + "project" => theme.style(StyleKind::Info), + _ => theme.style(StyleKind::Muted), + }; + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + + let mut spans = Vec::new(); + if include_checkbox { + spans.push(Span::styled( + if subagent.enabled { "[x] " } else { "[ ] " }, + if subagent.enabled { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }, + )); + } + spans.push(Span::styled(format!("[{}] ", source_marker), source_style)); + spans.push(Span::styled(subagent.name.clone(), name_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(subagent.description.clone(), desc_style)); + + ListItem::new(Line::from(spans)) + } } diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 62ba2ad1de..96b252a81f 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -2,7 +2,6 @@ /// /// Uses `syntect` for syntax analysis and `syntect-tui` to convert /// highlighted output into ratatui `Span`s. - use once_cell::sync::Lazy; use ratatui::{ style::Style, @@ -10,7 +9,7 @@ use ratatui::{ }; use syntect::{ easy::HighlightLines, - highlighting::{ThemeSet, Style as SyntectStyle}, + highlighting::{Style as SyntectStyle, ThemeSet}, parsing::SyntaxSet, util::LinesWithEndings, }; @@ -152,7 +151,10 @@ pub fn highlight_code_with_line_numbers<'a>( for (i, line_str) in LinesWithEndings::from(content).enumerate() { let line_num = i + 1; let mut spans = vec![ - Span::styled(format!("{:>width$}", line_num, width = num_width), line_num_style), + Span::styled( + format!("{:>width$}", line_num, width = num_width), + line_num_style, + ), Span::styled(" \u{2502} ", separator_style), // │ ]; diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index e0750acc25..044e2c6130 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,11 +1,14 @@ /// Theme and style definitions - -use ratatui::style::{Color, Modifier, Style}; -use std::io::{IsTerminal, Read}; use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; use std::path::Path; use std::time::{Duration, Instant}; + use once_cell::sync::Lazy; +use ratatui::style::{Color, Modifier, Style}; + +#[cfg(unix)] +use std::io::Read; #[derive(Debug, Clone)] pub struct Theme { @@ -21,6 +24,7 @@ pub struct Theme { // Panel backgrounds (inspired by opencode theme) pub background_panel: Color, pub background_element: Color, + pub input_background: Color, // Diff colors pub diff_added_fg: Color, @@ -90,7 +94,9 @@ fn terminal_supports_truecolor() -> bool { return false; } - let term = std::env::var("TERM").unwrap_or_default().to_ascii_lowercase(); + let term = std::env::var("TERM") + .unwrap_or_default() + .to_ascii_lowercase(); if term.is_empty() || term == "dumb" { return false; } @@ -102,7 +108,9 @@ fn terminal_supports_truecolor() -> bool { return false; } - let colorterm = std::env::var("COLORTERM").unwrap_or_default().to_ascii_lowercase(); + let colorterm = std::env::var("COLORTERM") + .unwrap_or_default() + .to_ascii_lowercase(); if colorterm.contains("truecolor") || colorterm.contains("24bit") { return true; } @@ -129,7 +137,9 @@ fn terminal_supports_truecolor() -> bool { pub fn resolve_appearance(preference: &str) -> Appearance { match preference.trim().to_ascii_lowercase().as_str() { "light" => Appearance::Light, - "auto" => detect_terminal_appearance(Duration::from_millis(250)).unwrap_or(Appearance::Dark), + "auto" => { + detect_terminal_appearance(Duration::from_millis(250)).unwrap_or(Appearance::Dark) + } _ => Appearance::Dark, } } @@ -203,7 +213,11 @@ fn detect_terminal_appearance(timeout: Duration) -> Option { let (r, g, b) = parse_osc_color(color)?; let lum = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0; - Some(if lum > 0.5 { Appearance::Light } else { Appearance::Dark }) + Some(if lum > 0.5 { + Appearance::Light + } else { + Appearance::Dark + }) } #[cfg(not(unix))] @@ -250,17 +264,18 @@ fn parse_osc_color(s: &str) -> Option<(u8, u8, u8)> { impl Theme { pub fn dark() -> Self { Self { - primary: Color::Rgb(59, 130, 246), // blue - success: Color::Rgb(34, 197, 94), // green - warning: Color::Rgb(251, 191, 36), // yellow - error: Color::Rgb(239, 68, 68), // red - info: Color::Rgb(147, 197, 253), // light blue - muted: Color::Rgb(156, 163, 175), // gray - background: Color::Rgb(17, 24, 39), // dark gray background - border: Color::Rgb(55, 65, 81), // border gray + primary: Color::Rgb(59, 130, 246), // blue + success: Color::Rgb(34, 197, 94), // green + warning: Color::Rgb(251, 191, 36), // yellow + error: Color::Rgb(239, 68, 68), // red + info: Color::Rgb(147, 197, 253), // light blue + muted: Color::Rgb(156, 163, 175), // gray + background: Color::Rgb(17, 24, 39), // dark gray background + border: Color::Rgb(55, 65, 81), // border gray background_panel: Color::Rgb(30, 38, 55), background_element: Color::Rgb(40, 50, 70), + input_background: Color::Rgb(40, 50, 70), diff_added_fg: Color::Rgb(34, 197, 94), diff_removed_fg: Color::Rgb(239, 68, 68), @@ -293,6 +308,7 @@ impl Theme { background_panel: Color::Reset, background_element: Color::Reset, + input_background: Color::Reset, diff_added_fg: Color::Green, diff_removed_fg: Color::Red, @@ -325,6 +341,7 @@ impl Theme { background_panel: Color::Rgb(243, 244, 246), background_element: Color::Rgb(229, 231, 235), + input_background: Color::Rgb(229, 231, 235), diff_added_fg: Color::Rgb(22, 163, 74), diff_removed_fg: Color::Rgb(220, 38, 38), @@ -357,6 +374,7 @@ impl Theme { background_panel: Color::Reset, background_element: Color::Reset, + input_background: Color::Reset, diff_added_fg: Color::Green, diff_removed_fg: Color::Red, @@ -389,6 +407,7 @@ impl Theme { background_panel: Color::Reset, background_element: Color::Reset, + input_background: Color::Reset, diff_added_fg: Color::Reset, diff_removed_fg: Color::Reset, @@ -423,6 +442,8 @@ impl Theme { self.border = to_ansi16(self.border); self.background_panel = to_ansi16(self.background_panel); self.background_element = to_ansi16(self.background_element); + // Keep the startup input panel as the preset-defined RGB color. + // Otherwise subtle dark theme variants collapse to the same ANSI black/blue. self.diff_added_fg = to_ansi16(self.diff_added_fg); self.diff_removed_fg = to_ansi16(self.diff_removed_fg); self.diff_added_bg = to_ansi16(self.diff_added_bg); @@ -455,19 +476,34 @@ impl Theme { muted: resolved.muted.unwrap_or(fallback.muted), background: resolved.background.unwrap_or(fallback.background), border: resolved.border.unwrap_or(fallback.border), - background_panel: resolved.background_panel.unwrap_or(fallback.background_panel), - background_element: resolved.background_element.unwrap_or(fallback.background_element), + background_panel: resolved + .background_panel + .unwrap_or(fallback.background_panel), + background_element: resolved + .background_element + .unwrap_or(fallback.background_element), + input_background: resolved.input_background.unwrap_or( + resolved + .background_element + .unwrap_or(fallback.input_background), + ), diff_added_fg: resolved.diff_added_fg.unwrap_or(fallback.diff_added_fg), diff_removed_fg: resolved.diff_removed_fg.unwrap_or(fallback.diff_removed_fg), diff_added_bg: resolved.diff_added_bg.unwrap_or(fallback.diff_added_bg), diff_removed_bg: resolved.diff_removed_bg.unwrap_or(fallback.diff_removed_bg), block_bg: resolved.block_bg.unwrap_or(fallback.block_bg), block_bg_hover: resolved.block_bg_hover.unwrap_or(fallback.block_bg_hover), - block_border_active: resolved.block_border_active.unwrap_or(fallback.block_border_active), + block_border_active: resolved + .block_border_active + .unwrap_or(fallback.block_border_active), inline_icon: resolved.inline_icon.unwrap_or(fallback.inline_icon), command_text: resolved.command_text.unwrap_or(fallback.command_text), - diff_hunk_header: resolved.diff_hunk_header.unwrap_or(fallback.diff_hunk_header), - diff_line_number: resolved.diff_line_number.unwrap_or(fallback.diff_line_number), + diff_hunk_header: resolved + .diff_hunk_header + .unwrap_or(fallback.diff_hunk_header), + diff_line_number: resolved + .diff_line_number + .unwrap_or(fallback.diff_line_number), }) } @@ -483,8 +519,12 @@ impl Theme { .fg(self.primary) .add_modifier(Modifier::BOLD), StyleKind::Border => Style::default().fg(self.border), - StyleKind::DiffAdded => Style::default().fg(self.diff_added_fg).bg(self.diff_added_bg), - StyleKind::DiffRemoved => Style::default().fg(self.diff_removed_fg).bg(self.diff_removed_bg), + StyleKind::DiffAdded => Style::default() + .fg(self.diff_added_fg) + .bg(self.diff_added_bg), + StyleKind::DiffRemoved => Style::default() + .fg(self.diff_removed_fg) + .bg(self.diff_removed_bg), StyleKind::BackgroundPanel => Style::default().bg(self.background_panel), StyleKind::BackgroundElement => Style::default().bg(self.background_element), StyleKind::BlockBackground => Style::default().bg(self.block_bg), @@ -496,6 +536,54 @@ impl Theme { StyleKind::DiffLineNumber => Style::default().fg(self.diff_line_number), } } + + pub fn selection_foreground(&self) -> Color { + readable_foreground_for(self.primary) + } +} + +fn readable_foreground_for(background: Color) -> Color { + match background { + Color::Reset => Color::Reset, + Color::Black + | Color::Red + | Color::Green + | Color::Blue + | Color::Magenta + | Color::DarkGray => Color::White, + Color::Yellow + | Color::Gray + | Color::LightRed + | Color::LightGreen + | Color::LightYellow + | Color::LightBlue + | Color::LightMagenta + | Color::LightCyan + | Color::White + | Color::Cyan => Color::Black, + Color::Indexed(idx) => readable_foreground_for(idx_to_ansi16(idx)), + Color::Rgb(r, g, b) => { + let lum = relative_luminance(r, g, b); + if lum > 0.36 { + Color::Black + } else { + Color::White + } + } + } +} + +fn relative_luminance(r: u8, g: u8, b: u8) -> f64 { + fn channel(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + } + + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) } fn to_ansi16(c: Color) -> Color { @@ -613,30 +701,30 @@ pub enum StyleKind { pub fn tool_icon(tool_name: &str) -> &'static str { match tool_name { "Bash" | "bash_tool" | "run_terminal_cmd" => "$", - "Read" | "read_file" | "read_file_tool" => "\u{2192}", // → + "Read" | "read_file" | "read_file_tool" => "\u{2192}", // → "Write" | "write_file" | "write_file_tool" => "\u{2190}", // ← - "Edit" | "search_replace" => "\u{2190}", // ← - "Delete" => "\u{00d7}", // × - "Grep" | "grep" => "\u{2731}", // ✱ - "Glob" | "codebase_search" => "\u{2731}", // ✱ - "LS" | "list_dir" | "ls" => "\u{2192}", // → + "Edit" | "search_replace" => "\u{2190}", // ← + "Delete" => "\u{00d7}", // × + "Grep" | "grep" => "\u{2731}", // ✱ + "Glob" | "codebase_search" => "\u{2731}", // ✱ + "LS" | "list_dir" | "ls" => "\u{2192}", // → "WebFetch" => "%", - "WebSearch" => "\u{25c8}", // ◈ + "WebSearch" => "\u{25c8}", // ◈ "Task" => "#", "HmosCompilation" => "\u{2692}", - "TodoWrite" => "\u{2699}", // ⚙ - "Skill" => "\u{2192}", // → - "Git" => "\u{2387}", // ⎇ + "TodoWrite" => "\u{2699}", // ⚙ + "Skill" => "\u{2192}", // → + "Git" => "\u{2387}", // ⎇ "AskUserQuestion" => "?", - "CreatePlan" => "\u{25b6}", // ▶ - "ReadLints" => "\u{25b3}", // △ - "GetFileDiff" => "\u{00b1}", // ± - "IdeControl" => "\u{2318}", // ⌘ - "MermaidInteractive" => "\u{25c7}", // ◇ - "ContextCompression" => "\u{21af}", // ↯ - "AnalyzeImage" => "\u{25a3}", // ▣ - _ if tool_name.starts_with("mcp_") => "\u{2261}", // ≡ - _ => "\u{00b7}", // · + "CreatePlan" => "\u{25b6}", // ▶ + "ReadLints" => "\u{25b3}", // △ + "GetFileDiff" => "\u{00b1}", // ± + "IdeControl" => "\u{2318}", // ⌘ + "MermaidInteractive" => "\u{25c7}", // ◇ + "ContextCompression" => "\u{21af}", // ↯ + "AnalyzeImage" => "\u{25a3}", // ▣ + _ if tool_name.starts_with("mcp_") => "\u{2261}", // ≡ + _ => "\u{00b7}", // · } } @@ -671,7 +759,7 @@ pub fn load_opencode_theme_json(path: &Path) -> anyhow::Result> = Lazy::new(|| { - fn parse(id: &'static str, raw: &'static str) -> ( &'static str, OpencodeThemeJson) { + fn parse(id: &'static str, raw: &'static str) -> (&'static str, OpencodeThemeJson) { let json = serde_json::from_str::(raw) .unwrap_or_else(|e| panic!("Failed to parse built-in theme {}: {}", id, e)); (id, json) @@ -679,52 +767,55 @@ static BUILTIN_OPENCODE_THEMES: Lazy> = HashMap::from([ parse( - "cursor", + "bitfun-cyber", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/cursor.json" + "/themes/presets/bitfun-cyber.json" )), ), parse( - "everforest", + "bitfun-dark", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/everforest.json" + "/themes/presets/bitfun-dark.json" )), ), parse( - "github", + "bitfun-ink-night", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/github.json" + "/themes/presets/bitfun-ink-night.json" )), ), parse( - "one-dark", + "bitfun-light", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/one-dark.json" + "/themes/presets/bitfun-light.json" )), ), parse( - "opencode", + "bitfun-midnight", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/opencode.json" + "/themes/presets/bitfun-midnight.json" )), ), parse( - "tokyonight", + "bitfun-tokyo-night", include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/themes/presets/tokyonight.json" + "/themes/presets/bitfun-tokyo-night.json" )), ), ]) }); pub fn builtin_theme_ids() -> Vec { - let mut ids: Vec = BUILTIN_OPENCODE_THEMES.keys().map(|k| (*k).to_string()).collect(); + let mut ids: Vec = BUILTIN_OPENCODE_THEMES + .keys() + .map(|k| (*k).to_string()) + .collect(); ids.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); ids } @@ -745,6 +836,7 @@ struct ResolvedTokens { border: Option, background_panel: Option, background_element: Option, + input_background: Option, diff_added_fg: Option, diff_removed_fg: Option, diff_added_bg: Option, @@ -758,8 +850,15 @@ struct ResolvedTokens { diff_line_number: Option, } -fn resolve_opencode_theme(json: &OpencodeThemeJson, appearance: Appearance) -> anyhow::Result { - let mode = if appearance.is_light() { "light" } else { "dark" }; +fn resolve_opencode_theme( + json: &OpencodeThemeJson, + appearance: Appearance, +) -> anyhow::Result { + let mode = if appearance.is_light() { + "light" + } else { + "dark" + }; let defs = json.defs.clone().unwrap_or_default(); let mut tokens = ResolvedTokens::default(); @@ -774,6 +873,7 @@ fn resolve_opencode_theme(json: &OpencodeThemeJson, appearance: Appearance) -> a tokens.border = resolve_key(json, &defs, "border", mode)?; tokens.background_panel = resolve_key(json, &defs, "backgroundPanel", mode)?; tokens.background_element = resolve_key(json, &defs, "backgroundElement", mode)?; + tokens.input_background = resolve_key(json, &defs, "inputBackground", mode)?; tokens.diff_added_fg = resolve_key(json, &defs, "diffAdded", mode)?; tokens.diff_removed_fg = resolve_key(json, &defs, "diffRemoved", mode)?; @@ -844,6 +944,17 @@ fn resolve_color_string( let g = u8::from_str_radix(&hex[2..4], 16)?; let b = u8::from_str_radix(&hex[4..6], 16)?; return Ok(Color::Rgb(r, g, b)); + } else if hex.len() == 8 { + let r = u8::from_str_radix(&hex[0..2], 16)?; + let g = u8::from_str_radix(&hex[2..4], 16)?; + let b = u8::from_str_radix(&hex[4..6], 16)?; + let a = u8::from_str_radix(&hex[6..8], 16)?; + let base = if mode == "light" { 255 } else { 0 }; + return Ok(Color::Rgb( + blend_alpha_channel(r, a, base), + blend_alpha_channel(g, a, base), + blend_alpha_channel(b, a, base), + )); } } @@ -861,3 +972,50 @@ fn resolve_color_string( anyhow::bail!("Theme color reference \"{}\" not found", t) } + +fn blend_alpha_channel(fg: u8, alpha: u8, bg: u8) -> u8 { + let fg = fg as u16; + let alpha = alpha as u16; + let bg = bg as u16; + (((fg * alpha) + (bg * (255 - alpha)) + 127) / 255) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_themes_resolve_for_dark_and_light() { + for id in builtin_theme_ids() { + let json = builtin_theme_json(&id).expect("built-in theme id should resolve"); + Theme::dark() + .apply_opencode_theme_json(json, Appearance::Dark) + .unwrap_or_else(|err| panic!("failed to resolve dark theme {id}: {err}")); + Theme::light() + .apply_opencode_theme_json(json, Appearance::Light) + .unwrap_or_else(|err| panic!("failed to resolve light theme {id}: {err}")); + } + } + + #[test] + fn eight_digit_hex_colors_are_supported() { + let json = serde_json::from_str::( + r##"{ + "theme": { + "primary": { "dark": "#ffffff80", "light": "#00000080" } + } + }"##, + ) + .unwrap(); + + let dark = Theme::dark() + .apply_opencode_theme_json(&json, Appearance::Dark) + .unwrap(); + let light = Theme::light() + .apply_opencode_theme_json(&json, Appearance::Light) + .unwrap(); + + assert_eq!(dark.primary, Color::Rgb(128, 128, 128)); + assert_eq!(light.primary, Color::Rgb(127, 127, 127)); + } +} diff --git a/src/apps/cli/src/ui/theme_selector.rs b/src/apps/cli/src/ui/theme_selector.rs index 5feeaf38ba..eca9c589b3 100644 --- a/src/apps/cli/src/ui/theme_selector.rs +++ b/src/apps/cli/src/ui/theme_selector.rs @@ -1,11 +1,10 @@ /// Theme selector popup for choosing a UI theme /// /// Overlay popup that displays all available themes and allows the user to select one. - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, @@ -161,7 +160,7 @@ impl ThemeSelectorState { .highlight_style( Style::default() .bg(theme.primary) - .fg(Color::White) + .fg(theme.selection_foreground()) .add_modifier(Modifier::BOLD), ); diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index 7d2f32678b..86b32c8bd7 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -4,7 +4,6 @@ /// - InlineTool: single-line for simple/exploratory tools (Read, Grep, Glob, LS, etc.) /// - BlockTool: multi-line with left border for complex tools (Bash, Edit, Write, Task, etc.) /// - Phase-aware: same tool can switch from Inline (pending) to Block (has output) - use std::collections::HashMap; use ratatui::{ @@ -13,11 +12,11 @@ use ratatui::{ widgets::ListItem, }; -use crate::chat_state::{ToolDisplayState, ToolDisplayStatus}; use super::diff_render::{self, DiffViewMode}; +use super::string_utils::{strip_ansi_codes, truncate_str, wrap_to_display_width}; use super::syntax_highlight::{self, HighlightTheme}; -use super::theme::{Theme, StyleKind, tool_icon}; -use super::string_utils::{truncate_str, strip_ansi_codes, wrap_to_display_width}; +use super::theme::{tool_icon, StyleKind, Theme}; +use crate::chat_state::{ToolDisplayState, ToolDisplayStatus}; // ============ Tool Card Render Cache ============ @@ -76,15 +75,17 @@ enum ToolDisplayMode { fn tool_display_mode(tool_name: &str, tool_state: &ToolDisplayState) -> ToolDisplayMode { match normalize_tool_name(tool_name) { // Always inline tools - "Read" | "Grep" | "Glob" | "LS" | "WebSearch" | "WebFetch" - | "Skill" | "ReadLints" | "Git" | "GetFileDiff" - | "IdeControl" | "MermaidInteractive" | "ContextCompression" + "Read" | "Grep" | "Glob" | "LS" | "WebSearch" | "WebFetch" | "Skill" | "ReadLints" + | "Git" | "GetFileDiff" | "IdeControl" | "MermaidInteractive" | "ContextCompression" | "AnalyzeImage" => ToolDisplayMode::Inline, // Phase-aware: Inline when pending, Block when has output/result "Bash" => { if tool_state.result.is_some() - || matches!(tool_state.status, ToolDisplayStatus::Running | ToolDisplayStatus::Streaming) + || matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming + ) { ToolDisplayMode::Block } else { @@ -94,7 +95,9 @@ fn tool_display_mode(tool_name: &str, tool_state: &ToolDisplayState) -> ToolDisp "HmosCompilation" => { if matches!( tool_state.status, - ToolDisplayStatus::Running | ToolDisplayStatus::Streaming | ToolDisplayStatus::Failed + ToolDisplayStatus::Running + | ToolDisplayStatus::Streaming + | ToolDisplayStatus::Failed ) || tool_state.result.is_some() { ToolDisplayMode::Block @@ -171,9 +174,7 @@ pub fn render_tool_card( focused, width: available_width, }; - let cached = TOOL_CARD_CACHE.with(|cache| { - cache.borrow().get(&key).cloned() - }); + let cached = TOOL_CARD_CACHE.with(|cache| cache.borrow().get(&key).cloned()); if let Some(rendered) = cached { return rendered; } @@ -195,7 +196,14 @@ pub fn render_tool_card( } // Non-terminal tools: render without caching - render_tool_card_inner(tool_state, theme, expanded, focused, spinner_frame, available_width) + render_tool_card_inner( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ) } /// Internal render function (no caching) @@ -282,7 +290,10 @@ fn render_inline_dispatch( let icon = tool_icon(&tool_state.tool_name); let is_complete = matches!( tool_state.status, - ToolDisplayStatus::Success | ToolDisplayStatus::Failed | ToolDisplayStatus::Rejected | ToolDisplayStatus::Cancelled + ToolDisplayStatus::Success + | ToolDisplayStatus::Failed + | ToolDisplayStatus::Rejected + | ToolDisplayStatus::Cancelled ); let is_error = matches!(tool_state.status, ToolDisplayStatus::Failed); let is_rejected = matches!(tool_state.status, ToolDisplayStatus::Rejected); @@ -294,7 +305,10 @@ fn render_inline_dispatch( return ToolCardRenderOutput { items: vec![ListItem::new(Line::from(vec![ Span::raw(" ".to_string()), - Span::styled(format!("{} ", spinner_frame), theme.style(StyleKind::Primary)), + Span::styled( + format!("{} ", spinner_frame), + theme.style(StyleKind::Primary), + ), Span::styled(pending_text.clone(), theme.style(StyleKind::Muted)), ]))], plain_lines: vec![format!(" {} {}", spinner_frame, pending_text)], @@ -314,7 +328,9 @@ fn render_inline_dispatch( let content_style = if is_error { theme.style(StyleKind::Error) } else if is_rejected { - theme.style(StyleKind::Error).add_modifier(Modifier::CROSSED_OUT) + theme + .style(StyleKind::Error) + .add_modifier(Modifier::CROSSED_OUT) } else if is_confirmation { theme.style(StyleKind::Warning) } else { @@ -409,7 +425,11 @@ fn inline_pending_text(canonical: &str, tool_state: &ToolDisplayState) -> String if tool_state.tool_name.starts_with("mcp_") { // Parse mcp_{server}_{tool} to show a cleaner name let parts: Vec<&str> = tool_state.tool_name.splitn(3, '_').collect(); - let tool = if parts.len() >= 3 { parts[2] } else { &tool_state.tool_name }; + let tool = if parts.len() >= 3 { + parts[2] + } else { + &tool_state.tool_name + }; if let Some(ref msg) = tool_state.progress_message { msg.clone() } else { @@ -428,7 +448,10 @@ fn inline_pending_text(canonical: &str, tool_state: &ToolDisplayState) -> String fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> String { match canonical { "Read" => { - let path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); format!("Read {}", path) } "Grep" => { @@ -452,7 +475,10 @@ fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> Strin text } "Glob" => { - let pattern = param_str(&tool_state.parameters, &["glob_pattern", "pattern", "query"]); + let pattern = param_str( + &tool_state.parameters, + &["glob_pattern", "pattern", "query"], + ); let count = tool_state .metadata .as_ref() @@ -521,7 +547,10 @@ fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> Strin format!("Analyze Image {}", path) } "HmosCompilation" => { - let path = param_str(&tool_state.parameters, &["project_abs_path", "project_path"]); + let path = param_str( + &tool_state.parameters, + &["project_abs_path", "project_path"], + ); if path.is_empty() { "HarmonyOS Compile".to_string() } else { @@ -569,16 +598,57 @@ fn render_block_dispatch( available_width: u16, ) -> ToolCardRenderOutput { match canonical { - "Bash" => render_bash_block(tool_state, theme, expanded, focused, spinner_frame, available_width), - "Edit" => render_edit_block(tool_state, theme, expanded, focused, spinner_frame, available_width), - "Write" => render_write_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "Bash" => render_bash_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), + "Edit" => render_edit_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), + "Write" => render_write_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), "Delete" => render_delete_block(tool_state, theme, focused, spinner_frame, available_width), "Task" => render_task_block(tool_state, theme, focused, spinner_frame, available_width), - "TodoWrite" => render_todo_block(tool_state, theme, focused, spinner_frame, available_width), - "AskUserQuestion" => render_question_block(tool_state, theme, focused, spinner_frame, available_width), - "CreatePlan" => render_plan_block(tool_state, theme, focused, spinner_frame, available_width), - "HmosCompilation" => render_hmos_compilation_block(tool_state, theme, expanded, focused, spinner_frame, available_width), - _ => render_generic_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "TodoWrite" => { + render_todo_block(tool_state, theme, focused, spinner_frame, available_width) + } + "AskUserQuestion" => { + render_question_block(tool_state, theme, focused, spinner_frame, available_width) + } + "CreatePlan" => { + render_plan_block(tool_state, theme, focused, spinner_frame, available_width) + } + "HmosCompilation" => render_hmos_compilation_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), + _ => render_generic_block( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ), } } @@ -631,16 +701,19 @@ fn render_hmos_compilation_block( ToolDisplayStatus::Running | ToolDisplayStatus::Streaming ); - let project_path = param_str_opt(&tool_state.parameters, &["project_abs_path", "project_path"]) - .or_else(|| { - tool_state - .metadata - .as_ref() - .and_then(|m| m.get("project_path")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or_default(); + let project_path = param_str_opt( + &tool_state.parameters, + &["project_abs_path", "project_path"], + ) + .or_else(|| { + tool_state + .metadata + .as_ref() + .and_then(|m| m.get("project_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); let product = param_str_opt(&tool_state.parameters, &["product"]) .or_else(|| { @@ -663,7 +736,17 @@ fn render_hmos_compilation_block( }) .unwrap_or_else(|| "debug".to_string()); - let (success, exit_code, execution_time_ms, deveco_home, stderr, stdout, error_kind, error_message, help) = tool_state + let ( + success, + exit_code, + execution_time_ms, + deveco_home, + stderr, + stdout, + error_kind, + error_message, + help, + ) = tool_state .metadata .as_ref() .and_then(|m| m.as_object()) @@ -693,7 +776,10 @@ fn render_hmos_compilation_block( .get("error_message") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let help = obj.get("help").and_then(|v| v.as_str()).map(|s| s.to_string()); + let help = obj + .get("help") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); ( success, exit_code, @@ -706,7 +792,17 @@ fn render_hmos_compilation_block( help, ) }) - .unwrap_or((None, None, None, None, String::new(), String::new(), None, None, None)); + .unwrap_or(( + None, + None, + None, + None, + String::new(), + String::new(), + None, + None, + None, + )); let mut title = "HarmonyOS Compile".to_string(); if !project_path.is_empty() { @@ -878,7 +974,10 @@ fn render_hmos_compilation_block( } if wrapped.len() > max { content_lines.push(Line::from(Span::styled( - format!("\u{25bc} {} more lines (Tab/Click to expand)", wrapped.len() - max), + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + wrapped.len() - max + ), theme.style(StyleKind::Muted), ))); } @@ -957,7 +1056,9 @@ fn render_bash_block( let mut content_lines = vec![Line::from(cmd_spans)]; // Extract output: prefer metadata.output (structured), fallback to result (display summary) - let output_text = tool_state.metadata.as_ref() + let output_text = tool_state + .metadata + .as_ref() .and_then(|m| m.get("output")) .and_then(|v| v.as_str()) .map(|s| s.to_string()) @@ -982,7 +1083,10 @@ fn render_bash_block( if output_lines.len() > 10 && !expanded { content_lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more lines, Ctrl+O to expand)", output_lines.len() - 10), + format!( + "\u{2026} ({} more lines, Ctrl+O to expand)", + output_lines.len() - 10 + ), theme.style(StyleKind::Muted), ))); } else if expanded && output_lines.len() > 10 { @@ -1000,7 +1104,17 @@ fn render_bash_block( None }; - assemble_block(&title, content_lines, theme, is_running, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + is_running, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render an Edit tool as a block (file path + diff preview) @@ -1012,13 +1126,22 @@ fn render_edit_block( spinner_frame: &str, available_width: u16, ) -> ToolCardRenderOutput { - let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let file_path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); let mut content_lines = Vec::new(); // Try to show diff from old_string/new_string parameters - let old_str = tool_state.parameters.get("old_string").and_then(|v| v.as_str()); - let new_str = tool_state.parameters.get("new_string").and_then(|v| v.as_str()); + let old_str = tool_state + .parameters + .get("old_string") + .and_then(|v| v.as_str()); + let new_str = tool_state + .parameters + .get("new_string") + .and_then(|v| v.as_str()); // Compute stats for title let (additions, deletions) = match (old_str, new_str) { @@ -1037,14 +1160,8 @@ fn render_edit_block( let max = if expanded { usize::MAX } else { 8 }; // Use the block's available width minus border overhead (~8 chars) let diff_width = available_width.saturating_sub(8); - let diff_lines = diff_render::render_diff( - old, - new, - theme, - max, - DiffViewMode::Auto, - diff_width, - ); + let diff_lines = + diff_render::render_diff(old, new, theme, max, DiffViewMode::Auto, diff_width); content_lines.extend(diff_lines); let total_changes = additions + deletions; @@ -1080,7 +1197,17 @@ fn render_edit_block( None }; - assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a Write tool as a block (file path + syntax-highlighted content preview) @@ -1092,12 +1219,17 @@ fn render_write_block( spinner_frame: &str, available_width: u16, ) -> ToolCardRenderOutput { - let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let file_path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); let mut content_lines = Vec::new(); // Show content preview with syntax highlighting and line numbers - if let Some(content) = tool_state.parameters.get("contents") + if let Some(content) = tool_state + .parameters + .get("contents") .or_else(|| tool_state.parameters.get("content")) .and_then(|v| v.as_str()) { @@ -1121,7 +1253,10 @@ fn render_write_block( if total_lines > 8 && !expanded { content_lines.push(Line::from(Span::styled( - format!("\u{2026} ({} more lines, Ctrl+O to expand)", total_lines - 8), + format!( + "\u{2026} ({} more lines, Ctrl+O to expand)", + total_lines - 8 + ), theme.style(StyleKind::Muted), ))); } else if expanded && total_lines > 8 { @@ -1152,7 +1287,17 @@ fn render_write_block( None }; - return assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width); + return assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ); } // Fallback: no content available @@ -1176,7 +1321,17 @@ fn render_write_block( None }; - assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a Delete tool as a block @@ -1187,7 +1342,10 @@ fn render_delete_block( spinner_frame: &str, available_width: u16, ) -> ToolCardRenderOutput { - let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let file_path = param_str( + &tool_state.parameters, + &["file_path", "target_file", "path"], + ); let title = format!("Delete {}", file_path); let mut content_lines = Vec::new(); @@ -1209,7 +1367,17 @@ fn render_delete_block( None }; - assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + false, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a Task tool as a block (sub-agent type + description + real-time progress) @@ -1242,16 +1410,18 @@ fn render_task_block( description.clone() }; - let mut content_lines = vec![ - Line::from(Span::styled(desc_text, theme.style(StyleKind::Muted))), - ]; + let mut content_lines = vec![Line::from(Span::styled( + desc_text, + theme.style(StyleKind::Muted), + ))]; // Show real-time subagent progress (current tool being executed) if is_running { if let Some(ref progress) = tool_state.subagent_progress { if let Some(ref tool_name) = progress.current_tool_name { let progress_text = if let Some(ref title) = progress.current_tool_title { - format!("\u{2514} {} {}", capitalize_first(tool_name), title) // └ + format!("\u{2514} {} {}", capitalize_first(tool_name), title) + // └ } else { format!("\u{2514} {}", capitalize_first(tool_name)) // └ }; @@ -1265,7 +1435,9 @@ fn render_task_block( // Show final result when completed if let Some(ref result) = tool_state.result { - let max_width = block_content_max_width(available_width).saturating_sub(2).max(1); + let max_width = block_content_max_width(available_width) + .saturating_sub(2) + .max(1); for line in wrap_display_lines(result, max_width) { content_lines.push(Line::from(Span::styled( format!("\u{2514} {}", line), // └ @@ -1274,7 +1446,17 @@ fn render_task_block( } } - assemble_block(&title, content_lines, theme, is_running, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + is_running, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a TodoWrite tool as a block (todo list with upgraded icons) @@ -1287,16 +1469,25 @@ fn render_todo_block( ) -> ToolCardRenderOutput { let mut content_lines = Vec::new(); - if let Some(todos) = tool_state.parameters.get("todos").and_then(|v| v.as_array()) { + if let Some(todos) = tool_state + .parameters + .get("todos") + .and_then(|v| v.as_array()) + { for todo in todos { - let status = todo.get("status").and_then(|v| v.as_str()).unwrap_or("pending"); + let status = todo + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); let content = todo.get("content").and_then(|v| v.as_str()).unwrap_or(""); let (marker, marker_style, content_style) = match status { "completed" => ( "\u{2713}", // ✓ theme.style(StyleKind::Success), - theme.style(StyleKind::Muted).add_modifier(Modifier::CROSSED_OUT), + theme + .style(StyleKind::Muted) + .add_modifier(Modifier::CROSSED_OUT), ), "in_progress" => ( "\u{25cf}", // ● @@ -1306,7 +1497,9 @@ fn render_todo_block( "cancelled" => ( "\u{2014}", // — theme.style(StyleKind::Muted), - theme.style(StyleKind::Muted).add_modifier(Modifier::CROSSED_OUT), + theme + .style(StyleKind::Muted) + .add_modifier(Modifier::CROSSED_OUT), ), _ => ( "\u{25cb}", // ○ @@ -1323,10 +1516,23 @@ fn render_todo_block( } if content_lines.is_empty() { - content_lines.push(Line::from(Span::styled("Updating todos...", theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + "Updating todos...", + theme.style(StyleKind::Muted), + ))); } - assemble_block("Todos", content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + "Todos", + content_lines, + theme, + false, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render an AskUserQuestion tool as a block @@ -1349,7 +1555,8 @@ fn render_question_block( let answer_text = match val { serde_json::Value::String(s) => s.clone(), serde_json::Value::Array(arr) => { - let items: Vec = arr.iter() + let items: Vec = arr + .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); items.join(", ") @@ -1369,7 +1576,10 @@ fn render_question_block( } for line in wrapped_answers.iter().skip(1) { content_lines.push(Line::from(vec![ - Span::styled(" ".repeat(key_prefix.len()), theme.style(StyleKind::Muted)), + Span::styled( + " ".repeat(key_prefix.len()), + theme.style(StyleKind::Muted), + ), Span::styled(line.clone(), theme.style(StyleKind::Success)), ])); } @@ -1397,7 +1607,11 @@ fn render_question_block( ))); } } else if tool_state.status == ToolDisplayStatus::Running { - if let Some(questions) = tool_state.parameters.get("questions").and_then(|v| v.as_array()) { + if let Some(questions) = tool_state + .parameters + .get("questions") + .and_then(|v| v.as_array()) + { for q in questions { let question_text = q.get("question").and_then(|v| v.as_str()).unwrap_or("?"); content_lines.push(Line::from(Span::styled( @@ -1411,12 +1625,21 @@ fn render_question_block( theme.style(StyleKind::Warning), ))); } else { - if let Some(questions) = tool_state.parameters.get("questions").and_then(|v| v.as_array()) { + if let Some(questions) = tool_state + .parameters + .get("questions") + .and_then(|v| v.as_array()) + { for q in questions { - let prompt = q.get("question").and_then(|v| v.as_str()) + let prompt = q + .get("question") + .and_then(|v| v.as_str()) .or_else(|| q.get("prompt").and_then(|v| v.as_str())) .unwrap_or("?"); - content_lines.push(Line::from(Span::styled(prompt.to_string(), theme.style(StyleKind::Info)))); + content_lines.push(Line::from(Span::styled( + prompt.to_string(), + theme.style(StyleKind::Info), + ))); if let Some(options) = q.get("options").and_then(|v| v.as_array()) { for opt in options { @@ -1432,11 +1655,24 @@ fn render_question_block( } if content_lines.is_empty() { - content_lines.push(Line::from(Span::styled("Asking questions...", theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + "Asking questions...", + theme.style(StyleKind::Muted), + ))); } } - assemble_block("Questions", content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + "Questions", + content_lines, + theme, + false, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a CreatePlan tool as a block @@ -1452,9 +1688,14 @@ fn render_plan_block( let title_text = param_str_opt(&tool_state.parameters, &["title", "name"]) .unwrap_or_else(|| "Plan".to_string()); - if let Some(steps) = tool_state.parameters.get("steps").and_then(|v| v.as_array()) { + if let Some(steps) = tool_state + .parameters + .get("steps") + .and_then(|v| v.as_array()) + { for (i, step) in steps.iter().enumerate() { - let desc = step.as_str() + let desc = step + .as_str() .or_else(|| step.get("description").and_then(|v| v.as_str())) .unwrap_or("..."); content_lines.push(Line::from(vec![ @@ -1467,15 +1708,31 @@ fn render_plan_block( if let Some(ref result) = tool_state.result { let max_width = block_content_max_width(available_width); for line in wrap_display_lines(result, max_width) { - content_lines.push(Line::from(Span::styled(line, theme.style(StyleKind::Success)))); + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Success), + ))); } } if content_lines.is_empty() { - content_lines.push(Line::from(Span::styled("Creating plan...", theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + "Creating plan...", + theme.style(StyleKind::Muted), + ))); } - assemble_block(&title_text, content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title_text, + content_lines, + theme, + false, + None, + focused, + tool_state, + spinner_frame, + available_width, + ) } /// Render a generic block tool (fallback for unknown block tools) @@ -1492,12 +1749,18 @@ fn render_generic_block( let summary = extract_key_params(&tool_state.parameters); if !summary.is_empty() { - content_lines.push(Line::from(Span::styled(summary, theme.style(StyleKind::Info)))); + content_lines.push(Line::from(Span::styled( + summary, + theme.style(StyleKind::Info), + ))); } if let Some(ref msg) = tool_state.progress_message { for line in wrap_display_lines(msg, block_content_max_width(available_width)) { - content_lines.push(Line::from(Span::styled(line, theme.style(StyleKind::Muted)))); + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Muted), + ))); } } @@ -1509,7 +1772,10 @@ fn render_generic_block( } if lines.len() > max { content_lines.push(Line::from(Span::styled( - format!("\u{25bc} {} more lines (Tab/Click to expand)", lines.len() - max), + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + lines.len() - max + ), theme.style(StyleKind::Muted), ))); } @@ -1525,7 +1791,17 @@ fn render_generic_block( None }; - assemble_block(&title, content_lines, theme, is_running, error, focused, tool_state, spinner_frame, available_width) + assemble_block( + &title, + content_lines, + theme, + is_running, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } // ============ Block Assembly ============ @@ -1571,16 +1847,20 @@ fn assemble_block( theme.style(StyleKind::BlockBackground) }; - let (status_icon, status_style) = status_icon_and_style(&tool_state.status, theme, spinner_frame); + let (status_icon, status_style) = + status_icon_and_style(&tool_state.status, theme, spinner_frame); // Duration text - let duration_text = tool_state.duration_ms.map(|ms| { - if ms < 1000 { - format!("{}ms", ms) - } else { - format!("{:.1}s", ms as f64 / 1000.0) - } - }).unwrap_or_default(); + let duration_text = tool_state + .duration_ms + .map(|ms| { + if ms < 1000 { + format!("{}ms", ms) + } else { + format!("{:.1}s", ms as f64 / 1000.0) + } + }) + .unwrap_or_default(); // Box dimensions: // Layout: " ╭─...─╮" => 2 (left margin) + 1 (corner) + inner_width (horizontal lines) + 1 (corner) @@ -1593,37 +1873,39 @@ fn assemble_block( // Helper: build a padded line inside the box. // Returns: " │" + content_spans + padding + "│" // Content is expected to be pre-wrapped by callers; this layer should not truncate. - let build_box_line = |content_spans: Vec>, - bs: Style, - bgs: Style| - -> (ListItem<'static>, String) { - let used_width: usize = content_spans - .iter() - .map(|span| unicode_display_width(span.content.as_ref())) - .sum(); - let pad = inner_w.saturating_sub(used_width); - let content_plain = content_spans - .iter() - .map(|span| span.content.as_ref()) - .collect::(); - - let mut spans = Vec::with_capacity(content_spans.len() + 4); - spans.push(Span::styled(" \u{2502}".to_string(), bs)); // " │" - spans.extend(content_spans); - if pad > 0 { - spans.push(Span::styled(" ".repeat(pad), bgs)); - } - spans.push(Span::styled("\u{2502}".to_string(), bs)); // "│" - let plain = format!(" │{}{}│", content_plain, " ".repeat(pad)); - - (ListItem::new(Line::from(spans)).style(bgs), plain) - }; + let build_box_line = + |content_spans: Vec>, bs: Style, bgs: Style| -> (ListItem<'static>, String) { + let used_width: usize = content_spans + .iter() + .map(|span| unicode_display_width(span.content.as_ref())) + .sum(); + let pad = inner_w.saturating_sub(used_width); + let content_plain = content_spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + let mut spans = Vec::with_capacity(content_spans.len() + 4); + spans.push(Span::styled(" \u{2502}".to_string(), bs)); // " │" + spans.extend(content_spans); + if pad > 0 { + spans.push(Span::styled(" ".repeat(pad), bgs)); + } + spans.push(Span::styled("\u{2502}".to_string(), bs)); // "│" + let plain = format!(" │{}{}│", content_plain, " ".repeat(pad)); + + (ListItem::new(Line::from(spans)).style(bgs), plain) + }; // ── Top border: " ╭─────...─────╮" let horiz_len = if inner_w > 0 { inner_w } else { 1 }; let top_line = format!(" \u{256D}{}\u{256E}", "\u{2500}".repeat(horiz_len)); items.push( - ListItem::new(Line::from(vec![Span::styled(top_line.clone(), border_style)])).style(bg_style), + ListItem::new(Line::from(vec![Span::styled( + top_line.clone(), + border_style, + )])) + .style(bg_style), ); plain_lines.push(top_line); @@ -1682,7 +1964,11 @@ fn assemble_block( // ── Bottom border: " ╰─────...─────╯" let bottom_line = format!(" \u{2570}{}\u{256F}", "\u{2500}".repeat(horiz_len)); items.push( - ListItem::new(Line::from(vec![Span::styled(bottom_line.clone(), border_style)])).style(bg_style), + ListItem::new(Line::from(vec![Span::styled( + bottom_line.clone(), + border_style, + )])) + .style(bg_style), ); plain_lines.push(bottom_line); @@ -1707,8 +1993,8 @@ fn status_icon_and_style( (spinner_frame.to_string(), theme.style(StyleKind::Primary)) } ToolDisplayStatus::Success => ("\u{2713}".to_string(), theme.style(StyleKind::Success)), // ✓ - ToolDisplayStatus::Failed => ("\u{2717}".to_string(), theme.style(StyleKind::Error)), // ✗ - ToolDisplayStatus::Queued => ("\u{2016}".to_string(), theme.style(StyleKind::Muted)), // ‖ + ToolDisplayStatus::Failed => ("\u{2717}".to_string(), theme.style(StyleKind::Error)), // ✗ + ToolDisplayStatus::Queued => ("\u{2016}".to_string(), theme.style(StyleKind::Muted)), // ‖ ToolDisplayStatus::Waiting => ("\u{2026}".to_string(), theme.style(StyleKind::Warning)), // … ToolDisplayStatus::EarlyDetected | ToolDisplayStatus::ParamsPartial => { (spinner_frame.to_string(), theme.style(StyleKind::Muted)) @@ -1746,7 +2032,16 @@ fn param_str_opt(params: &serde_json::Value, keys: &[&str]) -> Option { /// Extract a key parameter summary from JSON params fn extract_key_params(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority_keys = ["path", "file_path", "target_file", "query", "pattern", "command", "message", "url"]; + let priority_keys = [ + "path", + "file_path", + "target_file", + "query", + "pattern", + "command", + "message", + "url", + ]; for key in &priority_keys { if let Some(value) = obj.get(*key) { diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index b2fa999a4a..446ec18ff8 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -1,5 +1,4 @@ /// Custom TUI widgets - use ratatui::{ layout::Rect, style::{Color, Style}, @@ -33,7 +32,12 @@ impl Spinner { pub fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: Color) { let lines: Vec = message .lines() - .map(|l| Line::from(Span::styled(l.to_string(), Style::default().fg(Color::White)))) + .map(|l| { + Line::from(Span::styled( + l.to_string(), + Style::default().fg(Color::White), + )) + }) .collect(); let line_count = lines.len() as u16; @@ -44,7 +48,9 @@ pub fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: C .unwrap_or(20); // +2 for border, +2 for padding; +1 for hint line below popup - let popup_width = (max_line_width + 4).min(area.width.saturating_sub(4)).max(30); + let popup_width = (max_line_width + 4) + .min(area.width.saturating_sub(4)) + .max(30); let popup_height = (line_count + 2).min(area.height.saturating_sub(3)); let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; diff --git a/src/apps/cli/themes/presets/bitfun-cyber.json b/src/apps/cli/themes/presets/bitfun-cyber.json new file mode 100644 index 0000000000..ee7169442b --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-cyber.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#00e6ff", + "secondary": "#8a2be2", + "accent": "#8a2be2", + "error": "#ff0055", + "warning": "#ffcc00", + "success": "#00ff9f", + "info": "#00e6ff", + "text": "#e0f2ff", + "textMuted": "#7fadcc", + "background": "#101010", + "backgroundPanel": "#151515", + "backgroundElement": "#082a30", + "inputBackground": "#003f46", + "border": "#16404a", + "borderActive": "#00e6ff", + "borderSubtle": "#102b32", + "diffAdded": "#00ff9f", + "diffRemoved": "#ff0055", + "diffContext": "#7fadcc", + "diffHunkHeader": "#00e6ff", + "diffHighlightAdded": "#52ffc0", + "diffHighlightRemoved": "#ff4d88", + "diffAddedBg": "#08372a", + "diffRemovedBg": "#3d1325", + "diffContextBg": "#151515", + "diffLineNumber": "#5e879b", + "diffAddedLineNumberBg": "#0b4231", + "diffRemovedLineNumberBg": "#4a162b", + "markdownText": "#e0f2ff", + "markdownHeading": "#00e6ff", + "markdownLink": "#00e6ff", + "markdownLinkText": "#8a2be2", + "markdownCode": "#00ff9f", + "markdownBlockQuote": "#7fadcc", + "markdownEmph": "#ffcc00", + "markdownStrong": "#ffdd44", + "markdownHorizontalRule": "#16404a", + "markdownListItem": "#00e6ff", + "markdownListEnumeration": "#8a2be2", + "markdownImage": "#00e6ff", + "markdownImageText": "#8a2be2", + "markdownCodeBlock": "#e0f2ff", + "syntaxComment": "#7fadcc", + "syntaxKeyword": "#8a2be2", + "syntaxFunction": "#00e6ff", + "syntaxVariable": "#e0f2ff", + "syntaxString": "#00ff9f", + "syntaxNumber": "#ffcc00", + "syntaxType": "#ffdd44", + "syntaxOperator": "#ff0055", + "syntaxPunctuation": "#e0f2ff" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-dark.json b/src/apps/cli/themes/presets/bitfun-dark.json new file mode 100644 index 0000000000..ec86dd9d0e --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-dark.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#60a5fa", + "secondary": "#8b5cf6", + "accent": "#8b5cf6", + "error": "#ef4444", + "warning": "#f59e0b", + "success": "#34d399", + "info": "#a1a1aa", + "text": "#e8e8e8", + "textMuted": "#858585", + "background": "#0e0e10", + "backgroundPanel": "#1c1c1f", + "backgroundElement": "#252528", + "inputBackground": "#252528", + "border": "#3b3b40", + "borderActive": "#60a5fa", + "borderSubtle": "#2b2b30", + "diffAdded": "#34d399", + "diffRemoved": "#ef4444", + "diffContext": "#858585", + "diffHunkHeader": "#60a5fa", + "diffHighlightAdded": "#6ee7b7", + "diffHighlightRemoved": "#f87171", + "diffAddedBg": "#12352d", + "diffRemovedBg": "#3a1f24", + "diffContextBg": "#1c1c1f", + "diffLineNumber": "#66666d", + "diffAddedLineNumberBg": "#163c32", + "diffRemovedLineNumberBg": "#432329", + "markdownText": "#e8e8e8", + "markdownHeading": "#60a5fa", + "markdownLink": "#60a5fa", + "markdownLinkText": "#8b5cf6", + "markdownCode": "#34d399", + "markdownBlockQuote": "#858585", + "markdownEmph": "#f59e0b", + "markdownStrong": "#e8e8e8", + "markdownHorizontalRule": "#3b3b40", + "markdownListItem": "#60a5fa", + "markdownListEnumeration": "#8b5cf6", + "markdownImage": "#60a5fa", + "markdownImageText": "#8b5cf6", + "markdownCodeBlock": "#e8e8e8", + "syntaxComment": "#6a737d", + "syntaxKeyword": "#c792ea", + "syntaxFunction": "#7dcfff", + "syntaxVariable": "#80d4ff", + "syntaxString": "#a5e844", + "syntaxNumber": "#f78c6c", + "syntaxType": "#ffc777", + "syntaxOperator": "#c792ea", + "syntaxPunctuation": "#e0e6f0" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-ink-night.json b/src/apps/cli/themes/presets/bitfun-ink-night.json new file mode 100644 index 0000000000..607e76f922 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-ink-night.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#d7b56d", + "secondary": "#79a88f", + "accent": "#b35d4b", + "error": "#d45b4f", + "warning": "#e0a34f", + "success": "#79a88f", + "info": "#8aa8b8", + "text": "#efe7d2", + "textMuted": "#a79b83", + "background": "#17110c", + "backgroundPanel": "#241b12", + "backgroundElement": "#3a2817", + "inputBackground": "#4a2d15", + "border": "#5a472b", + "borderActive": "#d7b56d", + "borderSubtle": "#44341f", + "diffAdded": "#6bc072", + "diffRemoved": "#e85555", + "diffContext": "#928f89", + "diffHunkHeader": "#d7b56d", + "diffHighlightAdded": "#93bfa9", + "diffHighlightRemoved": "#e07368", + "diffAddedBg": "#24362a", + "diffRemovedBg": "#3e221c", + "diffContextBg": "#241b12", + "diffLineNumber": "#8e8065", + "diffAddedLineNumberBg": "#2b4031", + "diffRemovedLineNumberBg": "#4a281f", + "markdownText": "#efe7d2", + "markdownHeading": "#d7b56d", + "markdownLink": "#d7b56d", + "markdownLinkText": "#79a88f", + "markdownCode": "#79a88f", + "markdownBlockQuote": "#a79b83", + "markdownEmph": "#e0a34f", + "markdownStrong": "#d7b56d", + "markdownHorizontalRule": "#5a472b", + "markdownListItem": "#d7b56d", + "markdownListEnumeration": "#79a88f", + "markdownImage": "#d7b56d", + "markdownImageText": "#79a88f", + "markdownCodeBlock": "#efe7d2", + "syntaxComment": "#a79b83", + "syntaxKeyword": "#b35d4b", + "syntaxFunction": "#d7b56d", + "syntaxVariable": "#efe7d2", + "syntaxString": "#79a88f", + "syntaxNumber": "#e0a34f", + "syntaxType": "#8aa8b8", + "syntaxOperator": "#b35d4b", + "syntaxPunctuation": "#efe7d2" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-light.json b/src/apps/cli/themes/presets/bitfun-light.json new file mode 100644 index 0000000000..47887859f3 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-light.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#475569", + "secondary": "#7c6b99", + "accent": "#64748b", + "error": "#c26565", + "warning": "#c08c42", + "success": "#5b9a6f", + "info": "#64748b", + "text": "#1e293b", + "textMuted": "#64748b", + "background": "#f3f3f5", + "backgroundPanel": "#ffffff", + "backgroundElement": "#e7ecf3", + "inputBackground": "#e7ecf3", + "border": "#cbd5e1", + "borderActive": "#475569", + "borderSubtle": "#d9dee7", + "diffAdded": "#4f8f64", + "diffRemoved": "#b85454", + "diffContext": "#64748b", + "diffHunkHeader": "#475569", + "diffHighlightAdded": "#3f7d54", + "diffHighlightRemoved": "#9f4444", + "diffAddedBg": "#e4f0e7", + "diffRemovedBg": "#f3dfdf", + "diffContextBg": "#ffffff", + "diffLineNumber": "#94a3b8", + "diffAddedLineNumberBg": "#d8eadc", + "diffRemovedLineNumberBg": "#ebd2d2", + "markdownText": "#1e293b", + "markdownHeading": "#475569", + "markdownLink": "#475569", + "markdownLinkText": "#7c6b99", + "markdownCode": "#5b9a6f", + "markdownBlockQuote": "#64748b", + "markdownEmph": "#c08c42", + "markdownStrong": "#1e293b", + "markdownHorizontalRule": "#cbd5e1", + "markdownListItem": "#475569", + "markdownListEnumeration": "#7c6b99", + "markdownImage": "#475569", + "markdownImageText": "#7c6b99", + "markdownCodeBlock": "#1e293b", + "syntaxComment": "#64748b", + "syntaxKeyword": "#7c6b99", + "syntaxFunction": "#475569", + "syntaxVariable": "#1e293b", + "syntaxString": "#5b9a6f", + "syntaxNumber": "#c08c42", + "syntaxType": "#64748b", + "syntaxOperator": "#7c6b99", + "syntaxPunctuation": "#1e293b" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-midnight.json b/src/apps/cli/themes/presets/bitfun-midnight.json new file mode 100644 index 0000000000..0934fe9d03 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-midnight.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#8fbc9f", + "secondary": "#d6a657", + "accent": "#9bb3bf", + "error": "#d9827f", + "warning": "#d6a657", + "success": "#8fbc9f", + "info": "#9bb3bf", + "text": "#d7ded9", + "textMuted": "#77827d", + "background": "#0b0d0e", + "backgroundPanel": "#141819", + "backgroundElement": "#1b3328", + "inputBackground": "#143d2b", + "border": "#32403b", + "borderActive": "#8fbc9f", + "borderSubtle": "#24302d", + "diffAdded": "#8fbc9f", + "diffRemoved": "#d9827f", + "diffContext": "#77827d", + "diffHunkHeader": "#9bb3bf", + "diffHighlightAdded": "#b2d5bd", + "diffHighlightRemoved": "#e7a09d", + "diffAddedBg": "#17291f", + "diffRemovedBg": "#2f1d1d", + "diffContextBg": "#141819", + "diffLineNumber": "#61706a", + "diffAddedLineNumberBg": "#1c3326", + "diffRemovedLineNumberBg": "#3a2222", + "markdownText": "#d7ded9", + "markdownHeading": "#8fbc9f", + "markdownLink": "#9bb3bf", + "markdownLinkText": "#d6a657", + "markdownCode": "#8fbc9f", + "markdownBlockQuote": "#77827d", + "markdownEmph": "#d6a657", + "markdownStrong": "#edf3ee", + "markdownHorizontalRule": "#32403b", + "markdownListItem": "#8fbc9f", + "markdownListEnumeration": "#d6a657", + "markdownImage": "#9bb3bf", + "markdownImageText": "#d6a657", + "markdownCodeBlock": "#d7ded9", + "syntaxComment": "#77827d", + "syntaxKeyword": "#d6a657", + "syntaxFunction": "#8fbc9f", + "syntaxVariable": "#d7ded9", + "syntaxString": "#a8c98f", + "syntaxNumber": "#d6a657", + "syntaxType": "#9bb3bf", + "syntaxOperator": "#d9827f", + "syntaxPunctuation": "#d7ded9" + } +} diff --git a/src/apps/cli/themes/presets/bitfun-tokyo-night.json b/src/apps/cli/themes/presets/bitfun-tokyo-night.json new file mode 100644 index 0000000000..2c107f6b53 --- /dev/null +++ b/src/apps/cli/themes/presets/bitfun-tokyo-night.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "theme": { + "primary": "#8ab4ff", + "secondary": "#ff79c6", + "accent": "#7dcfff", + "error": "#f7768e", + "warning": "#e0af68", + "success": "#9ece6a", + "info": "#7dcfff", + "text": "#d5ddff", + "textMuted": "#8b92bd", + "background": "#121329", + "backgroundPanel": "#171933", + "backgroundElement": "#202b55", + "inputBackground": "#202b55", + "border": "#3a4070", + "borderActive": "#8ab4ff", + "borderSubtle": "#2a2f55", + "diffAdded": "#9ece6a", + "diffRemoved": "#f7768e", + "diffContext": "#787c99", + "diffHunkHeader": "#8ab4ff", + "diffHighlightAdded": "#b3dc82", + "diffHighlightRemoved": "#ff8fa3", + "diffAddedBg": "#2a3826", + "diffRemovedBg": "#3f2632", + "diffContextBg": "#171933", + "diffLineNumber": "#747ca8", + "diffAddedLineNumberBg": "#304128", + "diffRemovedLineNumberBg": "#482a38", + "markdownText": "#d5ddff", + "markdownHeading": "#8ab4ff", + "markdownLink": "#7dcfff", + "markdownLinkText": "#ff79c6", + "markdownCode": "#9ece6a", + "markdownBlockQuote": "#787c99", + "markdownEmph": "#e0af68", + "markdownStrong": "#d5ddff", + "markdownHorizontalRule": "#3a4070", + "markdownListItem": "#8ab4ff", + "markdownListEnumeration": "#ff79c6", + "markdownImage": "#7dcfff", + "markdownImageText": "#ff79c6", + "markdownCodeBlock": "#d5ddff", + "syntaxComment": "#8b92bd", + "syntaxKeyword": "#ff79c6", + "syntaxFunction": "#7dcfff", + "syntaxVariable": "#d5ddff", + "syntaxString": "#9ece6a", + "syntaxNumber": "#e0af68", + "syntaxType": "#8ab4ff", + "syntaxOperator": "#f7768e", + "syntaxPunctuation": "#d5ddff" + } +} diff --git a/src/apps/cli/themes/presets/cursor.json b/src/apps/cli/themes/presets/cursor.json deleted file mode 100644 index ab518dbe7e..0000000000 --- a/src/apps/cli/themes/presets/cursor.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#181818", - "darkPanel": "#141414", - "darkElement": "#262626", - "darkFg": "#e4e4e4", - "darkMuted": "#e4e4e45e", - "darkBorder": "#e4e4e413", - "darkBorderActive": "#e4e4e426", - "darkCyan": "#88c0d0", - "darkBlue": "#81a1c1", - "darkGreen": "#3fa266", - "darkGreenBright": "#70b489", - "darkRed": "#e34671", - "darkRedBright": "#fc6b83", - "darkYellow": "#f1b467", - "darkOrange": "#d2943e", - "darkPink": "#E394DC", - "darkPurple": "#AAA0FA", - "darkTeal": "#82D2CE", - "darkSyntaxYellow": "#F8C762", - "darkSyntaxOrange": "#EFB080", - "darkSyntaxGreen": "#A8CC7C", - "darkSyntaxBlue": "#87C3FF", - "lightBg": "#fcfcfc", - "lightPanel": "#f3f3f3", - "lightElement": "#ededed", - "lightFg": "#141414", - "lightMuted": "#141414ad", - "lightBorder": "#14141413", - "lightBorderActive": "#14141426", - "lightTeal": "#6f9ba6", - "lightBlue": "#3c7cab", - "lightBlueDark": "#206595", - "lightGreen": "#1f8a65", - "lightGreenBright": "#55a583", - "lightRed": "#cf2d56", - "lightRedBright": "#e75e78", - "lightOrange": "#db704b", - "lightYellow": "#c08532", - "lightPurple": "#9e94d5", - "lightPurpleDark": "#6049b3", - "lightPink": "#b8448b", - "lightMagenta": "#b3003f" - }, - "theme": { - "primary": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "secondary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "accent": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "backgroundElement": { - "dark": "darkElement", - "light": "lightElement" - }, - "border": { - "dark": "darkBorder", - "light": "lightBorder" - }, - "borderActive": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "borderSubtle": { - "dark": "#0f0f0f", - "light": "#e0e0e0" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHunkHeader": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreenBright" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRedBright" - }, - "diffAddedBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "diffContextBg": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "diffLineNumber": { - "dark": "#e4e4e442", - "light": "#1414147a" - }, - "diffAddedLineNumberBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedLineNumberBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightBlueDark" - }, - "markdownLink": { - "dark": "darkTeal", - "light": "lightBlueDark" - }, - "markdownLinkText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownEmph": { - "dark": "darkTeal", - "light": "lightFg" - }, - "markdownStrong": { - "dark": "darkSyntaxYellow", - "light": "lightFg" - }, - "markdownHorizontalRule": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownListItem": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightMuted" - }, - "markdownImage": { - "dark": "darkCyan", - "light": "lightBlueDark" - }, - "markdownImageText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "syntaxKeyword": { - "dark": "darkTeal", - "light": "lightMagenta" - }, - "syntaxFunction": { - "dark": "darkSyntaxOrange", - "light": "lightOrange" - }, - "syntaxVariable": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxString": { - "dark": "darkPink", - "light": "lightPurple" - }, - "syntaxNumber": { - "dark": "darkSyntaxYellow", - "light": "lightPink" - }, - "syntaxType": { - "dark": "darkSyntaxOrange", - "light": "lightBlueDark" - }, - "syntaxOperator": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } -} diff --git a/src/apps/cli/themes/presets/everforest.json b/src/apps/cli/themes/presets/everforest.json deleted file mode 100644 index 62dfb31ba8..0000000000 --- a/src/apps/cli/themes/presets/everforest.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#2d353b", - "darkStep2": "#333c43", - "darkStep3": "#343f44", - "darkStep4": "#3d484d", - "darkStep5": "#475258", - "darkStep6": "#7a8478", - "darkStep7": "#859289", - "darkStep8": "#9da9a0", - "darkStep9": "#a7c080", - "darkStep10": "#83c092", - "darkStep11": "#7a8478", - "darkStep12": "#d3c6aa", - "darkRed": "#e67e80", - "darkOrange": "#e69875", - "darkGreen": "#a7c080", - "darkCyan": "#83c092", - "darkYellow": "#dbbc7f", - "lightStep1": "#fdf6e3", - "lightStep2": "#efebd4", - "lightStep3": "#f4f0d9", - "lightStep4": "#efebd4", - "lightStep5": "#e6e2cc", - "lightStep6": "#a6b0a0", - "lightStep7": "#939f91", - "lightStep8": "#829181", - "lightStep9": "#8da101", - "lightStep10": "#35a77c", - "lightStep11": "#a6b0a0", - "lightStep12": "#5c6a72", - "lightRed": "#f85552", - "lightOrange": "#f57d26", - "lightGreen": "#8da101", - "lightCyan": "#35a77c", - "lightYellow": "#dfa000" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "#7fbbb3", - "light": "#3a94c5" - }, - "accent": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } -} diff --git a/src/apps/cli/themes/presets/github.json b/src/apps/cli/themes/presets/github.json deleted file mode 100644 index 99a80879e1..0000000000 --- a/src/apps/cli/themes/presets/github.json +++ /dev/null @@ -1,233 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0d1117", - "darkBgAlt": "#010409", - "darkBgPanel": "#161b22", - "darkFg": "#c9d1d9", - "darkFgMuted": "#8b949e", - "darkBlue": "#58a6ff", - "darkGreen": "#3fb950", - "darkRed": "#f85149", - "darkOrange": "#d29922", - "darkPurple": "#bc8cff", - "darkPink": "#ff7b72", - "darkYellow": "#e3b341", - "darkCyan": "#39c5cf", - "lightBg": "#ffffff", - "lightBgAlt": "#f6f8fa", - "lightBgPanel": "#f0f3f6", - "lightFg": "#24292f", - "lightFgMuted": "#57606a", - "lightBlue": "#0969da", - "lightGreen": "#1a7f37", - "lightRed": "#cf222e", - "lightOrange": "#bc4c00", - "lightPurple": "#8250df", - "lightPink": "#bf3989", - "lightYellow": "#9a6700", - "lightCyan": "#1b7c83" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#21262d", - "light": "#d8dee4" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffHighlightAdded": { - "dark": "#3fb950", - "light": "#1a7f37" - }, - "diffHighlightRemoved": { - "dark": "#f85149", - "light": "#cf222e" - }, - "diffAddedBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#484f58", - "light": "#afb8c1" - }, - "diffAddedLineNumberBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedLineNumberBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightPink" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxFunction": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxVariable": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxString": { - "dark": "darkCyan", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "darkBlue", - "light": "lightCyan" - }, - "syntaxType": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxOperator": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } -} diff --git a/src/apps/cli/themes/presets/one-dark.json b/src/apps/cli/themes/presets/one-dark.json deleted file mode 100644 index 73b24e9292..0000000000 --- a/src/apps/cli/themes/presets/one-dark.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#282c34", - "darkBgAlt": "#21252b", - "darkBgPanel": "#353b45", - "darkFg": "#abb2bf", - "darkFgMuted": "#5c6370", - "darkPurple": "#c678dd", - "darkBlue": "#61afef", - "darkRed": "#e06c75", - "darkGreen": "#98c379", - "darkYellow": "#e5c07b", - "darkOrange": "#d19a66", - "darkCyan": "#56b6c2", - "lightBg": "#fafafa", - "lightBgAlt": "#f0f0f1", - "lightBgPanel": "#eaeaeb", - "lightFg": "#383a42", - "lightFgMuted": "#a0a1a7", - "lightPurple": "#a626a4", - "lightBlue": "#4078f2", - "lightRed": "#e45649", - "lightGreen": "#50a14f", - "lightYellow": "#c18401", - "lightOrange": "#986801", - "lightCyan": "#0184bc" - }, - "theme": { - "primary": { "dark": "darkBlue", "light": "lightBlue" }, - "secondary": { "dark": "darkPurple", "light": "lightPurple" }, - "accent": { "dark": "darkCyan", "light": "lightCyan" }, - "error": { "dark": "darkRed", "light": "lightRed" }, - "warning": { "dark": "darkYellow", "light": "lightYellow" }, - "success": { "dark": "darkGreen", "light": "lightGreen" }, - "info": { "dark": "darkOrange", "light": "lightOrange" }, - "text": { "dark": "darkFg", "light": "lightFg" }, - "textMuted": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "background": { "dark": "darkBg", "light": "lightBg" }, - "backgroundPanel": { "dark": "darkBgAlt", "light": "lightBgAlt" }, - "backgroundElement": { "dark": "darkBgPanel", "light": "lightBgPanel" }, - "border": { "dark": "#393f4a", "light": "#d1d1d2" }, - "borderActive": { "dark": "darkBlue", "light": "lightBlue" }, - "borderSubtle": { "dark": "#2c313a", "light": "#e0e0e1" }, - "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, - "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, - "diffContext": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, - "diffHighlightAdded": { "dark": "#aad482", "light": "#489447" }, - "diffHighlightRemoved": { "dark": "#e8828b", "light": "#d65145" }, - "diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" }, - "diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" }, - "diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" }, - "diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" }, - "diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" }, - "diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" }, - "markdownText": { "dark": "darkFg", "light": "lightFg" }, - "markdownHeading": { "dark": "darkPurple", "light": "lightPurple" }, - "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, - "markdownLinkText": { "dark": "darkCyan", "light": "lightCyan" }, - "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, - "markdownBlockQuote": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, - "markdownStrong": { "dark": "darkOrange", "light": "lightOrange" }, - "markdownHorizontalRule": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, - "markdownListEnumeration": { "dark": "darkCyan", "light": "lightCyan" }, - "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, - "markdownImageText": { "dark": "darkCyan", "light": "lightCyan" }, - "markdownCodeBlock": { "dark": "darkFg", "light": "lightFg" }, - "syntaxComment": { "dark": "darkFgMuted", "light": "lightFgMuted" }, - "syntaxKeyword": { "dark": "darkPurple", "light": "lightPurple" }, - "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, - "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, - "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, - "syntaxNumber": { "dark": "darkOrange", "light": "lightOrange" }, - "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, - "syntaxOperator": { "dark": "darkCyan", "light": "lightCyan" }, - "syntaxPunctuation": { "dark": "darkFg", "light": "lightFg" } - } -} diff --git a/src/apps/cli/themes/presets/opencode.json b/src/apps/cli/themes/presets/opencode.json deleted file mode 100644 index 8f585a4509..0000000000 --- a/src/apps/cli/themes/presets/opencode.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#0a0a0a", - "darkStep2": "#141414", - "darkStep3": "#1e1e1e", - "darkStep4": "#282828", - "darkStep5": "#323232", - "darkStep6": "#3c3c3c", - "darkStep7": "#484848", - "darkStep8": "#606060", - "darkStep9": "#fab283", - "darkStep10": "#ffc09f", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#5c9cf5", - "darkAccent": "#9d7cd8", - "darkRed": "#e06c75", - "darkOrange": "#f5a742", - "darkGreen": "#7fd88f", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "lightStep1": "#ffffff", - "lightStep2": "#fafafa", - "lightStep3": "#f5f5f5", - "lightStep4": "#ebebeb", - "lightStep5": "#e1e1e1", - "lightStep6": "#d4d4d4", - "lightStep7": "#b8b8b8", - "lightStep8": "#a0a0a0", - "lightStep9": "#3b7dd8", - "lightStep10": "#2968c3", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#7b5bb6", - "lightAccent": "#d68c27", - "lightRed": "#d1383d", - "lightOrange": "#d68c27", - "lightGreen": "#3d9a57", - "lightCyan": "#318795", - "lightYellow": "#b0851f" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } -} diff --git a/src/apps/cli/themes/presets/tokyonight.json b/src/apps/cli/themes/presets/tokyonight.json deleted file mode 100644 index 1c9503a420..0000000000 --- a/src/apps/cli/themes/presets/tokyonight.json +++ /dev/null @@ -1,243 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#1a1b26", - "darkStep2": "#1e2030", - "darkStep3": "#222436", - "darkStep4": "#292e42", - "darkStep5": "#3b4261", - "darkStep6": "#545c7e", - "darkStep7": "#737aa2", - "darkStep8": "#9099b2", - "darkStep9": "#82aaff", - "darkStep10": "#89b4fa", - "darkStep11": "#828bb8", - "darkStep12": "#c8d3f5", - "darkRed": "#ff757f", - "darkOrange": "#ff966c", - "darkYellow": "#ffc777", - "darkGreen": "#c3e88d", - "darkCyan": "#86e1fc", - "darkPurple": "#c099ff", - "lightStep1": "#e1e2e7", - "lightStep2": "#d5d6db", - "lightStep3": "#c8c9ce", - "lightStep4": "#b9bac1", - "lightStep5": "#a8aecb", - "lightStep6": "#9699a8", - "lightStep7": "#737a8c", - "lightStep8": "#5a607d", - "lightStep9": "#2e7de9", - "lightStep10": "#1a6ce7", - "lightStep11": "#8990a3", - "lightStep12": "#3760bf", - "lightRed": "#f52a65", - "lightOrange": "#b15c00", - "lightYellow": "#8c6c3e", - "lightGreen": "#587539", - "lightCyan": "#007197", - "lightPurple": "#9854f1" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } -} diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 239ee278a2..12685a96d7 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -21,6 +21,7 @@ ## 本模块规则 - 桌面端专属集成留在这里,不要下沉到共享 core +- 窗口 lifecycle 行为(包括 close/minimize-to-tray 默认值)属于桌面端 surface;修改时必须保留用户已保存偏好。 - 涉及打包或 release 请求时,参见顶层 `AGENTS.md` ## 命令 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index db24f9b106..f8c439b13d 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -21,6 +21,8 @@ If a change affects shared product behavior across runtimes, the implementation ## Local rules - Keep desktop-only integrations here; do not move them into shared core +- Window lifecycle behavior, including close/minimize-to-tray defaults, is a + desktop surface concern. Preserve saved user preferences when changing it. - For packaging or release asks, see the top-level `AGENTS.md` ## Commands diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index fb7997f5de..d16b2cf2b0 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -14,6 +14,8 @@ use tauri::{AppHandle, Emitter, State}; #[serde(rename_all = "camelCase")] pub struct AcpClientIdRequest { pub client_id: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -146,7 +148,7 @@ pub async fn install_acp_client_cli( .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; service - .install_client_cli(&request.client_id) + .install_client_cli(&request.client_id, request.remote_connection_id.as_deref()) .await .map_err(|e| e.to_string()) } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index e2fdb0532d..d1a91df128 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1,6 +1,6 @@ //! Agentic API -use log::warn; +use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, State}; @@ -19,6 +19,13 @@ use bitfun_core::agentic::deep_review_policy::{ }; use bitfun_core::agentic::image_analysis::ImageContextData; use bitfun_core::agentic::tools::image_context::get_image_context; +use bitfun_core::service::session::{DialogTurnData, SessionRelationship}; + +const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; +const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; +const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n...[truncated for session view]"; +const SESSION_VIEW_OMITTED_MARKER: &str = "[truncated for session view]"; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -32,6 +39,10 @@ pub struct CreateSessionRequest { pub remote_connection_id: Option, #[serde(default)] pub remote_ssh_host: Option, + #[serde(default)] + pub relationship: Option, + #[serde(default)] + pub deep_review_run_manifest: Option, pub config: Option, } @@ -121,6 +132,8 @@ pub struct EnsureCoordinatorSessionRequest { pub remote_connection_id: Option, #[serde(default)] pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, } #[derive(Debug, Deserialize)] @@ -157,6 +170,218 @@ pub struct SessionResponse { pub created_at: u64, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionWithTurnsResponse { + pub session: SessionResponse, + pub turns: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionViewResponse { + pub session: SessionResponse, + pub turns: Vec, + pub context_restore_state: String, +} + +#[derive(Debug, Default)] +struct RestoreTurnPayloadStats { + tool_result_count: usize, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, + largest_raw_result_chars: usize, + largest_raw_result_path: String, + top_raw_results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RestoreToolOutputStats { + tool_name: String, + path: String, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, +} + +#[derive(Debug, Default)] +struct JsonStringStats { + total_chars: usize, + largest_chars: usize, + largest_path: String, +} + +fn collect_json_string_stats(value: &serde_json::Value, path: &str, stats: &mut JsonStringStats) { + match value { + serde_json::Value::String(text) => { + let char_count = text.chars().count(); + stats.total_chars += char_count; + if char_count > stats.largest_chars { + stats.largest_chars = char_count; + stats.largest_path = path.to_string(); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_json_string_stats(item, &format!("{}[{}]", path, index), stats); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + let next_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + collect_json_string_stats(item, &next_path, stats); + } + } + _ => {} + } +} + +fn push_top_raw_result(stats: &mut RestoreTurnPayloadStats, result: RestoreToolOutputStats) { + if result.raw_result_string_chars == 0 { + return; + } + stats.top_raw_results.push(result); + stats.top_raw_results.sort_by(|left, right| { + right + .raw_result_string_chars + .cmp(&left.raw_result_string_chars) + .then_with(|| left.tool_name.cmp(&right.tool_name)) + }); + stats.top_raw_results.truncate(3); +} + +fn format_top_raw_results(results: &[RestoreToolOutputStats]) -> String { + results + .iter() + .map(|item| { + format!( + "{}:{}:{}:{}", + item.tool_name, + item.raw_result_string_chars, + item.result_for_assistant_chars, + item.path + ) + }) + .collect::>() + .join("|") +} + +fn restore_turn_payload_stats(turns: &[DialogTurnData]) -> RestoreTurnPayloadStats { + let mut stats = RestoreTurnPayloadStats::default(); + for turn in turns { + for (round_index, round) in turn.model_rounds.iter().enumerate() { + for (tool_index, tool) in round.tool_items.iter().enumerate() { + let Some(result) = tool.tool_result.as_ref() else { + continue; + }; + stats.tool_result_count += 1; + let assistant_chars = result + .result_for_assistant + .as_deref() + .map(|text| text.chars().count()) + .unwrap_or(0); + stats.result_for_assistant_chars += assistant_chars; + let mut json_stats = JsonStringStats::default(); + collect_json_string_stats( + &result.result, + &format!( + "turn[{}].round[{}].tool[{}].{}", + turn.turn_index, round_index, tool_index, tool.tool_name + ), + &mut json_stats, + ); + stats.raw_result_string_chars += json_stats.total_chars; + if json_stats.largest_chars > stats.largest_raw_result_chars { + stats.largest_raw_result_chars = json_stats.largest_chars; + stats.largest_raw_result_path = json_stats.largest_path.clone(); + } + push_top_raw_result( + &mut stats, + RestoreToolOutputStats { + tool_name: tool.tool_name.clone(), + path: json_stats.largest_path, + raw_result_string_chars: json_stats.total_chars, + result_for_assistant_chars: assistant_chars, + }, + ); + } + } + } + stats +} + +fn truncate_string_for_session_view(text: &str, remaining_budget: &mut usize) -> Option { + let char_count = text.chars().count(); + let available = SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT.min(*remaining_budget); + + if char_count <= available { + *remaining_budget = remaining_budget.saturating_sub(char_count); + return None; + } + + let omitted_chars = SESSION_VIEW_OMITTED_MARKER.chars().count(); + if available <= omitted_chars { + *remaining_budget = remaining_budget.saturating_sub(omitted_chars.min(*remaining_budget)); + return Some(SESSION_VIEW_OMITTED_MARKER.to_string()); + } + + let suffix_chars = SESSION_VIEW_TRUNCATED_MARKER.chars().count(); + if available <= suffix_chars { + *remaining_budget = remaining_budget.saturating_sub(omitted_chars.min(*remaining_budget)); + return Some(SESSION_VIEW_OMITTED_MARKER.to_string()); + } + + let keep_chars = available - suffix_chars; + let mut preview = text.chars().take(keep_chars).collect::(); + preview.push_str(SESSION_VIEW_TRUNCATED_MARKER); + let preview_chars = preview.chars().count(); + *remaining_budget = remaining_budget.saturating_sub(preview_chars); + Some(preview) +} + +fn compact_json_for_session_view(value: &mut serde_json::Value, remaining_budget: &mut usize) { + match value { + serde_json::Value::String(text) => { + if let Some(preview) = truncate_string_for_session_view(text, remaining_budget) { + *text = preview; + } + } + serde_json::Value::Array(items) => { + for item in items { + compact_json_for_session_view(item, remaining_budget); + } + } + serde_json::Value::Object(map) => { + for item in map.values_mut() { + compact_json_for_session_view(item, remaining_budget); + } + } + _ => {} + } +} + +fn compact_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + let mut remaining_budget = SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET; + for turn in turns { + for round in &mut turn.model_rounds { + for tool in &mut round.tool_items { + if let Some(result) = tool.tool_result.as_mut() { + result.result_for_assistant = None; + compact_json_for_session_view(&mut result.result, &mut remaining_budget); + } + } + } + } +} + +#[cfg(test)] +fn omit_assistant_only_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + compact_tool_results_for_session_view(turns); +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CancelDialogTurnRequest { @@ -248,6 +473,10 @@ pub struct RestoreSessionRequest { pub remote_connection_id: Option, #[serde(default)] pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, + #[serde(default)] + pub trace_id: Option, } #[derive(Debug, Deserialize)] @@ -352,6 +581,22 @@ pub async fn create_session( } .map_err(|e| format!("Failed to create session: {}", e))?; + if let Some(relationship) = request.relationship { + coordinator + .get_session_manager() + .merge_session_relationship(&session.session_id, relationship) + .await + .map_err(|e| format!("Failed to persist session relationship: {}", e))?; + } + + if let Some(run_manifest) = request.deep_review_run_manifest { + coordinator + .get_session_manager() + .set_session_deep_review_run_manifest(&session.session_id, Some(run_manifest)) + .await + .map_err(|e| format!("Failed to persist Deep Review run manifest: {}", e))?; + } + Ok(CreateSessionResponse { session_id: session.session_id, session_name: session.session_name, @@ -447,9 +692,12 @@ pub async fn ensure_coordinator_session( request.remote_ssh_host.as_deref(), ) .await; - coordinator - .restore_session(&effective, session_id) - .await + let restore_result = if request.include_internal { + coordinator.restore_internal_session(&effective, session_id).await + } else { + coordinator.restore_session(&effective, session_id).await + }; + restore_result .map(|_| ()) .map_err(|e| e.to_string()) } @@ -871,14 +1119,166 @@ pub async fn restore_session( request.remote_ssh_host.as_deref(), ) .await; - let session = coordinator - .restore_session(&effective_path, &request.session_id) - .await - .map_err(|e| format!("Failed to restore session: {}", e))?; + let session = if request.include_internal { + coordinator + .restore_internal_session(&effective_path, &request.session_id) + .await + } else { + coordinator + .restore_session(&effective_path, &request.session_id) + .await + } + .map_err(|e| format!("Failed to restore session: {}", e))?; Ok(session_to_response(session)) } +#[tauri::command] +pub async fn restore_session_view( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: RestoreSessionRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_view request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = std::time::Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + debug!( + "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + + let (session, mut turns) = if request.include_internal { + coordinator + .restore_internal_session_view(&effective_path, &request.session_id) + .await + } else { + coordinator + .restore_session_view(&effective_path, &request.session_id) + .await + } + .map_err(|e| format!("Failed to restore session view: {}", e))?; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } + } + + compact_tool_results_for_session_view(&mut turns); + + debug!( + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, context_restore_state=pending, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionViewResponse { + session: session_to_response(session), + turns, + context_restore_state: "pending".to_string(), + }) +} + +#[tauri::command] +pub async fn restore_session_with_turns( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: RestoreSessionRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_with_turns request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = std::time::Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + debug!( + "restore_session_with_turns storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + let (session, turns) = if request.include_internal { + coordinator + .restore_internal_session_with_turns(&effective_path, &request.session_id) + .await + } else { + coordinator + .restore_session_with_turns(&effective_path, &request.session_id) + .await + } + .map_err(|e| format!("Failed to restore session: {}", e))?; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_with_turns payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } + } + + debug!( + "restore_session_with_turns completed: trace_id={}, session_id={}, turn_count={}, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionWithTurnsResponse { + session: session_to_response(session), + turns, + }) +} + #[tauri::command] pub async fn list_sessions( coordinator: State<'_, Arc>, @@ -1059,3 +1459,231 @@ fn system_time_to_unix_secs(time: std::time::SystemTime) -> u64 { } } } + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_core::service::session::{ + ModelRoundData, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, + }; + use serde_json::json; + + fn tool_item( + tool_name: &str, + result: serde_json::Value, + assistant: Option<&str>, + ) -> ToolItemData { + ToolItemData { + id: format!("tool-{}", tool_name), + tool_name: tool_name.to_string(), + tool_call: ToolCallData { + id: format!("call-{}", tool_name), + input: json!({}), + }, + tool_result: Some(ToolResultData { + result, + success: true, + result_for_assistant: assistant.map(str::to_string), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: None, + interruption_reason: None, + } + } + + #[test] + fn restore_turn_payload_stats_tracks_largest_outputs_without_contents() { + let turn = DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + agent_type: None, + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ + tool_item("Read", json!({ "content": "abc" }), Some("assistant")), + tool_item("Bash", json!({ "output": "x".repeat(20) }), Some("short")), + ], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }; + + let stats = restore_turn_payload_stats(&[turn]); + + assert_eq!(stats.tool_result_count, 2); + assert_eq!(stats.raw_result_string_chars, 23); + assert_eq!(stats.result_for_assistant_chars, 14); + assert_eq!(stats.largest_raw_result_chars, 20); + assert_eq!(stats.top_raw_results.len(), 2); + assert_eq!(stats.top_raw_results[0].tool_name, "Bash"); + assert_eq!(stats.top_raw_results[0].raw_result_string_chars, 20); + assert_eq!(stats.top_raw_results[0].result_for_assistant_chars, 5); + assert_eq!(stats.top_raw_results[1].tool_name, "Read"); + assert_eq!(stats.top_raw_results[1].raw_result_string_chars, 3); + assert!(!stats.top_raw_results[0].path.contains(&"x".repeat(20))); + } + + #[test] + fn omit_assistant_only_tool_results_preserves_visible_results() { + let mut turns = vec![DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + agent_type: None, + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![tool_item( + "Bash", + json!({ "output": "visible output" }), + Some("assistant-only payload"), + )], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }]; + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + let tool_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should remain present"); + assert_eq!(tool_result.result["output"], "visible output"); + assert_eq!(tool_result.result_for_assistant, None); + } + + #[test] + fn session_view_tool_result_compaction_truncates_large_visible_results() { + let large_output = "x".repeat(80 * 1024); + let mut turns = vec![DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + agent_type: None, + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![tool_item( + "Bash", + json!({ "output": large_output, "exit_code": 0 }), + Some("assistant-only payload"), + )], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }]; + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + let tool_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should remain present"); + let output = tool_result.result["output"] + .as_str() + .expect("output should remain a visible string preview"); + assert!(output.len() < 80 * 1024); + assert!(output.contains("truncated for session view")); + assert_eq!(tool_result.result["exit_code"], 0); + assert_eq!(tool_result.result_for_assistant, None); + } +} diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 45f4cff2f6..03b2786755 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1810,24 +1810,22 @@ pub async fn cleanup_invalid_workspaces( } async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> usize { - let saved_connection_ids: std::collections::HashSet = match state - .get_ssh_manager_async() - .await - { - Ok(manager) => manager - .get_saved_connections() - .await - .into_iter() - .map(|connection| connection.id) - .collect(), - Err(error) => { - warn!( - "Skipping remote workspace cleanup because SSH manager is unavailable: {}", - error - ); - return 0; - } - }; + let saved_connection_ids: std::collections::HashSet = + match state.get_ssh_manager_async().await { + Ok(manager) => manager + .get_saved_connections() + .await + .into_iter() + .map(|connection| connection.id) + .collect(), + Err(error) => { + warn!( + "Skipping remote workspace cleanup because SSH manager is unavailable: {}", + error + ); + return 0; + } + }; let workspaces = state.workspace_service.list_workspace_infos().await; let mut removed = 0usize; @@ -1860,7 +1858,11 @@ async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> u .await; } - match state.workspace_service.remove_workspace(&workspace.id).await { + match state + .workspace_service + .remove_workspace(&workspace.id) + .await + { Ok(()) => { removed += 1; info!( @@ -1873,8 +1875,7 @@ async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> u Err(error) => { warn!( "Failed to remove unrecoverable remote workspace: workspace_id={}, error={}", - workspace.id, - error + workspace.id, error ); } } diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 8a0297d759..a72c8e6111 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -5,6 +5,7 @@ use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; use tauri::State; #[derive(Debug, Deserialize)] @@ -15,6 +16,14 @@ pub struct GetConfigRequest { pub skip_retry_on_not_found: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetConfigsRequest { + pub paths: Vec, + #[serde(default)] + pub skip_retry_on_not_found: bool, +} + #[derive(Debug, Deserialize)] pub struct SetConfigRequest { pub path: String, @@ -66,6 +75,41 @@ pub async fn get_config( } } +#[tauri::command] +pub async fn get_configs( + state: State<'_, AppState>, + request: GetConfigsRequest, +) -> Result, String> { + let config_service = &state.config_service; + let mut configs = BTreeMap::new(); + + for path in request.paths { + if configs.contains_key(&path) { + continue; + } + + match config_service + .get_config::(Some(path.as_str())) + .await + { + Ok(config) => { + configs.insert(path, config); + } + Err(e) => { + if request.skip_retry_on_not_found + && is_expected_config_path_not_found(&e, Some(path.as_str())) + { + return Err(format!("Failed to get config: {}", e)); + } + error!("Failed to get config: path={}, error={}", path, e); + return Err(format!("Failed to get config: {}", e)); + } + } + } + + Ok(configs) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index 1ae54fa997..ed7c6a239b 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -11,7 +11,7 @@ use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; -use log::{error, info}; +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -588,6 +588,66 @@ pub async fn git_get_repository( }) } +#[tauri::command] +pub async fn git_get_repository_basic( + state: State<'_, AppState>, + request: GitRepositoryRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let current_branch = execute_remote_git_success( + &state, + &target, + &["branch".to_string(), "--show-current".to_string()], + ) + .await + .map(|s| { + let branch = s.trim(); + if branch.is_empty() { + "HEAD".to_string() + } else { + branch.to_string() + } + })?; + + let name = target + .repository_path + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("/") + .to_string(); + + Ok(GitRepository { + path: target.repository_path, + name, + current_branch, + is_bare: false, + has_changes: false, + remotes: Vec::new(), + }) + } else { + GitService::get_repository_basic(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to get basic Git repository info: path={}, error={}", + request.repository_path, e + ); + format!("Failed to get basic Git repository info: {}", e) + }) + }; + + let duration_ms = started_at.elapsed().as_millis(); + if duration_ms >= 80 { + debug!( + "Git basic repository lookup completed: path={}, duration_ms={}", + request.repository_path, duration_ms + ); + } + + result +} + #[tauri::command] pub async fn git_get_status( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 86d546e4f6..2a65fb2d29 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -235,6 +235,14 @@ pub struct MiniAppDraftStorageSetRequest { pub value: Value, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDeclineBuiltinUpdateRequest { + pub app_id: String, + pub builtin_version: u32, + pub source_hash: String, +} + #[derive(Debug, Serialize)] pub struct RuntimeStatus { pub available: bool, @@ -1082,6 +1090,23 @@ pub async fn miniapp_get_customization_metadata( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn miniapp_decline_builtin_update( + state: State<'_, AppState>, + request: MiniAppDeclineBuiltinUpdateRequest, +) -> Result, String> { + state + .miniapp_manager + .decline_builtin_update( + &request.app_id, + request.builtin_version, + &request.source_hash, + now_ms() as i64, + ) + .await + .map_err(|e| e.to_string()) +} + // ============== AI commands ============== /// Active AI stream cancellation flags: stream_id → cancel flag. diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 509ffad190..0447486192 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -8,6 +8,7 @@ use bitfun_core::service::snapshot::{ }; use log::{info, warn}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::{path::PathBuf, sync::Arc, time::Duration}; use tauri::{AppHandle, Emitter}; @@ -233,9 +234,11 @@ async fn resolve_workspace_dir(workspace_path: &str) -> Result Ok(workspace_dir) } -async fn ensure_snapshot_manager_ready( +async fn ensure_snapshot_manager_ready_for( workspace_path: &str, + caller: &str, ) -> Result, String> { + let started_at = std::time::Instant::now(); // Remote workspaces don't support the snapshot system if is_remote_path(workspace_path).await { return Err(format!( @@ -247,11 +250,21 @@ async fn ensure_snapshot_manager_ready( let workspace_dir = resolve_workspace_dir(workspace_path).await?; if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_dir) { + let duration_ms = started_at.elapsed().as_millis(); + if duration_ms >= 20 { + log::debug!( + "Snapshot manager ready: caller={}, workspace={}, source=cache, duration_ms={}", + caller, + workspace_dir.display(), + duration_ms + ); + } return Ok(manager); } info!( - "Snapshot manager missing, initializing lazily: workspace={}", + "Snapshot manager missing, initializing lazily: caller={}, workspace={}", + caller, workspace_dir.display() ); @@ -265,8 +278,21 @@ async fn ensure_snapshot_manager_ready( ) })?; - ensure_snapshot_manager_for_workspace(&workspace_dir) - .map_err(|e| format!("Failed to get snapshot manager: {}", e)) + let manager = ensure_snapshot_manager_for_workspace(&workspace_dir) + .map_err(|e| format!("Failed to get snapshot manager: {}", e))?; + log::debug!( + "Snapshot manager ready: caller={}, workspace={}, source=lazy_init, duration_ms={}", + caller, + workspace_dir.display(), + started_at.elapsed().as_millis() + ); + Ok(manager) +} + +async fn ensure_snapshot_manager_ready( + workspace_path: &str, +) -> Result, String> { + ensure_snapshot_manager_ready_for(workspace_path, "unspecified").await } #[tauri::command] @@ -274,7 +300,8 @@ pub async fn record_file_change( app_handle: AppHandle, request: RecordFileChangeRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "record_file_change").await?; let operation_type = match request.operation_type.as_str() { "Create" => OperationType::Create, @@ -323,7 +350,8 @@ pub async fn rollback_session( return Ok(vec![]); } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_session").await?; let restored_files = manager .rollback_session(&request.session_id) @@ -373,7 +401,8 @@ pub async fn rollback_to_turn( } } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_to_turn").await?; let restored_files = manager .rollback_to_turn(&request.session_id, request.turn_index) @@ -388,10 +417,61 @@ pub async fn rollback_to_turn( let mut deleted_turns_count = 0; if request.delete_turns { let workspace_path = PathBuf::from(&request.workspace_path); + let mut rolled_back_parent_turn_ids = HashSet::new(); + + use bitfun_core::agentic::persistence::PersistenceManager; + + match try_get_path_manager_arc() { + Ok(path_manager) => match PersistenceManager::new(path_manager) { + Ok(persistence_manager) => { + match persistence_manager + .load_session_turns(&workspace_path, &request.session_id) + .await + { + Ok(turns) => { + rolled_back_parent_turn_ids = turns + .into_iter() + .filter(|turn| turn.turn_index >= request.turn_index) + .map(|turn| turn.turn_id) + .collect(); + } + Err(e) => { + warn!( + "Failed to load parent turns before rollback cleanup: session_id={}, turn_index={}, error={}", + request.session_id, request.turn_index, e + ); + } + } + } + Err(e) => { + warn!("Failed to create PersistenceManager: error={}", e); + } + }, + Err(e) => { + warn!("Failed to create PathManager: error={}", e); + } + } + { use bitfun_core::agentic::coordination::get_global_coordinator; if let Some(coordinator) = get_global_coordinator() { + if !rolled_back_parent_turn_ids.is_empty() { + if let Err(e) = coordinator + .delete_hidden_subagent_sessions_for_parent_turns( + &workspace_path, + &request.session_id, + &rolled_back_parent_turn_ids, + ) + .await + { + warn!( + "Failed to delete hidden subagent sessions during rollback: session_id={}, turn_index={}, error={}", + request.session_id, request.turn_index, e + ); + } + } + if let Err(e) = coordinator .get_session_manager() .rollback_context_to_turn_start( @@ -411,8 +491,6 @@ pub async fn rollback_to_turn( } } - use bitfun_core::agentic::persistence::PersistenceManager; - match try_get_path_manager_arc() { Ok(path_manager) => match PersistenceManager::new(path_manager) { Ok(persistence_manager) => { @@ -469,7 +547,8 @@ pub async fn accept_session( app_handle: AppHandle, request: AcceptSessionRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_session").await?; manager .accept_session(&request.session_id) @@ -553,7 +632,8 @@ pub async fn get_session_files(request: GetSessionFilesRequest) -> Result Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_file_diff_stats") + .await?; let stats = manager .get_session_file_diff_stats(&request.sessionId, &request.filePath) @@ -704,7 +786,8 @@ pub async fn get_session_file_diff_stats( pub async fn get_operation_summary( request: GetOperationSummaryRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_operation_summary").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -859,7 +942,8 @@ pub async fn get_session_stats( })); } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_stats").await?; let stats = manager .get_session_stats(&request.session_id) diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 08e82744e3..425b12e7d5 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -357,9 +357,7 @@ pub async fn create_subagent( kind, ); subagent.review = review; - subagent - .save_to_file(None) - .map_err(|e| e.to_string())?; + subagent.save_to_file(None).map_err(|e| e.to_string())?; let custom_config = CustomSubagentConfig { model: subagent.model.clone(), diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 43eaca796e..ae9a4f00f4 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -102,7 +102,7 @@ pub async fn check_for_updates( release_notes: None, release_date: None, }) - + } #[derive(Debug, Deserialize, Default)] @@ -158,14 +158,14 @@ pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Re }, ) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string()) } - + #[cfg(target_env = "ohos")] { Err("Not supported on this platform".to_string()) } - + } #[derive(Debug, Deserialize, Default)] @@ -464,7 +464,7 @@ pub async fn toggle_main_window_fullscreen( request: ToggleMainWindowFullscreenRequest, ) -> Result { #[cfg(target_env = "ohos")] - { + { Err("Do not support the toggle main_window_fullscreen".to_string()) } #[cfg(not(target_env = "ohos"))] @@ -474,15 +474,15 @@ pub async fn toggle_main_window_fullscreen( return Err("Main window not found".to_string()); }; - let current_fullscreen = window.is_fullscreen().map_err(|error| { - format!("Failed to read main window fullscreen state: {}", error) - })?; - let current_maximized = window.is_maximized().map_err(|error| { - format!("Failed to read main window maximize state: {}", error) - })?; - let restore_maximized_after_fullscreen = *crate::api::system_api::main_window_fullscreen_restore_maximized() - .lock() - .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())?; + let current_fullscreen = window + .is_fullscreen() + .map_err(|error| format!("Failed to read main window fullscreen state: {}", error))?; + let current_maximized = window + .is_maximized() + .map_err(|error| format!("Failed to read main window maximize state: {}", error))?; + let restore_maximized_after_fullscreen = *main_window_fullscreen_restore_maximized() + .lock() + .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())?; let transition = crate::api::system_api::plan_main_window_fullscreen_transition( current_fullscreen, @@ -505,12 +505,8 @@ pub async fn toggle_main_window_fullscreen( .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = transition.next_restore_maximized_after_fullscreen; - return Ok(crate::api::system_api::read_main_window_fullscreen_response( - &window, - true, - false, - )); - } + return Ok(read_main_window_fullscreen_response(&window, true, false)); + } window .set_fullscreen(false) @@ -538,7 +534,7 @@ pub async fn toggle_main_window_fullscreen( restored_maximized, )) } - + } fn apply_main_window_fullscreen_monitor_bounds( @@ -556,14 +552,14 @@ fn apply_main_window_fullscreen_monitor_bounds( .map_err(|error| format!("Failed to read current monitor for fullscreen: {}", error))? .or_else(|| app.primary_monitor().ok().flatten()) .ok_or_else(|| "Failed to resolve monitor for fullscreen".to_string())?; - + window .set_position(Position::Physical(*monitor.position())) .map_err(|error| format!("Failed to align fullscreen window position: {}", error))?; window .set_size(Size::Physical(*monitor.size())) .map_err(|error| format!("Failed to align fullscreen window size: {}", error))?; - + Ok(()) } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 809c9e6e33..61f5b5acdf 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -20,7 +20,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; use tauri::Manager; @@ -221,6 +221,10 @@ pub fn run() { /// Tauri entry point. pub async fn _run() { let startup_started = Instant::now(); + let startup_trace_id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| format!("desktop-{}", duration.as_millis())) + .unwrap_or_else(|_| "desktop-unknown".to_string()); let mut startup_timings = TimingCollector::default(); let in_debug = cfg!(debug_assertions) || std::env::var("DEBUG").unwrap_or_default() == "1"; let log_config = logging::LogConfig::new(in_debug); @@ -322,6 +326,11 @@ pub async fn _run() { .manage(terminal_state) .setup(move |app| { let setup_started = Instant::now(); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=tauri_setup_start, since_process_start_ms={}", + startup_trace_id, + elapsed_ms(startup_started) + ); #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { @@ -426,16 +435,34 @@ pub async fn _run() { let app_handle = app.handle().clone(); let window_started = Instant::now(); - theme::create_main_window(&app_handle); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=main_window_create_start", + startup_trace_id + ); + theme::create_main_window(&app_handle, &startup_trace_id); + let window_duration_ms = elapsed_ms(window_started); log::debug!( "Desktop startup step completed: step=create_main_window, duration_ms={}", - elapsed_ms(window_started) + window_duration_ms + ); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=main_window_create_end, duration_ms={}", + startup_trace_id, + window_duration_ms ); bitfun_webdriver::maybe_start(app_handle.clone()); + let setup_duration_ms = elapsed_ms(setup_started); + let since_process_start_ms = elapsed_ms(startup_started); log::debug!( "Desktop startup timing: phase=tauri_setup, duration_ms={}, since_process_start_ms={}", - elapsed_ms(setup_started), - elapsed_ms(startup_started) + setup_duration_ms, + since_process_start_ms + ); + log::debug!( + "Desktop startup trace event: trace_id={}, phase=tauri_setup_end, duration_ms={}, since_process_start_ms={}", + startup_trace_id, + setup_duration_ms, + since_process_start_ms ); #[cfg(target_os = "macos")] @@ -579,6 +606,8 @@ pub async fn _run() { api::agentic_api::set_subagent_timeout, api::agentic_api::delete_session, api::agentic_api::restore_session, + api::agentic_api::restore_session_view, + api::agentic_api::restore_session_with_turns, api::agentic_api::list_sessions, api::agentic_api::confirm_tool_execution, api::agentic_api::reject_tool_execution, @@ -654,6 +683,7 @@ pub async fn _run() { get_clipboard_files, paste_files, get_config, + get_configs, computer_use_get_status, computer_use_request_permissions, computer_use_open_system_settings, @@ -693,6 +723,7 @@ pub async fn _run() { add_skill, delete_skill, git_is_repository, + git_get_repository_basic, git_get_repository, git_get_status, git_get_branches, @@ -973,6 +1004,7 @@ pub async fn _run() { api::miniapp_api::miniapp_draft_host_call, api::miniapp_api::miniapp_draft_worker_stop, api::miniapp_api::miniapp_get_customization_metadata, + api::miniapp_api::miniapp_decline_builtin_update, api::miniapp_api::miniapp_ai_complete, api::miniapp_api::miniapp_ai_chat, api::miniapp_api::miniapp_ai_cancel, @@ -1169,7 +1201,7 @@ async fn init_agentic_system() -> anyhow::Result<( coordination::DialogScheduler::new(coordinator.clone(), session_manager.clone()); coordinator.set_scheduler_notifier(scheduler.outcome_sender()); coordinator.set_round_preempt_source(scheduler.preempt_monitor()); - coordinator.set_round_steering_source(scheduler.steering_monitor()); + coordinator.set_round_injection_source(scheduler.round_injection_monitor()); coordination::set_global_scheduler(scheduler.clone()); let cron_service = diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 78d2d513be..5baae8d07f 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -5,12 +5,12 @@ use chrono::Local; use serde::Serialize; use std::path::PathBuf; use std::sync::{ - OnceLock, atomic::{AtomicU8, Ordering}, + OnceLock, }; use std::thread; -use tauri::{Runtime, plugin::TauriPlugin}; -use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy, fern}; +use tauri::{plugin::TauriPlugin, Runtime}; +use tauri_plugin_log::{fern, RotationStrategy, Target, TargetKind, TimezoneStrategy}; const SESSION_DIR_PATTERN: &str = r"^\d{8}T\d{6}$"; const MAX_LOG_SESSIONS: usize = 10; diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 2fca4665b9..78ada21650 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,10 +1,12 @@ //! Theme System use std::sync::{OnceLock, RwLock}; +use std::time::Instant; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; use log::{debug, error, warn}; +use tauri::webview::PageLoadEvent; use tauri::{Manager, WebviewUrl}; const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; @@ -263,12 +265,15 @@ impl ThemeConfig { "system" } - pub fn generate_init_script(&self) -> String { + pub fn generate_init_script(&self, startup_trace_id: &str) -> String { let theme_type = if self.is_light { "light" } else { "dark" }; + let startup_trace_id_json = serde_json::to_string(startup_trace_id) + .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); format!( r#" (function() {{ + window.__BITFUN_STARTUP_TRACE_ID__ = {startup_trace_id_json}; function applyTheme() {{ var root = document.documentElement; if (!root) return false; @@ -289,8 +294,7 @@ impl ThemeConfig { if (document.body) {{ document.body.style.backgroundColor = '{bg_primary}'; }} - - console.log('[Theme] Pre-injected theme: {id}'); + return true; }} @@ -311,6 +315,7 @@ impl ThemeConfig { bg_secondary = self.bg_secondary, bg_scene = self.bg_scene, text_primary = self.text_primary, + startup_trace_id_json = startup_trace_id_json, ) } @@ -323,10 +328,15 @@ impl ThemeConfig { } } -pub fn create_main_window(app_handle: &tauri::AppHandle) { +pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) { + let total_started_at = Instant::now(); let theme = ThemeConfig::load_from_config(); let bg_color = theme.to_tauri_color(); - let init_script = theme.generate_init_script(); + let init_script = theme.generate_init_script(startup_trace_id); + debug!( + "Main window creation step completed: step=prepare_theme duration_ms={}", + total_started_at.elapsed().as_millis() + ); let main_url = if cfg!(debug_assertions) { match "http://localhost:1422".parse() { @@ -339,12 +349,34 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { } else { WebviewUrl::App("index.html".into()) }; + let main_url_kind = match &main_url { + WebviewUrl::External(_) => "external", + WebviewUrl::App(_) => "app", + _ => "other", + }; #[allow(unused_mut)] let mut builder = tauri::WebviewWindowBuilder::new(app_handle, "main", main_url) .background_color(bg_color) .accept_first_mouse(true) - .initialization_script(&init_script); + .initialization_script(&init_script) + .on_page_load({ + let startup_trace_id = startup_trace_id.to_string(); + let total_started_at = total_started_at; + move |_window, payload| { + let event = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "finished", + }; + debug!( + "Main window page load event: trace_id={}, event={}, url={}, since_create_start_ms={}", + startup_trace_id, + event, + payload.url(), + total_started_at.elapsed().as_millis() + ); + } + }); // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); @@ -363,8 +395,15 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { builder = builder.decorations(false); } + let build_started_at = Instant::now(); match builder.build() { Ok(window) => { + debug!( + "Main window creation step completed: step=build url_kind={} duration_ms={} total_duration_ms={}", + main_url_kind, + build_started_at.elapsed().as_millis(), + total_started_at.elapsed().as_millis() + ); #[cfg(any(debug_assertions, feature = "devtools"))] { if std::env::var("BITFUN_OPEN_DEVTOOLS") @@ -379,7 +418,11 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { let _ = window; } Err(e) => { - error!("Failed to create main window: {}", e); + error!( + "Failed to create main window: error={} duration_ms={}", + e, + total_started_at.elapsed().as_millis() + ); } } } @@ -558,6 +601,9 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( #[cfg(not(target_env = "ohos"))] { let _guard = agent_companion_window_ops().lock().await; + let started_at = Instant::now(); + let _guard = agent_companion_window_ops().lock().await; + debug!("Agent companion window show requested"); // Reuse any existing window: never destroy here. A previous implementation destroyed // whenever `is_visible` was false, which raced with another `show` that had built the @@ -574,6 +620,25 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( })?; return Ok(()); } + // Reuse any existing window: never destroy here. A previous implementation destroyed + // whenever `is_visible` was false, which raced with another `show` that had built the + // window but not called `show()` yet (or with `hide`), producing duplicate pets or + // stuck windows. + if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { + if let Err(e) = window.unminimize() { + warn!("Failed to unminimize Agent companion window: {}", e); + } + position_agent_companion_window(&app, &window); + window.show().map_err(|e| { + error!("Failed to show Agent companion window: {}", e); + format!("Failed to show Agent companion window: {}", e) + })?; + debug!( + "Agent companion window reused: total_duration_ms={}", + started_at.elapsed().as_millis() + ); + return Ok(()); + } let url = app_url("?bitfunWindow=agent-companion"); let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) @@ -596,6 +661,42 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( .visible(false) .accept_first_mouse(true) .background_color(tauri::window::Color(0, 0, 0, 0)); + let url = app_url("?bitfunWindow=agent-companion"); + let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) + .title("BitFun Agent Companion") + .inner_size( + AGENT_COMPANION_WINDOW_MIN_SIZE, + AGENT_COMPANION_WINDOW_MIN_SIZE, + ) + .max_inner_size( + AGENT_COMPANION_WINDOW_MAX_WIDTH, + AGENT_COMPANION_WINDOW_MAX_HEIGHT, + ) + .min_inner_size(1.0, 1.0) + .resizable(false) + .decorations(false) + .transparent(true) + .always_on_top(true) + .skip_taskbar(true) + .shadow(false) + .visible(false) + .accept_first_mouse(true) + .background_color(tauri::window::Color(0, 0, 0, 0)) + .on_page_load({ + let started_at = started_at; + move |_window, payload| { + let event = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "finished", + }; + debug!( + "Agent companion window page load event: event={}, url={}, since_show_request_ms={}", + event, + payload.url(), + started_at.elapsed().as_millis() + ); + } + }); builder = builder.disable_drag_drop_handler(); @@ -603,6 +704,20 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( error!("Failed to create Agent companion window: {}", e); format!("Failed to create Agent companion window: {}", e) })?; + let build_started_at = Instant::now(); + let window = builder.build().map_err(|e| { + error!( + "Failed to create Agent companion window: error={} duration_ms={}", + e, + build_started_at.elapsed().as_millis() + ); + format!("Failed to create Agent companion window: {}", e) + })?; + debug!( + "Agent companion window creation step completed: step=build duration_ms={} total_duration_ms={}", + build_started_at.elapsed().as_millis(), + started_at.elapsed().as_millis() + ); position_agent_companion_window(&app, &window); @@ -610,6 +725,16 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; + let show_started_at = Instant::now(); + window.show().map_err(|e| { + error!("Failed to show Agent companion window: {}", e); + format!("Failed to show Agent companion window: {}", e) + })?; + debug!( + "Agent companion window shown: show_duration_ms={} total_duration_ms={}", + show_started_at.elapsed().as_millis(), + started_at.elapsed().as_millis() + ); Ok(()) } @@ -718,5 +843,4 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { { Err("Failed to show main window".to_string()) } - } diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index d7923a606d..f4a912cfcb 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -114,7 +114,7 @@ pub async fn initialize(workspace: Option) -> anyhow::Result String { - builtin_client_ids().collect::>().join(", ") -} - pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option { let preset = builtin_acp_client_preset(client_id)?; Some(AcpClientConfig { @@ -75,65 +71,10 @@ pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option Option { - let preset = builtin_acp_client_preset(client_id)?; - Some(render_shell_command(preset.command, preset.args)) -} - -pub(crate) fn remote_command_for_builtin_client_in_workspace( - client_id: &str, - workspace_path: &str, -) -> Option { - let command = remote_command_for_builtin_client(client_id)?; - let workspace_path = workspace_path.trim(); - if workspace_path.is_empty() { - return Some(command); - } - Some(format!( - "cd {} && {}", - shell_escape(workspace_path), - command - )) -} - -fn render_shell_command(command: &str, args: &[&str]) -> String { - std::iter::once(command) - .chain(args.iter().copied()) - .map(shell_escape) - .collect::>() - .join(" ") -} - -fn shell_escape(value: &str) -> String { - if value.chars().all(|ch| { - ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') - }) { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn builds_remote_builtin_command_for_opencode() { - assert_eq!( - remote_command_for_builtin_client("opencode").as_deref(), - Some("opencode acp") - ); - } - - #[test] - fn builds_remote_builtin_command_for_npx_adapter() { - assert_eq!( - remote_command_for_builtin_client("codex").as_deref(), - Some("npx --yes @zed-industries/codex-acp@latest") - ); - } - #[test] fn returns_default_config_for_builtin_client() { let config = default_config_for_builtin_client("claude-code").expect("builtin config"); @@ -144,17 +85,4 @@ mod tests { vec!["--yes", "@zed-industries/claude-code-acp@latest"] ); } - - #[test] - fn shell_escape_quotes_spaces() { - assert_eq!(shell_escape("hello world"), "'hello world'"); - } - - #[test] - fn builds_remote_builtin_command_in_workspace() { - assert_eq!( - remote_command_for_builtin_client_in_workspace("opencode", "/tmp/my repo").as_deref(), - Some("cd '/tmp/my repo' && opencode acp") - ); - } } diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index 93edeede4c..f93d74e7ee 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -33,26 +33,26 @@ use tokio::process::{Child, Command}; use tokio::sync::{oneshot, Mutex, RwLock}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use super::builtin_clients::{ - builtin_acp_client_preset, builtin_client_ids, default_config_for_builtin_client, - remote_command_for_builtin_client, remote_command_for_builtin_client_in_workspace, - supported_remote_acp_clients, -}; +use super::builtin_clients::{builtin_client_ids, default_config_for_builtin_client}; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; use super::requirements::{ acp_requirement_spec, apply_command_environment, install_npm_cli_package, - predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, - probe_remote_npx_adapter, resolve_configured_command, + install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, + probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; -use super::stream::{acp_dispatch_to_stream_events, AcpClientStreamEvent, AcpStreamRoundTracker}; +use super::stream::{ + acp_dispatch_to_stream_events_with_tracker, AcpClientStreamEvent, AcpStreamRoundTracker, + AcpToolCallTracker, +}; use super::tool::AcpAgentTool; const CONFIG_PATH: &str = "acp_clients"; @@ -362,21 +362,35 @@ impl AcpClientService { BitFunError::service("SSH manager is not available for remote ACP".to_string()) })?; - let mut ids = builtin_client_ids() - .map(ToString::to_string) - .collect::>(); + let config_file = self.load_config_file().await?; + let mut ids = config_file.acp_clients.keys().cloned().collect::>(); + for id in builtin_client_ids() { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.to_string()); + } + } ids.sort(); let mut probes = Vec::with_capacity(ids.len()); for id in ids { - let spec = acp_requirement_spec(&id, None); - let tool = - probe_remote_executable(&ssh_manager, remote_connection_id, spec.tool_command) - .await; + let config = resolve_config_for_client(&config_file, &id, Some(remote_connection_id)); + let spec = acp_requirement_spec(&id, config.as_ref()); + let tool = probe_remote_executable( + &ssh_manager, + remote_connection_id, + spec.tool_command, + config.as_ref().map(|config| &config.env), + ) + .await; let adapter = match spec.adapter { Some(adapter) => Some( - probe_remote_npx_adapter(&ssh_manager, remote_connection_id, adapter.package) - .await, + probe_remote_npx_adapter( + &ssh_manager, + remote_connection_id, + adapter.package, + config.as_ref().map(|config| &config.env), + ) + .await, ), None => None, }; @@ -432,9 +446,17 @@ impl AcpClientService { predownload_npm_adapter(adapter.package, adapter.bin).await } - pub async fn install_client_cli(self: &Arc, client_id: &str) -> BitFunResult<()> { - let configs = self.load_configs().await?; - let spec = acp_requirement_spec(client_id, configs.get(client_id)); + pub async fn install_client_cli( + self: &Arc, + client_id: &str, + remote_connection_id: Option<&str>, + ) -> BitFunResult<()> { + let remote_connection_id = remote_connection_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let config_file = self.load_config_file().await?; + let config = resolve_config_for_client(&config_file, client_id, remote_connection_id); + let spec = acp_requirement_spec(client_id, config.as_ref()); let package = spec.install_package.ok_or_else(|| { BitFunError::config(format!( "ACP client '{}' does not have a known CLI installer", @@ -442,7 +464,17 @@ impl AcpClientService { )) })?; - install_npm_cli_package(package).await + if let Some(remote_connection_id) = remote_connection_id { + let remote_manager = get_remote_workspace_manager().ok_or_else(|| { + BitFunError::service("Remote workspace manager is not initialized".to_string()) + })?; + let ssh_manager = remote_manager.get_ssh_manager().await.ok_or_else(|| { + BitFunError::service("SSH manager is not available for remote ACP".to_string()) + })?; + install_remote_npm_cli_package(&ssh_manager, remote_connection_id, package).await + } else { + install_npm_cli_package(package).await + } } pub async fn start_client_for_session( @@ -572,29 +604,30 @@ impl AcpClientService { connection_for_task.sessions.clear(); }); - let (cx, agent_capabilities) = - match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, cx_rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => { - connect_task.abort(); - self.cleanup_failed_startup(connection_id).await; - return Err(BitFunError::service(format!( - "ACP client '{}' exited before initialization completed", - client_id - ))); - } - Err(_) => { - warn!( + let (cx, agent_capabilities) = match tokio::time::timeout(CLIENT_STARTUP_TIMEOUT, cx_rx) + .await + { + Ok(Ok(result)) => result, + Ok(Err(_)) => { + connect_task.abort(); + self.cleanup_failed_startup(connection_id).await; + return Err(BitFunError::service(format!( + "ACP client '{}' exited before initialization completed", + client_id + ))); + } + Err(_) => { + warn!( "ACP client startup timed out during initialize: id={} connection_id={} timeout_secs={}", client_id, connection_id, CLIENT_STARTUP_TIMEOUT_SECS ); - connect_task.abort(); - self.cleanup_failed_startup(connection_id).await; - return Err(startup_timeout_error(client_id, "initialize")); - } - }; + connect_task.abort(); + self.cleanup_failed_startup(connection_id).await; + return Err(startup_timeout_error(client_id, "initialize")); + } + }; *connection.connection.write().await = Some(cx); *connection.agent_capabilities.write().await = Some(agent_capabilities); *connection.status.write().await = AcpClientStatus::Running; @@ -768,6 +801,7 @@ impl AcpClientService { self.config_service .set_config(CONFIG_PATH, canonical_value) .await?; + self.remote_capability_store.clear().await?; self.initialize_all().await } @@ -1017,11 +1051,17 @@ impl AcpClientService { .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; active.send_prompt(prompt).map_err(protocol_error)?; let mut round_tracker = AcpStreamRoundTracker::new(); + let mut tool_call_tracker = AcpToolCallTracker::new(); loop { match active.read_update().await.map_err(protocol_error)? { SessionMessage::SessionMessage(dispatch) => { - for event in acp_dispatch_to_stream_events(dispatch).await? { + for event in acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tool_call_tracker, + ) + .await? + { for event in round_tracker.apply(event) { on_event(event)?; } @@ -1307,10 +1347,7 @@ impl AcpClientService { Err(_) => { warn!( "ACP client startup timed out: id={} connection_id={} phase={} timeout_secs={}", - client.client_id, - client.id, - phase, - CLIENT_STARTUP_TIMEOUT_SECS + client.client_id, client.id, phase, CLIENT_STARTUP_TIMEOUT_SECS ); self.cleanup_failed_startup(&client.id).await; Err(agent_client_protocol::util::internal_error( @@ -1367,7 +1404,11 @@ impl AcpClientService { } async fn load_configs(&self) -> BitFunResult> { - Ok(parse_config_value(self.load_config_value().await?)?.acp_clients) + Ok(self.load_config_file().await?.acp_clients) + } + + async fn load_config_file(&self) -> BitFunResult { + parse_config_value(self.load_config_value().await?) } async fn load_config_value(&self) -> BitFunResult { @@ -1535,7 +1576,7 @@ impl AcpClientService { )> { match remote_connection_id { Some(remote_connection_id) => self - .start_remote_transport(client_id, workspace_path, remote_connection_id) + .start_remote_transport(client_id, config, workspace_path, remote_connection_id) .await .map(|transport| (transport, None)), None => self @@ -1548,22 +1589,11 @@ impl AcpClientService { async fn start_remote_transport( &self, client_id: &str, + config: &AcpClientConfig, workspace_path: Option<&str>, remote_connection_id: &str, ) -> BitFunResult> { - let command = workspace_path - .map(str::trim) - .filter(|value| !value.is_empty()) - .and_then(|workspace_path| { - remote_command_for_builtin_client_in_workspace(client_id, workspace_path) - }) - .or_else(|| remote_command_for_builtin_client(client_id)) - .ok_or_else(|| { - BitFunError::config(format!( - "Remote ACP currently supports only built-in clients: {}", - supported_remote_acp_clients() - )) - })?; + let command = render_remote_client_command(config, workspace_path)?; let remote_manager = get_remote_workspace_manager().ok_or_else(|| { BitFunError::service("Remote workspace manager is not initialized".to_string()) })?; @@ -1597,25 +1627,19 @@ impl AcpClientService { .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - let remote_builtin = remote_connection_id - .as_deref() - .and_then(|_| builtin_acp_client_preset(client_id)) - .is_some(); - let mut config = self - .load_configs() - .await? - .remove(client_id) - .or_else(|| { - if remote_connection_id.is_some() { - default_config_for_builtin_client(client_id) - } else { - None - } - }) - .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; - - if remote_builtin { - config.enabled = true; + let is_remote = remote_connection_id.is_some(); + let config_file = self.load_config_file().await?; + let config = + resolve_config_for_client(&config_file, client_id, remote_connection_id.as_deref()) + .ok_or_else(|| { + BitFunError::NotFound(format!("ACP client not found: {}", client_id)) + })?; + + if config.command.trim().is_empty() { + return Err(BitFunError::config(format!( + "ACP client command is empty: {}", + client_id + ))); } if !config.enabled { return Err(BitFunError::config(format!( @@ -1624,7 +1648,7 @@ impl AcpClientService { ))); } - if remote_connection_id.is_some() { + if is_remote { ensure_remote_client_supported(client_id, workspace_path)?; } @@ -1635,8 +1659,20 @@ impl AcpClientService { } } -fn ensure_remote_client_supported( +fn resolve_config_for_client( + config_file: &AcpClientConfigFile, client_id: &str, + remote_connection_id: Option<&str>, +) -> Option { + config_file + .acp_clients + .get(client_id) + .cloned() + .or_else(|| remote_connection_id.and_then(|_| default_config_for_builtin_client(client_id))) +} + +fn ensure_remote_client_supported( + _client_id: &str, workspace_path: Option<&str>, ) -> BitFunResult<()> { if workspace_path @@ -1648,14 +1684,41 @@ fn ensure_remote_client_supported( )); } - if builtin_acp_client_preset(client_id).is_none() { - return Err(BitFunError::config(format!( - "Remote ACP currently supports only built-in clients: {}", - supported_remote_acp_clients() - ))); + Ok(()) +} + +fn render_remote_client_command( + config: &AcpClientConfig, + workspace_path: Option<&str>, +) -> BitFunResult { + let command = config.command.trim(); + if command.is_empty() { + return Err(BitFunError::config( + "ACP client command is empty".to_string(), + )); } - Ok(()) + let mut command_parts = Vec::new(); + command_parts.push(shell_escape(command)); + command_parts.extend(config.args.iter().map(|arg| shell_escape(arg))); + + let mut parts = Vec::new(); + parts.push("exec".to_string()); + let env_assignments = render_remote_env_assignments(&config.env); + if !env_assignments.is_empty() { + parts.push("env".to_string()); + parts.extend(env_assignments); + } + parts.extend(command_parts); + + let command = parts.join(" "); + let workspace_path = workspace_path.map(str::trim).unwrap_or_default(); + let body = if workspace_path.is_empty() { + command + } else { + format!("cd {} && {}", shell_escape(workspace_path), command) + }; + Ok(remote_user_shell_command(&body)) } fn current_unix_timestamp_ms() -> u64 { @@ -1979,9 +2042,7 @@ fn startup_timeout_error_message(client_id: &str, phase: &str) -> String { } fn is_startup_timeout_error(error: &BitFunError) -> bool { - error - .to_string() - .contains(STARTUP_TIMEOUT_ERROR_PREFIX) + error.to_string().contains(STARTUP_TIMEOUT_ERROR_PREFIX) } fn select_permission_by_kind( @@ -2069,4 +2130,59 @@ mod tests { "ACP startup timed out: client 'codex' exceeded 60s during initialize and was terminated. Please try again after the client is ready." ); } + + #[test] + fn renders_remote_client_command_from_config() { + let config = AcpClientConfig { + name: Some("Custom".to_string()), + command: "custom-acp".to_string(), + args: vec!["--stdio".to_string(), "with space".to_string()], + env: HashMap::from([ + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("INVALID-NAME".to_string(), "ignored".to_string()), + ]), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }; + + let command = render_remote_client_command(&config, Some("/srv/my repo")).expect("command"); + assert!(command.starts_with("bash -lc ")); + assert!(command.contains(".nvm/nvm.sh")); + assert!(command.contains( + "cd '\\''/srv/my repo'\\'' && exec env PATH=/remote/bin:/usr/bin custom-acp --stdio '\\''with space'\\''" + )); + } + + #[test] + fn resolves_remote_client_config_from_global_config() { + let config_file = AcpClientConfigFile { + acp_clients: HashMap::from([( + "codex".to_string(), + AcpClientConfig { + name: Some("Codex".to_string()), + command: "npx".to_string(), + args: vec![ + "--yes".to_string(), + "@zed-industries/codex-acp@latest".to_string(), + ], + env: HashMap::from([("BASE".to_string(), "1".to_string())]), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + }, + )]), + }; + + let resolved = resolve_config_for_client(&config_file, "codex", Some("huawei-server")) + .expect("config"); + + assert_eq!(resolved.command, "npx"); + assert_eq!( + resolved.args, + vec!["--yes", "@zed-industries/codex-acp@latest"] + ); + assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); + assert!(resolved.enabled); + } } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index af6a6891f9..58959c3e9d 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -3,6 +3,7 @@ mod config; mod manager; mod remote_capability_store; mod remote_session; +mod remote_shell; mod requirements; mod session_options; mod session_persistence; diff --git a/src/crates/acp/src/client/remote_capability_store.rs b/src/crates/acp/src/client/remote_capability_store.rs index 759b71c419..2c9d894d3c 100644 --- a/src/crates/acp/src/client/remote_capability_store.rs +++ b/src/crates/acp/src/client/remote_capability_store.rs @@ -69,6 +69,14 @@ impl RemoteAcpCapabilityStore { self.persist(entries).await } + pub(crate) async fn clear(&self) -> BitFunResult<()> { + { + let mut guard = self.snapshots.write().await; + guard.clear(); + } + self.persist(Vec::new()).await + } + async fn persist( &self, snapshots: Vec, diff --git a/src/crates/acp/src/client/remote_shell.rs b/src/crates/acp/src/client/remote_shell.rs new file mode 100644 index 0000000000..c3c1162bd5 --- /dev/null +++ b/src/crates/acp/src/client/remote_shell.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; + +const REMOTE_ENV_BOOTSTRAP: &str = r#"__bitfun_home="${HOME:-}" +if [ -n "$__bitfun_home" ]; then + if [ -s "$__bitfun_home/.nvm/nvm.sh" ]; then + . "$__bitfun_home/.nvm/nvm.sh" >/dev/null 2>&1 + fi + for __bitfun_dir in "$__bitfun_home/.local/bin" "$__bitfun_home/.cargo/bin" "$__bitfun_home/.npm-global/bin"; do + if [ -d "$__bitfun_dir" ]; then + PATH="$__bitfun_dir:$PATH" + fi + done +fi +export PATH"#; + +pub(super) fn remote_user_shell_command(body: &str) -> String { + format!( + "bash -lc {}", + shell_escape(&format!("{REMOTE_ENV_BOOTSTRAP}\n{body}")) + ) +} + +pub(super) fn render_remote_env_assignments(env: &HashMap) -> Vec { + let mut entries = env + .iter() + .filter(|(key, _)| is_shell_env_key(key)) + .collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries + .into_iter() + .map(|(key, value)| format!("{key}={}", shell_escape(value))) + .collect() +} + +pub(super) fn shell_escape(value: &str) -> String { + if value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') + }) { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn is_shell_env_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_env_assignments_use_valid_keys_in_stable_order() { + let env = HashMap::from([ + ("INVALID-NAME".to_string(), "ignored".to_string()), + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("ACP_HOME".to_string(), "/tmp/acp home".to_string()), + ]); + + assert_eq!( + render_remote_env_assignments(&env), + vec![ + "ACP_HOME='/tmp/acp home'".to_string(), + "PATH=/remote/bin:/usr/bin".to_string(), + ] + ); + } + + #[test] + fn remote_user_shell_command_loads_common_user_toolchains() { + let command = remote_user_shell_command("command -v codex"); + + assert!(command.starts_with("bash -lc ")); + assert!(command.contains(".nvm/nvm.sh")); + assert!(command.contains(".local/bin")); + assert!(command.contains("command -v codex")); + } +} diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs index ca87ad5562..8789628a2f 100644 --- a/src/crates/acp/src/client/requirements.rs +++ b/src/crates/acp/src/client/requirements.rs @@ -4,12 +4,13 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::time::Duration; -use bitfun_core::service::remote_ssh::SSHConnectionManager; +use bitfun_core::service::remote_ssh::{SSHCommandOptions, SSHConnectionManager}; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use tokio::process::Command; use super::builtin_clients::builtin_acp_client_preset; use super::config::{AcpClientConfig, AcpRequirementProbeItem}; +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; const REQUIREMENT_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const ADAPTER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); @@ -156,6 +157,7 @@ pub(crate) async fn probe_remote_executable( ssh_manager: &SSHConnectionManager, connection_id: &str, command: &str, + env: Option<&HashMap>, ) -> AcpRequirementProbeItem { let mut item = AcpRequirementProbeItem { name: command.to_string(), @@ -165,7 +167,9 @@ pub(crate) async fn probe_remote_executable( error: None, }; - let resolve_command = format!("command -v {}", shell_escape(command)); + let env_prefix = render_remote_env_prefix(env); + let resolve_command = + remote_user_shell_command(&format!("{env_prefix}command -v {}", shell_escape(command))); match ssh_manager .execute_command(connection_id, &resolve_command) .await @@ -191,7 +195,8 @@ pub(crate) async fn probe_remote_executable( } if item.installed { - let version_command = format!("{} --version", shell_escape(command)); + let version_command = + remote_user_shell_command(&format!("{env_prefix}{} --version", shell_escape(command))); match ssh_manager .execute_command(connection_id, &version_command) .await @@ -216,6 +221,7 @@ pub(crate) async fn probe_remote_npx_adapter( ssh_manager: &SSHConnectionManager, connection_id: &str, package: &str, + env: Option<&HashMap>, ) -> AcpRequirementProbeItem { let mut item = AcpRequirementProbeItem { name: package.to_string(), @@ -225,9 +231,10 @@ pub(crate) async fn probe_remote_npx_adapter( error: None, }; - let resolve_command = "command -v npx"; + let env_prefix = render_remote_env_prefix(env); + let resolve_command = remote_user_shell_command(&format!("{env_prefix}command -v npx")); match ssh_manager - .execute_command(connection_id, resolve_command) + .execute_command(connection_id, &resolve_command) .await { Ok((stdout, _stderr, exit_code)) if exit_code == 0 => { @@ -306,6 +313,45 @@ pub(crate) async fn install_npm_cli_package(package: &str) -> BitFunResult<()> { } } +pub(crate) async fn install_remote_npm_cli_package( + ssh_manager: &SSHConnectionManager, + connection_id: &str, + package: &str, +) -> BitFunResult<()> { + let command = remote_user_shell_command(&format!("npm install -g {}", shell_escape(package))); + let timeout_ms = u64::try_from(CLI_INSTALL_TIMEOUT.as_millis()).unwrap_or(u64::MAX); + match ssh_manager + .execute_command_with_options( + connection_id, + &command, + SSHCommandOptions { + timeout_ms: Some(timeout_ms), + cancellation_token: None, + }, + ) + .await + { + Ok(output) if output.exit_code == 0 && !output.timed_out && !output.interrupted => Ok(()), + Ok(output) if output.timed_out => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': command timed out", + package + ))), + Ok(output) if output.interrupted => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': command was cancelled", + package + ))), + Ok(output) => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': {}", + package, + remote_command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to install remote ACP agent CLI '{}': {}", + package, error + ))), + } +} + pub(crate) fn resolve_configured_command( command: &str, extra_env: &HashMap, @@ -403,14 +449,15 @@ fn truncate_error(value: String) -> String { format!("{}...", value.chars().take(MAX_LEN).collect::()) } -fn shell_escape(value: &str) -> String { - if value.chars().all(|ch| { - ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '-' | '_' | ':' | '=' | '@') - }) { - value.to_string() - } else { - format!("'{}'", value.replace('\'', "'\\''")) +fn render_remote_env_prefix(env: Option<&HashMap>) -> String { + let Some(env) = env else { + return String::new(); + }; + let assignments = render_remote_env_assignments(env); + if assignments.is_empty() { + return String::new(); } + format!("{} ", assignments.join(" ")) } fn find_executable(command: &str) -> Option { @@ -595,4 +642,18 @@ mod tests { let _ = std::fs::remove_dir_all(&test_dir); assert_eq!(found, Some(executable)); } + + #[test] + fn remote_env_prefix_uses_valid_keys_in_stable_order() { + let env = HashMap::from([ + ("INVALID-NAME".to_string(), "ignored".to_string()), + ("PATH".to_string(), "/remote/bin:/usr/bin".to_string()), + ("ACP_HOME".to_string(), "/tmp/acp home".to_string()), + ]); + + assert_eq!( + render_remote_env_prefix(Some(&env)), + "ACP_HOME='/tmp/acp home' PATH=/remote/bin:/usr/bin " + ); + } } diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs index acf80f176d..024fcfb204 100644 --- a/src/crates/acp/src/client/stream.rs +++ b/src/crates/acp/src/client/stream.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use agent_client_protocol::schema::{ ContentBlock, ContentChunk, SessionNotification, SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus, ToolCallUpdate, @@ -34,6 +36,19 @@ pub(super) struct AcpStreamRoundTracker { last_item_kind: Option, } +#[derive(Debug, Default)] +pub(super) struct AcpToolCallTracker { + calls: HashMap, +} + +#[derive(Debug, Clone)] +struct AcpToolCallSnapshot { + title: String, + tool_name: String, + raw_input: Option, + kind: Option, +} + impl AcpStreamRoundTracker { pub(super) fn new() -> Self { Self::default() @@ -82,8 +97,9 @@ impl AcpStreamRoundTracker { } } -pub async fn acp_dispatch_to_stream_events( +pub(super) async fn acp_dispatch_to_stream_events_with_tracker( dispatch: agent_client_protocol::Dispatch, + tracker: &mut AcpToolCallTracker, ) -> BitFunResult> { let mut events = Vec::new(); MatchDispatch::new(dispatch) @@ -100,12 +116,10 @@ pub async fn acp_dispatch_to_stream_events( } } SessionUpdate::ToolCall(tool_call) => { - events.extend(acp_tool_call_events(tool_call)); + events.extend(acp_tool_call_events(tool_call, tracker)); } SessionUpdate::ToolCallUpdate(tool_call_update) => { - if let Some(event) = acp_tool_call_update_event(tool_call_update) { - events.push(event); - } + events.extend(acp_tool_call_update_events(tool_call_update, tracker)); } _ => {} } @@ -124,16 +138,73 @@ fn content_chunk_text(chunk: ContentChunk) -> Option { } } -fn acp_tool_call_events(tool_call: ToolCall) -> Vec { +impl AcpToolCallTracker { + pub(super) fn new() -> Self { + Self::default() + } + + fn upsert_from_call( + &mut self, + tool_id: String, + title: String, + raw_input: Option, + kind: Option, + ) -> AcpToolCallSnapshot { + let tool_name = acp_tool_name(&title, raw_input.as_ref(), kind.as_ref()); + let snapshot = AcpToolCallSnapshot { + title, + tool_name, + raw_input, + kind, + }; + self.calls.insert(tool_id, snapshot.clone()); + snapshot + } + + fn update_from_fields( + &mut self, + tool_id: &str, + title: Option, + raw_input: Option, + kind: Option, + ) -> AcpToolCallSnapshot { + let previous = self.calls.get(tool_id).cloned(); + let title = title + .or_else(|| previous.as_ref().map(|snapshot| snapshot.title.clone())) + .unwrap_or_else(|| tool_id.to_string()); + let raw_input = raw_input.or_else(|| { + previous + .as_ref() + .and_then(|snapshot| snapshot.raw_input.clone()) + }); + let kind = kind.or_else(|| previous.as_ref().and_then(|snapshot| snapshot.kind.clone())); + let tool_name = acp_tool_name(&title, raw_input.as_ref(), kind.as_ref()); + let snapshot = AcpToolCallSnapshot { + title, + tool_name, + raw_input, + kind, + }; + self.calls.insert(tool_id.to_string(), snapshot.clone()); + snapshot + } +} + +fn acp_tool_call_events( + tool_call: ToolCall, + tracker: &mut AcpToolCallTracker, +) -> Vec { let tool_id = tool_call.tool_call_id.to_string(); - let tool_name = acp_tool_name( - &tool_call.title, - tool_call.raw_input.as_ref(), - Some(&tool_call.kind), + let snapshot = tracker.upsert_from_call( + tool_id.clone(), + tool_call.title.clone(), + tool_call.raw_input.clone(), + Some(tool_call.kind.clone()), ); + let tool_name = snapshot.tool_name; let params = normalize_tool_params( &tool_name, - tool_call.raw_input.clone().unwrap_or_else(|| { + snapshot.raw_input.clone().unwrap_or_else(|| { serde_json::json!({ "title": tool_call.title, "kind": format!("{:?}", tool_call.kind), @@ -185,18 +256,31 @@ fn acp_tool_call_events(tool_call: ToolCall) -> Vec { events } -fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { +fn acp_tool_call_update_events( + update: ToolCallUpdate, + tracker: &mut AcpToolCallTracker, +) -> Vec { let tool_id = update.tool_call_id.to_string(); - let title = update.fields.title.unwrap_or_else(|| tool_id.clone()); - let tool_name = acp_tool_name( - &title, - update.fields.raw_input.as_ref(), - update.fields.kind.as_ref(), + let snapshot = tracker.update_from_fields( + &tool_id, + update.fields.title.clone(), + update.fields.raw_input.clone(), + update.fields.kind.clone(), ); + let tool_name = snapshot.tool_name.clone(); match update.fields.status { Some(ToolCallStatus::Completed) => { - Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + let mut events = Vec::new(); + if let Some(raw_input) = snapshot.raw_input { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params: normalize_tool_params(&tool_name, raw_input), + timeout_seconds: None, + })); + } + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { tool_id, tool_name, result: acp_tool_result_value( @@ -210,10 +294,20 @@ fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { - Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + let mut events = Vec::new(); + if let Some(raw_input) = snapshot.raw_input { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params: normalize_tool_params(&tool_name, raw_input), + timeout_seconds: None, + })); + } + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { tool_id, tool_name, error: acp_tool_error_text( @@ -225,33 +319,37 @@ fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { let params = normalize_tool_params( &tool_name, - update.fields.raw_input.unwrap_or_else(|| { + snapshot.raw_input.unwrap_or_else(|| { serde_json::json!({ - "title": title, + "title": snapshot.title, }) }), ); - Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { tool_id, tool_name, params, timeout_seconds: None, - })) + })] } - None => update.fields.raw_input.map(|params| { - let params = normalize_tool_params(&tool_name, params); - AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id, - tool_name, - params, - timeout_seconds: None, + None => snapshot + .raw_input + .map(|params| { + let params = normalize_tool_params(&tool_name, params); + vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: None, + })] }) - }), + .unwrap_or_default(), } } @@ -305,6 +403,7 @@ fn protocol_error(error: impl std::fmt::Display) -> BitFunError { #[cfg(test)] mod tests { use super::*; + use agent_client_protocol::schema::{ToolCallUpdateFields, ToolKind}; use serde_json::json; fn tool_event(id: &str) -> AcpClientStreamEvent { @@ -375,4 +474,53 @@ mod tests { assert_eq!(event_kinds(&events), vec!["round", "text", "text"]); } + + #[test] + fn tool_call_tracker_replays_cached_input_before_completed_update() { + let mut tracker = AcpToolCallTracker::new(); + let in_progress = ToolCallUpdate::new( + "tool-1", + ToolCallUpdateFields::new() + .title("Edit file") + .kind(ToolKind::Edit) + .status(ToolCallStatus::InProgress) + .raw_input(json!({ + "path": "src/lib.rs", + "oldString": "before", + "newString": "after" + })), + ); + let completed = ToolCallUpdate::new( + "tool-1", + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .raw_output(json!({ "success": true })), + ); + + let first = acp_tool_call_update_events(in_progress, &mut tracker); + assert!(matches!( + first.first(), + Some(AcpClientStreamEvent::ToolEvent( + ToolEventData::Started { .. } + )) + )); + + let second = acp_tool_call_update_events(completed, &mut tracker); + assert_eq!(second.len(), 2); + match &second[0] { + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_name, params, .. + }) => { + assert_eq!(tool_name, "Edit"); + assert_eq!(params["file_path"], "src/lib.rs"); + assert_eq!(params["old_string"], "before"); + assert_eq!(params["new_string"], "after"); + } + other => panic!("expected cached Started event, got {other:?}"), + } + assert!(matches!( + second[1], + AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { .. }) + )); + } } diff --git a/src/crates/agent-stream/src/lib.rs b/src/crates/agent-stream/src/lib.rs index ad32cdd81f..fdbcb83d48 100644 --- a/src/crates/agent-stream/src/lib.rs +++ b/src/crates/agent-stream/src/lib.rs @@ -6,10 +6,7 @@ use bitfun_ai_adapters::tool_call_accumulator::{ FinalizedToolCall, PendingToolCalls, ToolCallBoundary, ToolCallStreamKey, }; use bitfun_ai_adapters::{GeminiUsage, UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; -use bitfun_events::{ - AgenticEvent, AgenticEventPriority as EventPriority, - SubagentParentInfo as EventSubagentParentInfo, ToolEventData, -}; +use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority, ToolEventData}; use futures::{Stream, StreamExt}; use log::{debug, error, trace}; use serde::{Deserialize, Serialize}; @@ -42,27 +39,6 @@ impl ToolCall { } } -/// Parent task metadata needed to scope stream events for subagents. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SubagentParentInfo { - #[serde(rename = "toolCallId")] - pub tool_call_id: String, - #[serde(rename = "sessionId")] - pub session_id: String, - #[serde(rename = "dialogTurnId")] - pub dialog_turn_id: String, -} - -impl From for EventSubagentParentInfo { - fn from(info: SubagentParentInfo) -> Self { - Self { - tool_call_id: info.tool_call_id, - session_id: info.session_id, - dialog_turn_id: info.dialog_turn_id, - } - } -} - /// Stream-processor specific error that avoids depending on core runtime errors. #[derive(Debug, Clone, PartialEq, Eq)] pub enum StreamProcessorError { @@ -234,8 +210,6 @@ struct StreamContext { session_id: String, dialog_turn_id: String, round_id: String, - event_subagent_parent_info: Option, - subagent_parent_info: Option, // Accumulated results full_thinking: String, @@ -267,15 +241,11 @@ impl StreamContext { session_id: String, dialog_turn_id: String, round_id: String, - subagent_parent_info: Option, ) -> Self { - let event_subagent_parent_info = subagent_parent_info.clone().map(|info| info.into()); Self { session_id, dialog_turn_id, round_id, - event_subagent_parent_info, - subagent_parent_info, full_thinking: String::new(), reasoning_content_present: false, thinking_signature: None, @@ -463,7 +433,6 @@ impl StreamProcessor { round_id: ctx.round_id.clone(), content: String::new(), is_end: true, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), }, Some(EventPriority::Normal), ) @@ -500,9 +469,9 @@ impl StreamProcessor { self.graceful_shutdown( ctx.session_id.clone(), ctx.dialog_turn_id.clone(), + ctx.round_id.clone(), ctx.tool_calls.clone(), reason, - ctx.subagent_parent_info.clone(), ) .await; } @@ -512,9 +481,9 @@ impl StreamProcessor { &self, session_id: String, turn_id: String, + round_id: String, tool_calls: Vec, reason: String, - subagent_parent_info: Option, ) { debug!( "Starting graceful shutdown: session_id={}, reason={}", @@ -523,7 +492,6 @@ impl StreamProcessor { let is_user_cancellation = reason.contains("cancelled") || reason.contains("cancelled"); let tool_call_count = tool_calls.len(); - let event_subagent_parent_info = subagent_parent_info.map(|info| info.clone().into()); // 1. Cleanup all tool calls for tool_call in tool_calls { @@ -563,8 +531,8 @@ impl StreamProcessor { AgenticEvent::ToolEvent { session_id: session_id.clone(), turn_id: turn_id.clone(), + round_id: round_id.clone(), tool_event, - subagent_parent_info: event_subagent_parent_info.clone(), }, Some(EventPriority::High), ) @@ -577,7 +545,6 @@ impl StreamProcessor { AgenticEvent::DialogTurnCancelled { session_id: session_id.clone(), turn_id: turn_id.clone(), - subagent_parent_info: event_subagent_parent_info.clone(), } } else { AgenticEvent::DialogTurnFailed { @@ -586,7 +553,6 @@ impl StreamProcessor { error: reason, error_category: None, error_detail: None, - subagent_parent_info: event_subagent_parent_info.clone(), } }; let _ = self @@ -609,6 +575,7 @@ impl StreamProcessor { total_token_count: response_usage.total_token_count, reasoning_token_count: response_usage.reasoning_token_count, cached_content_token_count: response_usage.cached_content_token_count, + cache_creation_token_count: response_usage.cache_creation_token_count, }); debug!( "Received token usage stats: input={}, output={}, total={}", @@ -649,11 +616,11 @@ impl StreamProcessor { AgenticEvent::ToolEvent { session_id: ctx.session_id.clone(), turn_id: ctx.dialog_turn_id.clone(), + round_id: ctx.round_id.clone(), tool_event: ToolEventData::EarlyDetected { tool_id: early_detected.tool_id, tool_name: early_detected.tool_name, }, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), }, None, ) @@ -669,12 +636,12 @@ impl StreamProcessor { AgenticEvent::ToolEvent { session_id: ctx.session_id.clone(), turn_id: ctx.dialog_turn_id.clone(), + round_id: ctx.round_id.clone(), tool_event: ToolEventData::ParamsPartial { tool_id: params_partial.tool_id, tool_name: params_partial.tool_name, params: params_partial.params_chunk, }, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), }, None, ) @@ -700,7 +667,6 @@ impl StreamProcessor { turn_id: ctx.dialog_turn_id.clone(), round_id: ctx.round_id.clone(), text, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), }, None, ) @@ -726,7 +692,6 @@ impl StreamProcessor { round_id: ctx.round_id.clone(), content: thinking_content, is_end: false, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), }, None, ) @@ -793,7 +758,6 @@ impl StreamProcessor { /// * `session_id` - Session ID /// * `dialog_turn_id` - Dialog turn ID /// * `round_id` - Model round ID - /// * `subagent_parent_info` - Subagent parent info /// * `cancellation_token` - Cancellation token #[allow(clippy::too_many_arguments)] pub async fn process_stream( @@ -804,7 +768,6 @@ impl StreamProcessor { session_id: String, dialog_turn_id: String, round_id: String, - subagent_parent_info: Option, cancellation_token: &tokio_util::sync::CancellationToken, ) -> Result { self.process_stream_with_options( @@ -814,7 +777,6 @@ impl StreamProcessor { session_id, dialog_turn_id, round_id, - subagent_parent_info, cancellation_token, StreamProcessOptions::default(), ) @@ -830,12 +792,10 @@ impl StreamProcessor { session_id: String, dialog_turn_id: String, round_id: String, - subagent_parent_info: Option, cancellation_token: &tokio_util::sync::CancellationToken, options: StreamProcessOptions, ) -> Result { - let mut ctx = - StreamContext::new(session_id, dialog_turn_id, round_id, subagent_parent_info); + let mut ctx = StreamContext::new(session_id, dialog_turn_id, round_id); // Start SSE log collector (if raw_sse_rx is provided) let sse_collector = if let Some(mut rx) = raw_sse_rx { let collector = Arc::new(tokio::sync::Mutex::new(SseLogCollector::new( @@ -1071,6 +1031,7 @@ mod tests { total_token_count: total_tokens, reasoning_token_count: None, cached_content_token_count: None, + cache_creation_token_count: None, } } @@ -1099,7 +1060,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &cancellation_token, StreamProcessOptions { recover_partial_on_cancel: true, @@ -1152,7 +1112,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1187,7 +1146,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1223,7 +1181,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1270,7 +1227,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1305,7 +1261,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1358,7 +1313,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1431,7 +1385,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await @@ -1464,7 +1417,6 @@ mod tests { "session_1".to_string(), "turn_1".to_string(), "round_1".to_string(), - None, &CancellationToken::new(), ) .await diff --git a/src/crates/agent-stream/tests/common/stream_test_harness.rs b/src/crates/agent-stream/tests/common/stream_test_harness.rs index b182642268..6d2c0ee398 100644 --- a/src/crates/agent-stream/tests/common/stream_test_harness.rs +++ b/src/crates/agent-stream/tests/common/stream_test_harness.rs @@ -167,7 +167,6 @@ pub async fn run_stream_fixture_with_options( "session_fixture".to_string(), "turn_fixture".to_string(), "round_fixture".to_string(), - None, &cancellation_token, ) .await; diff --git a/src/crates/agent-stream/tests/stream_processor_openai.rs b/src/crates/agent-stream/tests/stream_processor_openai.rs index 1f4f408317..21ec50ad86 100644 --- a/src/crates/agent-stream/tests/stream_processor_openai.rs +++ b/src/crates/agent-stream/tests/stream_processor_openai.rs @@ -33,9 +33,10 @@ async fn openai_fixture_keeps_collecting_tool_args_across_usage_chunks() { matches!( event, AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, .. - } if tool_id == "call_1" && tool_name == "tool_a" + } if round_id == "round_fixture" && tool_id == "call_1" && tool_name == "tool_a" ) }); assert!(early_detected, "expected early tool detection event"); @@ -45,9 +46,13 @@ async fn openai_fixture_keeps_collecting_tool_args_across_usage_chunks() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::ParamsPartial { params, .. }, .. - } => Some(params.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(params.as_str()) + }, _ => None, }) .collect(); @@ -116,9 +121,13 @@ async fn openai_fixture_keeps_malformed_tool_arguments_invalid() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, .. }, .. - } => Some(tool_id.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(tool_id.as_str()) + }, _ => None, }) .collect(); @@ -205,9 +214,10 @@ async fn openai_fixture_reattaches_id_only_prelude_to_following_payload_chunk() matches!( event, AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, .. - } if tool_id == "call_1" && tool_name == "tool_a" + } if round_id == "round_fixture" && tool_id == "call_1" && tool_name == "tool_a" ) }); assert!( @@ -220,9 +230,13 @@ async fn openai_fixture_reattaches_id_only_prelude_to_following_payload_chunk() .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::ParamsPartial { params, .. }, .. - } => Some(params.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(params.as_str()) + }, _ => None, }) .collect(); @@ -346,9 +360,13 @@ async fn openai_fixture_filters_orphan_id_only_block_when_it_shares_chunk_with_f .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, .. }, .. - } => Some(tool_id.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(tool_id.as_str()) + }, _ => None, }) .collect(); @@ -359,12 +377,16 @@ async fn openai_fixture_filters_orphan_id_only_block_when_it_shares_chunk_with_f .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::ParamsPartial { tool_id, params, .. }, .. - } => Some((tool_id.as_str(), params.as_str())), + } => { + assert_eq!(round_id, "round_fixture"); + Some((tool_id.as_str(), params.as_str())) + }, _ => None, }) .collect(); @@ -411,9 +433,13 @@ async fn openai_fixture_routes_interleaved_tool_args_by_index() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, .. }, .. - } => Some(tool_id.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(tool_id.as_str()) + }, _ => None, }) .collect(); @@ -424,12 +450,16 @@ async fn openai_fixture_routes_interleaved_tool_args_by_index() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::ParamsPartial { tool_id, params, .. }, .. - } => Some((tool_id.as_str(), params.as_str())), + } => { + assert_eq!(round_id, "round_fixture"); + Some((tool_id.as_str(), params.as_str())) + }, _ => None, }) .collect(); @@ -464,9 +494,10 @@ async fn openai_fixture_accepts_tool_call_without_type_field() { matches!( event, AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, .. - } if tool_id == "call_abc123" && tool_name == "test_tool" + } if round_id == "round_fixture" && tool_id == "call_abc123" && tool_name == "test_tool" ) }); assert!( @@ -504,9 +535,13 @@ async fn openai_fixture_ignores_trailing_empty_tool_args_finish_chunk() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, .. }, .. - } => Some(tool_id.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(tool_id.as_str()) + }, _ => None, }) .collect(); @@ -517,9 +552,13 @@ async fn openai_fixture_ignores_trailing_empty_tool_args_finish_chunk() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::ParamsPartial { params, .. }, .. - } => Some(params.as_str()), + } => { + assert_eq!(round_id, "round_fixture"); + Some(params.as_str()) + }, _ => None, }) .collect(); diff --git a/src/crates/agent-stream/tests/stream_replay_regressions.rs b/src/crates/agent-stream/tests/stream_replay_regressions.rs index 04861d333b..f2be430681 100644 --- a/src/crates/agent-stream/tests/stream_replay_regressions.rs +++ b/src/crates/agent-stream/tests/stream_replay_regressions.rs @@ -93,12 +93,16 @@ async fn replays_structurally_empty_openai_reasoning_content_with_tool_call() { .iter() .filter_map(|event| match event { AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::ParamsPartial { tool_id, params, .. }, .. - } => Some((tool_id.as_str(), params.as_str())), + } => { + assert_eq!(round_id, "round_fixture"); + Some((tool_id.as_str(), params.as_str())) + }, _ => None, }) .collect(); @@ -180,9 +184,10 @@ async fn replays_structurally_empty_anthropic_thinking_with_signature_and_tool_u matches!( event, AgenticEvent::ToolEvent { + round_id, tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, .. - } if tool_id == "toolu_ds_1" && tool_name == "lookup_status" + } if round_id == "round_fixture" && tool_id == "toolu_ds_1" && tool_name == "lookup_status" ) }); assert!( diff --git a/src/crates/agent-tools/AGENTS.md b/src/crates/agent-tools/AGENTS.md new file mode 100644 index 0000000000..02cfeb694a --- /dev/null +++ b/src/crates/agent-tools/AGENTS.md @@ -0,0 +1,34 @@ +# agent-tools Agent Guide + +Scope: this guide applies to `src/crates/agent-tools`. + +`bitfun-agent-tools` owns portable tool contracts. It must stay independent of +the product tool runtime. + +## Guardrails + +- Do not depend on `bitfun-core`, concrete service crates, `tool-packs`, app + crates, Tauri, Git, MCP, network clients, or CLI UI dependencies. +- This crate may own `ToolResult`, validation DTOs, runtime restriction DTOs, + path-resolution DTOs, generic/static/dynamic provider contracts, pure + manifest/exposure helpers, generic contextual prompt-manifest resolver + contracts, generic catalog snapshot provider contracts, generic GetToolSpec + catalog provider/detail/summary helpers, provider-backed GetToolSpec runtime + facades, and `ToolContextFacts` / `PortableToolContextProvider`. +- This crate may own generic provider containers such as + `StaticToolProviderGroup`, but concrete tool construction and product runtime + registration stay outside this crate until H1 explicitly moves an owner. +- Do not move `ToolUseContext`, concrete tools, workspace services, cancellation + tokens, snapshot decoration, collapsed unlock state, product registry + snapshot access, or concrete `GetToolSpecTool` execution here without H1 + approval and equivalence tests. +- Provider-specific wire serialization belongs in AI adapters, not in these + provider-neutral contracts. + +## Verification + +```bash +cargo test -p bitfun-agent-tools +cargo test -p bitfun-agent-tools --test tool_contracts +node scripts/check-core-boundaries.mjs +``` diff --git a/src/crates/agent-tools/src/framework.rs b/src/crates/agent-tools/src/framework.rs index 806ec10997..17042906d9 100644 --- a/src/crates/agent-tools/src/framework.rs +++ b/src/crates/agent-tools/src/framework.rs @@ -6,8 +6,9 @@ use bitfun_core_types::ToolImageAttachment; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; use std::fmt; +use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -31,6 +32,513 @@ pub struct DynamicToolInfo { pub mcp: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ToolWorkspaceKind { + Local, + Remote, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ToolContextFacts { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dialog_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, + #[serde(default)] + pub runtime_tool_restrictions: ToolRuntimeRestrictions, +} + +pub trait PortableToolContextProvider: Send + Sync { + fn tool_context_facts(&self) -> ToolContextFacts; +} + +pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ToolExposure { + Expanded, + Collapsed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolManifestDefinition { + pub name: String, + pub description: String, + pub parameters: Value, +} + +impl ToolManifestDefinition { + pub fn new(name: impl Into, description: impl Into, parameters: Value) -> Self { + Self { + name: name.into(), + description: description.into(), + parameters, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PromptVisibleToolManifestItem { + Expanded(ToolManifestDefinition), + Collapsed { + name: String, + short_description: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolManifestPolicyTool { + pub name: String, + pub default_exposure: ToolExposure, + pub available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolManifestPolicyResolution { + pub allowed_tool_names: Vec, + pub expanded_tool_names: Vec, + pub collapsed_tool_names: Vec, +} + +#[derive(Clone)] +pub struct ContextualVisibleTools { + pub allowed_tool_names: Vec, + pub expanded_tools: Vec>, + pub collapsed_tool_names: Vec, + pub collapsed_tools: Vec>, +} + +#[derive(Clone)] +pub struct ContextualToolManifest { + pub allowed_tool_names: Vec, + pub expanded_tools: Vec>, + pub collapsed_tool_names: Vec, + pub collapsed_tools: Vec>, + pub tool_definitions: Vec, +} + +pub fn resolve_tool_manifest_policy( + tool_snapshot: &[ToolManifestPolicyTool], + allowed_tools: &[String], + exposure_overrides: &IndexMap, + get_tool_spec_tool_name: &str, +) -> ToolManifestPolicyResolution { + let allowed_set = allowed_tools + .iter() + .map(String::as_str) + .collect::>(); + let mut allowed_tool_names = allowed_tools.to_vec(); + let mut expanded_tool_names = Vec::new(); + let mut collapsed_tool_names = Vec::new(); + + for tool in tool_snapshot { + if !tool.available || !allowed_set.contains(tool.name.as_str()) { + continue; + } + + let exposure = exposure_overrides + .get(&tool.name) + .copied() + .unwrap_or(tool.default_exposure); + match exposure { + ToolExposure::Expanded => expanded_tool_names.push(tool.name.clone()), + ToolExposure::Collapsed => collapsed_tool_names.push(tool.name.clone()), + } + } + + if !collapsed_tool_names.is_empty() { + if !allowed_tool_names + .iter() + .any(|name| name == get_tool_spec_tool_name) + { + allowed_tool_names.push(get_tool_spec_tool_name.to_string()); + } + if tool_snapshot + .iter() + .any(|tool| tool.name == get_tool_spec_tool_name) + { + expanded_tool_names.push(get_tool_spec_tool_name.to_string()); + } + } + + ToolManifestPolicyResolution { + allowed_tool_names, + expanded_tool_names, + collapsed_tool_names, + } +} + +pub fn build_tool_manifest_policy_tools( + tool_snapshot: &[ToolRef], + available_tool_names: &HashSet, +) -> Vec { + tool_snapshot + .iter() + .map(|tool| { + let name = tool.name().to_string(); + ToolManifestPolicyTool { + available: available_tool_names.contains(&name), + default_exposure: tool.default_exposure(), + name, + } + }) + .collect() +} + +fn tools_by_name( + tool_snapshot: &[ToolRef], + tool_names: &[String], +) -> Vec> { + tool_names + .iter() + .filter_map(|name| { + tool_snapshot + .iter() + .find(|tool| tool.name() == name) + .cloned() + }) + .collect() +} + +pub fn build_collapsed_tool_stub_definition( + tool_name: &str, + short_description: &str, +) -> ToolManifestDefinition { + ToolManifestDefinition::new( + tool_name, + format!( + "{} [This tool is collapsed. Do not call `{}` directly yet. First call `GetToolSpec` with {{\"tool_name\":\"{}\"}} to load its full description and input schema, then retry `{}` using the returned schema.]", + short_description, tool_name, tool_name, tool_name + ), + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": format!( + "Do not supply {} arguments here while the tool is collapsed. Use GetToolSpec with {{\"tool_name\":\"{}\"}} first.", + tool_name, + tool_name + ) + } + } + }), + ) +} + +pub fn build_get_tool_spec_collapsed_tool_entry( + tool_name: &str, + short_description: &str, +) -> String { + format!("- {}: {}", tool_name, short_description) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetToolSpecCollapsedToolSummary { + pub name: String, + pub short_description: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GetToolSpecDetail { + pub tool_name: String, + pub description: String, + pub input_schema: Value, +} + +impl GetToolSpecDetail { + pub fn to_value(&self) -> Value { + serde_json::json!({ + "tool_name": self.tool_name.clone(), + "description": self.description.clone(), + "input_schema": self.input_schema.clone(), + }) + } +} + +pub fn build_get_tool_spec_catalog_description( + collapsed_tools: &[GetToolSpecCollapsedToolSummary], +) -> String { + let collapsed_tools_list = if collapsed_tools.is_empty() { + "No additional tools are available.".to_string() + } else { + collapsed_tools + .iter() + .map(|tool| { + build_get_tool_spec_collapsed_tool_entry(&tool.name, &tool.short_description) + }) + .collect::>() + .join("\n") + }; + + build_get_tool_spec_description(&collapsed_tools_list) +} + +pub fn build_get_tool_spec_description(collapsed_tools_list: &str) -> String { + format!( + r#"Read usage instructions for additional tools. + +You have access to the additional tools listed below. These tools are collapsed: +their names may appear in the tool list, but you must not call them directly +until you have loaded their definition with GetToolSpec. + + +{} + + +Before using one of these tools, first call GetToolSpec with its exact tool name +to read its full description and input schema. If a direct call to a collapsed +tool fails with a message like "Tool 'Git' is collapsed", make the next tool +call `GetToolSpec` with `{{"tool_name":"Git"}}`, then retry the real tool after +reading the returned schema. + +After reading the returned definition, call the real tool directly using its own name. + +Do not call GetToolSpec again for a tool whose definition is already loaded in the current conversation. + +Example: +- Suppose the catalog includes a tool named `GetWeather` and you need to use it. +- First call `GetToolSpec` with `{{"tool_name":"GetWeather"}}` +- Then read the returned schema and call `GetWeather` itself with the appropriate arguments +"#, + collapsed_tools_list + ) +} + +pub fn get_tool_spec_input_schema() -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "required": ["tool_name"], + "properties": { + "tool_name": { + "type": "string", + "description": "Exact collapsed tool name to load, using the tool's canonical casing from the catalog (for example, \"Git\"). Do not pass a command such as \"git status\" or an operation such as \"status\" here." + } + } + }) +} + +pub fn get_tool_spec_short_description() -> String { + "Discover collapsed tools and read their detailed definitions.".to_string() +} + +pub fn get_tool_spec_is_readonly() -> bool { + true +} + +pub fn get_tool_spec_is_concurrency_safe(_input: Option<&Value>) -> bool { + true +} + +pub fn get_tool_spec_needs_permissions(_input: Option<&Value>) -> bool { + false +} + +pub fn render_get_tool_spec_tool_use_message(input: &Value) -> String { + let tool_name = input + .get("tool_name") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + format!("Reading tool spec for '{}'.", tool_name) +} + +pub fn validate_get_tool_spec_input(input: &Value) -> ValidationResult { + let Some(tool_name) = input.get("tool_name").and_then(|value| value.as_str()) else { + return ValidationResult { + result: false, + message: Some("tool_name is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + }; + + if tool_name.is_empty() { + return ValidationResult { + result: false, + message: Some("tool_name is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + ValidationResult::default() +} + +pub fn build_get_tool_spec_duplicate_load_hint(tool_name: &str) -> String { + format!( + "Tool '{}' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use '{}' directly.", + tool_name, tool_name + ) +} + +pub fn build_get_tool_spec_duplicate_load_result(tool_name: &str) -> ToolResult { + ToolResult::Result { + data: serde_json::json!({ + "tool_name": tool_name, + "already_loaded": true + }), + result_for_assistant: Some(build_get_tool_spec_duplicate_load_hint(tool_name)), + image_attachments: None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GetToolSpecExecutionError { + MissingToolName, + Detail(String), +} + +impl fmt::Display for GetToolSpecExecutionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GetToolSpecExecutionError::MissingToolName => write!(f, "tool_name is required"), + GetToolSpecExecutionError::Detail(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for GetToolSpecExecutionError {} + +#[derive(Debug, Clone)] +pub enum GetToolSpecExecutionPlan<'a> { + DuplicateLoad(ToolResult), + LoadDetail { tool_name: &'a str }, +} + +pub fn resolve_get_tool_spec_execution_plan<'a>( + input: &'a Value, + loaded_collapsed_tools: &[String], +) -> Result, GetToolSpecExecutionError> { + let tool_name = input + .get("tool_name") + .and_then(|value| value.as_str()) + .ok_or(GetToolSpecExecutionError::MissingToolName)?; + + if loaded_collapsed_tools + .iter() + .any(|loaded| loaded == tool_name) + { + return Ok(GetToolSpecExecutionPlan::DuplicateLoad( + build_get_tool_spec_duplicate_load_result(tool_name), + )); + } + + Ok(GetToolSpecExecutionPlan::LoadDetail { tool_name }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GetToolSpecLoadObservation<'a> { + pub tool_name: &'a str, + pub loaded_tool_name: Option<&'a str>, + pub is_error: bool, +} + +pub fn collect_loaded_collapsed_tool_names( + observations: &[GetToolSpecLoadObservation<'_>], + collapsed_tool_names: &[String], + get_tool_spec_tool_name: &str, +) -> Vec { + let collapsed_set: HashSet<&str> = collapsed_tool_names.iter().map(String::as_str).collect(); + let mut loaded = BTreeSet::new(); + + for observation in observations { + if observation.is_error || observation.tool_name != get_tool_spec_tool_name { + continue; + } + + let Some(tool_name) = observation.loaded_tool_name else { + continue; + }; + + if collapsed_set.contains(tool_name) { + loaded.insert(tool_name.to_string()); + } + } + + loaded.into_iter().collect() +} + +pub fn build_get_tool_spec_assistant_detail(description: &str, input_schema: &Value) -> String { + format!( + "\n{}\n\n\n{}\n", + escape_get_tool_spec_xml_text(description), + escape_get_tool_spec_xml_text(&input_schema.to_string()) + ) +} + +pub fn build_get_tool_spec_detail_result(detail: &GetToolSpecDetail) -> ToolResult { + ToolResult::Result { + data: detail.to_value(), + result_for_assistant: Some(build_get_tool_spec_assistant_detail( + &detail.description, + &detail.input_schema, + )), + image_attachments: None, + } +} + +fn escape_get_tool_spec_xml_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +pub fn tool_manifest_sort_rank(tool_name: &str) -> usize { + match tool_name { + "Task" => 1, + "Bash" => 2, + "TerminalControl" => 3, + "Glob" => 4, + "Grep" => 5, + "Read" => 6, + "Edit" => 7, + "Write" => 8, + "Delete" => 9, + "WebFetch" => 10, + "WebSearch" => 11, + "TodoWrite" => 12, + "Skill" => 13, + "Log" => 14, + GET_TOOL_SPEC_TOOL_NAME => 15, + "ControlHub" => 16, + _ => 100, + } +} + +pub fn sort_tool_manifest_definitions(tool_definitions: &mut [ToolManifestDefinition]) { + tool_definitions.sort_by_key(|tool| tool_manifest_sort_rank(&tool.name)); +} + +pub fn build_prompt_visible_tool_manifest_definitions( + items: &[PromptVisibleToolManifestItem], +) -> Vec { + let mut definitions = items + .iter() + .map(|item| match item { + PromptVisibleToolManifestItem::Expanded(definition) => definition.clone(), + PromptVisibleToolManifestItem::Collapsed { + name, + short_description, + } => build_collapsed_tool_stub_definition(name, short_description), + }) + .collect::>(); + sort_tool_manifest_definitions(&mut definitions); + definitions +} + #[async_trait] pub trait ToolRegistryItem: Send + Sync { fn name(&self) -> &str; @@ -39,6 +547,22 @@ pub trait ToolRegistryItem: Send + Sync { fn input_schema(&self) -> Value; + fn short_description(&self) -> String { + self.name().to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Expanded + } + + fn is_readonly(&self) -> bool { + false + } + + async fn is_enabled(&self) -> bool { + true + } + async fn input_schema_for_model(&self) -> Value { self.input_schema() } @@ -57,6 +581,463 @@ pub trait ToolRegistryItem: Send + Sync { } } +#[async_trait] +pub trait ContextualToolManifestItem: ToolRegistryItem +where + Context: Sync, +{ + async fn is_available_in_context(&self, _context: &Context) -> bool { + true + } + + async fn description_with_context(&self, _context: &Context) -> Result { + self.description().await + } + + async fn input_schema_for_model_with_context(&self, _context: &Context) -> Value { + self.input_schema_for_model().await + } +} + +#[async_trait] +pub trait ToolCatalogSnapshotProvider: Send + Sync { + async fn tool_snapshot(&self) -> Vec>; +} + +#[async_trait] +pub trait GetToolSpecCatalogProvider: Send + Sync +where + Context: Sync, +{ + async fn collapsed_tools_for_get_tool_spec( + &self, + context: Option<&Context>, + ) -> Result>, String>; +} + +pub fn summarize_get_tool_spec_collapsed_tools( + collapsed_tools: &[ToolRef], +) -> Vec { + collapsed_tools + .iter() + .map(|tool| GetToolSpecCollapsedToolSummary { + name: tool.name().to_string(), + short_description: tool.short_description(), + }) + .collect() +} + +pub async fn resolve_readonly_enabled_tools( + tool_snapshot: &[ToolRef], +) -> Vec> { + let mut readonly_tools = Vec::new(); + + for tool in tool_snapshot { + if tool.is_readonly() && tool.is_enabled().await { + readonly_tools.push(tool.clone()); + } + } + + readonly_tools +} + +pub struct ToolCatalogRuntime<'a, Tool: ?Sized, Context, Provider: ?Sized> { + provider: &'a Provider, + get_tool_spec_tool_name: &'a str, + _marker: PhantomData, +} + +impl<'a, Tool: ?Sized, Context, Provider: ?Sized> ToolCatalogRuntime<'a, Tool, Context, Provider> { + pub fn new(provider: &'a Provider, get_tool_spec_tool_name: &'a str) -> Self { + Self { + provider, + get_tool_spec_tool_name, + _marker: PhantomData, + } + } +} + +impl<'a, Tool, Context, Provider> ToolCatalogRuntime<'a, Tool, Context, Provider> +where + Tool: ToolRegistryItem + ?Sized, + Provider: ToolCatalogSnapshotProvider + ?Sized, +{ + pub async fn readonly_enabled_tools(&self) -> Vec> { + let tool_snapshot = self.provider.tool_snapshot().await; + resolve_readonly_enabled_tools(&tool_snapshot).await + } +} + +impl<'a, Tool, Context, Provider> ToolCatalogRuntime<'a, Tool, Context, Provider> +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, + Provider: ToolCatalogSnapshotProvider + ?Sized, +{ + pub async fn visible_tools( + &self, + allowed_tools: &[String], + exposure_overrides: &IndexMap, + context: &Context, + ) -> ContextualVisibleTools { + resolve_contextual_visible_tools_from_provider( + self.provider, + allowed_tools, + exposure_overrides, + context, + self.get_tool_spec_tool_name, + ) + .await + } + + pub async fn tool_manifest( + &self, + allowed_tools: &[String], + exposure_overrides: &IndexMap, + context: &Context, + ) -> ContextualToolManifest { + resolve_contextual_tool_manifest_from_provider( + self.provider, + allowed_tools, + exposure_overrides, + context, + self.get_tool_spec_tool_name, + ) + .await + } +} + +pub async fn resolve_get_tool_spec_detail( + collapsed_tools: &[ToolRef], + tool_name: &str, + context: &Context, + get_tool_spec_tool_name: &str, +) -> Result +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, +{ + let tool = collapsed_tools + .iter() + .find(|tool| tool.name() == tool_name) + .ok_or_else(|| { + format!("Tool '{tool_name}' is not an available collapsed tool in the current context") + })?; + + if tool.name() == get_tool_spec_tool_name { + return Err(format!("Tool '{tool_name}' cannot inspect itself")); + } + + let description = tool + .description_with_context(context) + .await + .unwrap_or_else(|_| format!("Tool: {}", tool.name())); + let input_schema = tool.input_schema_for_model_with_context(context).await; + + Ok(GetToolSpecDetail { + tool_name: tool_name.to_string(), + description, + input_schema, + }) +} + +pub async fn build_get_tool_spec_catalog_description_from_provider( + provider: &Provider, + context: Option<&Context>, +) -> String +where + Tool: ToolRegistryItem + ?Sized, + Context: Sync, + Provider: GetToolSpecCatalogProvider + ?Sized, +{ + let summaries = provider + .collapsed_tools_for_get_tool_spec(context) + .await + .map(|tools| summarize_get_tool_spec_collapsed_tools(&tools)) + .unwrap_or_default(); + + build_get_tool_spec_catalog_description(&summaries) +} + +pub async fn resolve_get_tool_spec_detail_from_provider( + provider: &Provider, + tool_name: &str, + context: &Context, + get_tool_spec_tool_name: &str, +) -> Result +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, + Provider: GetToolSpecCatalogProvider + ?Sized, +{ + let collapsed_tools = provider + .collapsed_tools_for_get_tool_spec(Some(context)) + .await?; + resolve_get_tool_spec_detail( + &collapsed_tools, + tool_name, + context, + get_tool_spec_tool_name, + ) + .await +} + +pub async fn resolve_get_tool_spec_execution_result_from_provider( + provider: &Provider, + input: &Value, + loaded_collapsed_tools: &[String], + context: &Context, + get_tool_spec_tool_name: &str, +) -> Result +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, + Provider: GetToolSpecCatalogProvider + ?Sized, +{ + match resolve_get_tool_spec_execution_plan(input, loaded_collapsed_tools)? { + GetToolSpecExecutionPlan::DuplicateLoad(result) => Ok(result), + GetToolSpecExecutionPlan::LoadDetail { tool_name } => { + let detail = resolve_get_tool_spec_detail_from_provider( + provider, + tool_name, + context, + get_tool_spec_tool_name, + ) + .await + .map_err(GetToolSpecExecutionError::Detail)?; + Ok(build_get_tool_spec_detail_result(&detail)) + } + } +} + +pub struct GetToolSpecRuntime<'a, Tool: ?Sized, Context, Provider: ?Sized> { + provider: &'a Provider, + tool_name: &'a str, + _marker: PhantomData, +} + +impl<'a, Tool: ?Sized, Context, Provider: ?Sized> GetToolSpecRuntime<'a, Tool, Context, Provider> { + pub fn new(provider: &'a Provider, tool_name: &'a str) -> Self { + Self { + provider, + tool_name, + _marker: PhantomData, + } + } + + pub fn name(&self) -> &str { + self.tool_name + } + + pub fn short_description(&self) -> String { + get_tool_spec_short_description() + } + + pub fn input_schema(&self) -> Value { + get_tool_spec_input_schema() + } + + pub fn is_readonly(&self) -> bool { + get_tool_spec_is_readonly() + } + + pub fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + get_tool_spec_is_concurrency_safe(input) + } + + pub fn needs_permissions(&self, input: Option<&Value>) -> bool { + get_tool_spec_needs_permissions(input) + } + + pub fn render_tool_use_message(&self, input: &Value) -> String { + render_get_tool_spec_tool_use_message(input) + } + + pub fn validate_input(&self, input: &Value) -> ValidationResult { + validate_get_tool_spec_input(input) + } +} + +impl<'a, Tool, Context, Provider> GetToolSpecRuntime<'a, Tool, Context, Provider> +where + Tool: ToolRegistryItem + ?Sized, + Context: Sync, + Provider: GetToolSpecCatalogProvider + ?Sized, +{ + pub async fn catalog_description(&self, context: Option<&Context>) -> String { + build_get_tool_spec_catalog_description_from_provider(self.provider, context).await + } +} + +impl<'a, Tool, Context, Provider> GetToolSpecRuntime<'a, Tool, Context, Provider> +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, + Provider: GetToolSpecCatalogProvider + ?Sized, +{ + pub async fn execute( + &self, + input: &Value, + loaded_collapsed_tools: &[String], + context: &Context, + ) -> Result { + resolve_get_tool_spec_execution_result_from_provider( + self.provider, + input, + loaded_collapsed_tools, + context, + self.tool_name, + ) + .await + } + + pub async fn call_results( + &self, + input: &Value, + loaded_collapsed_tools: &[String], + context: &Context, + ) -> Result, GetToolSpecExecutionError> { + self.execute(input, loaded_collapsed_tools, context) + .await + .map(|result| vec![result]) + } +} + +pub async fn resolve_contextual_visible_tools_from_provider( + provider: &Provider, + allowed_tools: &[String], + exposure_overrides: &IndexMap, + context: &Context, + get_tool_spec_tool_name: &str, +) -> ContextualVisibleTools +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, + Provider: ToolCatalogSnapshotProvider + ?Sized, +{ + let tool_snapshot = provider.tool_snapshot().await; + resolve_contextual_visible_tools( + &tool_snapshot, + allowed_tools, + exposure_overrides, + context, + get_tool_spec_tool_name, + ) + .await +} + +pub async fn resolve_contextual_tool_manifest_from_provider( + provider: &Provider, + allowed_tools: &[String], + exposure_overrides: &IndexMap, + context: &Context, + get_tool_spec_tool_name: &str, +) -> ContextualToolManifest +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, + Provider: ToolCatalogSnapshotProvider + ?Sized, +{ + let tool_snapshot = provider.tool_snapshot().await; + resolve_contextual_tool_manifest( + &tool_snapshot, + allowed_tools, + exposure_overrides, + context, + get_tool_spec_tool_name, + ) + .await +} + +pub async fn resolve_contextual_visible_tools( + tool_snapshot: &[ToolRef], + allowed_tools: &[String], + exposure_overrides: &IndexMap, + context: &Context, + get_tool_spec_tool_name: &str, +) -> ContextualVisibleTools +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, +{ + let mut available_tool_names = HashSet::new(); + for tool in tool_snapshot { + if tool.is_available_in_context(context).await { + available_tool_names.insert(tool.name().to_string()); + } + } + + let policy_tools = build_tool_manifest_policy_tools(tool_snapshot, &available_tool_names); + let policy = resolve_tool_manifest_policy( + &policy_tools, + allowed_tools, + exposure_overrides, + get_tool_spec_tool_name, + ); + let expanded_tools = tools_by_name(tool_snapshot, &policy.expanded_tool_names); + let collapsed_tools = tools_by_name(tool_snapshot, &policy.collapsed_tool_names); + + ContextualVisibleTools { + allowed_tool_names: policy.allowed_tool_names, + expanded_tools, + collapsed_tool_names: policy.collapsed_tool_names, + collapsed_tools, + } +} + +pub async fn resolve_contextual_tool_manifest( + tool_snapshot: &[ToolRef], + allowed_tools: &[String], + exposure_overrides: &IndexMap, + context: &Context, + get_tool_spec_tool_name: &str, +) -> ContextualToolManifest +where + Tool: ContextualToolManifestItem + ?Sized, + Context: Sync, +{ + let visible_tools = resolve_contextual_visible_tools( + tool_snapshot, + allowed_tools, + exposure_overrides, + context, + get_tool_spec_tool_name, + ) + .await; + + let mut manifest_items = Vec::with_capacity( + visible_tools.expanded_tools.len() + visible_tools.collapsed_tools.len(), + ); + for tool in &visible_tools.expanded_tools { + let description = tool + .description_with_context(context) + .await + .unwrap_or_else(|_| format!("Tool: {}", tool.name())); + let parameters = tool.input_schema_for_model_with_context(context).await; + + manifest_items.push(PromptVisibleToolManifestItem::Expanded( + ToolManifestDefinition::new(tool.name().to_string(), description, parameters), + )); + } + + for tool in &visible_tools.collapsed_tools { + manifest_items.push(PromptVisibleToolManifestItem::Collapsed { + name: tool.name().to_string(), + short_description: tool.short_description(), + }); + } + + let tool_definitions = build_prompt_visible_tool_manifest_definitions(&manifest_items); + + ContextualToolManifest { + allowed_tool_names: visible_tools.allowed_tool_names, + expanded_tools: visible_tools.expanded_tools, + collapsed_tool_names: visible_tools.collapsed_tool_names, + collapsed_tools: visible_tools.collapsed_tools, + tool_definitions, + } +} + #[derive(Debug, Clone)] struct DynamicToolMetadata { provider_id: String, @@ -74,6 +1055,89 @@ impl ToolDecorator for IdentityToolDecorator { pub type ToolRef = Arc; pub type ToolDecoratorRef = Arc>>; +pub trait SnapshotToolWrapper: Send + Sync { + fn wrap_for_snapshot_tracking(&self, tool: ToolRef) -> ToolRef; +} + +pub type SnapshotToolWrapperRef = Arc>; + +pub struct SnapshotToolDecorator { + wrapper: SnapshotToolWrapperRef, +} + +impl SnapshotToolDecorator { + pub fn new(wrapper: SnapshotToolWrapperRef) -> Self { + Self { wrapper } + } +} + +impl ToolDecorator> for SnapshotToolDecorator { + fn decorate(&self, tool: ToolRef) -> ToolRef { + self.wrapper.wrap_for_snapshot_tracking(tool) + } +} + +pub trait StaticToolProvider: Send + Sync { + fn provider_id(&self) -> &'static str; + + fn tools(&self) -> Vec>; +} + +pub struct StaticToolProviderGroup { + provider_id: &'static str, + tools: Vec>, +} + +impl StaticToolProviderGroup { + pub fn new(provider_id: &'static str, tools: Vec>) -> Self { + Self { provider_id, tools } + } +} + +impl StaticToolProvider for StaticToolProviderGroup { + fn provider_id(&self) -> &'static str { + self.provider_id + } + + fn tools(&self) -> Vec> { + self.tools.clone() + } +} + +pub struct ToolRuntimeAssembly { + tool_decorator: ToolDecoratorRef, +} + +impl Default for ToolRuntimeAssembly { + fn default() -> Self { + Self::new() + } +} + +impl ToolRuntimeAssembly { + pub fn new() -> Self { + Self::with_tool_decorator(Arc::new(IdentityToolDecorator)) + } + + pub fn with_tool_decorator(tool_decorator: ToolDecoratorRef) -> Self { + Self { tool_decorator } + } + + pub fn create_registry_from_static_providers( + &self, + providers: &[Provider], + ) -> ToolRegistry + where + Provider: StaticToolProvider, + { + let mut registry = ToolRegistry::with_tool_decorator(self.tool_decorator.clone()); + for provider in providers { + registry.install_static_provider(provider); + } + registry + } +} + pub struct ToolRegistry { tools: IndexMap>, dynamic_tools: IndexMap, @@ -124,6 +1188,15 @@ impl ToolRegistry { self.tools.insert(name, tool); } + pub fn install_static_provider(&mut self, provider: &Provider) + where + Provider: StaticToolProvider + ?Sized, + { + for tool in provider.tools() { + self.register_tool(tool); + } + } + pub fn unregister_mcp_server_tools(&mut self, server_id: &str) { let to_remove = self .dynamic_tools @@ -171,6 +1244,21 @@ impl ToolRegistry { .map(|metadata| metadata.info.clone()) } + pub fn is_tool_collapsed(&self, name: &str) -> bool { + self.tools + .get(name) + .is_some_and(|tool| tool.default_exposure() == ToolExposure::Collapsed) + } + + pub fn get_collapsed_tool_names(&self) -> Vec { + self.tools + .iter() + .filter_map(|(name, tool)| { + (tool.default_exposure() == ToolExposure::Collapsed).then(|| name.clone()) + }) + .collect() + } + pub fn get_tool_names(&self) -> Vec { self.tools.keys().cloned().collect() } diff --git a/src/crates/agent-tools/src/lib.rs b/src/crates/agent-tools/src/lib.rs index 4e2f358da0..10c46fd228 100644 --- a/src/crates/agent-tools/src/lib.rs +++ b/src/crates/agent-tools/src/lib.rs @@ -11,8 +11,31 @@ pub use bitfun_runtime_ports::{ DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, }; pub use framework::{ - DynamicMcpToolInfo, DynamicToolInfo, ToolPathBackend, ToolPathOperation, ToolPathPolicy, - ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, - ToolRestrictionError, ToolResult, ToolRuntimeRestrictions, ValidationResult, + build_collapsed_tool_stub_definition, build_get_tool_spec_assistant_detail, + build_get_tool_spec_catalog_description, build_get_tool_spec_catalog_description_from_provider, + build_get_tool_spec_collapsed_tool_entry, build_get_tool_spec_description, + build_get_tool_spec_detail_result, build_get_tool_spec_duplicate_load_hint, + build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, + build_tool_manifest_policy_tools, collect_loaded_collapsed_tool_names, + get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, + get_tool_spec_needs_permissions, get_tool_spec_short_description, + render_get_tool_spec_tool_use_message, resolve_contextual_tool_manifest, + resolve_contextual_tool_manifest_from_provider, resolve_contextual_visible_tools, + resolve_contextual_visible_tools_from_provider, resolve_get_tool_spec_detail, + resolve_get_tool_spec_detail_from_provider, resolve_get_tool_spec_execution_plan, + resolve_get_tool_spec_execution_result_from_provider, resolve_readonly_enabled_tools, + resolve_tool_manifest_policy, sort_tool_manifest_definitions, + summarize_get_tool_spec_collapsed_tools, tool_manifest_sort_rank, validate_get_tool_spec_input, + ContextualToolManifest, ContextualToolManifestItem, ContextualVisibleTools, DynamicMcpToolInfo, + DynamicToolInfo, GetToolSpecCatalogProvider, GetToolSpecCollapsedToolSummary, + GetToolSpecDetail, GetToolSpecExecutionError, GetToolSpecExecutionPlan, + GetToolSpecLoadObservation, GetToolSpecRuntime, PortableToolContextProvider, + PromptVisibleToolManifestItem, SnapshotToolDecorator, SnapshotToolWrapper, + SnapshotToolWrapperRef, StaticToolProvider, StaticToolProviderGroup, ToolCatalogRuntime, + ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, ToolExposure, + ToolManifestDefinition, ToolManifestPolicyResolution, ToolManifestPolicyTool, ToolPathBackend, + ToolPathOperation, ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, + ToolRenderOptions, ToolRestrictionError, ToolResult, ToolRuntimeAssembly, + ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, GET_TOOL_SPEC_TOOL_NAME, }; pub use input_validator::InputValidator; diff --git a/src/crates/agent-tools/tests/tool_contracts.rs b/src/crates/agent-tools/tests/tool_contracts.rs index 1cb2de965b..1d260c320a 100644 --- a/src/crates/agent-tools/tests/tool_contracts.rs +++ b/src/crates/agent-tools/tests/tool_contracts.rs @@ -1,10 +1,29 @@ use bitfun_agent_tools::{ - DynamicMcpToolInfo, DynamicToolInfo, InputValidator, ToolImageAttachment, ToolPathBackend, - ToolPathResolution, ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, ValidationResult, + build_collapsed_tool_stub_definition, build_get_tool_spec_assistant_detail, + build_get_tool_spec_catalog_description, build_get_tool_spec_catalog_description_from_provider, + build_get_tool_spec_collapsed_tool_entry, build_get_tool_spec_description, + build_get_tool_spec_detail_result, build_get_tool_spec_duplicate_load_hint, + build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, + collect_loaded_collapsed_tool_names, get_tool_spec_input_schema, + get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, + get_tool_spec_short_description, render_get_tool_spec_tool_use_message, + resolve_contextual_tool_manifest, resolve_contextual_tool_manifest_from_provider, + resolve_get_tool_spec_detail, resolve_get_tool_spec_detail_from_provider, + resolve_get_tool_spec_execution_result_from_provider, resolve_readonly_enabled_tools, + resolve_tool_manifest_policy, sort_tool_manifest_definitions, + summarize_get_tool_spec_collapsed_tools, validate_get_tool_spec_input, DynamicMcpToolInfo, + DynamicToolInfo, GetToolSpecCollapsedToolSummary, GetToolSpecExecutionError, + GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime, InputValidator, + PromptVisibleToolManifestItem, ToolContextFacts, ToolExposure, ToolImageAttachment, + ToolManifestDefinition, ToolManifestPolicyTool, ToolPathBackend, ToolPathResolution, + ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, + GET_TOOL_SPEC_TOOL_NAME, }; use bitfun_agent_tools::{ - DynamicToolDescriptor, DynamicToolProvider, PortResult, ToolDecorator, ToolRegistry, - ToolRegistryItem, + ContextualToolManifestItem, DynamicToolDescriptor, DynamicToolProvider, + GetToolSpecCatalogProvider, PortResult, PortableToolContextProvider, StaticToolProvider, + StaticToolProviderGroup, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolDecorator, + ToolDecoratorRef, ToolRegistry, ToolRegistryItem, ToolRuntimeAssembly, }; use serde_json::json; use std::path::PathBuf; @@ -134,6 +153,69 @@ fn runtime_restrictions_keep_allow_deny_semantics_without_core_dependency() { ); } +#[test] +fn tool_context_facts_keep_portable_wire_shape_without_runtime_handles() { + let facts = ToolContextFacts { + tool_call_id: Some("call-1".to_string()), + agent_type: Some("Agentic".to_string()), + session_id: Some("session-1".to_string()), + dialog_turn_id: Some("turn-1".to_string()), + workspace_kind: Some(ToolWorkspaceKind::Remote), + workspace_root: Some("/remote/workspace".to_string()), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + }; + + let value = serde_json::to_value(&facts).expect("serialize context facts"); + + assert_eq!(value["toolCallId"], "call-1"); + assert_eq!(value["agentType"], "Agentic"); + assert_eq!(value["sessionId"], "session-1"); + assert_eq!(value["dialogTurnId"], "turn-1"); + assert_eq!(value["workspaceKind"], "remote"); + assert_eq!(value["workspaceRoot"], "/remote/workspace"); + assert!(value.get("unlockedCollapsedTools").is_none()); + assert!(value.get("computer_use_host").is_none()); + assert!(value.get("workspace_services").is_none()); + assert!(value.get("cancellation_token").is_none()); + + let round_trip: ToolContextFacts = + serde_json::from_value(value).expect("deserialize context facts"); + assert_eq!(round_trip.workspace_kind, Some(ToolWorkspaceKind::Remote)); +} + +#[test] +fn portable_tool_context_provider_exposes_facts_only() { + struct FactsOnlyProvider { + facts: ToolContextFacts, + } + + impl PortableToolContextProvider for FactsOnlyProvider { + fn tool_context_facts(&self) -> ToolContextFacts { + self.facts.clone() + } + } + + let provider = FactsOnlyProvider { + facts: ToolContextFacts { + tool_call_id: Some("call-2".to_string()), + agent_type: Some("Agentic".to_string()), + session_id: Some("session-2".to_string()), + dialog_turn_id: None, + workspace_kind: Some(ToolWorkspaceKind::Local), + workspace_root: Some("/repo/project".to_string()), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + }, + }; + + let value = + serde_json::to_value(provider.tool_context_facts()).expect("serialize context facts"); + + assert_eq!(value["toolCallId"], "call-2"); + assert_eq!(value["workspaceKind"], "local"); + assert!(value.get("workspace_services").is_none()); + assert!(value.get("unlockedCollapsedTools").is_none()); +} + #[test] fn runtime_restrictions_keep_current_snake_case_wire_shape() { let value = json!({ @@ -224,9 +306,492 @@ fn dynamic_tool_provider_contract_is_available_from_agent_tools_boundary() { assert_decorator_contract::(); } +#[test] +fn tool_exposure_contract_keeps_lightweight_wire_shape() { + let collapsed = ToolExposure::Collapsed; + let value = serde_json::to_value(collapsed).expect("serialize exposure"); + + assert_eq!(value, json!("Collapsed")); + assert_eq!( + serde_json::from_value::(value).expect("deserialize exposure"), + ToolExposure::Collapsed + ); +} + +#[test] +fn tool_manifest_definition_keeps_lightweight_wire_shape() { + let definition = ToolManifestDefinition::new( + "Read", + "Read a file", + json!({ + "type": "object", + "properties": { + "file_path": { "type": "string" } + }, + "required": ["file_path"] + }), + ); + + let value = serde_json::to_value(&definition).expect("serialize definition"); + + assert_eq!(value["name"], json!("Read")); + assert_eq!(value["description"], json!("Read a file")); + assert_eq!(value["parameters"]["required"], json!(["file_path"])); + assert_eq!( + serde_json::from_value::(value).expect("deserialize definition"), + definition + ); +} + +#[test] +fn tool_manifest_policy_keeps_get_tool_spec_insertion_and_registry_order() { + let tools = vec![ + ToolManifestPolicyTool { + name: "Read".to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "WebSearch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ToolManifestPolicyTool { + name: "WebFetch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ToolManifestPolicyTool { + name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "HiddenUnavailable".to_string(), + default_exposure: ToolExposure::Expanded, + available: false, + }, + ]; + let allowed_tools = vec![ + "WebFetch".to_string(), + "Read".to_string(), + "WebSearch".to_string(), + "HiddenUnavailable".to_string(), + ]; + let overrides = Default::default(); + + let policy = + resolve_tool_manifest_policy(&tools, &allowed_tools, &overrides, GET_TOOL_SPEC_TOOL_NAME); + + assert_eq!( + policy.allowed_tool_names, + vec![ + "WebFetch", + "Read", + "WebSearch", + "HiddenUnavailable", + GET_TOOL_SPEC_TOOL_NAME, + ] + ); + assert_eq!( + policy.expanded_tool_names, + vec!["Read", GET_TOOL_SPEC_TOOL_NAME] + ); + assert_eq!(policy.collapsed_tool_names, vec!["WebSearch", "WebFetch"]); +} + +#[test] +fn tool_manifest_policy_preserves_explicit_get_tool_spec_duplicate_runtime_contract() { + let tools = vec![ + ToolManifestPolicyTool { + name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "WebFetch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ]; + let allowed_tools = vec![GET_TOOL_SPEC_TOOL_NAME.to_string(), "WebFetch".to_string()]; + let overrides = Default::default(); + + let policy = + resolve_tool_manifest_policy(&tools, &allowed_tools, &overrides, GET_TOOL_SPEC_TOOL_NAME); + + assert_eq!( + policy.allowed_tool_names, + vec![GET_TOOL_SPEC_TOOL_NAME, "WebFetch"] + ); + assert_eq!( + policy.expanded_tool_names, + vec![GET_TOOL_SPEC_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME], + "core currently appends the runtime GetToolSpec entry whenever collapsed tools exist" + ); + assert_eq!(policy.collapsed_tool_names, vec!["WebFetch"]); +} + +#[test] +fn get_tool_spec_load_collector_preserves_collapsed_runtime_contract() { + let collapsed_tools = vec!["WebFetch".to_string(), "GetFileDiff".to_string()]; + let observations = vec![ + GetToolSpecLoadObservation { + tool_name: GET_TOOL_SPEC_TOOL_NAME, + loaded_tool_name: Some("WebFetch"), + is_error: false, + }, + GetToolSpecLoadObservation { + tool_name: GET_TOOL_SPEC_TOOL_NAME, + loaded_tool_name: Some("Read"), + is_error: false, + }, + GetToolSpecLoadObservation { + tool_name: GET_TOOL_SPEC_TOOL_NAME, + loaded_tool_name: Some("GetFileDiff"), + is_error: true, + }, + GetToolSpecLoadObservation { + tool_name: "Read", + loaded_tool_name: Some("WebFetch"), + is_error: false, + }, + GetToolSpecLoadObservation { + tool_name: GET_TOOL_SPEC_TOOL_NAME, + loaded_tool_name: Some("WebFetch"), + is_error: false, + }, + ]; + + let loaded = collect_loaded_collapsed_tool_names( + &observations, + &collapsed_tools, + GET_TOOL_SPEC_TOOL_NAME, + ); + + assert_eq!(loaded, vec!["WebFetch".to_string()]); +} + +#[test] +fn collapsed_tool_stub_definition_preserves_prompt_visible_guardrail() { + let stub = build_collapsed_tool_stub_definition( + "WebFetch", + "Fetch a URL and return readable content.", + ); + + assert_eq!(stub.name, "WebFetch"); + assert!(stub.description.contains("Fetch a URL")); + assert!(stub + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); + assert_eq!( + stub.parameters, + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": "Do not supply WebFetch arguments here while the tool is collapsed. Use GetToolSpec with {\"tool_name\":\"WebFetch\"} first." + } + } + }) + ); +} + +#[test] +fn tool_manifest_sorting_preserves_prompt_visible_order() { + let mut definitions = vec![ + ToolManifestDefinition::new("ControlHub", "control", json!({ "type": "object" })), + ToolManifestDefinition::new("Read", "read", json!({ "type": "object" })), + ToolManifestDefinition::new("ExternalTool", "external", json!({ "type": "object" })), + ToolManifestDefinition::new("GetToolSpec", "spec", json!({ "type": "object" })), + ToolManifestDefinition::new("Task", "task", json!({ "type": "object" })), + ]; + + sort_tool_manifest_definitions(&mut definitions); + + assert_eq!( + definitions + .iter() + .map(|definition| definition.name.as_str()) + .collect::>(), + vec!["Task", "Read", "GetToolSpec", "ControlHub", "ExternalTool"] + ); +} + +#[test] +fn prompt_visible_manifest_builder_preserves_expanded_and_collapsed_contract() { + let definitions = build_prompt_visible_tool_manifest_definitions(&[ + PromptVisibleToolManifestItem::Collapsed { + name: "WebFetch".to_string(), + short_description: "Fetch readable web content.".to_string(), + }, + PromptVisibleToolManifestItem::Expanded(ToolManifestDefinition::new( + "Read", + "Read files from the workspace.", + json!({ "type": "object", "properties": { "path": { "type": "string" } } }), + )), + PromptVisibleToolManifestItem::Expanded(ToolManifestDefinition::new( + "Bash", + "Run shell commands.", + json!({ "type": "object", "properties": { "command": { "type": "string" } } }), + )), + ]); + + assert_eq!( + definitions + .iter() + .map(|definition| definition.name.as_str()) + .collect::>(), + vec!["Bash", "Read", "WebFetch"] + ); + assert_eq!(definitions[0].description, "Run shell commands."); + assert_eq!( + definitions[0].parameters["properties"]["command"]["type"], + json!("string") + ); + assert!(definitions[2] + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); +} + +#[test] +fn get_tool_spec_contract_preserves_input_schema_and_validation() { + let schema = get_tool_spec_input_schema(); + + assert_eq!(schema["type"], "object"); + assert_eq!(schema["additionalProperties"], false); + assert_eq!(schema["required"], json!(["tool_name"])); + assert_eq!(schema["properties"]["tool_name"]["type"], "string"); + assert!(schema["properties"]["tool_name"]["description"] + .as_str() + .unwrap_or_default() + .contains("canonical casing")); + + let missing = validate_get_tool_spec_input(&json!({})); + assert!(!missing.result); + assert_eq!( + missing.message.as_deref(), + Some("tool_name is required and cannot be empty") + ); + assert_eq!(missing.error_code, Some(400)); + + let empty = validate_get_tool_spec_input(&json!({ "tool_name": "" })); + assert!(!empty.result); + assert_eq!( + empty.message.as_deref(), + Some("tool_name is required and cannot be empty") + ); + assert_eq!(empty.error_code, Some(400)); + + assert!(validate_get_tool_spec_input(&json!({ "tool_name": "Git" })).result); +} + +#[test] +fn get_tool_spec_contract_preserves_static_metadata_and_use_message() { + assert_eq!( + get_tool_spec_short_description(), + "Discover collapsed tools and read their detailed definitions." + ); + assert!(get_tool_spec_is_readonly()); + assert!(get_tool_spec_is_concurrency_safe(Some(&json!({ + "tool_name": "WebFetch" + })))); + assert!(!get_tool_spec_needs_permissions(Some(&json!({ + "tool_name": "WebFetch" + })))); + assert_eq!( + render_get_tool_spec_tool_use_message(&json!({ "tool_name": "Git" })), + "Reading tool spec for 'Git'." + ); + assert_eq!( + render_get_tool_spec_tool_use_message(&json!({})), + "Reading tool spec for '?'." + ); +} + +#[test] +fn get_tool_spec_contract_preserves_collapsed_prompt_description() { + let collapsed_tools_list = [ + build_get_tool_spec_collapsed_tool_entry("Git", "Inspect the repository."), + build_get_tool_spec_collapsed_tool_entry("WebFetch", "Fetch readable web content."), + ] + .join("\n"); + + let description = build_get_tool_spec_description(&collapsed_tools_list); + + assert!(description.contains("\n- Git: Inspect the repository.")); + assert!(description.contains("- WebFetch: Fetch readable web content.")); + assert!(description.contains("Do not call GetToolSpec again")); + assert!(description.contains("call `GetToolSpec` with `{\"tool_name\":\"Git\"}`")); +} + +#[test] +fn get_tool_spec_catalog_description_uses_summary_entries_and_empty_fallback() { + let description = build_get_tool_spec_catalog_description(&[ + GetToolSpecCollapsedToolSummary { + name: "Git".to_string(), + short_description: "Inspect the repository.".to_string(), + }, + GetToolSpecCollapsedToolSummary { + name: "WebFetch".to_string(), + short_description: "Fetch readable web content.".to_string(), + }, + ]); + + assert!(description.contains("- Git: Inspect the repository.")); + assert!(description.contains("- WebFetch: Fetch readable web content.")); + + let empty = build_get_tool_spec_catalog_description(&[]); + assert!(empty.contains("No additional tools are available.")); +} + +#[test] +fn get_tool_spec_contract_escapes_assistant_detail_for_xml_sections() { + let detail = build_get_tool_spec_assistant_detail( + "Use & keep output valid.", + &json!({ + "type": "object", + "properties": { + "query": { + "description": "Match & symbols" + } + } + }), + ); + + assert!(detail.contains("\nUse <danger> & keep output valid.")); + assert!(detail.contains("\"description\":\"Match <tag> & symbols\"")); + assert!(!detail.contains("Use & keep output valid.")); +} + +#[test] +fn get_tool_spec_contract_preserves_duplicate_load_hint() { + assert_eq!( + build_get_tool_spec_duplicate_load_hint("WebFetch"), + "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly." + ); +} + +#[test] +fn get_tool_spec_contract_builds_duplicate_load_result() { + let result = build_get_tool_spec_duplicate_load_result("WebFetch"); + + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = result + else { + panic!("expected normal tool result"); + }; + + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["already_loaded"], true); + assert_eq!( + result_for_assistant.as_deref(), + Some("Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly.") + ); + assert_eq!(image_attachments, None); +} + +#[test] +fn get_tool_spec_contract_builds_detail_result() { + let result = build_get_tool_spec_detail_result(&bitfun_agent_tools::GetToolSpecDetail { + tool_name: "Git".to_string(), + description: "Use & inspect changes.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Run git commands" + } + } + }), + }); + + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = result + else { + panic!("expected normal tool result"); + }; + + assert_eq!(data["tool_name"], "Git"); + assert_eq!(data["description"], "Use & inspect changes."); + assert_eq!( + data["input_schema"]["properties"]["command"]["type"], + "string" + ); + let assistant = result_for_assistant.expect("assistant detail"); + assert!(assistant.contains("Use <repo> & inspect changes.")); + assert!(assistant.contains("Run <safe> git commands")); + assert_eq!(image_attachments, None); +} + +#[test] +fn get_tool_spec_contract_plans_duplicate_load_without_core_context() { + let input = json!({ "tool_name": "WebFetch" }); + let plan = + bitfun_agent_tools::resolve_get_tool_spec_execution_plan(&input, &["WebFetch".to_string()]) + .expect("duplicate load should be planned"); + + let GetToolSpecExecutionPlan::DuplicateLoad(result) = plan else { + panic!("expected duplicate-load plan"); + }; + + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = result + else { + panic!("expected normal tool result"); + }; + + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["already_loaded"], true); + assert!(result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("already loaded in the current conversation")); + assert_eq!(image_attachments, None); +} + +#[test] +fn get_tool_spec_contract_plans_detail_load_without_resolving_product_detail() { + let input = json!({ "tool_name": "Git" }); + let plan = + bitfun_agent_tools::resolve_get_tool_spec_execution_plan(&input, &["WebFetch".to_string()]) + .expect("detail load should be planned"); + + let GetToolSpecExecutionPlan::LoadDetail { tool_name } = plan else { + panic!("expected detail-load plan"); + }; + + assert_eq!(tool_name, "Git"); +} + +#[test] +fn get_tool_spec_contract_rejects_missing_tool_name_in_execution_plan() { + let err = bitfun_agent_tools::resolve_get_tool_spec_execution_plan(&json!({}), &[]) + .expect_err("missing tool name should be rejected"); + + assert_eq!(err, GetToolSpecExecutionError::MissingToolName); + assert_eq!(err.to_string(), "tool_name is required"); +} + +#[derive(Clone)] struct RegistryMarkerTool { name: String, provider_id: Option, + exposure: ToolExposure, + readonly: bool, + enabled: bool, } #[async_trait::async_trait] @@ -243,6 +808,18 @@ impl ToolRegistryItem for RegistryMarkerTool { json!({ "type": "object" }) } + fn default_exposure(&self) -> ToolExposure { + self.exposure + } + + fn is_readonly(&self) -> bool { + self.readonly + } + + async fn is_enabled(&self) -> bool { + self.enabled + } + async fn input_schema_for_model(&self) -> serde_json::Value { self.input_schema() } @@ -258,13 +835,1003 @@ impl ToolRegistryItem for RegistryMarkerTool { } } +#[derive(Debug, Clone, Copy)] +struct ManifestTestContext { + agent: &'static str, +} + +#[derive(Clone)] +struct ContextualManifestTool { + name: String, + exposure: ToolExposure, + available_for_agent: Option<&'static str>, +} + +#[async_trait::async_trait] +impl ToolRegistryItem for ContextualManifestTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> Result { + Ok(format!("{} default description", self.name)) + } + + fn short_description(&self) -> String { + format!("{} short description", self.name) + } + + fn input_schema(&self) -> serde_json::Value { + json!({ "type": "object" }) + } + + fn default_exposure(&self) -> ToolExposure { + self.exposure + } +} + +#[async_trait::async_trait] +impl ContextualToolManifestItem for ContextualManifestTool { + async fn is_available_in_context(&self, context: &ManifestTestContext) -> bool { + self.available_for_agent + .is_none_or(|agent| agent == context.agent) + } + + async fn description_with_context( + &self, + context: &ManifestTestContext, + ) -> Result { + Ok(format!("{} description for {}", self.name, context.agent)) + } + + async fn input_schema_for_model_with_context( + &self, + context: &ManifestTestContext, + ) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "agent": { + "const": context.agent + } + } + }) + } +} + fn registry_marker_tool(name: &str, provider_id: Option<&str>) -> Arc { + registry_marker_tool_with_exposure(name, provider_id, ToolExposure::Expanded) +} + +fn registry_marker_tool_with_exposure( + name: &str, + provider_id: Option<&str>, + exposure: ToolExposure, +) -> Arc { + registry_marker_tool_with_access(name, provider_id, exposure, false, true) +} + +fn registry_marker_tool_with_access( + name: &str, + provider_id: Option<&str>, + exposure: ToolExposure, + readonly: bool, + enabled: bool, +) -> Arc { Arc::new(RegistryMarkerTool { name: name.to_string(), provider_id: provider_id.map(str::to_string), + exposure, + readonly, + enabled, }) } +fn contextual_manifest_tool( + name: &str, + exposure: ToolExposure, + available_for_agent: Option<&'static str>, +) -> Arc { + Arc::new(ContextualManifestTool { + name: name.to_string(), + exposure, + available_for_agent, + }) +} + +struct ContextualManifestSnapshotProvider { + tools: Vec>, +} + +struct RegistryMarkerSnapshotProvider { + tools: Vec>, +} + +struct ErroringGetToolSpecProvider; + +#[async_trait::async_trait] +impl ToolCatalogSnapshotProvider for ContextualManifestSnapshotProvider { + async fn tool_snapshot(&self) -> Vec> { + self.tools.clone() + } +} + +#[async_trait::async_trait] +impl ToolCatalogSnapshotProvider for RegistryMarkerSnapshotProvider { + async fn tool_snapshot(&self) -> Vec> { + self.tools.clone() + } +} + +#[async_trait::async_trait] +impl GetToolSpecCatalogProvider + for ContextualManifestSnapshotProvider +{ + async fn collapsed_tools_for_get_tool_spec( + &self, + context: Option<&ManifestTestContext>, + ) -> Result>, String> { + let tools = match context { + Some(context) => { + let mut tools = Vec::new(); + for tool in &self.tools { + if tool.default_exposure() == ToolExposure::Collapsed + && tool.is_available_in_context(context).await + { + tools.push(tool.clone()); + } + } + tools + } + None => self + .tools + .iter() + .filter(|tool| tool.default_exposure() == ToolExposure::Collapsed) + .cloned() + .collect(), + }; + + Ok(tools) + } +} + +#[async_trait::async_trait] +impl GetToolSpecCatalogProvider + for ErroringGetToolSpecProvider +{ + async fn collapsed_tools_for_get_tool_spec( + &self, + _context: Option<&ManifestTestContext>, + ) -> Result>, String> { + Err("provider should not be called for duplicate-load execution".to_string()) + } +} + +struct RegistryMarkerProvider { + provider_id: &'static str, + tools: Vec>, +} + +impl StaticToolProvider for RegistryMarkerProvider { + fn provider_id(&self) -> &'static str { + self.provider_id + } + + fn tools(&self) -> Vec> { + self.tools.clone() + } +} + +#[test] +fn static_tool_provider_group_preserves_provider_id_and_tool_order() { + let provider = StaticToolProviderGroup::new( + "core-basic", + vec![ + registry_marker_tool("Read", None), + registry_marker_tool("Write", None), + ], + ); + + assert_eq!(provider.provider_id(), "core-basic"); + assert_eq!( + provider + .tools() + .iter() + .map(|tool| tool.name()) + .collect::>(), + vec!["Read", "Write"] + ); +} + +struct RegistryMarkerDecorator; + +impl ToolDecorator> for RegistryMarkerDecorator { + fn decorate(&self, tool: Arc) -> Arc { + Arc::new(RegistryMarkerTool { + name: format!("decorated_{}", tool.name), + provider_id: tool.provider_id.clone(), + exposure: tool.exposure, + readonly: tool.readonly, + enabled: tool.enabled, + }) + } +} + +struct RegistryMarkerSnapshotWrapper; + +impl bitfun_agent_tools::SnapshotToolWrapper for RegistryMarkerSnapshotWrapper { + fn wrap_for_snapshot_tracking(&self, tool: Arc) -> Arc { + Arc::new(RegistryMarkerTool { + name: format!("snapshot_{}", tool.name), + provider_id: tool.provider_id.clone(), + exposure: tool.exposure, + readonly: tool.readonly, + enabled: tool.enabled, + }) + } +} + +#[test] +fn generic_tool_registry_installs_static_provider_in_order() { + let mut registry = ToolRegistry::new(); + let provider = RegistryMarkerProvider { + provider_id: "core-basic", + tools: vec![ + registry_marker_tool("Read", None), + registry_marker_tool("Write", None), + ], + }; + + registry.install_static_provider(&provider); + + assert_eq!(provider.provider_id(), "core-basic"); + assert_eq!( + registry.get_tool_names(), + vec!["Read".to_string(), "Write".to_string()] + ); +} + +#[test] +fn generic_tool_registry_applies_decorator_to_static_provider_tools() { + let mut registry = ToolRegistry::with_tool_decorator(Arc::new(RegistryMarkerDecorator)); + let provider = RegistryMarkerProvider { + provider_id: "decorated-provider", + tools: vec![registry_marker_tool("Read", None)], + }; + + registry.install_static_provider(&provider); + + assert_eq!( + registry.get_tool_names(), + vec!["decorated_Read".to_string()] + ); +} + +#[test] +fn generic_snapshot_tool_decorator_delegates_to_snapshot_wrapper_port() { + let decorator: ToolDecoratorRef = Arc::new( + bitfun_agent_tools::SnapshotToolDecorator::new(Arc::new(RegistryMarkerSnapshotWrapper)), + ); + let providers = vec![StaticToolProviderGroup::new( + "core-basic", + vec![registry_marker_tool("Write", None)], + )]; + + let registry = ToolRuntimeAssembly::with_tool_decorator(decorator) + .create_registry_from_static_providers(&providers); + + assert_eq!( + registry.get_tool_names(), + vec!["snapshot_Write".to_string()], + "snapshot decorator must delegate wrapping through the portable wrapper port" + ); +} + +#[test] +fn generic_tool_runtime_assembly_installs_static_providers_with_decorator() { + let decorator: ToolDecoratorRef = Arc::new(RegistryMarkerDecorator); + let providers = vec![ + StaticToolProviderGroup::new("core-basic", vec![registry_marker_tool("Read", None)]), + StaticToolProviderGroup::new( + "core-integration", + vec![registry_marker_tool_with_exposure( + "WebFetch", + None, + ToolExposure::Collapsed, + )], + ), + ]; + + let registry = ToolRuntimeAssembly::with_tool_decorator(decorator) + .create_registry_from_static_providers(&providers); + + assert_eq!( + registry.get_tool_names(), + vec![ + "decorated_Read".to_string(), + "decorated_WebFetch".to_string() + ], + "runtime assembly must preserve static provider order while applying the decorator" + ); + assert_eq!( + registry.get_collapsed_tool_names(), + vec!["decorated_WebFetch".to_string()], + "runtime assembly must preserve collapsed exposure after decoration" + ); +} + +#[test] +fn generic_tool_registry_preserves_exposure_catalog_contract() { + let mut registry = ToolRegistry::new(); + registry.register_tool(registry_marker_tool("Read", None)); + registry.register_tool(registry_marker_tool_with_exposure( + "WebFetch", + None, + ToolExposure::Collapsed, + )); + registry.register_tool(registry_marker_tool_with_exposure( + "Git", + None, + ToolExposure::Collapsed, + )); + + assert!(!registry.is_tool_collapsed("Read")); + assert!(registry.is_tool_collapsed("WebFetch")); + assert_eq!( + registry.get_collapsed_tool_names(), + vec!["WebFetch".to_string(), "Git".to_string()] + ); +} + +#[tokio::test] +async fn generic_readonly_enabled_filter_preserves_registry_order() { + let tools = vec![ + registry_marker_tool_with_access("Read", None, ToolExposure::Expanded, true, true), + registry_marker_tool_with_access("Write", None, ToolExposure::Expanded, false, true), + registry_marker_tool_with_access( + "DisabledReadonly", + None, + ToolExposure::Expanded, + true, + false, + ), + registry_marker_tool_with_access("WebFetch", None, ToolExposure::Collapsed, true, true), + ]; + + let readonly_names = resolve_readonly_enabled_tools(&tools) + .await + .iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + assert_eq!( + readonly_names, + vec!["Read".to_string(), "WebFetch".to_string()], + "readonly filtering must keep registry order and skip disabled or mutating tools" + ); +} + +#[test] +fn manifest_policy_tools_from_registry_snapshot_preserve_exposure_and_availability() { + let tools = vec![ + registry_marker_tool("Read", None), + registry_marker_tool_with_exposure("WebFetch", None, ToolExposure::Collapsed), + registry_marker_tool_with_exposure("Git", None, ToolExposure::Collapsed), + ]; + let available_tool_names = ["Read".to_string(), "Git".to_string()] + .into_iter() + .collect(); + + let policy_tools = + bitfun_agent_tools::build_tool_manifest_policy_tools(&tools, &available_tool_names); + + assert_eq!( + policy_tools, + vec![ + ToolManifestPolicyTool { + name: "Read".to_string(), + default_exposure: ToolExposure::Expanded, + available: true, + }, + ToolManifestPolicyTool { + name: "WebFetch".to_string(), + default_exposure: ToolExposure::Collapsed, + available: false, + }, + ToolManifestPolicyTool { + name: "Git".to_string(), + default_exposure: ToolExposure::Collapsed, + available: true, + }, + ] + ); +} + +#[tokio::test] +async fn contextual_manifest_resolver_preserves_runtime_visible_manifest_contract() { + let tools = vec![ + contextual_manifest_tool("Read", ToolExposure::Expanded, None), + contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), + contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Expanded, None), + ]; + + let manifest = resolve_contextual_tool_manifest( + &tools, + &[ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + ], + &Default::default(), + &ManifestTestContext { agent: "agentic" }, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await; + + assert_eq!( + manifest.allowed_tool_names, + vec![ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + GET_TOOL_SPEC_TOOL_NAME.to_string(), + ], + "GetToolSpec insertion must preserve the runtime allowed-list contract" + ); + assert_eq!( + manifest.collapsed_tool_names, + vec!["WebFetch".to_string()], + "unavailable collapsed tools must not leak into the prompt-visible unlock catalog" + ); + assert_eq!( + manifest + .expanded_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", GET_TOOL_SPEC_TOOL_NAME], + "expanded tool handles must follow the resolved runtime policy" + ); + assert_eq!( + manifest + .collapsed_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["WebFetch"], + "collapsed tool handles must follow the resolved runtime policy" + ); + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", "WebFetch", GET_TOOL_SPEC_TOOL_NAME], + "prompt-visible manifest ordering must stay stable when the owner moves" + ); + + let read = manifest + .tool_definitions + .iter() + .find(|tool| tool.name == "Read") + .expect("expanded Read manifest"); + assert_eq!(read.description, "Read description for agentic"); + assert_eq!(read.parameters["properties"]["agent"]["const"], "agentic"); + + let web_fetch = manifest + .tool_definitions + .iter() + .find(|tool| tool.name == "WebFetch") + .expect("collapsed WebFetch stub"); + assert!(web_fetch + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); + assert_eq!(web_fetch.parameters["additionalProperties"], false); +} + +#[tokio::test] +async fn contextual_manifest_resolver_accepts_snapshot_provider_boundary() { + let provider = ContextualManifestSnapshotProvider { + tools: vec![ + contextual_manifest_tool("Read", ToolExposure::Expanded, None), + contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), + contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Expanded, None), + ], + }; + + let manifest = resolve_contextual_tool_manifest_from_provider( + &provider, + &[ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + ], + &Default::default(), + &ManifestTestContext { agent: "agentic" }, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await; + + assert_eq!( + manifest.allowed_tool_names, + vec![ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + GET_TOOL_SPEC_TOOL_NAME.to_string(), + ], + "provider-backed resolution must preserve allowed-list semantics" + ); + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", "WebFetch", GET_TOOL_SPEC_TOOL_NAME], + "provider-backed resolution must preserve prompt-visible manifest ordering" + ); + assert_eq!( + manifest.collapsed_tool_names, + vec!["WebFetch".to_string()], + "provider-backed resolution must preserve context-aware availability filtering" + ); +} + +#[tokio::test] +async fn tool_catalog_runtime_facade_owns_manifest_and_readonly_paths() { + let manifest_provider = ContextualManifestSnapshotProvider { + tools: vec![ + contextual_manifest_tool("Read", ToolExposure::Expanded, None), + contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), + contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Expanded, None), + ], + }; + let runtime = ToolCatalogRuntime::::new( + &manifest_provider, + GET_TOOL_SPEC_TOOL_NAME, + ); + + let visible_tools = runtime + .visible_tools( + &[ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + ], + &Default::default(), + &ManifestTestContext { agent: "agentic" }, + ) + .await; + assert_eq!( + visible_tools.allowed_tool_names, + vec![ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + GET_TOOL_SPEC_TOOL_NAME.to_string(), + ], + "runtime facade must preserve allowed-list insertion" + ); + assert_eq!( + visible_tools + .expanded_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", GET_TOOL_SPEC_TOOL_NAME], + "runtime facade must preserve expanded handle order" + ); + assert_eq!( + visible_tools.collapsed_tool_names, + vec!["WebFetch".to_string()], + "runtime facade must preserve context-aware collapsed filtering" + ); + + let manifest = runtime + .tool_manifest( + &[ + "Read".to_string(), + "WebFetch".to_string(), + "Git".to_string(), + ], + &Default::default(), + &ManifestTestContext { agent: "agentic" }, + ) + .await; + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", "WebFetch", GET_TOOL_SPEC_TOOL_NAME], + "runtime facade must preserve prompt-visible manifest order" + ); + + let readonly_provider = RegistryMarkerSnapshotProvider { + tools: vec![ + registry_marker_tool_with_access("Read", None, ToolExposure::Expanded, true, true), + registry_marker_tool_with_access("Write", None, ToolExposure::Expanded, false, true), + registry_marker_tool_with_access("Disabled", None, ToolExposure::Expanded, true, false), + registry_marker_tool_with_access("WebFetch", None, ToolExposure::Collapsed, true, true), + ], + }; + let readonly_runtime = ToolCatalogRuntime::::new( + &readonly_provider, + GET_TOOL_SPEC_TOOL_NAME, + ); + let readonly_names = readonly_runtime + .readonly_enabled_tools() + .await + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + assert_eq!( + readonly_names, + vec!["Read".to_string(), "WebFetch".to_string()], + "runtime facade must preserve readonly enabled filtering order" + ); +} + +#[tokio::test] +async fn get_tool_spec_detail_resolver_preserves_contextual_detail_contract() { + let collapsed_tools = vec![ + contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Collapsed, None), + ]; + let context = ManifestTestContext { agent: "agentic" }; + + let summaries = summarize_get_tool_spec_collapsed_tools(&collapsed_tools); + assert_eq!( + summaries, + vec![ + GetToolSpecCollapsedToolSummary { + name: "WebFetch".to_string(), + short_description: "WebFetch short description".to_string(), + }, + GetToolSpecCollapsedToolSummary { + name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + short_description: "GetToolSpec short description".to_string(), + }, + ], + "catalog summaries must preserve collapsed tool order and short descriptions" + ); + + let detail = resolve_get_tool_spec_detail( + &collapsed_tools, + "WebFetch", + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect("collapsed WebFetch detail"); + + assert_eq!(detail.tool_name, "WebFetch"); + assert_eq!(detail.description, "WebFetch description for agentic"); + assert_eq!( + detail.input_schema["properties"]["agent"]["const"], + "agentic" + ); + assert_eq!( + detail.to_value(), + json!({ + "tool_name": "WebFetch", + "description": "WebFetch description for agentic", + "input_schema": { + "type": "object", + "properties": { + "agent": { + "const": "agentic" + } + } + } + }), + "detail JSON shape must stay compatible with GetToolSpec execution output" + ); + + let missing = + resolve_get_tool_spec_detail(&collapsed_tools, "Git", &context, GET_TOOL_SPEC_TOOL_NAME) + .await + .expect_err("missing tool should stay a validation-style error"); + assert_eq!( + missing, + "Tool 'Git' is not an available collapsed tool in the current context" + ); + + let self_inspection = resolve_get_tool_spec_detail( + &collapsed_tools, + GET_TOOL_SPEC_TOOL_NAME, + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect_err("GetToolSpec should not inspect itself"); + assert_eq!(self_inspection, "Tool 'GetToolSpec' cannot inspect itself"); +} + +#[tokio::test] +async fn get_tool_spec_catalog_provider_preserves_runtime_catalog_contract() { + let provider = ContextualManifestSnapshotProvider { + tools: vec![ + contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), + contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), + contextual_manifest_tool("Read", ToolExposure::Expanded, None), + ], + }; + let context = ManifestTestContext { agent: "agentic" }; + + let description = + build_get_tool_spec_catalog_description_from_provider(&provider, Some(&context)).await; + assert!(description.contains("- WebFetch: WebFetch short description")); + assert!( + !description.contains("- Git: Git short description"), + "provider-backed catalog must preserve context-aware availability filtering" + ); + + let default_description = + build_get_tool_spec_catalog_description_from_provider(&provider, None).await; + assert!(default_description.contains("- WebFetch: WebFetch short description")); + assert!(default_description.contains("- Git: Git short description")); + + let detail = resolve_get_tool_spec_detail_from_provider( + &provider, + "WebFetch", + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect("provider-backed detail"); + assert_eq!(detail.tool_name, "WebFetch"); + assert_eq!(detail.description, "WebFetch description for agentic"); +} + +#[tokio::test] +async fn get_tool_spec_provider_execution_returns_duplicate_result_without_detail_lookup() { + let context = ManifestTestContext { agent: "agentic" }; + let input = json!({ "tool_name": "WebFetch" }); + + let result = resolve_get_tool_spec_execution_result_from_provider( + &ErroringGetToolSpecProvider, + &input, + &["WebFetch".to_string()], + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect("duplicate load should not call provider detail lookup"); + + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = result + else { + panic!("expected normal tool result"); + }; + + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["already_loaded"], true); + assert!(result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("already loaded in the current conversation")); + assert_eq!(image_attachments, None); +} + +#[tokio::test] +async fn get_tool_spec_provider_execution_returns_detail_result_from_provider() { + let provider = ContextualManifestSnapshotProvider { + tools: vec![contextual_manifest_tool( + "WebFetch", + ToolExposure::Collapsed, + None, + )], + }; + let context = ManifestTestContext { agent: "agentic" }; + let input = json!({ "tool_name": "WebFetch" }); + + let result = resolve_get_tool_spec_execution_result_from_provider( + &provider, + &input, + &[], + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect("detail result should come from provider"); + + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = result + else { + panic!("expected normal tool result"); + }; + + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["description"], "WebFetch description for agentic"); + assert_eq!( + data["input_schema"]["properties"]["agent"]["const"], + "agentic" + ); + let assistant = result_for_assistant.expect("assistant detail"); + assert!(assistant.contains("\nWebFetch description for agentic")); + assert!(assistant.contains("\"agent\"")); + assert!(assistant.contains("\"agentic\"")); + assert_eq!(image_attachments, None); +} + +#[tokio::test] +async fn get_tool_spec_runtime_facade_owns_catalog_and_execution_paths() { + let provider = ContextualManifestSnapshotProvider { + tools: vec![contextual_manifest_tool( + "WebFetch", + ToolExposure::Collapsed, + None, + )], + }; + let context = ManifestTestContext { agent: "agentic" }; + let input = json!({ "tool_name": "WebFetch" }); + let runtime = GetToolSpecRuntime::::new( + &provider, + GET_TOOL_SPEC_TOOL_NAME, + ); + + let description = runtime.catalog_description(Some(&context)).await; + assert!(description.contains("- WebFetch: WebFetch short description")); + + let result = runtime + .execute(&input, &[], &context) + .await + .expect("collapsed tool detail should resolve through runtime facade"); + + let ToolResult::Result { data, .. } = result else { + panic!("expected normal tool result"); + }; + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["description"], "WebFetch description for agentic"); + assert_eq!( + data["input_schema"]["properties"]["agent"]["const"], + "agentic" + ); +} + +#[tokio::test] +async fn get_tool_spec_runtime_facade_owns_tool_result_vector_adapter_shape() { + let provider = ContextualManifestSnapshotProvider { + tools: vec![contextual_manifest_tool( + "WebFetch", + ToolExposure::Collapsed, + None, + )], + }; + let context = ManifestTestContext { agent: "agentic" }; + let runtime = GetToolSpecRuntime::::new( + &provider, + GET_TOOL_SPEC_TOOL_NAME, + ); + + let mut results = runtime + .call_results(&json!({ "tool_name": "WebFetch" }), &[], &context) + .await + .expect("runtime facade should produce the Tool impl result vector shape"); + + assert_eq!(results.len(), 1); + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = results.remove(0) + else { + panic!("expected normal detail result"); + }; + assert_eq!(data["tool_name"], "WebFetch"); + assert!(result_for_assistant + .expect("assistant detail") + .contains("\nWebFetch description for agentic")); + assert_eq!(image_attachments, None); + + let duplicate_runtime = + GetToolSpecRuntime::::new( + &ErroringGetToolSpecProvider, + GET_TOOL_SPEC_TOOL_NAME, + ); + let duplicate_results = duplicate_runtime + .call_results( + &json!({ "tool_name": "WebFetch" }), + &["WebFetch".to_string()], + &context, + ) + .await + .expect("duplicate-load path should not consult provider detail"); + + assert_eq!(duplicate_results.len(), 1); + let ToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = &duplicate_results[0] + else { + panic!("expected duplicate-load result"); + }; + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!( + result_for_assistant.as_deref(), + Some("Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly.") + ); + assert!(image_attachments.is_none()); +} + +#[test] +fn get_tool_spec_runtime_facade_owns_static_tool_surface() { + let provider = ContextualManifestSnapshotProvider { tools: Vec::new() }; + let runtime = GetToolSpecRuntime::::new( + &provider, + GET_TOOL_SPEC_TOOL_NAME, + ); + + assert_eq!(runtime.name(), GET_TOOL_SPEC_TOOL_NAME); + assert_eq!( + runtime.short_description(), + get_tool_spec_short_description() + ); + assert_eq!(runtime.input_schema(), get_tool_spec_input_schema()); + assert!(runtime.is_readonly()); + assert!(runtime.is_concurrency_safe(None)); + assert!(!runtime.needs_permissions(None)); + assert_eq!( + runtime.render_tool_use_message(&json!({ "tool_name": "WebFetch" })), + "Reading tool spec for 'WebFetch'." + ); + assert!( + runtime + .validate_input(&json!({ "tool_name": "WebFetch" })) + .result + ); + assert!(!runtime.validate_input(&json!({})).result); +} + +#[tokio::test] +async fn get_tool_spec_provider_execution_classifies_detail_errors() { + let provider = ContextualManifestSnapshotProvider { + tools: vec![contextual_manifest_tool( + "WebFetch", + ToolExposure::Collapsed, + None, + )], + }; + let context = ManifestTestContext { agent: "agentic" }; + let input = json!({ "tool_name": "Git" }); + + let err = resolve_get_tool_spec_execution_result_from_provider( + &provider, + &input, + &[], + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect_err("missing detail should be classified separately from input errors"); + + assert_eq!( + err, + GetToolSpecExecutionError::Detail( + "Tool 'Git' is not an available collapsed tool in the current context".to_string() + ) + ); + assert_eq!( + err.to_string(), + "Tool 'Git' is not an available collapsed tool in the current context" + ); +} + #[tokio::test] async fn generic_tool_registry_preserves_dynamic_descriptor_contract() { let mut registry = ToolRegistry::new(); diff --git a/src/crates/ai-adapters/AGENTS.md b/src/crates/ai-adapters/AGENTS.md index de68f2c6fd..dbe651e371 100644 --- a/src/crates/ai-adapters/AGENTS.md +++ b/src/crates/ai-adapters/AGENTS.md @@ -1 +1,28 @@ -If you modify this crate, run the stream integration tests in `src/crates/core/tests` before finishing. +# AI Adapters Agent Guide + +Scope: this guide applies to `src/crates/ai-adapters`. + +`bitfun-ai-adapters` owns provider-specific request/response mapping and stream +normalization. Keep provider quirks here instead of leaking them into core tool +contracts or product runtime logic. + +## Guardrails + +- OpenAI Responses and Codex ChatGPT flat tool schemas are adapter + serialization behavior. Keep core/tool manifests provider-neutral. +- `cached_content_token_count` means cache reads/hits. Keep + `cache_creation_token_count` separate, and preserve provider-specific mappings + such as DeepSeek prompt-cache hits and Gemini's current lack of creation + count. +- Do not change shared stream or usage semantics without updating the focused + adapter tests and downstream usage expectations. + +## Verification + +```bash +cargo test -p bitfun-agent-stream +cargo test -p bitfun-ai-adapters +``` + +If stream behavior affects core integration, also run the relevant tests in +`src/crates/core/tests`. diff --git a/src/crates/ai-adapters/src/client/response_aggregator.rs b/src/crates/ai-adapters/src/client/response_aggregator.rs index 6ae2e8b195..24ac03c504 100644 --- a/src/crates/ai-adapters/src/client/response_aggregator.rs +++ b/src/crates/ai-adapters/src/client/response_aggregator.rs @@ -166,5 +166,6 @@ pub(crate) fn unified_usage_to_gemini_usage( total_token_count: usage.total_token_count, reasoning_token_count: usage.reasoning_token_count, cached_content_token_count: usage.cached_content_token_count, + cache_creation_token_count: usage.cache_creation_token_count, } } diff --git a/src/crates/ai-adapters/src/lib.rs b/src/crates/ai-adapters/src/lib.rs index f12f390814..73894edef6 100644 --- a/src/crates/ai-adapters/src/lib.rs +++ b/src/crates/ai-adapters/src/lib.rs @@ -10,7 +10,7 @@ pub mod types; pub use client::{AIClient, StreamOptions, StreamResponse}; pub use stream::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; pub use types::{ - AIConfig, ConnectionTestMessageCode, ConnectionTestResult, GeminiResponse, GeminiUsage, - Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, - ToolImageAttachment, resolve_request_url, + resolve_request_url, AIConfig, ConnectionTestMessageCode, ConnectionTestResult, GeminiResponse, + GeminiUsage, Message, ProxyConfig, ReasoningMode, RemoteModelInfo, ToolCall, ToolDefinition, + ToolImageAttachment, }; diff --git a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs b/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs index 5e8a87a3db..6d5e5119d4 100644 --- a/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs +++ b/src/crates/ai-adapters/src/providers/openai/codex_chatgpt.rs @@ -36,25 +36,6 @@ pub(crate) fn is_codex_chatgpt_endpoint(request_url: &str) -> bool { request_url.contains("chatgpt.com/backend-api/codex") } -/// Convert a `ToolDefinition` list to the *flat* Responses tool schema that -/// Codex backend (and OpenAI's public Responses API) expects: -/// `{ "type": "function", "name": ..., "description": ..., "parameters": ..., "strict": false }`. -fn convert_tools_flat(tools: Option>) -> Option> { - tools.map(|defs| { - defs.into_iter() - .map(|tool| { - json!({ - "type": "function", - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - "strict": false, - }) - }) - .collect() - }) -} - fn attach_tools(request_body: &mut Value, tools: Option>) { if let Some(tools) = tools { let names: Vec = tools @@ -173,7 +154,7 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); - let tools_flat = convert_tools_flat(tools); + let tools_flat = common::convert_tools_flat(tools); let request_body = build_request_body(client, instructions, response_input, tools_flat, extra_body); let idle_timeout = client.stream_options.idle_timeout; diff --git a/src/crates/ai-adapters/src/providers/openai/common.rs b/src/crates/ai-adapters/src/providers/openai/common.rs index 8b4674ca4e..6301bf57b7 100644 --- a/src/crates/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/ai-adapters/src/providers/openai/common.rs @@ -2,7 +2,7 @@ use crate::client::quirks::apply_openai_compatible_reasoning_fields; use crate::client::utils::{dedupe_remote_models, normalize_base_url_for_discovery}; use crate::client::AIClient; use crate::providers::shared; -use crate::types::RemoteModelInfo; +use crate::types::{RemoteModelInfo, ToolDefinition}; use anyhow::Result; use log::warn; use reqwest::RequestBuilder; @@ -335,6 +335,7 @@ pub(crate) fn extract_tool_name(tool: &serde_json::Value) -> String { tool.get("function") .and_then(|function| function.get("name")) .and_then(|name| name.as_str()) + .or_else(|| tool.get("name").and_then(|name| name.as_str())) .unwrap_or("unknown") .to_string() } @@ -358,3 +359,21 @@ pub(crate) fn attach_tools( } } } + +pub(crate) fn convert_tools_flat( + tools: Option>, +) -> Option> { + tools.map(|defs| { + defs.into_iter() + .map(|tool| { + serde_json::json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + "strict": false, + }) + }) + .collect() + }) +} diff --git a/src/crates/ai-adapters/src/providers/openai/responses.rs b/src/crates/ai-adapters/src/providers/openai/responses.rs index 0b39414341..56073b4ec5 100644 --- a/src/crates/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/ai-adapters/src/providers/openai/responses.rs @@ -111,7 +111,7 @@ pub(crate) async fn send_stream( let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); - let openai_tools = OpenAIMessageConverter::convert_tools(tools); + let openai_tools = common::convert_tools_flat(tools); let request_body = build_request_body( client, instructions, @@ -133,3 +133,64 @@ pub(crate) async fn send_stream( ) .await } + +#[cfg(test)] +mod tests { + use super::build_request_body; + use crate::types::{ReasoningMode, ToolDefinition}; + use crate::{client::AIClient, types::AIConfig}; + use serde_json::json; + + fn test_client() -> AIClient { + AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.openai.com/v1".to_string(), + request_url: "https://api.openai.com/v1/responses".to_string(), + api_key: "test-key".to_string(), + model: "gpt-5.4".to_string(), + format: "responses".to_string(), + context_window: 128_000, + max_tokens: None, + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + }) + } + + #[test] + fn attaches_flat_tool_schema_for_responses_api() { + let client = test_client(); + let request_body = build_request_body( + &client, + None, + vec![json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": "hello" }] + })], + crate::providers::openai::common::convert_tools_flat(Some(vec![ToolDefinition { + name: "get_weather".to_string(), + description: "Get weather".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + } + }), + }])), + None, + ); + + assert_eq!(request_body["tools"][0]["name"], json!("get_weather")); + assert_eq!(request_body["tools"][0]["type"], json!("function")); + assert!(request_body["tools"][0].get("function").is_none()); + } +} diff --git a/src/crates/ai-adapters/src/providers/shared.rs b/src/crates/ai-adapters/src/providers/shared.rs index 8e9ea1277a..5dc05de751 100644 --- a/src/crates/ai-adapters/src/providers/shared.rs +++ b/src/crates/ai-adapters/src/providers/shared.rs @@ -1,7 +1,7 @@ -use crate::client::AIClient; use crate::client::utils::{ build_request_body_subset, is_trim_custom_request_body_mode, merge_json_value, }; +use crate::client::AIClient; use reqwest::RequestBuilder; pub(crate) fn apply_header_policy( diff --git a/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs b/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs index b50c9af9fe..eb6498cff9 100644 --- a/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs +++ b/src/crates/ai-adapters/src/stream/stream_handler/anthropic.rs @@ -1,12 +1,12 @@ use super::inline_think::InlineThinkParser; use super::stream_stats::StreamStats; -use super::{TimedStreamItem, next_stream_item}; +use super::{next_stream_item, TimedStreamItem}; use crate::stream::types::anthropic::{ AnthropicSSEError, ContentBlock, ContentBlockDelta, ContentBlockStart, MessageDelta, MessageStart, Usage, }; use crate::stream::types::unified::UnifiedResponse; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use eventsource_stream::Eventsource; use log::{error, trace}; use reqwest::Response; diff --git a/src/crates/ai-adapters/src/stream/stream_handler/openai.rs b/src/crates/ai-adapters/src/stream/stream_handler/openai.rs index ef670581e3..f0e093ff65 100644 --- a/src/crates/ai-adapters/src/stream/stream_handler/openai.rs +++ b/src/crates/ai-adapters/src/stream/stream_handler/openai.rs @@ -12,6 +12,10 @@ use std::time::Duration; use tokio::sync::mpsc; const OPENAI_CHAT_COMPLETION_CHUNK_OBJECT: &str = "chat.completion.chunk"; +/// MiniMax (and possibly other providers) close a streaming response with a +/// non-streaming `chat.completion` frame instead of a true `chunk`. That final +/// frame is the only one carrying authoritative usage, so we accept it too. +const OPENAI_CHAT_COMPLETION_OBJECT: &str = "chat.completion"; const AI_STREAM_RESPONSE_TARGET: &str = "ai::openai_stream_response"; #[derive(Debug)] @@ -42,9 +46,14 @@ impl OpenAIResponseNormalizer { } fn is_valid_chat_completion_chunk_weak(event_json: &Value) -> bool { + // Standard streaming frames use `chat.completion.chunk`. MiniMax's final + // SSE frame, however, switches to the non-streaming `chat.completion` + // shape (choice carries `message` rather than `delta`) and is the ONLY + // chunk that contains the authoritative usage block. Accept both — the + // OpenAISSEData deserialization downstream tolerates either choice shape. matches!( event_json.get("object").and_then(|value| value.as_str()), - Some(OPENAI_CHAT_COMPLETION_CHUNK_OBJECT) + Some(OPENAI_CHAT_COMPLETION_CHUNK_OBJECT) | Some(OPENAI_CHAT_COMPLETION_OBJECT) ) } @@ -261,6 +270,19 @@ mod tests { assert!(!is_valid_chat_completion_chunk_weak(&event)); } + #[test] + fn weak_filter_accepts_minimax_final_chat_completion_object() { + // MiniMax's last SSE frame uses `chat.completion` (non-streaming shape) + // instead of `chat.completion.chunk`. That frame carries the only + // authoritative usage block, so it must NOT be dropped at the gate. + let event = serde_json::json!({ + "object": "chat.completion", + "choices": [{"finish_reason": "stop", "index": 0, "message": {}}], + "usage": {"prompt_tokens": 45, "completion_tokens": 47, "total_tokens": 92} + }); + assert!(is_valid_chat_completion_chunk_weak(&event)); + } + #[test] fn extracts_api_error_message_from_object_shape() { let event = serde_json::json!({ diff --git a/src/crates/ai-adapters/src/stream/types/anthropic.rs b/src/crates/ai-adapters/src/stream/types/anthropic.rs index b785069bf4..d598b7bc28 100644 --- a/src/crates/ai-adapters/src/stream/types/anthropic.rs +++ b/src/crates/ai-adapters/src/stream/types/anthropic.rs @@ -45,22 +45,27 @@ impl Usage { impl From for UnifiedTokenUsage { fn from(value: Usage) -> Self { - let cache_read = value.cache_read_input_tokens.unwrap_or(0); - let cache_creation = value.cache_creation_input_tokens.unwrap_or(0); - let prompt_token_count = value.input_tokens.unwrap_or(0) + cache_read + cache_creation; + let cache_read = value.cache_read_input_tokens; + let cache_creation = value.cache_creation_input_tokens; + + // prompt_token_count = total context tokens occupied (industry-standard + // "input tokens" metric). For Anthropic this is the three disjoint + // components summed; for other providers the API reports this directly. + let prompt_token_count = value.input_tokens.unwrap_or(0) + + cache_read.unwrap_or(0) + + cache_creation.unwrap_or(0); let candidates_token_count = value.output_tokens.unwrap_or(0); + Self { prompt_token_count, candidates_token_count, total_token_count: prompt_token_count + candidates_token_count, reasoning_token_count: None, - cached_content_token_count: match ( - value.cache_read_input_tokens, - value.cache_creation_input_tokens, - ) { - (None, None) => None, - (read, creation) => Some(read.unwrap_or(0) + creation.unwrap_or(0)), - }, + // cached_content_token_count = cache READS only. This is the + // numerator for `cache hit rate = cached / prompt`. Writes go + // to cache_creation_token_count below. + cached_content_token_count: cache_read, + cache_creation_token_count: cache_creation, } } } @@ -210,3 +215,76 @@ impl From for String { format!("{}: {}", value.error_type, value.message) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::stream::types::unified::UnifiedTokenUsage; + + #[test] + fn cached_content_token_count_is_reads_only_not_sum() { + let raw = r#"{ + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 30, + "cache_creation_input_tokens": 20 + }"#; + let usage: Usage = serde_json::from_str(raw).expect("valid anthropic usage"); + let unified: UnifiedTokenUsage = usage.into(); + + // cached_content_token_count must be reads only — NOT read + creation. + // This guarantees `cached_content / prompt` is a correct hit rate. + assert_eq!(unified.cached_content_token_count, Some(30)); + assert_eq!(unified.cache_creation_token_count, Some(20)); + + // prompt_token_count keeps "total context" semantic (matches industry + // standard "input tokens" metric across providers). + assert_eq!(unified.prompt_token_count, 150); + assert_eq!(unified.candidates_token_count, 50); + assert_eq!(unified.total_token_count, 200); + + // Hit rate computed by downstream: + // 30 / 150 == 20% (correct: only reads count as hits) + // Pre-fix this would have been wrongly 50/150 == 33%. + } + + #[test] + fn absent_cache_fields_stay_none() { + let raw = r#"{ "input_tokens": 100, "output_tokens": 50 }"#; + let usage: Usage = serde_json::from_str(raw).expect("valid anthropic usage"); + let unified: UnifiedTokenUsage = usage.into(); + assert_eq!(unified.cached_content_token_count, None); + assert_eq!(unified.cache_creation_token_count, None); + } + + #[test] + fn zero_cache_fields_are_some_zero_not_none() { + // Cache support reported but zero this call must be distinguishable + // from "provider did not report cache fields at all". + let raw = r#"{ + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0 + }"#; + let usage: Usage = serde_json::from_str(raw).expect("valid anthropic usage"); + let unified: UnifiedTokenUsage = usage.into(); + assert_eq!(unified.cached_content_token_count, Some(0)); + assert_eq!(unified.cache_creation_token_count, Some(0)); + } + + #[test] + fn only_read_present_no_creation() { + let raw = r#"{ + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 30 + }"#; + let usage: Usage = serde_json::from_str(raw).expect("valid anthropic usage"); + let unified: UnifiedTokenUsage = usage.into(); + assert_eq!(unified.cached_content_token_count, Some(30)); + assert_eq!(unified.cache_creation_token_count, None); + // prompt_token_count = input + read (no creation contribution) + assert_eq!(unified.prompt_token_count, 130); + } +} diff --git a/src/crates/ai-adapters/src/stream/types/gemini.rs b/src/crates/ai-adapters/src/stream/types/gemini.rs index 4927c91b35..6bd6a15d73 100644 --- a/src/crates/ai-adapters/src/stream/types/gemini.rs +++ b/src/crates/ai-adapters/src/stream/types/gemini.rs @@ -104,6 +104,7 @@ impl From for UnifiedTokenUsage { total_token_count: usage.total_token_count, reasoning_token_count, cached_content_token_count: usage.cached_content_token_count, + cache_creation_token_count: None, } } } @@ -770,4 +771,21 @@ mod tests { .and_then(|metadata| metadata.get("promptFeedback")) .is_some()); } + + #[test] + fn gemini_cache_creation_is_always_none() { + let payload = serde_json::json!({ + "candidates": [{ "content": { "parts": [{ "text": "answer" }] } }], + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 20, + "totalTokenCount": 120, + "cachedContentTokenCount": 35 + } + }); + let data: GeminiSSEData = serde_json::from_value(payload).expect("gemini payload"); + let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + assert_eq!(usage.cached_content_token_count, Some(35)); + assert_eq!(usage.cache_creation_token_count, None); + } } diff --git a/src/crates/ai-adapters/src/stream/types/openai.rs b/src/crates/ai-adapters/src/stream/types/openai.rs index 03aeda3993..cbdcff3fe7 100644 --- a/src/crates/ai-adapters/src/stream/types/openai.rs +++ b/src/crates/ai-adapters/src/stream/types/openai.rs @@ -15,18 +15,34 @@ struct OpenAIUsage { #[serde(default)] total_tokens: u32, prompt_tokens_details: Option, + /// DeepSeek extension. Subset of `prompt_tokens`. Absent on non-DeepSeek + /// providers. Prefer this over `prompt_tokens_details.cached_tokens` when + /// both are present — DeepSeek-native is the authoritative source. + #[serde(default)] + prompt_cache_hit_tokens: Option, + /// DeepSeek extension. Equals `prompt_tokens - prompt_cache_hit_tokens`. + /// Deserialized so a future strict serde lint doesn't reject the payload; + /// not propagated (the miss count is derivable from the other two). + #[serde(default)] + #[allow(dead_code)] + prompt_cache_miss_tokens: Option, } impl From for UnifiedTokenUsage { fn from(usage: OpenAIUsage) -> Self { + let standard_cached = usage + .prompt_tokens_details + .and_then(|details| details.cached_tokens); + // DeepSeek extension wins when both present. + let cache_read = usage.prompt_cache_hit_tokens.or(standard_cached); + Self { prompt_token_count: usage.prompt_tokens, candidates_token_count: usage.completion_tokens, total_token_count: usage.total_tokens, reasoning_token_count: None, - cached_content_token_count: usage - .prompt_tokens_details - .and_then(|prompt_tokens_details| prompt_tokens_details.cached_tokens), + cached_content_token_count: cache_read, + cache_creation_token_count: None, } } } @@ -35,6 +51,13 @@ impl From for UnifiedTokenUsage { struct Choice { #[allow(dead_code)] index: usize, + /// MiniMax's last SSE frame switches to non-streaming `chat.completion` + /// shape and puts the content under `message` instead of `delta`. We don't + /// need that frame's content (earlier chunks already streamed it), but the + /// frame also carries the only authoritative `usage` block. Default the + /// field so such frames deserialize cleanly and the top-level usage flows + /// through. + #[serde(default)] delta: Delta, finish_reason: Option, #[serde(default, deserialize_with = "deserialize_optional_stringish")] @@ -50,7 +73,7 @@ struct ReasoningDetail { text: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] struct Delta { #[allow(dead_code)] role: Option, @@ -438,6 +461,57 @@ mod tests { assert!(responses[0].tool_call.is_none()); } + #[test] + fn parses_minimax_final_chunk_with_message_field_instead_of_delta() { + // MiniMax's last SSE frame uses non-streaming `chat.completion` shape: + // choice has `message` instead of `delta`, and the real usage lives at + // the top level. Pre-fix this chunk failed to deserialize (`delta` was + // a required field), so the real prompt/completion tokens were silently + // dropped. After the fix, the chunk parses cleanly and usage flows + // through. + let raw = r#"{ + "id": "065b58b7a16cf30f1e20c8f1942efeae", + "created": 1779180983, + "model": "MiniMax-M2.7-highspeed", + "object": "chat.completion", + "choices": [{ + "finish_reason": "stop", + "index": 0, + "message": { + "content": "hi", + "role": "assistant", + "name": "MiniMax AI", + "reasoning_content": "The user wants hi." + } + }], + "usage": { + "total_tokens": 92, + "prompt_tokens": 45, + "completion_tokens": 47, + "completion_tokens_details": {"reasoning_tokens": 45} + } + }"#; + + let sse_data: OpenAISSEData = serde_json::from_str(raw) + .expect("MiniMax final chunk must deserialize even without delta"); + let responses = sse_data.into_unified_responses(); + + // Critical: the usage from this chunk must propagate. + let usage = responses + .iter() + .find_map(|r| r.usage.as_ref()) + .expect("usage from MiniMax final chunk must be preserved"); + assert_eq!(usage.prompt_token_count, 45); + assert_eq!(usage.candidates_token_count, 47); + assert_eq!(usage.total_token_count, 92); + + // finish_reason should also be preserved (lives at choice top level). + assert!( + responses.iter().any(|r| r.finish_reason.as_deref() == Some("stop")), + "finish_reason from MiniMax final chunk must be preserved" + ); + } + #[test] fn handles_empty_choices_without_usage_chunk() { let raw = r#"{ @@ -692,4 +766,85 @@ mod tests { assert_eq!(responses.len(), 1); assert!(responses[0].tool_call.is_some()); } + + #[test] + fn standard_openai_cached_tokens_maps_through() { + let raw = r#"{ + "id": "chatcmpl_test", + "created": 1, + "model": "gpt-test", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_tokens_details": { "cached_tokens": 40 } + } + }"#; + let data: OpenAISSEData = serde_json::from_str(raw).expect("valid openai sse data"); + let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + assert_eq!(usage.cached_content_token_count, Some(40)); + assert_eq!(usage.cache_creation_token_count, None); + } + + #[test] + fn deepseek_prompt_cache_hit_tokens_is_captured() { + // Pre-fix this field was silently dropped (strict serde, unknown key). + let raw = r#"{ + "id": "chatcmpl_test", + "created": 1, + "model": "deepseek-chat", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_cache_hit_tokens": 64, + "prompt_cache_miss_tokens": 36 + } + }"#; + let data: OpenAISSEData = serde_json::from_str(raw).expect("valid deepseek sse data"); + let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + assert_eq!(usage.cached_content_token_count, Some(64)); + } + + #[test] + fn deepseek_extension_preferred_over_standard_cached_tokens_if_both() { + // Defensive: if a proxy forwards both, prefer the DeepSeek-native field. + let raw = r#"{ + "id": "chatcmpl_test", + "created": 1, + "model": "deepseek-chat", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_cache_hit_tokens": 64, + "prompt_tokens_details": { "cached_tokens": 0 } + } + }"#; + let data: OpenAISSEData = serde_json::from_str(raw).expect("valid proxy payload"); + let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + assert_eq!(usage.cached_content_token_count, Some(64)); + } + + #[test] + fn openai_no_cache_fields_stays_none() { + let raw = r#"{ + "id": "chatcmpl_test", + "created": 1, + "model": "gpt-test", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120 + } + }"#; + let data: OpenAISSEData = serde_json::from_str(raw).expect("valid openai sse data"); + let usage = data.into_unified_responses()[0].usage.as_ref().expect("usage").clone(); + assert_eq!(usage.cached_content_token_count, None); + assert_eq!(usage.cache_creation_token_count, None); + } } diff --git a/src/crates/ai-adapters/src/stream/types/responses.rs b/src/crates/ai-adapters/src/stream/types/responses.rs index 8e9b480713..7a093e3e59 100644 --- a/src/crates/ai-adapters/src/stream/types/responses.rs +++ b/src/crates/ai-adapters/src/stream/types/responses.rs @@ -62,6 +62,7 @@ impl From for UnifiedTokenUsage { cached_content_token_count: usage .input_tokens_details .map(|details| details.cached_tokens), + cache_creation_token_count: None, } } } @@ -128,9 +129,33 @@ pub fn parse_responses_output_item( #[cfg(test)] mod tests { - use super::{parse_responses_output_item, ResponsesCompleted, ResponsesStreamEvent}; + use super::{parse_responses_output_item, ResponsesCompleted, ResponsesStreamEvent, ResponsesUsage}; + use crate::stream::types::unified::UnifiedTokenUsage; use serde_json::json; + #[test] + fn responses_cached_tokens_maps_to_cached_content() { + let raw = r#"{ + "input_tokens": 200, + "input_tokens_details": { "cached_tokens": 80 }, + "output_tokens": 40, + "total_tokens": 240 + }"#; + let usage: ResponsesUsage = serde_json::from_str(raw).expect("valid responses usage"); + let unified: UnifiedTokenUsage = usage.into(); + assert_eq!(unified.cached_content_token_count, Some(80)); + assert_eq!(unified.cache_creation_token_count, None); + } + + #[test] + fn responses_absent_cache_stays_none() { + let raw = r#"{ "input_tokens": 200, "output_tokens": 40, "total_tokens": 240 }"#; + let usage: ResponsesUsage = serde_json::from_str(raw).expect("valid responses usage"); + let unified: UnifiedTokenUsage = usage.into(); + assert_eq!(unified.cached_content_token_count, None); + assert_eq!(unified.cache_creation_token_count, None); + } + #[test] fn parses_output_text_message_item() { let response = parse_responses_output_item( diff --git a/src/crates/ai-adapters/src/stream/types/unified.rs b/src/crates/ai-adapters/src/stream/types/unified.rs index 27048acc51..fb5948eeac 100644 --- a/src/crates/ai-adapters/src/stream/types/unified.rs +++ b/src/crates/ai-adapters/src/stream/types/unified.rs @@ -66,6 +66,16 @@ pub struct UnifiedTokenUsage { pub total_token_count: u32, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_token_count: Option, + /// Cache READ tokens (i.e., served from cache this call). Universal across + /// providers: OpenAI `cached_tokens`, DeepSeek `prompt_cache_hit_tokens`, + /// Anthropic `cache_read_input_tokens`, Gemini `cachedContentTokenCount`. + /// Hit rate consumers must use this as numerator and `prompt_token_count` + /// as denominator. #[serde(skip_serializing_if = "Option::is_none")] pub cached_content_token_count: Option, + /// Cache WRITE tokens (only Anthropic reports this per-token; others either + /// have no creation concept or bill creation by storage time). Disjoint from + /// `cached_content_token_count`. Do NOT include in hit-rate numerator. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub cache_creation_token_count: Option, } diff --git a/src/crates/ai-adapters/src/types/ai.rs b/src/crates/ai-adapters/src/types/ai.rs index 72a76d9a5b..ee89826113 100644 --- a/src/crates/ai-adapters/src/types/ai.rs +++ b/src/crates/ai-adapters/src/types/ai.rs @@ -32,6 +32,9 @@ pub struct GeminiUsage { #[serde(rename = "cachedContentTokenCount")] #[serde(skip_serializing_if = "Option::is_none")] pub cached_content_token_count: Option, + #[serde(rename = "cacheCreationTokenCount")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation_token_count: Option, } /// Structured message codes for localized connection test messaging. @@ -69,3 +72,40 @@ pub struct RemoteModelInfo { #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, } + +#[cfg(test)] +mod tests { + use super::GeminiUsage; + + #[test] + fn gemini_usage_roundtrips_cache_creation_field() { + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: Some(20), + }; + let json = serde_json::to_string(&usage).expect("serialize"); + assert!(json.contains("\"cacheCreationTokenCount\":20")); + + let parsed: GeminiUsage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.cache_creation_token_count, Some(20)); + } + + #[test] + fn gemini_usage_legacy_payload_parses_with_new_field_absent() { + // Records persisted before this plan don't have cacheCreationTokenCount; + // they must still parse, with the new field defaulting to None. + let raw = r#"{ + "promptTokenCount": 10, + "candidatesTokenCount": 5, + "totalTokenCount": 15, + "cachedContentTokenCount": 3 + }"#; + let parsed: GeminiUsage = serde_json::from_str(raw).expect("legacy payload"); + assert_eq!(parsed.cached_content_token_count, Some(3)); + assert_eq!(parsed.cache_creation_token_count, None); + } +} diff --git a/src/crates/core-types/src/session.rs b/src/crates/core-types/src/session.rs index d61a06fdea..86444a5fbd 100644 --- a/src/crates/core-types/src/session.rs +++ b/src/crates/core-types/src/session.rs @@ -6,4 +6,5 @@ pub enum SessionKind { #[default] Standard, Subagent, + EphemeralChild, } diff --git a/src/crates/core-types/tests/session_contracts.rs b/src/crates/core-types/tests/session_contracts.rs index bccd6f04d3..84d1e559c6 100644 --- a/src/crates/core-types/tests/session_contracts.rs +++ b/src/crates/core-types/tests/session_contracts.rs @@ -7,6 +7,11 @@ fn session_kind_preserves_default_and_serialized_shape() { serde_json::to_value(SessionKind::Subagent).expect("session kind should serialize"), serde_json::json!("subagent") ); + assert_eq!( + serde_json::to_value(SessionKind::EphemeralChild) + .expect("ephemeral child kind should serialize"), + serde_json::json!("ephemeral_child") + ); } #[test] diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index 5f1897d038..cbde21600f 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -29,9 +29,19 @@ SessionManager → Session → DialogTurn → ModelRound - 使用 `bitfun_events::EventEmitter` 等共享抽象 - 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 连接回来 - core 拆解期间,`bitfun-core` 是兼容 facade 与完整产品 runtime assembly 点;新模块优先放到 `docs/architecture/core-decomposition.md` 指定的 owner crate。 -- Tool 相关轻量 contract 与 generic registry/provider container 归属 `bitfun-agent-tools`;core tool runtime 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、tool exposure / manifest resolution,以及按需工具说明发现(`GetToolSpec`)。 +- Tool 相关轻量 contract、portable tool context facts/provider、纯 manifest/exposure contract 与 generic registry / static-provider / dynamic-provider container 归属 `bitfun-agent-tools`;core tool runtime 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、runtime manifest assembly / context filtering,以及按需工具说明发现(`GetToolSpec`)执行。 - `ToolUseContext` 与具体工具实现继续留在 core,除非已有评审过的 port/provider 方案和等价测试。 - Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 +- 不要把 OpenAI Responses / Codex ChatGPT flat tool schema 等 provider-specific 序列化行为写进 core tool contract;AI adapter 负责 provider 序列化,core 保持 provider-neutral manifest。 +- 调整 session/token usage 路径时,`cached_content_token_count` 必须继续表示 cache reads/hits,`cache_creation_token_count` 必须作为独立 provider fact 保留。 +- Function-agent commit-message 与 Startchat work-state orchestration 可以经由 + `bitfun-product-domains`;Git/AI service adapter、prompt template、JSON + extraction 和 error mapping 仍由 core 拥有。JSON extraction 之后的 JSON string + parser 可以放在 `bitfun-product-domains`。 +- MiniApp built-in bundle/hash/marker seed plan 与 marker wire helper 可以放在 + `bitfun-product-domains`;bundled asset include、filesystem writes、marker IO、 + customization metadata IO、recompile orchestration、worker process runtime 和 + host dispatch execution 仍由 core 拥有,直到有评审过的迁移和等价测试。 - 不要在没有小型 port/interface 边界的情况下新增 `service` 到 `agentic` 的跨层引用。 - 不要在 core 拆解中把平台专属逻辑、构建脚本行为或产品能力选择下沉到 shared core。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index ceadaf8208..4747bd07a6 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -31,15 +31,45 @@ SessionManager → Session → DialogTurn → ModelRound - During core decomposition, `bitfun-core` is a compatibility facade and full product runtime assembly point. New modules should prefer the extracted owner crate listed in `docs/architecture/core-decomposition.md`. -- For tools, keep lightweight contracts and generic registry/provider container - logic in `bitfun-agent-tools`. Core tool runtime should assemble product - tools, adapt `dyn Tool`, apply snapshot decoration, and own tool exposure / - manifest resolution plus on-demand spec discovery (`GetToolSpec`) for now. +- For tools, keep lightweight contracts, pure manifest/exposure contracts, + generic contextual prompt-manifest resolver contracts, generic catalog + snapshot provider contracts, generic GetToolSpec catalog provider/detail/ + summary/static tool surface/execution-plan/provider-backed runtime facade / execution-result/ + result-vector adapter / result-assembly helpers, and portable tool context facts/provider plus generic registry / static-provider / dynamic-provider container + contracts in `bitfun-agent-tools`. Provider-backed visible-tools / prompt-visible manifest / readonly catalog runtime facades, + generic decorator references, snapshot decorator adapters, static-provider runtime assembly, and readonly/enabled + registry-snapshot filtering belong in + `bitfun-agent-tools`; core tool runtime should materialize concrete tools from the `bitfun-tool-packs` + provider group plan and keep product snapshot wrapper adapter injection in + `runtime_assembly.rs` + `static_providers.rs`, adapt core `Tool` into + provider-neutral contracts through `tool_adapter.rs`, keep product catalog + access and product manifest / GetToolSpec facade wiring in + `catalog_provider.rs`, apply snapshot decoration, and own on-demand spec + discovery Tool impl and unlock-state source for now. + `bitfun-tool-packs` may expose planned + feature-group scaffold metadata, but it must not own concrete tools yet. - Keep `ToolUseContext` and concrete tool implementations in core unless a - reviewed port/provider plan and equivalence tests exist. + reviewed port/provider plan and equivalence tests exist. `ToolContextFacts` + / `PortableToolContextProvider` are only portable projections; they must not + carry runtime handles, workspace services, or cancellation tokens. - Any tool migration must preserve expanded/collapsed exposure, prompt-visible manifests, `ToolUseContext.unlocked_collapsed_tools`, and desktop/MCP/ACP tool catalog behavior. +- Do not encode provider-specific OpenAI Responses / Codex ChatGPT flat tool + schema behavior in core tool contracts; AI adapters own provider + serialization while core keeps provider-neutral manifests. +- When touching session/token usage paths, keep `cached_content_token_count` + as cache reads/hits and `cache_creation_token_count` as a separate provider + fact. +- Function-agent commit-message and Startchat work-state orchestration may + route through `bitfun-product-domains`; keep Git/AI service adapters, prompt + templates, JSON extraction, and error mapping core-owned. JSON-string parsing + helpers may live in `bitfun-product-domains` after extraction has happened. +- MiniApp built-in bundle/hash/marker seed-plan and marker wire helpers may + live in `bitfun-product-domains`; keep bundled asset includes, filesystem + writes, marker IO, customization metadata IO, recompile orchestration, worker + process runtime, and host dispatch execution core-owned until a reviewed + migration proves equivalence. - Do not add new cross-layer references from `service` to `agentic` without a small port/interface boundary. - Do not move platform-specific logic, build-script behavior, or product diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index c649f91a98..7b6acc2099 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -94,6 +94,9 @@ bitfun-agent-stream = { path = "../agent-stream" } # Agent tool contracts bitfun-agent-tools = { path = "../agent-tools" } +# Tool pack provider plan +bitfun-tool-packs = { path = "../tool-packs", features = ["product-full"] } + # Core service owner crate bitfun-services-core = { path = "../services-core" } diff --git a/src/crates/core/src/agentic/agents/citation_renumber.rs b/src/crates/core/src/agentic/agents/citation_renumber.rs index c31feb0b90..6306385ea9 100644 --- a/src/crates/core/src/agentic/agents/citation_renumber.rs +++ b/src/crates/core/src/agentic/agents/citation_renumber.rs @@ -393,16 +393,11 @@ async fn write_display_map_sidecar( "citation_count": order.len(), "entries": entries, }); - let serialized = serde_json::to_string_pretty(&body).map_err(|e| { - BitFunError::tool(format!("serialize display_map.json failed: {}", e)) - })?; - fs::write(&map_path, serialized).await.map_err(|e| { - BitFunError::tool(format!( - "write {} failed: {}", - map_path.display(), - e - )) - })?; + let serialized = serde_json::to_string_pretty(&body) + .map_err(|e| BitFunError::tool(format!("serialize display_map.json failed: {}", e)))?; + fs::write(&map_path, serialized) + .await + .map_err(|e| BitFunError::tool(format!("write {} failed: {}", map_path.display(), e)))?; Ok(map_path) } @@ -579,7 +574,10 @@ cit_003 | claim c | url=u3 | status=accepted assert!(data_rows[1].contains("[2] cit_001")); // cit_002 must not appear anywhere in the rendered Index. - assert!(!out.contains("cit_002"), "REJECTED cit_002 leaked into index"); + assert!( + !out.contains("cit_002"), + "REJECTED cit_002 leaked into index" + ); assert!(!out.contains("[REJECTED]"), "no REJECTED tag should remain"); } diff --git a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs b/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs index 7d4ab0038c..87f93ff0cc 100644 --- a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs +++ b/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs @@ -199,17 +199,35 @@ impl CustomSubagent { mod tests { use super::*; use std::fs; + use std::path::PathBuf; use uuid::Uuid; - fn temp_subagent_path(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("bitfun-subagent-test-{}", Uuid::new_v4())); - fs::create_dir_all(&dir).expect("temp subagent dir should be created"); - dir.join(name).to_string_lossy().to_string() + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temp dir should be created"); + Self { path } + } + + fn join(&self, name: &str) -> String { + self.path.join(name).to_string_lossy().to_string() + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } } #[test] fn review_metadata_round_trips_through_front_matter() { - let path = temp_subagent_path("review-agent.md"); + let dir = TestTempDir::new("bitfun-subagent-test"); + let path = dir.join("review-agent.md"); let mut subagent = CustomSubagent::new( "ReviewExtra".to_string(), "Additional code reviewer".to_string(), diff --git a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs index a43b7959dc..9a8b6fe742 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs @@ -1,6 +1,8 @@ //! Agentic Mode -use crate::agentic::agents::Agent; +use crate::agentic::agents::{ + shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; use async_trait::async_trait; pub struct AgenticMode { default_tools: Vec, @@ -15,26 +17,7 @@ impl Default for AgenticMode { impl AgenticMode { pub fn new() -> Self { Self { - default_tools: vec![ - "Task".to_string(), - "Read".to_string(), - "Write".to_string(), - "Edit".to_string(), - "Delete".to_string(), - "Bash".to_string(), - "Grep".to_string(), - "Glob".to_string(), - "WebSearch".to_string(), - "WebFetch".to_string(), - "TodoWrite".to_string(), - "GenerativeUI".to_string(), - "Skill".to_string(), - "AskUserQuestion".to_string(), - "Git".to_string(), - "TerminalControl".to_string(), - "ControlHub".to_string(), - "InitMiniApp".to_string(), - ], + default_tools: shared_coding_mode_tools(), } } } @@ -58,7 +41,7 @@ impl Agent for AgenticMode { } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { - "agentic_mode" + SHARED_CODING_MODE_PROMPT_TEMPLATE } fn default_tools(&self) -> Vec { @@ -69,23 +52,3 @@ impl Agent for AgenticMode { false } } - -#[cfg(test)] -mod tests { - use super::{Agent, AgenticMode}; - - #[test] - fn always_uses_default_prompt_template() { - let agent = AgenticMode::new(); - assert_eq!(agent.prompt_template_name(Some("gpt-5.1")), "agentic_mode"); - assert_eq!( - agent.prompt_template_name(Some("GPT-5-CODEX")), - "agentic_mode" - ); - assert_eq!( - agent.prompt_template_name(Some("claude-sonnet-4")), - "agentic_mode" - ); - assert_eq!(agent.prompt_template_name(None), "agentic_mode"); - } -} diff --git a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs b/src/crates/core/src/agentic/agents/definitions/modes/debug.rs index aaad02b210..6716aacda4 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/debug.rs @@ -1,6 +1,6 @@ //! Debug Mode - Evidence-driven debugging mode -use crate::agentic::agents::{Agent, PromptBuilder, PromptBuilderContext}; +use crate::agentic::agents::{get_embedded_prompt, Agent, PromptBuilder, PromptBuilderContext}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo}; @@ -11,7 +11,7 @@ use std::path::Path; pub struct DebugMode; -include!(concat!(env!("OUT_DIR"), "/embedded_agents_prompt.rs")); +const DEBUG_MODE_PROMPT_TEMPLATE: &str = "debug_mode"; impl Default for DebugMode { fn default() -> Self { @@ -179,7 +179,7 @@ impl DebugMode { section } - /// Builds session-level configuration with dynamic values like server endpoint and log path. + /// Builds session-level reminder details with dynamic values like server endpoint and log path. fn build_session_level_rule(&self, config: &DebugModeConfig, workspace_path: &str) -> String { let log_path = if config.log_path.starts_with('/') || config.log_path.starts_with('.') { config.log_path.clone() @@ -279,7 +279,7 @@ impl Agent for DebugMode { } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { - "debug_mode" + DEBUG_MODE_PROMPT_TEMPLATE } async fn build_prompt(&self, context: &PromptBuilderContext) -> BitFunResult { @@ -295,7 +295,7 @@ impl Agent for DebugMode { project_info.languages, project_info.project_types ); - let system_prompt_template = get_embedded_prompt("debug_mode") + let system_prompt_template = get_embedded_prompt(DEBUG_MODE_PROMPT_TEMPLATE) .unwrap_or("Debug mode prompt not found in embedded files"); let language_templates = @@ -320,7 +320,11 @@ impl Agent for DebugMode { Ok(prompt_list.join("")) } - async fn get_system_reminder(&self, _index: usize) -> BitFunResult { + async fn get_system_reminder( + &self, + _previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> BitFunResult { Ok(self.build_system_reminder()) } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs b/src/crates/core/src/agentic/agents/definitions/modes/mod.rs index 237d8f795e..85895d86c5 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/mod.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/mod.rs @@ -1,15 +1,17 @@ mod agentic; mod claw; mod cowork; -mod deep_research; mod debug; +mod deep_research; +mod multitask; mod plan; mod team; pub use agentic::AgenticMode; pub use claw::ClawMode; pub use cowork::CoworkMode; -pub use deep_research::DeepResearchMode; pub use debug::DebugMode; +pub use deep_research::DeepResearchMode; +pub use multitask::MultitaskMode; pub use plan::PlanMode; pub use team::TeamMode; diff --git a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs new file mode 100644 index 0000000000..10af4cb979 --- /dev/null +++ b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs @@ -0,0 +1,90 @@ +//! Multitask Mode + +use crate::agentic::agents::{ + shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; +use async_trait::async_trait; + +pub struct MultitaskMode { + default_tools: Vec, +} + +impl Default for MultitaskMode { + fn default() -> Self { + Self::new() + } +} + +impl MultitaskMode { + pub fn new() -> Self { + Self { + default_tools: shared_coding_mode_tools(), + } + } + + fn build_first_entry_reminder(&self) -> String { + r#"You are now in Multitask mode. + +Treat the task as a parallel work orchestration problem whenever it is beneficial. First decompose the work into orthogonal subtasks or an explicit DAG with clear dependency edges. Then use subagents proactively to execute independent branches in parallel. + +Prefer: +- independent subtasks with minimal overlap +- clear ownership and deliverables per subagent +- parallel execution for non-blocking branches +- background execution for independent subagents whenever possible; prefer the Task tool's `run_in_background` mode instead of blocking on each branch +- local execution only for the immediate critical path + +Do not force parallelism when the task is tiny or tightly coupled, but default to decomposition-first thinking in this mode."# + .to_string() + } + + fn build_ongoing_reminder(&self) -> String { + r#"You are still in Multitask mode. + +Continue working with a parallel-first mindset. Prefer orthogonal decomposition, preserve clear dependency edges, and keep using subagents proactively for independent branches whenever parallel execution is beneficial."# + .to_string() + } +} + +#[async_trait] +impl Agent for MultitaskMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "Multitask" + } + + fn name(&self) -> &str { + "Multitask" + } + + fn description(&self) -> &str { + "Agentic coding mode optimized for orthogonal task decomposition and proactive parallel subagent execution" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + SHARED_CODING_MODE_PROMPT_TEMPLATE + } + + async fn get_system_reminder( + &self, + previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> crate::util::errors::BitFunResult { + if previous_agent_type == Some(self.id()) { + Ok(self.build_ongoing_reminder()) + } else { + Ok(self.build_first_entry_reminder()) + } + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs index 7385aec88f..ad7f557540 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs @@ -2,6 +2,9 @@ use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; + +const PLAN_MODE_PROMPT_TEMPLATE: &str = "plan_mode"; + pub struct PlanMode { default_tools: Vec, } @@ -29,6 +32,7 @@ impl PlanMode { ], } } + } #[async_trait] @@ -50,7 +54,7 @@ impl Agent for PlanMode { } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { - "plan_mode" + PLAN_MODE_PROMPT_TEMPLATE } fn default_tools(&self) -> Vec { diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs index d806fa4758..8f79bc0198 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs +++ b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs @@ -1,9 +1,9 @@ +use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::deep_review_policy::{ REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::tools::framework::ToolExposure; use crate::define_readonly_subagent_with_overrides; diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs new file mode 100644 index 0000000000..048612640a --- /dev/null +++ b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -0,0 +1,64 @@ +use crate::agentic::agents::{Agent, RequestContextPolicy}; +use async_trait::async_trait; + +pub struct GeneralPurposeAgent { + default_tools: Vec, +} + +impl Default for GeneralPurposeAgent { + fn default() -> Self { + Self::new() + } +} + +impl GeneralPurposeAgent { + pub fn new() -> Self { + Self { + default_tools: vec![ + "Bash".to_string(), + "Read".to_string(), + "Glob".to_string(), + "Grep".to_string(), + "Write".to_string(), + "Edit".to_string(), + "WebSearch".to_string(), + "WebFetch".to_string(), + ], + } + } +} + +#[async_trait] +impl Agent for GeneralPurposeAgent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "GeneralPurpose" + } + + fn name(&self) -> &str { + "General Purpose" + } + + fn description(&self) -> &str { + r#"General-purpose implementation and research subagent for multi-step tasks that need focused codebase search, targeted file edits."# + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "general_purpose_agent" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::instructions_only() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs index ea21841eaa..37309c8e3f 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/mod.rs @@ -1,9 +1,11 @@ mod computer_use; mod explore; mod file_finder; +mod general_purpose; mod research_specialist; pub use computer_use::ComputerUseMode; pub use explore::ExploreAgent; pub use file_finder::FileFinderAgent; +pub use general_purpose::GeneralPurposeAgent; pub use research_specialist::ResearchSpecialistAgent; diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 10c8c7055b..203fe81d28 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -11,32 +11,29 @@ mod registry; pub(crate) mod citation_renumber; use crate::agentic::tools::framework::ToolExposure; +use crate::agentic::WorkspaceBinding; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use indexmap::IndexMap; pub use definitions::custom::{CustomSubagent, CustomSubagentKind}; -pub use definitions::hidden::{ - CodeReviewAgent, DeepReviewAgent, GenerateDocAgent, InitAgent, -}; +pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent, InitAgent}; pub use definitions::modes::{ - AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, PlanMode, TeamMode, + AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, MultitaskMode, PlanMode, + TeamMode, }; pub use definitions::review::{ ArchitectureReviewerAgent, BusinessLogicReviewerAgent, FrontendReviewerAgent, PerformanceReviewerAgent, ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, }; +pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - ComputerUseMode, ExploreAgent, FileFinderAgent, ResearchSpecialistAgent, + ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, }; -pub use definitions::shared::ReadonlySubagent; +use indexmap::IndexMap; pub use prompt_builder::{ PromptBuilder, PromptBuilderContext, RemoteExecutionHints, RequestContextPolicy, RequestContextSection, }; -pub use registry::{get_agent_registry, AgentRegistry, CustomSubagentDetail}; -pub use registry::catalog::{ - builtin_agent_specs, BuiltinAgentSpec, -}; +pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; pub use registry::types::{ AgentCategory, AgentInfo, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, SubagentStateReason, @@ -44,6 +41,7 @@ pub use registry::types::{ pub use registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, }; +pub use registry::{get_agent_registry, AgentRegistry, CustomSubagentDetail}; use std::any::Any; // Include embedded prompts generated at compile time @@ -54,6 +52,31 @@ pub type AgentToolPolicyOverrides = IndexMap; static EMPTY_AGENT_TOOL_POLICY_OVERRIDES: std::sync::LazyLock = std::sync::LazyLock::new(AgentToolPolicyOverrides::default); +pub const SHARED_CODING_MODE_PROMPT_TEMPLATE: &str = "agentic_mode"; + +pub fn shared_coding_mode_tools() -> Vec { + vec![ + "Task".to_string(), + "Read".to_string(), + "Write".to_string(), + "Edit".to_string(), + "Delete".to_string(), + "Bash".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "WebSearch".to_string(), + "WebFetch".to_string(), + "TodoWrite".to_string(), + "GenerativeUI".to_string(), + "Skill".to_string(), + "AskUserQuestion".to_string(), + "Git".to_string(), + "TerminalControl".to_string(), + "ControlHub".to_string(), + "InitMiniApp".to_string(), + ] +} + /// Agent trait defining the interface for all agents #[async_trait] pub trait Agent: Send + Sync + 'static { @@ -111,9 +134,13 @@ pub trait Agent: Send + Sync + 'static { /// Get the system reminder for this agent, only used for modes /// system_reminder will be appended to the user_query - /// This is not necessary for all modes - /// index is not used for now (Cursor first time enter plan mode and keep plan mode will use different reminder) - async fn get_system_reminder(&self, _index: usize) -> BitFunResult { + /// `previous_agent_type` can be used to distinguish first entry vs staying + /// in the same mode across turns. + async fn get_system_reminder( + &self, + _previous_agent_type: Option<&str>, + _workspace: Option<&WorkspaceBinding>, + ) -> BitFunResult { if let Some(system_reminder_template_name) = self.system_reminder_template_name() { let system_reminder = get_embedded_prompt(system_reminder_template_name).ok_or_else(|| { diff --git a/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md b/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md new file mode 100644 index 0000000000..16ee626dd7 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md @@ -0,0 +1,36 @@ +You are a general-purpose agent for BitFun, a desktop AI IDE and agent runtime. Given the user's message, use the available tools to complete the task. Complete the task fully. Do not over-engineer, but do not leave the task half-done. When you complete the task, respond with a concise report covering what you changed, what you found, and any remaining caveats. + +{LANGUAGE_PREFERENCE} + +## Strengths + +- Searching for code, configurations, and patterns across large codebases +- Analyzing multiple files to understand system architecture +- Investigating complex questions that require exploring many files +- Performing multi-step research tasks + +## Working style + +- Search broadly when you do not yet know where something lives. Narrow quickly once you find strong candidates. +- Prefer `Grep` and `Glob` before reading many files in full. +- Use `Read` when you know the path or have narrowed the candidate set enough that reading is justified. +- Read before you edit. Do not propose or apply changes to code you have not inspected. +- Prefer focused edits to existing files over broad rewrites. +- Do not create new files unless they are clearly necessary for completing the requested task. +- Do not proactively create documentation files such as `README` or `*.md` unless the user explicitly asks for them. + +## Editing rules + +- Preserve existing architecture, naming, and local style unless the task explicitly calls for a structural change. +- Keep changes scoped to the user's request. Avoid opportunistic refactors. +- If the task appears ambiguous or risky, bias toward the smallest safe change and explain the constraint in your final report. +- When using `Write`, prefer creating or replacing only files that are clearly intended outputs of the task. When modifying existing files, prefer `Edit` whenever practical. + +## Final response + +- Keep the final response concise and concrete. +- Include the relevant file paths you changed or inspected when they matter to the parent agent. +- Include short code snippets only when the exact text is load-bearing. +- Avoid emojis. + +{ENV_INFO} \ No newline at end of file diff --git a/src/crates/core/src/agentic/agents/registry/availability.rs b/src/crates/core/src/agentic/agents/registry/availability.rs index 07fd41f03f..93d13c41bd 100644 --- a/src/crates/core/src/agentic/agents/registry/availability.rs +++ b/src/crates/core/src/agentic/agents/registry/availability.rs @@ -66,12 +66,15 @@ pub fn subagent_override_for_parent( parent_agent_type: Option<&str>, subagent_key: &str, ) -> Option { - override_for_parent(overrides, parent_agent_type).and_then(|parent| parent.get(subagent_key).copied()) + override_for_parent(overrides, parent_agent_type) + .and_then(|parent| parent.get(subagent_key).copied()) } pub fn resolve_default_enabled(entry: &AgentEntry, parent_agent_type: Option<&str>) -> bool { match entry.subagent_source { - Some(SubAgentSource::Builtin) => entry.visibility_policy.can_access_from_parent(parent_agent_type), + Some(SubAgentSource::Builtin) => entry + .visibility_policy + .can_access_from_parent(parent_agent_type), Some(SubAgentSource::Project) | Some(SubAgentSource::User) => true, None => true, } @@ -96,7 +99,11 @@ pub fn resolve_override_layers( }, Some(SubAgentSource::Builtin) | Some(SubAgentSource::User) => ResolvedOverrideLayers { project_override: None, - user_override: subagent_override_for_parent(user_overrides, parent_agent_type, &subagent_key), + user_override: subagent_override_for_parent( + user_overrides, + parent_agent_type, + &subagent_key, + ), }, None => ResolvedOverrideLayers::default(), } @@ -109,7 +116,8 @@ pub fn resolve_availability( user_overrides: &AgentSubagentOverrideConfig, ) -> ResolvedSubagentAvailability { let default_enabled = resolve_default_enabled(entry, parent_agent_type); - let layers = resolve_override_layers(entry, parent_agent_type, project_overrides, user_overrides); + let layers = + resolve_override_layers(entry, parent_agent_type, project_overrides, user_overrides); if let Some(project_override) = layers.project_override { return ResolvedSubagentAvailability { @@ -203,7 +211,11 @@ mod tests { } } - fn overrides(parent: &str, subagent_key: &str, state: AgentSubagentOverrideState) -> AgentSubagentOverrideConfig { + fn overrides( + parent: &str, + subagent_key: &str, + state: AgentSubagentOverrideState, + ) -> AgentSubagentOverrideConfig { let mut parent_overrides = HashMap::new(); parent_overrides.insert(subagent_key.to_string(), state); @@ -215,16 +227,24 @@ mod tests { #[test] fn builtin_and_user_subagents_only_use_global_overrides() { let builtin_entry = make_entry(SubAgentSource::Builtin, "Explore"); - let builtin_key = subagent_key_for(builtin_entry.subagent_source, builtin_entry.agent.as_ref()) - .expect("builtin key"); + let builtin_key = + subagent_key_for(builtin_entry.subagent_source, builtin_entry.agent.as_ref()) + .expect("builtin key"); let builtin_layers = resolve_override_layers( &builtin_entry, Some("agentic"), - Some(&overrides("agentic", &builtin_key, AgentSubagentOverrideState::Disabled)), + Some(&overrides( + "agentic", + &builtin_key, + AgentSubagentOverrideState::Disabled, + )), &overrides("agentic", &builtin_key, AgentSubagentOverrideState::Enabled), ); assert_eq!(builtin_layers.project_override, None); - assert_eq!(builtin_layers.user_override, Some(AgentSubagentOverrideState::Enabled)); + assert_eq!( + builtin_layers.user_override, + Some(AgentSubagentOverrideState::Enabled) + ); let user_entry = make_entry(SubAgentSource::User, "UserScout"); let user_key = subagent_key_for(user_entry.subagent_source, user_entry.agent.as_ref()) @@ -232,26 +252,40 @@ mod tests { let user_layers = resolve_override_layers( &user_entry, Some("agentic"), - Some(&overrides("agentic", &user_key, AgentSubagentOverrideState::Disabled)), + Some(&overrides( + "agentic", + &user_key, + AgentSubagentOverrideState::Disabled, + )), &overrides("agentic", &user_key, AgentSubagentOverrideState::Enabled), ); assert_eq!(user_layers.project_override, None); - assert_eq!(user_layers.user_override, Some(AgentSubagentOverrideState::Enabled)); + assert_eq!( + user_layers.user_override, + Some(AgentSubagentOverrideState::Enabled) + ); } #[test] fn project_subagents_only_use_project_overrides() { let entry = make_entry(SubAgentSource::Project, "ProjectScout"); - let key = subagent_key_for(entry.subagent_source, entry.agent.as_ref()) - .expect("project key"); + let key = + subagent_key_for(entry.subagent_source, entry.agent.as_ref()).expect("project key"); let layers = resolve_override_layers( &entry, Some("agentic"), - Some(&overrides("agentic", &key, AgentSubagentOverrideState::Disabled)), + Some(&overrides( + "agentic", + &key, + AgentSubagentOverrideState::Disabled, + )), &overrides("agentic", &key, AgentSubagentOverrideState::Enabled), ); - assert_eq!(layers.project_override, Some(AgentSubagentOverrideState::Disabled)); + assert_eq!( + layers.project_override, + Some(AgentSubagentOverrideState::Disabled) + ); assert_eq!(layers.user_override, None); } } diff --git a/src/crates/core/src/agentic/agents/registry/builtin.rs b/src/crates/core/src/agentic/agents/registry/builtin.rs index 401e34567a..82738c54bc 100644 --- a/src/crates/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/core/src/agentic/agents/registry/builtin.rs @@ -1,10 +1,8 @@ use super::types::AgentEntry; use super::visibility::SubagentVisibilityPolicy; use super::AgentRegistry; -use crate::agentic::agents::{ - Agent, AgentCategory, SubAgentSource, -}; use crate::agentic::agents::registry::catalog::builtin_agent_specs; +use crate::agentic::agents::{Agent, AgentCategory, SubAgentSource}; use log::error; use std::collections::HashMap; use std::sync::Arc; @@ -12,7 +10,7 @@ use std::sync::Arc; pub(crate) fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { match agent_type { "agentic" | "Cowork" | "ComputerUse" | "Plan" | "debug" | "Claw" | "DeepResearch" - | "Team" => "auto", + | "Team" | "Multitask" => "auto", "DeepReview" | "ReviewBusinessLogic" | "ReviewPerformance" @@ -22,6 +20,7 @@ pub(crate) fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static s | "ReviewJudge" | "ReviewFixer" => "fast", "Explore" | "FileFinder" | "CodeReview" | "GenerateDoc" | "Init" => "primary", + "GeneralPurpose" => "fast", _ => "fast", } } diff --git a/src/crates/core/src/agentic/agents/registry/catalog.rs b/src/crates/core/src/agentic/agents/registry/catalog.rs index 120ebdc3b0..c0691f399e 100644 --- a/src/crates/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/core/src/agentic/agents/registry/catalog.rs @@ -2,10 +2,10 @@ use super::types::AgentCategory; use super::visibility::SubagentVisibilityPolicy; use crate::agentic::agents::{ Agent, AgenticMode, ArchitectureReviewerAgent, BusinessLogicReviewerAgent, ClawMode, - CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, - DeepReviewAgent, ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GenerateDocAgent, - InitAgent, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, - ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, + CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchMode, DeepReviewAgent, + ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GeneralPurposeAgent, GenerateDocAgent, + InitAgent, MultitaskMode, PerformanceReviewerAgent, PlanMode, ResearchSpecialistAgent, + ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, }; use std::sync::Arc; @@ -33,6 +33,11 @@ pub fn builtin_agent_specs() -> Vec { category: AgentCategory::Mode, visibility_policy: SubagentVisibilityPolicy::default(), }, + BuiltinAgentSpec { + factory: || Arc::new(MultitaskMode::new()), + category: AgentCategory::Mode, + visibility_policy: SubagentVisibilityPolicy::default(), + }, BuiltinAgentSpec { factory: || Arc::new(PlanMode::new()), category: AgentCategory::Mode, @@ -63,6 +68,11 @@ pub fn builtin_agent_specs() -> Vec { category: AgentCategory::SubAgent, visibility_policy: SubagentVisibilityPolicy::public(), }, + BuiltinAgentSpec { + factory: || Arc::new(GeneralPurposeAgent::new()), + category: AgentCategory::SubAgent, + visibility_policy: SubagentVisibilityPolicy::public(), + }, BuiltinAgentSpec { factory: || Arc::new(ResearchSpecialistAgent::new()), category: AgentCategory::SubAgent, diff --git a/src/crates/core/src/agentic/agents/registry/custom.rs b/src/crates/core/src/agentic/agents/registry/custom.rs index 9f7a56d98f..08d1c9f64b 100644 --- a/src/crates/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/core/src/agentic/agents/registry/custom.rs @@ -1,16 +1,15 @@ -use super::types::AgentEntry; -use super::custom_loader::CustomSubagentLoader; use super::availability::{prune_override_config, resolve_default_enabled, set_override_state}; +use super::custom_loader::CustomSubagentLoader; use super::support::{ - get_subagent_overrides, load_project_subagent_overrides_local, save_project_subagent_overrides_local, -}; -use super::{CustomSubagentDetail, AgentRegistry}; -use crate::agentic::agents::{ - Agent, AgentCategory, CustomSubagentConfig, SubAgentSource, + get_subagent_overrides, load_project_subagent_overrides_local, + save_project_subagent_overrides_local, }; +use super::types::AgentEntry; +use super::{AgentRegistry, CustomSubagentDetail}; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::types::subagent_key_for; use crate::agentic::agents::registry::visibility::SubagentVisibilityPolicy; +use crate::agentic::agents::{Agent, AgentCategory, CustomSubagentConfig, SubAgentSource}; use crate::agentic::tools::{get_all_registered_tool_names, get_readonly_registered_tool_names}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::AgentSubagentOverrideState; @@ -590,9 +589,10 @@ impl AgentRegistry { ))); } - let subagent_key = subagent_key_for(entry.subagent_source, entry.agent.as_ref()).ok_or_else( - || BitFunError::agent(format!("Failed to resolve subagent key for '{}'", agent_id)), - )?; + let subagent_key = subagent_key_for(entry.subagent_source, entry.agent.as_ref()) + .ok_or_else(|| { + BitFunError::agent(format!("Failed to resolve subagent key for '{}'", agent_id)) + })?; let default_enabled = resolve_default_enabled(&entry, Some(parent_agent_type)); let state = if enabled { AgentSubagentOverrideState::Enabled @@ -608,11 +608,17 @@ impl AgentRegistry { agent_id )) })?; - let mut project_overrides = load_project_subagent_overrides_local(workspace_root).await?; + let mut project_overrides = + load_project_subagent_overrides_local(workspace_root).await?; if enabled == default_enabled { prune_override_config(&mut project_overrides, parent_agent_type, &subagent_key); } else { - set_override_state(&mut project_overrides, parent_agent_type, &subagent_key, state); + set_override_state( + &mut project_overrides, + parent_agent_type, + &subagent_key, + state, + ); } save_project_subagent_overrides_local(workspace_root, &project_overrides).await?; Ok(()) @@ -623,7 +629,12 @@ impl AgentRegistry { if enabled == default_enabled { prune_override_config(&mut user_overrides, parent_agent_type, &subagent_key); } else { - set_override_state(&mut user_overrides, parent_agent_type, &subagent_key, state); + set_override_state( + &mut user_overrides, + parent_agent_type, + &subagent_key, + state, + ); } config_service .set_config("ai.agent_subagent_overrides", &user_overrides) diff --git a/src/crates/core/src/agentic/agents/registry/custom_loader.rs b/src/crates/core/src/agentic/agents/registry/custom_loader.rs index c70ee16908..f6d310c6bd 100644 --- a/src/crates/core/src/agentic/agents/registry/custom_loader.rs +++ b/src/crates/core/src/agentic/agents/registry/custom_loader.rs @@ -76,7 +76,10 @@ impl CustomSubagentLoader { /// Agents with the same name are prioritized by path order: earlier paths have higher priority, later ones won't override already loaded agents with the same name. pub fn load_custom_subagents(workspace_root: &Path) -> Vec { let mut candidates = Vec::new(); - for (root_priority, entry) in Self::get_possible_paths(workspace_root).into_iter().enumerate() { + for (root_priority, entry) in Self::get_possible_paths(workspace_root) + .into_iter() + .enumerate() + { for md_path in Self::list_md_files(&entry.path) { let path_str = md_path.to_string_lossy(); match CustomSubagent::from_file(path_str.as_ref(), entry.kind) { @@ -99,7 +102,12 @@ impl CustomSubagentLoader { candidates.sort_by(|a, b| { a.root_priority .cmp(&b.root_priority) - .then_with(|| a.agent.id().to_lowercase().cmp(&b.agent.id().to_lowercase())) + .then_with(|| { + a.agent + .id() + .to_lowercase() + .cmp(&b.agent.id().to_lowercase()) + }) .then_with(|| a.agent.id().cmp(b.agent.id())) .then_with(|| a.path.cmp(&b.path)) }); diff --git a/src/crates/core/src/agentic/agents/registry/mod.rs b/src/crates/core/src/agentic/agents/registry/mod.rs index e43d109396..89af200bb0 100644 --- a/src/crates/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/core/src/agentic/agents/registry/mod.rs @@ -1,4 +1,3 @@ -pub mod types; mod availability; mod builtin; pub mod catalog; @@ -7,17 +6,14 @@ mod custom_loader; mod query; mod resolution; mod support; -pub mod visibility; #[cfg(test)] mod tests; +pub mod types; +pub mod visibility; -use super::{ - Agent, -}; use self::types::AgentEntry; -use self::types::{ - AgentCategory, SubAgentSource, -}; +use self::types::{AgentCategory, SubAgentSource}; +use super::Agent; use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/src/crates/core/src/agentic/agents/registry/query.rs b/src/crates/core/src/agentic/agents/registry/query.rs index d6347ac659..7f9076cfe3 100644 --- a/src/crates/core/src/agentic/agents/registry/query.rs +++ b/src/crates/core/src/agentic/agents/registry/query.rs @@ -65,7 +65,8 @@ impl AgentRegistry { let allowed_tool_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); let mut exposure_overrides = entry.agent.tool_exposure_overrides().clone(); - exposure_overrides.retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); + exposure_overrides + .retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); AgentToolPolicy { allowed_tools, @@ -77,7 +78,8 @@ impl AgentRegistry { let allowed_tool_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); let mut exposure_overrides = entry.agent.tool_exposure_overrides().clone(); - exposure_overrides.retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); + exposure_overrides + .retain(|tool_name, _| allowed_tool_set.contains(tool_name.as_str())); AgentToolPolicy { allowed_tools, @@ -113,11 +115,12 @@ impl AgentRegistry { let order = |id: &str| -> u8 { match id { "agentic" => 0, - "Cowork" => 1, - "Plan" => 2, - "debug" => 3, - "DeepResearch" => 4, - "Team" => 5, + "Multitask" => 1, + "Cowork" => 2, + "Plan" => 3, + "debug" => 4, + "DeepResearch" => 5, + "Team" => 6, _ => 99, } }; @@ -184,7 +187,9 @@ impl AgentRegistry { } match query.list_scope { - SubagentListScope::RegistryManagement => entry.visibility_policy.show_in_global_registry, + SubagentListScope::RegistryManagement => { + entry.visibility_policy.show_in_global_registry + } SubagentListScope::TaskVisible => { entry.visibility_policy.show_in_global_registry || entry @@ -306,7 +311,8 @@ impl AgentRegistry { }; if let Some(workspace_root) = query.workspace_root { - let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_root); + let is_project_cache_loaded = + self.read_project_subagents().contains_key(workspace_root); if !is_project_cache_loaded { self.load_custom_subagents(workspace_root).await; } diff --git a/src/crates/core/src/agentic/agents/registry/support.rs b/src/crates/core/src/agentic/agents/registry/support.rs index 23eaeb5217..ff755326ed 100644 --- a/src/crates/core/src/agentic/agents/registry/support.rs +++ b/src/crates/core/src/agentic/agents/registry/support.rs @@ -1,5 +1,5 @@ -use crate::service::config::global::GlobalConfigManager; use crate::infrastructure::get_path_manager_arc; +use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{AgentSubagentOverrideConfig, ModeConfig}; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::{Map, Value}; @@ -73,7 +73,10 @@ pub(super) fn merge_dynamic_mcp_tools( continue; } - if configured_tools.iter().any(|existing| existing == tool_name) { + if configured_tools + .iter() + .any(|existing| existing == tool_name) + { continue; } diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index e0bb0d448e..bd6c45ac01 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -1,7 +1,7 @@ use super::support::merge_dynamic_mcp_tools; use super::AgentRegistry; -use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; +use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::registry::types::{ AgentCategory, AgentEntry, CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, @@ -71,6 +71,7 @@ fn insert_project_subagent(registry: &AgentRegistry, workspace: &Path, id: &str, fn top_level_modes_default_to_auto() { for agent_type in [ "agentic", + "Multitask", "Cowork", "Plan", "debug", @@ -119,6 +120,14 @@ fn non_deep_review_builtin_subagents_default_to_primary() { } } +#[test] +fn general_purpose_builtin_subagent_defaults_to_fast() { + assert_eq!( + default_model_id_for_builtin_agent("GeneralPurpose"), + "fast" + ); +} + #[test] fn deep_review_family_defaults_to_fast() { for agent_type in [ @@ -185,7 +194,12 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { }) .await; assert!(agentic_visible.iter().any(|agent| agent.id == "Explore")); - assert!(!agentic_visible.iter().any(|agent| agent.id == "ReviewSecurity")); + assert!(agentic_visible + .iter() + .any(|agent| agent.id == "GeneralPurpose")); + assert!(!agentic_visible + .iter() + .any(|agent| agent.id == "ReviewSecurity")); assert!(!agentic_visible .iter() .any(|agent| agent.id == "ResearchSpecialist")); @@ -298,8 +312,14 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() ); let mut project_entries = HashMap::new(); - project_entries.insert("zProject".to_string(), test_project_entry("zProject", "fast")); - project_entries.insert("AProject".to_string(), test_project_entry("AProject", "fast")); + project_entries.insert( + "zProject".to_string(), + test_project_entry("zProject", "fast"), + ); + project_entries.insert( + "AProject".to_string(), + test_project_entry("AProject", "fast"), + ); registry .write_project_subagents() .insert(workspace.clone(), project_entries); @@ -339,6 +359,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() "ABuiltin", "Explore", "FileFinder", + "GeneralPurpose", "zBuiltin", "AProject", "zProject", @@ -408,14 +429,26 @@ async fn parent_subagent_overrides_follow_source_scopes() { let builtin_override_key = "builtin::builtin::Explore".to_string(); let mut project_parent_map = HashMap::new(); - project_parent_map.insert(project_override_key.clone(), AgentSubagentOverrideState::Disabled); - project_parent_map.insert(user_override_key.clone(), AgentSubagentOverrideState::Disabled); - project_parent_map.insert(builtin_override_key.clone(), AgentSubagentOverrideState::Disabled); + project_parent_map.insert( + project_override_key.clone(), + AgentSubagentOverrideState::Disabled, + ); + project_parent_map.insert( + user_override_key.clone(), + AgentSubagentOverrideState::Disabled, + ); + project_parent_map.insert( + builtin_override_key.clone(), + AgentSubagentOverrideState::Disabled, + ); let mut project_overrides = HashMap::new(); project_overrides.insert("agentic".to_string(), project_parent_map); let mut user_parent_map = HashMap::new(); - user_parent_map.insert(project_override_key.clone(), AgentSubagentOverrideState::Enabled); + user_parent_map.insert( + project_override_key.clone(), + AgentSubagentOverrideState::Enabled, + ); user_parent_map.insert(user_override_key, AgentSubagentOverrideState::Disabled); user_parent_map.insert(builtin_override_key, AgentSubagentOverrideState::Disabled); let mut user_overrides = HashMap::new(); @@ -435,13 +468,37 @@ async fn parent_subagent_overrides_follow_source_scopes() { .expect("project entry"); ( - resolve_availability(&explore, builtin_query.parent_agent_type, Some(&project_overrides), &user_overrides), - resolve_availability(&user, builtin_query.parent_agent_type, Some(&project_overrides), &user_overrides), - resolve_availability(&project, builtin_query.parent_agent_type, Some(&project_overrides), &user_overrides), + resolve_availability( + &explore, + builtin_query.parent_agent_type, + Some(&project_overrides), + &user_overrides, + ), + resolve_availability( + &user, + builtin_query.parent_agent_type, + Some(&project_overrides), + &user_overrides, + ), + resolve_availability( + &project, + builtin_query.parent_agent_type, + Some(&project_overrides), + &user_overrides, + ), ) }; - assert_eq!(visible.0.override_state, Some(AgentSubagentOverrideState::Disabled)); - assert_eq!(visible.1.override_state, Some(AgentSubagentOverrideState::Disabled)); - assert_eq!(visible.2.override_state, Some(AgentSubagentOverrideState::Disabled)); + assert_eq!( + visible.0.override_state, + Some(AgentSubagentOverrideState::Disabled) + ); + assert_eq!( + visible.1.override_state, + Some(AgentSubagentOverrideState::Disabled) + ); + assert_eq!( + visible.2.override_state, + Some(AgentSubagentOverrideState::Disabled) + ); } diff --git a/src/crates/core/src/agentic/agents/registry/types.rs b/src/crates/core/src/agentic/agents/registry/types.rs index 3d0dc2b13e..7bb1bcc7ec 100644 --- a/src/crates/core/src/agentic/agents/registry/types.rs +++ b/src/crates/core/src/agentic/agents/registry/types.rs @@ -1,13 +1,13 @@ use crate::agentic::agents::definitions::custom::{CustomSubagent, CustomSubagentKind}; +use crate::agentic::agents::registry::visibility::{ + SubagentVisibilityPolicy, SubagentVisibilitySummary, +}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; use crate::agentic::deep_review_policy::{ REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; -use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; -use crate::agentic::agents::registry::visibility::{ - SubagentVisibilityPolicy, SubagentVisibilitySummary, -}; use crate::service::config::types::AgentSubagentOverrideState; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -128,10 +128,7 @@ fn default_true() -> bool { true } -pub fn subagent_key_for( - source: Option, - agent: &dyn Agent, -) -> Option { +pub fn subagent_key_for(source: Option, agent: &dyn Agent) -> Option { let source = source?; let slot = match source { SubAgentSource::Builtin => "builtin", @@ -164,7 +161,10 @@ impl AgentInfo { let default_tools = agent.default_tools(); // get model from custom_config; path by downcast - let model = entry.custom_config.as_ref().map(|config| config.model.clone()); + let model = entry + .custom_config + .as_ref() + .map(|config| config.model.clone()); // get path by downcast to CustomSubagent (only custom subagent has path) let path = agent @@ -173,7 +173,8 @@ impl AgentInfo { .map(|c| c.path.clone()); AgentInfo { - key: subagent_key_for(entry.subagent_source, agent).unwrap_or_else(|| agent.id().to_string()), + key: subagent_key_for(entry.subagent_source, agent) + .unwrap_or_else(|| agent.id().to_string()), id: agent.id().to_string(), name: agent.name().to_string(), description: agent.description().to_string(), diff --git a/src/crates/core/src/agentic/agents/registry/visibility.rs b/src/crates/core/src/agentic/agents/registry/visibility.rs index 2632649827..ff32324d4e 100644 --- a/src/crates/core/src/agentic/agents/registry/visibility.rs +++ b/src/crates/core/src/agentic/agents/registry/visibility.rs @@ -108,8 +108,10 @@ impl SubagentVisibilityPolicy { match self.exposure { BuiltinSubagentExposure::Public => true, - BuiltinSubagentExposure::Restricted | BuiltinSubagentExposure::Hidden => normalized_parent - .is_some_and(|parent| self.allowed_parent_agent_ids.contains(parent)), + BuiltinSubagentExposure::Restricted | BuiltinSubagentExposure::Hidden => { + normalized_parent + .is_some_and(|parent| self.allowed_parent_agent_ids.contains(parent)) + } } } } diff --git a/src/crates/core/src/agentic/context_profile.rs b/src/crates/core/src/agentic/context_profile.rs index a969490888..3b108af974 100644 --- a/src/crates/core/src/agentic/context_profile.rs +++ b/src/crates/core/src/agentic/context_profile.rs @@ -207,7 +207,7 @@ impl ContextProfilePolicy { fn is_long_task_agent(agent_type: &str) -> bool { matches!( agent_type, - "agentic" | "DeepReview" | "DeepResearch" | "ComputerUse" | "Team" + "agentic" | "Multitask" | "DeepReview" | "DeepResearch" | "ComputerUse" | "Team" ) || agent_type.starts_with("Review") } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index bb1d6f44e0..d1968da19b 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -17,7 +17,7 @@ use crate::agentic::fork_agent::{ ForkAgentContextSnapshot, ForkAgentExecutionRequest, ForkAgentExecutionResult, }; use crate::agentic::image_analysis::ImageContextData; -use crate::agentic::round_preempt::{DialogRoundPreemptSource, DialogRoundSteeringSource}; +use crate::agentic::round_preempt::{DialogRoundInjectionSource, DialogRoundPreemptSource}; use crate::agentic::session::SessionManager; use crate::agentic::side_question::build_btw_user_input; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; @@ -26,11 +26,15 @@ use crate::agentic::WorkspaceBinding; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; +use crate::service::session::{SessionRelationship, SessionRelationshipKind}; use crate::service::config::global::GlobalConfigManager; +use crate::service::workspace::{ + get_global_workspace_service, WorkspaceCreateOptions, WorkspaceKind, +}; use crate::util::errors::{BitFunError, BitFunResult}; use dashmap::DashMap; use log::{debug, error, info, warn}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -96,6 +100,54 @@ impl SubagentResult { } } +#[derive(Debug, Clone)] +pub struct BackgroundSubagentStartResult { + pub background_task_id: String, +} + +fn format_background_subagent_delivery_text( + background_task_id: &str, + agent_type: &str, + outcome: Result<&SubagentResult, &BitFunError>, +) -> String { + match outcome { + Ok(result) => { + if result.is_partial_timeout() { + format!( + "Background subagent '{}' (background_task_id='{}') completed with partial timeout result:\n\n{}\n", + agent_type, background_task_id, result.text + ) + } else { + format!( + "Background subagent '{}' (background_task_id='{}') completed successfully:\n\n{}\n", + agent_type, background_task_id, result.text + ) + } + } + Err(error) => { + format!( + "Background subagent '{}' (background_task_id='{}') failed before producing a final result.\nError: {}", + agent_type, background_task_id, error + ) + } + } +} + +fn build_subagent_session_relationship( + parent_info: Option<&SubagentParentInfo>, + agent_type: &str, +) -> SessionRelationship { + SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: parent_info.map(|info| info.session_id.clone()), + parent_request_id: None, + parent_dialog_turn_id: parent_info.map(|info| info.dialog_turn_id.clone()), + parent_turn_index: None, + parent_tool_call_id: parent_info.map(|info| info.tool_call_id.clone()), + subagent_type: Some(agent_type.to_string()), + } +} + struct HiddenSubagentExecutionRequest { session_name: String, agent_type: String, @@ -301,7 +353,7 @@ pub struct ConversationCoordinator { /// Round-boundary yield (same source as scheduler's yield flags); injected after construction round_preempt_source: OnceLock>, /// Round-boundary user steering source (mid-turn user message injection); injected after construction - round_steering_source: OnceLock>, + round_injection_source: OnceLock>, /// In-flight dialog turn tracker per session, used to serialize cancel→start /// transitions so a new turn never starts touching the in-memory message /// list while the previous (cancelled) turn's spawn task is still draining. @@ -311,6 +363,38 @@ pub struct ConversationCoordinator { } impl ConversationCoordinator { + async fn track_session_workspace_activity_best_effort(config: &SessionConfig, reason: &str) { + let Some(workspace_path) = config.workspace_path.as_ref() else { + return; + }; + + let Some(workspace_service) = get_global_workspace_service() else { + return; + }; + + let mut options = WorkspaceCreateOptions { + auto_set_current: false, + add_to_recent: true, + ..Default::default() + }; + + if config.remote_connection_id.is_some() { + options.workspace_kind = WorkspaceKind::Remote; + options.remote_connection_id = config.remote_connection_id.clone(); + options.remote_ssh_host = config.remote_ssh_host.clone(); + } + + if let Err(error) = workspace_service + .track_workspace_activity(PathBuf::from(workspace_path), options) + .await + { + warn!( + "Failed to track session workspace activity: reason={}, workspace_path={}, error={}", + reason, workspace_path, error + ); + } + } + /// Build a workspace binding that is remote-aware. /// If the global remote workspace is active and matches the session path, /// returns a `WorkspaceBinding` with remote metadata and correct local @@ -719,7 +803,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_timeout_registry: Arc::new(RwLock::new(HashMap::new())), scheduler_notify_tx: OnceLock::new(), round_preempt_source: OnceLock::new(), - round_steering_source: OnceLock::new(), + round_injection_source: OnceLock::new(), active_turns_per_session: Arc::new(DashMap::new()), } } @@ -735,10 +819,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let _ = self.round_preempt_source.set(source); } - /// Wire round-boundary user-steering source (typically the scheduler's - /// [`SessionSteeringBuffer`](crate::agentic::round_preempt::SessionSteeringBuffer)). - pub fn set_round_steering_source(&self, source: Arc) { - let _ = self.round_steering_source.set(source); + /// Wire round-boundary injection source (typically the scheduler's + /// [`SessionRoundInjectionBuffer`](crate::agentic::round_preempt::SessionRoundInjectionBuffer)). + pub fn set_round_injection_source(&self, source: Arc) { + let _ = self.round_injection_source.set(source); } /// Dynamically adjust a running subagent's timeout. @@ -878,6 +962,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; + Self::track_session_workspace_activity_best_effort(&session.config, "session_created") + .await; + // SessionManager::create_session_with_id_and_creator already persists the // session into the effective workspace session storage path. Avoid writing // a second copy here using the raw workspace path, because remote workspaces @@ -894,7 +981,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(session) } - /// Create a hidden, non-persisted session that is still addressable by the UI. + /// Create a hidden internal subagent session that is persisted but excluded + /// from normal user-facing session lists. pub async fn create_hidden_subagent_session_with_workspace( &self, session_id: Option, @@ -931,6 +1019,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, turn_id: &str, turn_index: usize, + agent_type: &str, user_input: &str, workspace_path: &str, // Pre-resolved on-disk session storage path (mirror dir for remote workspaces). @@ -993,6 +1082,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet snapshot_session_id: None, tags: Vec::new(), custom_metadata: None, + relationship: None, todos: None, deep_review_run_manifest: None, deep_review_cache: None, @@ -1025,6 +1115,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet metadata: user_message_metadata, }, ); + turn_data.agent_type = Some(agent_type.to_string()); turn_data.status = status; turn_data.end_time = Some(now_ms); turn_data.duration_ms = Some(now_ms.saturating_sub(turn_data.start_time)); @@ -1100,6 +1191,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet async fn wrap_user_input( &self, agent_type: &str, + previous_agent_type: Option<&str>, user_input: String, workspace: Option<&WorkspaceBinding>, ) -> BitFunResult { @@ -1112,24 +1204,27 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let current_agent = agent_registry .get_agent(agent_type, workspace.map(|binding| binding.root_path())) .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; - let system_reminder = current_agent.get_system_reminder(0).await?; + let system_reminder = current_agent + .get_system_reminder(previous_agent_type, workspace) + .await?; - let mut wrapped_user_input = if has_prompt_markup(&user_input) { - user_input + if has_prompt_markup(&user_input) { + if system_reminder.is_empty() { + Ok(user_input) + } else { + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(system_reminder); + envelope.push_user_query(user_input); + Ok(envelope.render()) + } } else { let mut envelope = PromptEnvelope::new(); - envelope.push_user_query(user_input); - envelope.render() - }; - if !system_reminder.is_empty() { - let mut envelope = PromptEnvelope::new(); - envelope.push_system_reminder(system_reminder); - if !wrapped_user_input.is_empty() { - wrapped_user_input.push('\n'); + if !system_reminder.is_empty() { + envelope.push_system_reminder(system_reminder); } - wrapped_user_input.push_str(&envelope.render()); + envelope.push_user_query(user_input); + Ok(envelope.render()) } - Ok(wrapped_user_input) } pub async fn ensure_assistant_bootstrap( @@ -1376,7 +1471,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input: MANUAL_COMPACTION_COMMAND.to_string(), original_user_input: None, user_message_metadata: user_message_metadata.clone(), - subagent_parent_info: None, }) .await; @@ -1439,7 +1533,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet total_rounds: 1, total_tools: 1, duration_ms: outcome.duration_ms, - subagent_parent_info: None, partial_recovery_reason: None, success: Some(true), finish_reason: Some("complete".to_string()), @@ -1477,7 +1570,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet error: error_text.clone(), error_category: Some(err.error_category()), error_detail: Some(err.error_detail()), - subagent_parent_info: None, }) .await; Err(err) @@ -1520,6 +1612,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } }; + let previous_agent_type = session.last_user_dialog_agent_type.clone(); let requested_agent_type = agent_type.trim().to_string(); let provisional_agent_type = if !requested_agent_type.is_empty() { requested_agent_type.clone() @@ -1530,6 +1623,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let effective_agent_type = Self::normalize_agent_type(&provisional_agent_type); + Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started") + .await; + debug!( "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}, skip_tool_confirmation={}", session_id, @@ -1754,6 +1850,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let wrapped_user_input = self .wrap_user_input( &effective_agent_type, + previous_agent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), user_input, session_workspace.as_ref(), ) @@ -1778,6 +1878,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .start_dialog_turn( &session_id, + effective_agent_type.clone(), wrapped_user_input.clone(), turn_id, image_contexts, @@ -1831,7 +1932,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet None }, user_message_metadata: user_message_metadata.clone(), - subagent_parent_info: None, }) .await; @@ -1911,7 +2011,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services, round_preempt: self.round_preempt_source.get().cloned(), - round_steering: self.round_steering_source.get().cloned(), + round_injection: self.round_injection_source.get().cloned(), recover_partial_on_cancel: false, }; @@ -2046,7 +2146,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } let workspace_turn_status = match execution_engine - .execute_dialog_turn(effective_agent_type_clone, messages, execution_context) + .execute_dialog_turn( + effective_agent_type_clone.clone(), + messages, + execution_context, + ) .await { Ok(execution_result) => { @@ -2132,7 +2236,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet AgenticEvent::DialogTurnCancelled { session_id: session_id_clone.clone(), turn_id: turn_id_clone.clone(), - subagent_parent_info: None, }, Some(EventPriority::Critical), ) @@ -2199,7 +2302,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet error: error_text.clone(), error_category: Some(e.error_category()), error_detail: Some(e.error_detail()), - subagent_parent_info: None, }, Some(EventPriority::Critical), ) @@ -2269,6 +2371,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id_clone, &turn_id_clone, turn_index, + &effective_agent_type_clone, &user_input_for_workspace, wp, session_storage_path_for_finalize.as_deref(), @@ -2457,6 +2560,41 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(()) } + pub async fn delete_hidden_subagent_sessions_for_parent_turns( + &self, + workspace_path: &Path, + parent_session_id: &str, + parent_dialog_turn_ids: &HashSet, + ) -> BitFunResult> { + let session_ids = self + .session_manager + .collect_hidden_subagent_cascade_for_parent_turns( + workspace_path, + parent_session_id, + parent_dialog_turn_ids, + ) + .await?; + + let mut deleted_session_ids = Vec::new(); + + for session_id in session_ids { + if let Err(e) = self + .cancel_active_turn_for_session(&session_id, Duration::from_secs(2)) + .await + { + warn!( + "Failed to cancel hidden subagent session before deletion: session_id={}, parent_session_id={}, error={}", + session_id, parent_session_id, e + ); + } + + self.delete_session(workspace_path, &session_id).await?; + deleted_session_ids.push(session_id); + } + + Ok(deleted_session_ids) + } + /// Restore session pub async fn restore_session( &self, @@ -2468,11 +2606,69 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_internal_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.session_manager + .restore_internal_session(workspace_path, session_id) + .await + } + + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_session_with_turns(workspace_path, session_id) + .await + } + + pub async fn restore_internal_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_internal_session_with_turns(workspace_path, session_id) + .await + } + + /// Restore only the UI-visible persisted session view. + pub async fn restore_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_session_view(workspace_path, session_id) + .await + } + + pub async fn restore_internal_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_internal_session_view(workspace_path, session_id) + .await + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { self.session_manager.list_sessions(workspace_path).await } + pub async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + self.session_manager + .resolve_session_workspace_path(session_id) + .await + } + /// Get a best-effort message view for a session. pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { self.session_manager.get_messages(session_id).await @@ -2876,6 +3072,26 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; let session_id = session.session_id.clone(); + self.session_manager + .persist_session_lineage( + &session_id, + build_subagent_session_relationship( + subagent_parent_info.as_ref(), + &agent_type, + ), + ) + .await?; + + if let Some(parent_info) = subagent_parent_info.as_ref() { + self.emit_event(AgenticEvent::SubagentSessionLinked { + session_id: session_id.clone(), + parent_session_id: parent_info.session_id.clone(), + parent_dialog_turn_id: parent_info.dialog_turn_id.clone(), + parent_tool_call_id: parent_info.tool_call_id.clone(), + agent_type: Some(agent_type.clone()), + }) + .await; + } // Register timeout handle so it can be adjusted at runtime. let timeout_handle = Arc::new(SubagentTimeoutHandle { @@ -2950,9 +3166,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; - // Emit DialogTurnStarted with subagent_parent_info so the frontend can - // associate the subagent session ID with the parent tool (enabling the - // "ignore timeout" feature for deep-review subagents). + // Emit DialogTurnStarted after the dedicated linking event. let user_input_text = initial_messages .first() .map(|m| match &m.content { @@ -2967,7 +3181,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input: user_input_text, original_user_input: None, user_message_metadata: None, - subagent_parent_info: subagent_parent_info.clone().map(Into::into), }) .await; @@ -2991,7 +3204,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Subagents are autonomous; user steering is targeted at top-level // dialog turns only. Leave None so we don't intercept buffer entries // that belong to a different (parent) session/turn. - round_steering: None, + round_injection: None, recover_partial_on_cancel: true, }; @@ -3419,12 +3632,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .unwrap_or("Side thread") .to_string(); let child_session = self - .create_hidden_subagent_session( + .session_manager + .create_session_with_id_and_details( Some(child_session_id.to_string()), session_name, snapshot.parent_agent_type.clone(), snapshot.build_child_session_config(None), Some(format!("session-{}", snapshot.parent_session_id)), + SessionKind::EphemeralChild, ) .await?; @@ -3603,9 +3818,113 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } - /// Clean up subagent session resources + pub async fn start_background_subagent( + &self, + agent_type: String, + task_description: String, + subagent_parent_info: SubagentParentInfo, + workspace_path: Option, + context: Option>, + model_id: Option, + timeout_seconds: Option, + ) -> BitFunResult { + let workspace_path = workspace_path.ok_or_else(|| { + BitFunError::Validation( + "workspace_path is required when creating a background subagent session" + .to_string(), + ) + })?; + let model_id = model_id + .map(|model_id| model_id.trim().to_string()) + .filter(|model_id| !model_id.is_empty()); + let parent_session = self + .session_manager + .get_session(&subagent_parent_info.session_id) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Parent session not found: {}", + subagent_parent_info.session_id + )) + })?; + let parent_agent_type = parent_session.agent_type.clone(); + let parent_workspace_path = parent_session.config.workspace_path.clone(); + let background_task_id = format!("bg-subagent-{}", uuid::Uuid::new_v4()); + let background_task_id_for_delivery = background_task_id.clone(); + let request = HiddenSubagentExecutionRequest { + session_name: format!("Subagent: {}", task_description), + agent_type: agent_type.clone(), + session_config: Self::build_session_config_for_workspace(workspace_path, model_id) + .await, + initial_messages: vec![Message::user(task_description.clone())], + created_by: Some(format!("session-{}", subagent_parent_info.session_id)), + subagent_parent_info: Some(subagent_parent_info.clone()), + context: context.unwrap_or_default(), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + }; + let coordinator = get_global_coordinator().ok_or_else(|| { + BitFunError::service("Coordinator not initialized".to_string()) + })?; + + tokio::spawn(async move { + let delivery_text = match coordinator + .execute_hidden_subagent_internal(request, None, timeout_seconds) + .await + { + Ok(result) => format_background_subagent_delivery_text( + &background_task_id_for_delivery, + &agent_type, + Ok(&result), + ), + Err(error) => format_background_subagent_delivery_text( + &background_task_id_for_delivery, + &agent_type, + Err(&error), + ), + }; + + let metadata = serde_json::json!({ + "kind": "background_subagent_result", + "backgroundTaskId": background_task_id_for_delivery, + "subagentType": agent_type, + "taskDescription": task_description, + }); + + if let Some(scheduler) = super::scheduler::get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_subagent_result( + subagent_parent_info.session_id.clone(), + parent_agent_type, + parent_workspace_path, + delivery_text, + None, + Some(metadata), + ) + .await + { + warn!( + "Failed to deliver background subagent result: background_task_id={}, parent_session_id={}, error={}", + background_task_id_for_delivery, + subagent_parent_info.session_id, + error + ); + } + } else { + warn!( + "Scheduler not initialized; background subagent result dropped: background_task_id={}, parent_session_id={}", + background_task_id_for_delivery, + subagent_parent_info.session_id + ); + } + }); + + Ok(BackgroundSubagentStartResult { background_task_id }) + } + + /// Clean up runtime-only subagent resources. /// - /// Release resources occupied by subagent session (sandbox, etc.) and delete session + /// Subagent sessions are now persisted so users can reopen them from the UI. + /// This cleanup path must only release ephemeral runtime resources such as + /// snapshot bookkeeping; it must not delete the persisted session itself. async fn cleanup_subagent_resources(&self, session_id: &str) -> BitFunResult<()> { let cleanup_started_at = Instant::now(); debug!( @@ -3649,43 +3968,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); } - // Delete the subagent session itself, including runtime context and persisted turn data. - let workspace_path = self - .session_manager - .get_session(session_id) - .and_then(|session| session.config.workspace_path.map(std::path::PathBuf::from)); - - if let Some(workspace_path) = workspace_path { - debug!( - "Subagent cleanup stage starting: session_id={}, stage=session_delete, workspace_path={}", - session_id, - workspace_path.display() - ); - let stage_started_at = Instant::now(); - if let Err(e) = self - .session_manager - .delete_session(&workspace_path, session_id) - .await - { - warn!( - "Failed to delete subagent session: session={}, error={}", - session_id, e - ); - } else { - debug!("Subagent session deleted: session={}", session_id); - } - debug!( - "Subagent cleanup stage completed: session_id={}, stage=session_delete, duration_ms={}", - session_id, - stage_started_at.elapsed().as_millis() - ); - } else { - warn!( - "Failed to delete subagent session because workspace_path is missing: session={}", - session_id - ); - } - debug!( "Subagent resource cleanup completed: session_id={}, duration_ms={}", session_id, @@ -3804,7 +4086,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: session_id.to_string(), turn_id: turn_id.to_string(), queue_state, - subagent_parent_info: None, }; let _ = self .event_queue @@ -4184,6 +4465,46 @@ mod tests { assert_eq!(normalize_subagent_max_concurrency(usize::MAX), 64); } + #[test] + fn background_subagent_delivery_text_includes_background_task_id() { + let completed = super::SubagentResult::completed("done".to_string()); + let completed_text = super::format_background_subagent_delivery_text( + "bg-subagent-123", + "GeneralPurpose", + Ok(&completed), + ); + assert!(completed_text.contains( + "Background subagent 'GeneralPurpose' (background_task_id='bg-subagent-123') completed successfully:" + )); + assert!(completed_text.contains("\n")); + assert!(!completed_text.contains("background_task_id=\"bg-subagent-123\"")); + + let partial = super::SubagentResult::partial_timeout( + "partial".to_string(), + "timeout".to_string(), + ); + let partial_text = super::format_background_subagent_delivery_text( + "bg-subagent-456", + "GeneralPurpose", + Ok(&partial), + ); + assert!(partial_text.contains( + "Background subagent 'GeneralPurpose' (background_task_id='bg-subagent-456') completed with partial timeout result:" + )); + assert!(partial_text.contains("\n")); + assert!(!partial_text.contains("background_task_id=\"bg-subagent-456\"")); + + let failed_text = super::format_background_subagent_delivery_text( + "bg-subagent-789", + "GeneralPurpose", + Err(&crate::util::errors::BitFunError::tool("boom".to_string())), + ); + assert!(failed_text.contains( + "Background subagent 'GeneralPurpose' (background_task_id='bg-subagent-789') failed before producing a final result." + )); + assert!(failed_text.contains("Error:")); + } + #[test] fn agent_submission_turn_id_prefers_explicit_field_over_metadata() { let mut metadata = serde_json::Map::new(); diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index a94ec44768..724d19d736 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -15,8 +15,9 @@ use super::turn_outcome::{TurnOutcome, TurnOutcomeQueueAction, TurnOutcomeStatus use crate::agentic::core::{PromptEnvelope, SessionState}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{ - DialogRoundPreemptSource, DialogRoundSteeringSource, SessionRoundYieldFlags, - SessionSteeringBuffer, SteeringMessage, + DialogRoundInjectionSource, DialogRoundPreemptSource, RoundInjection, + RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, + SessionRoundYieldFlags, }; use crate::agentic::session::SessionManager; use dashmap::DashMap; @@ -163,9 +164,9 @@ pub struct DialogScheduler { outcome_tx: mpsc::Sender<(String, TurnOutcome)>, /// When a user submits while `Processing`, engine yields after the current model round. round_yield_flags: Arc, - /// Per-session FIFO buffer of user "steering" messages drained at round boundaries + /// Per-session FIFO buffer of round injections drained at round boundaries /// by the engine and injected into the running dialog turn. - steering_buffer: Arc, + round_injection_buffer: Arc, } /// Outcome of [`DialogScheduler::submit_steering`]. @@ -200,7 +201,7 @@ impl DialogScheduler { suppressed_cancelled_replies: Arc::new(DashMap::new()), outcome_tx, round_yield_flags: Arc::new(SessionRoundYieldFlags::default()), - steering_buffer: Arc::new(SessionSteeringBuffer::default()), + round_injection_buffer: Arc::new(SessionRoundInjectionBuffer::default()), }); let scheduler_for_handler = Arc::clone(&scheduler); @@ -221,9 +222,9 @@ impl DialogScheduler { self.round_yield_flags.clone() } - /// Pass to [`ConversationCoordinator::set_round_steering_source`](super::coordinator::ConversationCoordinator::set_round_steering_source). - pub fn steering_monitor(&self) -> Arc { - self.steering_buffer.clone() + /// Pass to [`ConversationCoordinator::set_round_injection_source`](super::coordinator::ConversationCoordinator::set_round_injection_source). + pub fn round_injection_monitor(&self) -> Arc { + self.round_injection_buffer.clone() } /// Submit a user "steering" message into the currently running dialog turn. @@ -265,21 +266,22 @@ impl DialogScheduler { let steering_id = Uuid::new_v4().to_string(); let display = display_content.unwrap_or_else(|| content.clone()); - let message = SteeringMessage { + let message = RoundInjection { id: steering_id.clone(), - turn_id: turn_id.clone(), + kind: RoundInjectionKind::UserSteering, + target: RoundInjectionTarget::ExactTurn(turn_id.clone()), content, display_content: display, created_at: SystemTime::now(), }; - self.steering_buffer.push(&session_id, message); + self.round_injection_buffer.push(&session_id, message); info!( "Steering message buffered: session_id={}, turn_id={}, steering_id={}, pending={}", session_id, turn_id, steering_id, - self.steering_buffer.pending_count(&session_id) + self.round_injection_buffer.pending_count(&session_id) ); Ok(DialogSteerOutcome::Buffered { @@ -289,6 +291,61 @@ impl DialogScheduler { }) } + /// Deliver a completed background subagent result back to the parent + /// session. If the session is currently processing, inject the result into + /// the running turn at the next model-round boundary. Otherwise, start a + /// new turn immediately so the result is handled without waiting for an + /// unrelated future message. + pub async fn deliver_background_subagent_result( + &self, + session_id: String, + agent_type: String, + workspace_path: Option, + content: String, + display_content: Option, + user_message_metadata: Option, + ) -> Result<(), String> { + let display = display_content.unwrap_or_else(|| content.clone()); + let state = self + .session_manager + .get_session(&session_id) + .map(|s| s.state.clone()); + + match state { + Some(SessionState::Processing { .. }) => { + let injection_id = Uuid::new_v4().to_string(); + self.round_injection_buffer.push( + &session_id, + RoundInjection { + id: injection_id, + kind: RoundInjectionKind::BackgroundSubagentResult, + target: RoundInjectionTarget::CurrentRunningTurn, + content, + display_content: display, + created_at: SystemTime::now(), + }, + ); + Ok(()) + } + _ => { + self.submit( + session_id, + content, + Some(display), + None, + agent_type, + workspace_path, + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + None, + user_message_metadata, + None, + ) + .await + .map(|_| ()) + } + } + } + fn user_message_may_preempt(policy: &DialogSubmissionPolicy) -> bool { matches!( policy.trigger_source, @@ -715,7 +772,7 @@ Status: {status}" // outcome is processed (race window between turn finalize and the // next turn starting). Targeting by turn_id keeps those alive. let _drained = self - .steering_buffer + .round_injection_buffer .drain_for_turn(&session_id, outcome.turn_id()); let suppressed_cancelled_reply = self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index f5155cb778..2539c11757 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -11,7 +11,15 @@ use uuid::Uuid; pub struct Session { pub session_id: String, pub session_name: String, + /// Current/default mode selection for the session. + /// This reflects what the next turn should run with by default, not + /// necessarily what the last surviving history turn used. pub agent_type: String, + /// Cached mode of the last surviving user dialog turn in history. + /// `previous_agent_type` reminders should read this instead of `agent_type` + /// so rollback-to-empty can still be treated as a fresh mode entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_user_dialog_agent_type: Option, #[serde( default, skip_serializing_if = "Option::is_none", @@ -71,6 +79,7 @@ impl Session { session_id: Uuid::new_v4().to_string(), session_name, agent_type, + last_user_dialog_agent_type: None, created_by: None, kind: SessionKind::Standard, snapshot_session_id: None, @@ -95,6 +104,7 @@ impl Session { session_id, session_name, agent_type, + last_user_dialog_agent_type: None, created_by: None, kind: SessionKind::Standard, snapshot_session_id: None, diff --git a/src/crates/core/src/agentic/deep_review/execution_policy.rs b/src/crates/core/src/agentic/deep_review/execution_policy.rs index 3ddeb3a9bf..87b9371271 100644 --- a/src/crates/core/src/agentic/deep_review/execution_policy.rs +++ b/src/crates/core/src/agentic/deep_review/execution_policy.rs @@ -328,16 +328,19 @@ impl DeepReviewExecutionPolicy { fn apply_strategy_runtime_budget(&mut self) { let budget = strategy_runtime_budget(self.strategy_level); - self.reviewer_timeout_seconds = - strategy_bounded_timeout(self.reviewer_timeout_seconds, budget.reviewer_timeout_seconds); + self.reviewer_timeout_seconds = strategy_bounded_timeout( + self.reviewer_timeout_seconds, + budget.reviewer_timeout_seconds, + ); self.judge_timeout_seconds = strategy_bounded_timeout(self.judge_timeout_seconds, budget.judge_timeout_seconds); self.reviewer_file_split_threshold = strategy_bounded_split_threshold( self.reviewer_file_split_threshold, budget.reviewer_file_split_threshold, ); - self.max_same_role_instances = - self.max_same_role_instances.min(budget.max_same_role_instances); + self.max_same_role_instances = self + .max_same_role_instances + .min(budget.max_same_role_instances); } /// Returns true when the file count exceeds the split threshold and @@ -436,7 +439,10 @@ fn strategy_bounded_timeout(configured_timeout_seconds: u64, strategy_timeout_se configured_timeout_seconds.min(strategy_timeout_seconds) } -fn strategy_bounded_split_threshold(configured_threshold: usize, strategy_threshold: usize) -> usize { +fn strategy_bounded_split_threshold( + configured_threshold: usize, + strategy_threshold: usize, +) -> usize { if configured_threshold == 0 || strategy_threshold == 0 { return 0; } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index c76362afff..83ab84014f 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -5,21 +5,24 @@ use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult}; use crate::agentic::agents::{ - get_agent_registry, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, + PromptBuilder, PromptBuilderContext, RemoteExecutionHints, get_agent_registry, }; use crate::agentic::context_profile::{ContextProfilePolicy, ModelCapabilityProfile}; use crate::agentic::core::{ - render_system_reminder, Message, MessageContent, MessageHelper, MessageRole, - MessageSemanticKind, RequestReasoningTokenPolicy, Session, + Message, MessageContent, MessageHelper, MessageRole, MessageSemanticKind, + RequestReasoningTokenPolicy, Session, render_system_reminder, }; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::execution::types::FinishReason; use crate::agentic::image_analysis::{ - build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, - ImageLimits, + ImageContextData, ImageLimits, build_multimodal_message_with_images, + process_image_contexts_for_provider, }; +use crate::agentic::round_preempt::RoundInjectionKind; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; -use crate::agentic::tools::{resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions}; +use crate::agentic::tools::{ + ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, resolve_tool_manifest, +}; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; use crate::infrastructure::ai::get_global_ai_client_factory; @@ -31,9 +34,10 @@ use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; +use bitfun_agent_tools::{GetToolSpecLoadObservation, collect_loaded_collapsed_tool_names}; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -498,35 +502,32 @@ impl ExecutionEngine { messages: &[Message], collapsed_tools: &[String], ) -> Vec { - let collapsed_set: HashSet<&str> = collapsed_tools.iter().map(String::as_str).collect(); - let mut unlocked = BTreeSet::new(); - - for message in messages { - let MessageContent::ToolResult { - tool_name, - result, - is_error, - .. - } = &message.content - else { - continue; - }; - - if *is_error || tool_name != "GetToolSpec" { - continue; - } - - let Some(tool_name) = result.get("tool_name").and_then(|v| v.as_str()) - else { - continue; - }; + let observations = messages + .iter() + .filter_map(|message| { + let MessageContent::ToolResult { + tool_name, + result, + is_error, + .. + } = &message.content + else { + return None; + }; - if collapsed_set.contains(tool_name) { - unlocked.insert(tool_name.to_string()); - } - } + Some(GetToolSpecLoadObservation { + tool_name, + loaded_tool_name: result.get("tool_name").and_then(|v| v.as_str()), + is_error: *is_error, + }) + }) + .collect::>(); - unlocked.into_iter().collect() + collect_loaded_collapsed_tool_names( + &observations, + collapsed_tools, + crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME, + ) } async fn build_prompt_context( @@ -988,7 +989,7 @@ impl ExecutionEngine { &self, session_id: &str, dialog_turn_id: &str, - subagent_parent_info: Option, + _subagent_parent_info: Option, messages: Vec, current_tokens: usize, context_window: usize, @@ -997,7 +998,6 @@ impl ExecutionEngine { compression_contract_limit: usize, tail_policy: CompressionTailPolicy, ) -> BitFunResult)>> { - let event_subagent_parent_info = subagent_parent_info.map(|info| info.clone().into()); let mut session = self .session_manager .get_session(session_id) @@ -1029,7 +1029,6 @@ impl ExecutionEngine { tokens_before: current_tokens, context_window, threshold: session.config.compression_threshold, - subagent_parent_info: event_subagent_parent_info.clone(), }, EventPriority::Normal, ) @@ -1106,7 +1105,6 @@ impl ExecutionEngine { duration_ms, has_summary: compression_result.has_model_summary, summary_source: summary_source.to_string(), - subagent_parent_info: event_subagent_parent_info.clone(), }, EventPriority::Normal, ) @@ -1122,7 +1120,6 @@ impl ExecutionEngine { turn_id: dialog_turn_id.to_string(), compression_id: compression_id.clone(), error: e.to_string(), - subagent_parent_info: event_subagent_parent_info.clone(), }, EventPriority::High, ) @@ -1162,7 +1159,6 @@ impl ExecutionEngine { tokens_before: current_tokens, context_window, threshold: session.config.compression_threshold, - subagent_parent_info: None, }, EventPriority::Normal, ) @@ -1193,7 +1189,6 @@ impl ExecutionEngine { duration_ms, has_summary: false, summary_source: "none".to_string(), - subagent_parent_info: None, }, EventPriority::Normal, ) @@ -1278,7 +1273,6 @@ impl ExecutionEngine { } else { "local_fallback".to_string() }, - subagent_parent_info: None, }, EventPriority::Normal, ) @@ -1307,7 +1301,6 @@ impl ExecutionEngine { turn_id: dialog_turn_id.to_string(), compression_id: compression_id.clone(), error: err.to_string(), - subagent_parent_info: None, }, EventPriority::High, ) @@ -1365,8 +1358,6 @@ impl ExecutionEngine { start_time: std::time::Instant, initial_count: usize, ) -> BitFunResult { - let event_subagent_parent_info = - context.subagent_parent_info.clone().map(|info| info.into()); let dialog_turn_id = context.dialog_turn_id.clone(); debug!( @@ -1856,8 +1847,8 @@ impl ExecutionEngine { agent_type: agent_type.clone(), context_vars: round_context_vars, runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), - steering_interrupt: context.round_steering.as_ref().map(|source| { - crate::agentic::round_preempt::DialogRoundSteeringInterrupt::new( + steering_interrupt: context.round_injection.as_ref().map(|source| { + crate::agentic::round_preempt::DialogRoundInjectionInterrupt::new( context.session_id.clone(), context.dialog_turn_id.clone(), Arc::clone(source), @@ -2095,26 +2086,28 @@ impl ExecutionEngine { // contrast with the `round_preempt` path below which finalizes the turn so a // queued *new turn* can take over. If the model wanted to finish but the user // steered, we keep the turn running so the steering message gets a response. - let mut steering_injected = false; - if let Some(steer) = context.round_steering.as_ref() { - let pending = steer.take_pending(&context.session_id, &context.dialog_turn_id); + let mut injection_applied = false; + if let Some(source) = context.round_injection.as_ref() { + let pending = source.take_pending(&context.session_id, &context.dialog_turn_id); if !pending.is_empty() { info!( - "Injecting {} user steering message(s) at round boundary: session_id={}, dialog_turn_id={}, round_index={}", + "Injecting {} round message(s) at round boundary: session_id={}, dialog_turn_id={}, round_index={}", pending.len(), context.session_id, context.dialog_turn_id, round_index ); - for steering_msg in pending { - // Wrap the steering content in a system_reminder envelope so the - // model treats it as an out-of-band course correction layered on - // top of the running task, not as a brand-new top-level instruction - // that supersedes everything before it. Matches Codex CLI semantics. - let wrapped = format!( - "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", - steering_msg.content - ); + for injection in pending { + let wrapped = match injection.kind { + RoundInjectionKind::UserSteering => format!( + "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", + injection.content + ), + RoundInjectionKind::BackgroundSubagentResult => format!( + "\nA background subagent has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground subagent result:\n{}\n", + injection.content + ), + }; let user_msg = Message::user(wrapped); messages.push(user_msg.clone()); if let Err(e) = self @@ -2130,15 +2123,14 @@ impl ExecutionEngine { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), round_index, - steering_id: steering_msg.id, - content: steering_msg.content, - display_content: steering_msg.display_content, - subagent_parent_info: event_subagent_parent_info.clone(), + steering_id: injection.id, + content: injection.content, + display_content: injection.display_content, }, EventPriority::Normal, ) .await; - steering_injected = true; + injection_applied = true; } } } @@ -2157,7 +2149,7 @@ impl ExecutionEngine { // (write the answer), and continue. // - Model emitted nothing at all -> partial recovery / truncation. // Retrying without new context will not help, so end the turn. - if steering_injected { + if injection_applied { // fall through to next round so the model can respond to the steering } else if !round_result.has_more_rounds { if round_result.had_assistant_text { @@ -2227,7 +2219,6 @@ impl ExecutionEngine { AgenticEvent::DialogTurnCancelled { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), - subagent_parent_info: event_subagent_parent_info.clone(), }, EventPriority::High, ) @@ -2395,7 +2386,6 @@ impl ExecutionEngine { total_rounds: completed_rounds, total_tools, duration_ms, - subagent_parent_info: event_subagent_parent_info, partial_recovery_reason: last_partial_recovery_reason, success: Some(success), finish_reason: Some(effective_finish_reason.to_string()), @@ -2813,6 +2803,98 @@ mod tests { assert_eq!(unlocked, vec!["WebFetch".to_string()]); } + #[test] + fn collect_unlocked_collapsed_tools_dedupes_and_filters_runtime_unlocks() { + let unlocked = ExecutionEngine::collect_unlocked_collapsed_tools( + &[ + Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "WebFetch", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + Message::tool_result(ToolResult { + tool_id: "tool-2".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "WebFetch", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + Message::tool_result(ToolResult { + tool_id: "tool-3".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "Git", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + Message::tool_result(ToolResult { + tool_id: "tool-4".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "Read", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + Message::tool_result(ToolResult { + tool_id: "tool-5".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": "GetFileDiff", + }), + result_for_assistant: None, + is_error: true, + duration_ms: Some(1), + image_attachments: None, + }), + Message::tool_result(ToolResult { + tool_id: "tool-6".to_string(), + tool_name: "GetToolSpec".to_string(), + result: json!({ + "tool_name": 42, + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + Message::tool_result(ToolResult { + tool_id: "tool-7".to_string(), + tool_name: "Read".to_string(), + result: json!({ + "tool_name": "GetFileDiff", + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + ], + &[ + "WebFetch".to_string(), + "GetFileDiff".to_string(), + "Git".to_string(), + ], + ); + + assert_eq!(unlocked, vec!["Git".to_string(), "WebFetch".to_string()]); + } + #[test] fn detects_tool_result_after_last_assistant() { let assistant = Message::assistant_with_tools( diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 5035a02ec7..3dd1b712dd 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -4,15 +4,15 @@ use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult}; use super::types::{FinishReason, RoundContext, RoundResult}; -use crate::agentic::MessageContent; use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; use crate::agentic::tools::implementations::file_write_tool::FileWriteTool; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::tools::ToolPathOperation; +use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; use crate::service::config::GlobalConfigManager; use crate::util::elapsed_ms_u64; @@ -75,7 +75,6 @@ impl RoundExecutor { let round_started_at = Instant::now(); let subagent_parent_info = context.subagent_parent_info.clone(); let is_subagent = subagent_parent_info.is_some(); - let event_subagent_parent_info = subagent_parent_info.clone().map(|info| info.into()); let round_id = uuid::Uuid::new_v4().to_string(); @@ -100,7 +99,6 @@ impl RoundExecutor { turn_id: context.dialog_turn_id.clone(), round_id: round_id.clone(), round_index: context.round_number, - subagent_parent_info: event_subagent_parent_info.clone(), model_id: Some(context.model_name.clone()), }, EventPriority::High, @@ -192,7 +190,6 @@ impl RoundExecutor { context.session_id.clone(), context.dialog_turn_id.clone(), round_id.clone(), - subagent_parent_info.clone(), &cancel_token, StreamProcessOptions { recover_partial_on_cancel: context.recover_partial_on_cancel, @@ -245,9 +242,9 @@ impl RoundExecutor { ); self.emit_failed_partial_tool_calls( &context, + &round_id, &result.tool_calls, &err_msg, - event_subagent_parent_info.clone(), ) .await; let mut recovered = result; @@ -259,9 +256,9 @@ impl RoundExecutor { self.emit_failed_partial_tool_calls( &context, + &round_id, &result.tool_calls, &err_msg, - event_subagent_parent_info.clone(), ) .await; return Err(BitFunError::AIClient(format!( @@ -317,9 +314,9 @@ impl RoundExecutor { let err_msg = "Provider returned only invalid tool arguments"; self.emit_failed_partial_tool_calls( &context, + &round_id, &result.tool_calls, err_msg, - event_subagent_parent_info.clone(), ) .await; return Err(BitFunError::AIClient(format!( @@ -469,7 +466,6 @@ impl RoundExecutor { turn_id: context.dialog_turn_id.clone(), round_id: round_id.clone(), has_tool_calls: !stream_result.tool_calls.is_empty(), - subagent_parent_info: event_subagent_parent_info.clone(), duration_ms: Some(elapsed_ms_u64(round_started_at)), provider_id: None, model_id: Some(context.model_name.clone()), @@ -563,10 +559,10 @@ impl RoundExecutor { .generate_write_tool_contents( ai_client.clone(), &context, + &round_id, &ai_messages, tool_calls, &cancel_token, - event_subagent_parent_info.clone(), ) .await?; @@ -582,6 +578,7 @@ impl RoundExecutor { let tool_context = ToolExecutionContext { session_id: context.session_id.clone(), dialog_turn_id: context.dialog_turn_id.clone(), + round_id: round_id.clone(), agent_type: context.agent_type.clone(), workspace: context.workspace.clone(), context_vars: context.context_vars.clone(), @@ -837,10 +834,10 @@ impl RoundExecutor { &self, ai_client: Arc, context: &RoundContext, + round_id: &str, ai_messages: &[AIMessage], mut tool_calls: Vec, cancel_token: &CancellationToken, - subagent_parent_info: Option, ) -> BitFunResult> { // Find indices of Write tool calls that need content generation let write_indices: Vec = tool_calls @@ -899,13 +896,13 @@ impl RoundExecutor { AgenticEvent::ToolEvent { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), tool_event: ToolEventData::Started { tool_id: tool_id.clone(), tool_name: "Write".to_string(), params: tc.arguments.clone(), timeout_seconds: None, }, - subagent_parent_info: subagent_parent_info.clone(), }, EventPriority::High, ) @@ -926,11 +923,36 @@ impl RoundExecutor { 5. Do NOT output anything outside the tags — no explanations, no commentary, \ no thinking blocks, no markdown fences (```), no extra XML wrapper tags.\n\ 6. The text between the tags must be EXACTLY what gets written to disk — raw file content only.\n\ + 7. Do NOT output any tool_call XML, JSON tool invocations, or agent framework syntax inside the tags. \ + You are not calling a tool here — you are outputting raw file content.\n\ \n", file_path = file_path ); - let mut content_messages = ai_messages.to_vec(); + // Strip tool_calls and tool results from history to prevent weak models + // from imitating tool-call format inside the generated file content. + let mut content_messages: Vec = ai_messages + .iter() + .filter_map(|m| { + if m.role == "tool" { + // Drop tool result messages entirely + None + } else if m.tool_calls.is_some() { + // Replace assistant tool-call messages with a plain-text summary + let names = m + .tool_calls + .as_ref() + .unwrap() + .iter() + .map(|tc| tc.name.as_str()) + .collect::>() + .join(", "); + Some(AIMessage::assistant(format!("[called tools: {names}]"))) + } else { + Some(m.clone()) + } + }) + .collect(); // Add an assistant prefill to prime the model to output content directly // inside the tags, reducing the chance of preamble text. content_messages.push(AIMessage::user(content_prompt)); @@ -982,12 +1004,12 @@ impl RoundExecutor { AgenticEvent::ToolEvent { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), tool_event: ToolEventData::ParamsPartial { tool_id: tool_id.clone(), tool_name: "Write".to_string(), params: params.to_string(), }, - subagent_parent_info: subagent_parent_info.clone(), }, EventPriority::Normal, ) @@ -1040,12 +1062,12 @@ impl RoundExecutor { AgenticEvent::ToolEvent { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), tool_event: ToolEventData::ParamsPartial { tool_id: tool_id.clone(), tool_name: "Write".to_string(), params: final_params.to_string(), }, - subagent_parent_info: subagent_parent_info.clone(), }, EventPriority::Normal, ) @@ -1168,15 +1190,16 @@ impl RoundExecutor { async fn emit_failed_partial_tool_calls( &self, context: &RoundContext, + round_id: &str, tool_calls: &[ToolCall], error: &str, - subagent_parent_info: Option, ) { for tool_call in tool_calls { self.emit_event( AgenticEvent::ToolEvent { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), tool_event: ToolEventData::Failed { tool_id: tool_call.tool_id.clone(), tool_name: tool_call.tool_name.clone(), @@ -1187,7 +1210,6 @@ impl RoundExecutor { confirmation_wait_ms: None, execution_ms: None, }, - subagent_parent_info: subagent_parent_info.clone(), }, EventPriority::High, ) @@ -1331,6 +1353,13 @@ fn token_details_from_usage( serde_json::json!(cached_tokens), ); } + // Cache writes (Anthropic only at the moment). Disjoint from reads. + if let Some(creation_tokens) = usage.cache_creation_token_count { + details.insert( + "cacheCreationTokenCount".to_string(), + serde_json::json!(creation_tokens), + ); + } (!details.is_empty()).then_some(serde_json::Value::Object(details)) } @@ -1577,12 +1606,12 @@ fn detect_placeholder_patterns(content: &str) -> Option<&'static str> { #[cfg(test)] mod tests { - use super::{RoundExecutor, StreamProcessor, extract_bitfun_contents}; - use crate::agentic::WorkspaceBinding; + use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::types::RoundContext; use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use dashmap::DashMap; use std::collections::HashMap; use std::path::PathBuf; @@ -1666,12 +1695,10 @@ mod tests { let _ = std::fs::remove_dir_all(&root); - assert!( - error - .as_deref() - .unwrap_or_default() - .contains("already exists") - ); + assert!(error + .as_deref() + .unwrap_or_default() + .contains("already exists")); } #[tokio::test] @@ -1950,4 +1977,50 @@ mod tests { use super::detect_placeholder_patterns; assert!(detect_placeholder_patterns("").is_none()); } + + #[test] + fn token_details_emits_both_cache_keys_when_present() { + use crate::util::types::ai::GeminiUsage; + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: Some(20), + }; + let details = super::token_details_from_usage(&usage).expect("details"); + assert_eq!(details.get("cachedContentTokenCount").and_then(|v| v.as_u64()), Some(30)); + assert_eq!(details.get("cacheCreationTokenCount").and_then(|v| v.as_u64()), Some(20)); + } + + #[test] + fn token_details_emits_only_read_when_creation_absent() { + use crate::util::types::ai::GeminiUsage; + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: Some(30), + cache_creation_token_count: None, + }; + let details = super::token_details_from_usage(&usage).expect("details"); + assert_eq!(details.get("cachedContentTokenCount").and_then(|v| v.as_u64()), Some(30)); + assert!(details.get("cacheCreationTokenCount").is_none()); + } + + #[test] + fn token_details_is_none_when_no_cache_info() { + use crate::util::types::ai::GeminiUsage; + let usage = GeminiUsage { + prompt_token_count: 100, + candidates_token_count: 20, + total_token_count: 120, + reasoning_token_count: None, + cached_content_token_count: None, + cache_creation_token_count: None, + }; + assert!(super::token_details_from_usage(&usage).is_none()); + } } diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index 3221e167a0..01001efdbf 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -2,7 +2,6 @@ use crate::agentic::core::ToolCall; use crate::agentic::events::EventQueue; -use crate::agentic::tools::SubagentParentInfo; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; use futures::stream::BoxStream; @@ -91,7 +90,6 @@ impl StreamProcessor { session_id: String, dialog_turn_id: String, round_id: String, - subagent_parent_info: Option, cancellation_token: &CancellationToken, ) -> Result { self.process_stream_with_options( @@ -101,7 +99,6 @@ impl StreamProcessor { session_id, dialog_turn_id, round_id, - subagent_parent_info, cancellation_token, StreamProcessOptions::default(), ) @@ -117,7 +114,6 @@ impl StreamProcessor { session_id: String, dialog_turn_id: String, round_id: String, - subagent_parent_info: Option, cancellation_token: &CancellationToken, options: StreamProcessOptions, ) -> Result { @@ -129,7 +125,6 @@ impl StreamProcessor { session_id, dialog_turn_id, round_id, - subagent_parent_info.map(Into::into), cancellation_token, options, ) diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 89176a93d1..60b3a89a1c 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -2,7 +2,7 @@ use crate::agentic::core::Message; use crate::agentic::round_preempt::{ - DialogRoundPreemptSource, DialogRoundSteeringInterrupt, DialogRoundSteeringSource, + DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, }; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::ToolRuntimeRestrictions; @@ -29,9 +29,9 @@ pub struct ExecutionContext { pub workspace_services: Option, /// When set, engine may end the turn after a full model round if a user message was queued. pub round_preempt: Option>, - /// When set, engine drains pending user steering messages at each round boundary + /// When set, engine drains pending round injections at each round boundary /// and injects them into the dialog history without ending the turn. - pub round_steering: Option>, + pub round_injection: Option>, /// When true, stream cancellation may be converted into a partial assistant /// result if text/tool output has already been produced. pub recover_partial_on_cancel: bool, @@ -54,9 +54,9 @@ pub struct RoundContext { pub agent_type: String, pub context_vars: HashMap, pub runtime_tool_restrictions: ToolRuntimeRestrictions, - /// Cooperative interrupt checked by tool execution so user steering can be + /// Cooperative interrupt checked by tool execution so round injections can be /// applied after the currently running atomic tool/batch finishes. - pub steering_interrupt: Option, + pub steering_interrupt: Option, pub cancellation_token: CancellationToken, pub workspace_services: Option, pub recover_partial_on_cancel: bool, diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index df12e26309..698c163f50 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -56,9 +56,10 @@ pub use fork_agent::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ - DialogRoundPreemptSource, DialogRoundSteeringInterrupt, DialogRoundSteeringSource, - NoopDialogRoundPreemptSource, NoopDialogRoundSteeringSource, SessionRoundYieldFlags, - SessionSteeringBuffer, SteeringMessage, + DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, + NoopDialogRoundInjectionSource, NoopDialogRoundPreemptSource, RoundInjection, + RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, + SessionRoundYieldFlags, }; pub use session::*; pub use side_question::*; diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 4354105d71..8552645d6e 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -4,27 +4,27 @@ use crate::agentic::core::{ strip_prompt_markup, CompressionState, Message, MessageContent, Session, SessionConfig, - SessionState, SessionSummary, + SessionKind, SessionState, SessionSummary, }; use crate::infrastructure::PathManager; use crate::service::remote_ssh::workspace_state::{ resolve_workspace_session_identity, LOCAL_WORKSPACE_SSH_HOST, }; use crate::service::session::{ - DialogTurnData, SessionMetadata, SessionStatus, SessionTranscriptExport, + DialogTurnData, SessionMetadata, SessionRelationship, SessionRelationshipKind, SessionStatus, SessionTranscriptExport, SessionTranscriptExportOptions, SessionTranscriptIndexEntry, StoredSessionIndexFile, StoredSessionMetadataFile, ToolItemData, TranscriptLineRange, SESSION_STORAGE_SCHEMA_VERSION, }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; -use log::{info, warn}; +use log::{debug, info, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::fs; use tokio::sync::Mutex; @@ -35,6 +35,8 @@ const SESSION_TRANSCRIPT_PREVIEW_CHAR_LIMIT: usize = 120; static JSON_FILE_WRITE_LOCKS: OnceLock>>>> = OnceLock::new(); static SESSION_INDEX_LOCKS: OnceLock>>>> = OnceLock::new(); +static SESSION_METADATA_UPDATE_LOCKS: OnceLock>>>> = + OnceLock::new(); #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredDialogTurnFile { @@ -48,6 +50,10 @@ struct StoredSessionStateFile { schema_version: u32, config: SessionConfig, snapshot_session_id: Option, + // Derived runtime cache for reminder semantics. The source of truth lives + // on persisted dialog turns via `DialogTurnData.agent_type`. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_user_dialog_agent_type: Option, compression_state: CompressionState, runtime_state: SessionState, } @@ -60,6 +66,83 @@ struct StoredTurnContextSnapshotFile { messages: Vec, } +#[derive(Debug, Default)] +struct ContextSnapshotPayloadStats { + tool_result_count: usize, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, + largest_raw_result_chars: usize, + largest_raw_result_path: String, +} + +fn collect_json_string_stats( + value: &serde_json::Value, + path: &str, + total: &mut usize, + largest: &mut (usize, String), +) { + match value { + serde_json::Value::String(text) => { + let char_count = text.chars().count(); + *total += char_count; + if char_count > largest.0 { + *largest = (char_count, path.to_string()); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_json_string_stats(item, &format!("{}[{}]", path, index), total, largest); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + let next_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + collect_json_string_stats(item, &next_path, total, largest); + } + } + _ => {} + } +} + +fn context_snapshot_payload_stats(messages: &[Message]) -> ContextSnapshotPayloadStats { + let mut stats = ContextSnapshotPayloadStats::default(); + for (message_index, message) in messages.iter().enumerate() { + let MessageContent::ToolResult { + tool_name, + result, + result_for_assistant, + .. + } = &message.content + else { + continue; + }; + + stats.tool_result_count += 1; + if let Some(text) = result_for_assistant.as_deref() { + stats.result_for_assistant_chars += text.chars().count(); + } + + let mut raw_chars = 0usize; + let mut largest = (0usize, String::new()); + collect_json_string_stats( + result, + &format!("message[{}].{}", message_index, tool_name), + &mut raw_chars, + &mut largest, + ); + stats.raw_result_string_chars += raw_chars; + if largest.0 > stats.largest_raw_result_chars { + stats.largest_raw_result_chars = largest.0; + stats.largest_raw_result_path = largest.1; + } + } + stats +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionTranscriptFile { schema_version: u32, @@ -157,6 +240,98 @@ pub struct PersistenceManager { } impl PersistenceManager { + fn build_session_relationship( + session: &Session, + existing: Option<&SessionMetadata>, + ) -> Option { + let existing_relationship = existing.and_then(|value| value.relationship.clone()); + let existing_custom_metadata = existing.and_then(|value| value.custom_metadata.as_ref()); + + let kind = match session.kind { + SessionKind::Subagent => Some(SessionRelationshipKind::Subagent), + SessionKind::EphemeralChild => Some(SessionRelationshipKind::Btw), + SessionKind::Standard => existing_relationship + .as_ref() + .and_then(|value| value.kind.clone()), + }; + + let parent_session_id = existing_relationship + .as_ref() + .and_then(|value| value.parent_session_id.clone()) + .or_else(|| { + existing_custom_metadata + .and_then(|value| value.get("parentSessionId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }); + let parent_request_id = existing_relationship + .as_ref() + .and_then(|value| value.parent_request_id.clone()) + .or_else(|| { + existing_custom_metadata + .and_then(|value| value.get("parentRequestId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }); + let parent_dialog_turn_id = existing_relationship + .as_ref() + .and_then(|value| value.parent_dialog_turn_id.clone()) + .or_else(|| { + existing_custom_metadata + .and_then(|value| value.get("parentDialogTurnId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }); + let parent_turn_index = existing_relationship + .as_ref() + .and_then(|value| value.parent_turn_index) + .or_else(|| { + existing_custom_metadata + .and_then(|value| value.get("parentTurnIndex")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + }); + let parent_tool_call_id = existing_relationship + .as_ref() + .and_then(|value| value.parent_tool_call_id.clone()) + .or_else(|| { + existing_custom_metadata + .and_then(|value| value.get("parentToolCallId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }); + let subagent_type = existing_relationship + .as_ref() + .and_then(|value| value.subagent_type.clone()) + .or_else(|| { + existing_custom_metadata + .and_then(|value| value.get("subagentType")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }); + + if kind.is_none() + && parent_session_id.is_none() + && parent_request_id.is_none() + && parent_dialog_turn_id.is_none() + && parent_turn_index.is_none() + && parent_tool_call_id.is_none() + && subagent_type.is_none() + { + return None; + } + + Some(SessionRelationship { + kind, + parent_session_id, + parent_request_id, + parent_dialog_turn_id, + parent_turn_index, + parent_tool_call_id, + subagent_type, + }) + } + pub fn new(path_manager: Arc) -> BitFunResult { Ok(Self { runtime_service: Arc::new(WorkspaceRuntimeService::new(path_manager.clone())), @@ -322,10 +497,22 @@ impl PersistenceManager { &self, path: &Path, ) -> BitFunResult> { - if !path.exists() { - return Ok(None); - } + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); + let metadata = match fs::metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(BitFunError::io(format!( + "Failed to read JSON metadata {}: {}", + path.display(), + error + ))); + } + }; + let metadata_duration = metadata_started_at.elapsed(); + let read_started_at = Instant::now(); let content = fs::read_to_string(path).await.map_err(|e| { BitFunError::io(format!( "Failed to read JSON file {}: {}", @@ -333,7 +520,9 @@ impl PersistenceManager { e )) })?; + let read_duration = read_started_at.elapsed(); + let parse_started_at = Instant::now(); let value = serde_json::from_str::(&content).map_err(|e| { BitFunError::Deserialization(format!( "Failed to deserialize JSON file {}: {}", @@ -341,6 +530,21 @@ impl PersistenceManager { e )) })?; + let parse_duration = parse_started_at.elapsed(); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(80) || metadata.len() >= 1024 * 1024 { + debug!( + "Read JSON file: path={} type={} size_bytes={} metadata_duration_ms={} read_duration_ms={} parse_duration_ms={} total_duration_ms={}", + path.display(), + std::any::type_name::(), + metadata.len(), + metadata_duration.as_millis(), + read_duration.as_millis(), + parse_duration.as_millis(), + total_duration.as_millis() + ); + } Ok(Some(value)) } @@ -442,6 +646,20 @@ impl PersistenceManager { .clone() } + async fn get_session_metadata_update_lock( + &self, + workspace_path: &Path, + session_id: &str, + ) -> Arc> { + let metadata_path = self.metadata_path(workspace_path, session_id); + let registry = SESSION_METADATA_UPDATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut registry_guard = registry.lock().await; + registry_guard + .entry(metadata_path) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + fn build_temp_json_path(path: &Path, attempt: usize) -> BitFunResult { let parent = path.parent().ok_or_else(|| { BitFunError::io(format!( @@ -656,6 +874,7 @@ impl PersistenceManager { .or_else(|| existing.and_then(|value| value.snapshot_session_id.clone())), tags: existing.map(|value| value.tags.clone()).unwrap_or_default(), custom_metadata: existing.and_then(|value| value.custom_metadata.clone()), + relationship: Self::build_session_relationship(session, existing), todos: existing.and_then(|value| value.todos.clone()), deep_review_run_manifest: existing .and_then(|value| value.deep_review_run_manifest.clone()), @@ -1521,12 +1740,15 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult)>> { + let started_at = Instant::now(); let dir = self.snapshots_dir(workspace_path, session_id); if !dir.exists() { return Ok(None); } + let scan_started_at = Instant::now(); let mut latest: Option = None; + let mut snapshot_file_count = 0usize; let mut rd = fs::read_dir(&dir) .await .map_err(|e| BitFunError::io(format!("Failed to read snapshots directory: {}", e)))?; @@ -1547,20 +1769,44 @@ impl PersistenceManager { continue; }; if let Ok(index) = index_str.parse::() { + snapshot_file_count += 1; latest = Some(latest.map(|value| value.max(index)).unwrap_or(index)); } } + let scan_duration = scan_started_at.elapsed(); let Some(turn_index) = latest else { return Ok(None); }; + let load_started_at = Instant::now(); let Some(messages) = self .load_turn_context_snapshot(workspace_path, session_id, turn_index) .await? else { return Ok(None); }; + let load_duration = load_started_at.elapsed(); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(80) || snapshot_file_count >= 10 { + let payload_stats = context_snapshot_payload_stats(&messages); + debug!( + "Loaded latest context snapshot: session_id={} turn_index={} snapshot_file_count={} scan_duration_ms={} load_duration_ms={} total_duration_ms={} message_count={} tool_result_count={} raw_result_string_chars={} result_for_assistant_chars={} largest_raw_result_chars={} largest_raw_result_path={}", + session_id, + turn_index, + snapshot_file_count, + scan_duration.as_millis(), + load_duration.as_millis(), + total_duration.as_millis(), + messages.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path + ); + } Ok(Some((turn_index, messages))) } @@ -1626,6 +1872,7 @@ impl PersistenceManager { schema_version: SESSION_STORAGE_SCHEMA_VERSION, config: session.config.clone(), snapshot_session_id: session.snapshot_session_id.clone(), + last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), compression_state: session.compression_state.clone(), runtime_state: Self::sanitize_runtime_state(&session.state), }; @@ -1639,6 +1886,18 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { + let (session, _) = self + .load_session_with_turns(workspace_path, session_id) + .await?; + Ok(session) + } + + /// Load session and return the persisted turns read while rebuilding the session header. + pub async fn load_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { let metadata = self .load_session_metadata(workspace_path, session_id) .await? @@ -1678,23 +1937,29 @@ impl PersistenceManager { let created_at = Self::unix_ms_to_system_time(metadata.created_at); let last_activity_at = Self::unix_ms_to_system_time(metadata.last_active_at); - Ok(Session { + let dialog_turn_ids = turns.iter().map(|turn| turn.turn_id.clone()).collect(); + let session = Session { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), + last_user_dialog_agent_type: stored_state + .as_ref() + .and_then(|value| value.last_user_dialog_agent_type.clone()), created_by: metadata.created_by.clone(), kind: metadata.session_kind, snapshot_session_id: stored_state .and_then(|value| value.snapshot_session_id) .or(metadata.snapshot_session_id.clone()), - dialog_turn_ids: turns.into_iter().map(|turn| turn.turn_id).collect(), + dialog_turn_ids, state: runtime_state, config, compression_state, created_at, updated_at: last_activity_at, last_activity_at, - }) + }; + + Ok((session, turns)) } /// Save session state @@ -1715,6 +1980,7 @@ impl PersistenceManager { ..Default::default() }, snapshot_session_id: None, + last_user_dialog_agent_type: None, compression_state: CompressionState::default(), runtime_state: SessionState::Idle, }); @@ -1780,12 +2046,74 @@ impl PersistenceManager { 1 + assistant_text_count } + fn refresh_metadata_from_turns( + metadata: &mut SessionMetadata, + workspace_path: &Path, + turns: &[DialogTurnData], + last_active_at: u64, + ) { + metadata.turn_count = turns.len(); + metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); + metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.last_active_at = last_active_at; + if metadata.workspace_path.is_none() { + metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + } + } + + fn try_refresh_metadata_for_saved_turn( + metadata: &mut SessionMetadata, + workspace_path: &Path, + previous_turn: Option<&DialogTurnData>, + turn: &DialogTurnData, + last_active_at: u64, + ) -> bool { + let new_message_count = Self::estimate_turn_message_count(turn); + let new_tool_call_count = turn.count_tool_calls(); + + match previous_turn { + Some(previous) + if previous.session_id == turn.session_id + && previous.turn_index == turn.turn_index + && turn.turn_index < metadata.turn_count => + { + metadata.message_count = metadata + .message_count + .saturating_sub(Self::estimate_turn_message_count(previous)) + .saturating_add(new_message_count); + metadata.tool_call_count = metadata + .tool_call_count + .saturating_sub(previous.count_tool_calls()) + .saturating_add(new_tool_call_count); + } + None if turn.turn_index == metadata.turn_count => { + metadata.turn_count += 1; + metadata.message_count = metadata.message_count.saturating_add(new_message_count); + metadata.tool_call_count = + metadata.tool_call_count.saturating_add(new_tool_call_count); + } + _ => return false, + } + + metadata.last_active_at = last_active_at; + if metadata.workspace_path.is_none() { + metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + } + + true + } + pub async fn save_dialog_turn( &self, workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { + let save_started_at = Instant::now(); self.ensure_runtime_for_write(workspace_path).await?; + let metadata_update_lock = self + .get_session_metadata_update_lock(workspace_path, &turn.session_id) + .await; + let _metadata_update_guard = metadata_update_lock.lock().await; let mut metadata = self .load_session_metadata(workspace_path, &turn.session_id) .await? @@ -1796,32 +2124,81 @@ impl PersistenceManager { self.ensure_turns_dir(workspace_path, &turn.session_id) .await?; + let previous_turn = match self + .load_dialog_turn(workspace_path, &turn.session_id, turn.turn_index) + .await + { + Ok(turn) => turn, + Err(error) => { + warn!( + "Failed to load existing dialog turn before save; falling back to full metadata refresh: session_id={} turn_index={} error={}", + turn.session_id, + turn.turn_index, + error + ); + None + } + }; + let previous_turn_load_failed = previous_turn.is_none() + && self + .turn_path(workspace_path, &turn.session_id, turn.turn_index) + .exists(); + let file = StoredDialogTurnFile { schema_version: SESSION_STORAGE_SCHEMA_VERSION, turn: turn.clone(), }; + let write_started_at = Instant::now(); self.write_json_atomic( &self.turn_path(workspace_path, &turn.session_id, turn.turn_index), &file, ) .await?; + let write_duration = write_started_at.elapsed(); - let turns = self - .load_session_turns(workspace_path, &turn.session_id) - .await?; - metadata.turn_count = turns.len(); - metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = turn + let last_active_at = turn .end_time .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); - metadata.workspace_path = metadata.workspace_path.clone().or_else(|| { - turns - .first() - .and(None::) - .or_else(|| Some(workspace_path.to_string_lossy().to_string())) - }); - self.save_session_metadata(workspace_path, &metadata).await + let mut metadata_refresh_mode = "incremental"; + if previous_turn_load_failed + || !Self::try_refresh_metadata_for_saved_turn( + &mut metadata, + workspace_path, + previous_turn.as_ref(), + turn, + last_active_at, + ) + { + metadata_refresh_mode = "full_scan"; + let turns = self + .load_session_turns(workspace_path, &turn.session_id) + .await?; + Self::refresh_metadata_from_turns( + &mut metadata, + workspace_path, + &turns, + last_active_at, + ); + } + + let metadata_started_at = Instant::now(); + self.save_session_metadata(workspace_path, &metadata) + .await?; + let metadata_duration = metadata_started_at.elapsed(); + let total_duration = save_started_at.elapsed(); + if total_duration >= Duration::from_millis(80) || metadata_refresh_mode == "full_scan" { + debug!( + "Saved dialog turn: session_id={} turn_index={} metadata_refresh={} write_duration_ms={} metadata_duration_ms={} total_duration_ms={}", + turn.session_id, + turn.turn_index, + metadata_refresh_mode, + write_duration.as_millis(), + metadata_duration.as_millis(), + total_duration.as_millis() + ); + } + + Ok(()) } pub async fn load_dialog_turn( @@ -1845,11 +2222,13 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + let started_at = Instant::now(); let turns_dir = self.turns_dir(workspace_path, session_id); if !turns_dir.exists() { return Ok(Vec::new()); } + let scan_started_at = Instant::now(); let mut indexed_paths = Vec::new(); let mut entries = fs::read_dir(&turns_dir) .await @@ -1877,8 +2256,11 @@ impl PersistenceManager { } indexed_paths.sort_by_key(|(index, _)| *index); + let scan_duration = scan_started_at.elapsed(); + let read_started_at = Instant::now(); let mut turns = Vec::with_capacity(indexed_paths.len()); + let turn_file_count = indexed_paths.len(); for (_, path) in indexed_paths { if let Some(file) = self .read_json_optional::(&path) @@ -1887,6 +2269,19 @@ impl PersistenceManager { turns.push(file.turn); } } + let read_duration = read_started_at.elapsed(); + let total_duration = started_at.elapsed(); + if total_duration >= Duration::from_millis(80) || turn_file_count >= 50 { + debug!( + "Loaded session turns: session_id={} turn_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + session_id, + turns.len(), + turn_file_count, + scan_duration.as_millis(), + read_duration.as_millis(), + total_duration.as_millis() + ); + } Ok(turns) } @@ -2252,11 +2647,12 @@ impl PersistenceManager { #[cfg(test)] mod tests { - use super::PersistenceManager; - use crate::agentic::core::SessionKind; + use super::{context_snapshot_payload_stats, PersistenceManager}; + use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; use crate::infrastructure::PathManager; use crate::service::session::{ - DialogTurnData, SessionMetadata, SessionTranscriptExportOptions, UserMessageData, + DialogTurnData, ModelRoundData, SessionMetadata, SessionTranscriptExportOptions, + TextItemData, UserMessageData, }; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -2277,6 +2673,12 @@ mod tests { fn path(&self) -> &Path { &self.path } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } } impl Drop for TestWorkspace { @@ -2327,8 +2729,8 @@ mod tests { #[tokio::test] async fn export_session_transcript_handles_first_selected_turn_without_panicking() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let session_id = Uuid::new_v4().to_string(); let metadata = SessionMetadata::new( @@ -2375,10 +2777,287 @@ mod tests { } #[tokio::test] - async fn subagent_session_kind_is_hidden_from_visible_session_index() { + async fn load_session_with_turns_returns_session_and_persisted_turns() { let workspace = TestWorkspace::new(); let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Load once".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let user_message = UserMessageData { + id: "user-1".to_string(), + content: "hello once".to_string(), + timestamp: 0, + metadata: None, + }; + let mut turn = + DialogTurnData::new("turn-1".to_string(), 0, session_id.clone(), user_message); + turn.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (loaded_session, loaded_turns) = manager + .load_session_with_turns(workspace.path(), &session_id) + .await + .expect("session and turns should load together"); + + assert_eq!(loaded_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(loaded_turns.len(), 1); + assert_eq!(loaded_turns[0].turn_id, "turn-1"); + } + + fn user_message(content: &str) -> UserMessageData { + UserMessageData { + id: format!("user-{}", content), + content: content.to_string(), + timestamp: 0, + metadata: None, + } + } + + fn text_item(id: &str, content: &str) -> TextItemData { + TextItemData { + id: id.to_string(), + content: content.to_string(), + is_streaming: false, + timestamp: 0, + is_markdown: true, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: None, + } + } + + fn round_with_text(turn_id: &str, text_items: Vec) -> ModelRoundData { + ModelRoundData { + id: format!("round-{}", turn_id), + turn_id: turn_id.to_string(), + round_index: 0, + timestamp: 0, + text_items, + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: 0, + end_time: Some(0), + duration_ms: Some(0), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + } + } + + #[tokio::test] + async fn save_dialog_turn_updates_metadata_without_scanning_unrelated_turn_files() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Incremental metadata".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let mut turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first"), + ); + turn_0.model_rounds.push(round_with_text( + "turn-0", + vec![text_item("text-0", "first response")], + )); + turn_0.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + + let mut turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second"), + ); + turn_1.model_rounds.push(round_with_text( + "turn-1", + vec![text_item("text-1", "second response")], + )); + turn_1.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + std::fs::write( + manager.turn_path(workspace.path(), &session_id, 0), + "{ not valid json", + ) + .expect("old turn file should be replaceable for test"); + + turn_1.model_rounds[0] + .text_items + .push(text_item("text-2", "additional response")); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("saving current turn should not scan unrelated old turn files"); + + let metadata = manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 2); + assert_eq!(metadata.message_count, 5); + } + + #[tokio::test] + async fn concurrent_dialog_turn_saves_keep_metadata_counts_consistent() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Concurrent metadata".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let mut turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first"), + ); + turn_0.model_rounds.push(round_with_text( + "turn-0", + vec![text_item("text-0", "first response")], + )); + turn_0.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + + let mut turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second"), + ); + turn_1.model_rounds.push(round_with_text( + "turn-1", + vec![text_item("text-1", "second response")], + )); + turn_1.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + let mut updated_turn_0 = turn_0.clone(); + updated_turn_0.model_rounds[0] + .text_items + .push(text_item("text-0b", "first follow-up")); + + let mut updated_turn_1 = turn_1.clone(); + updated_turn_1.model_rounds[0] + .text_items + .push(text_item("text-1b", "second follow-up")); + updated_turn_1.model_rounds[0] + .text_items + .push(text_item("text-1c", "second final")); + + let (first_result, second_result) = tokio::join!( + manager.save_dialog_turn(workspace.path(), &updated_turn_0), + manager.save_dialog_turn(workspace.path(), &updated_turn_1) + ); + first_result.expect("first concurrent save should succeed"); + second_result.expect("second concurrent save should succeed"); + + let metadata = manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 2); + assert_eq!(metadata.message_count, 7); + } + + #[test] + fn context_snapshot_payload_stats_counts_tool_result_payloads_without_contents() { + let messages = vec![ + Message::assistant("hello".to_string()), + Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + result: serde_json::json!({ "output": "x".repeat(40) }), + result_for_assistant: Some("assistant summary".to_string()), + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + ]; + + let stats = context_snapshot_payload_stats(&messages); + + assert_eq!(stats.tool_result_count, 1); + assert_eq!(stats.raw_result_string_chars, 40); + assert_eq!(stats.result_for_assistant_chars, 17); + assert_eq!(stats.largest_raw_result_chars, 40); + assert_eq!(stats.largest_raw_result_path, "message[1].Bash.output"); + assert!(!stats.largest_raw_result_path.contains(&"x".repeat(40))); + } + + #[tokio::test] + async fn subagent_session_kind_is_hidden_from_visible_session_index() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let mut metadata = SessionMetadata::new( Uuid::new_v4().to_string(), @@ -2410,8 +3089,8 @@ mod tests { #[tokio::test] async fn legacy_leaked_subagent_is_hidden_from_visible_session_index() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let mut metadata = SessionMetadata::new( Uuid::new_v4().to_string(), @@ -2443,8 +3122,8 @@ mod tests { #[tokio::test] async fn listing_sessions_does_not_create_sessions_dir_for_uninitialized_runtime() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let visible = manager .list_session_metadata(workspace.path()) @@ -2466,8 +3145,8 @@ mod tests { #[tokio::test] async fn saving_session_metadata_ensures_runtime_layout_before_writing() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let metadata = SessionMetadata::new( Uuid::new_v4().to_string(), diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs index 4cac36ab12..1df6692d30 100644 --- a/src/crates/core/src/agentic/persistence/mod.rs +++ b/src/crates/core/src/agentic/persistence/mod.rs @@ -4,10 +4,6 @@ pub mod manager; pub mod session_branch; -pub mod session_workspace_maintenance; pub use manager::PersistenceManager; pub use session_branch::{SessionBranchRequest, SessionBranchResult}; -pub use session_workspace_maintenance::{ - SessionWorkspaceMaintenanceReport, SessionWorkspaceMaintenanceService, -}; diff --git a/src/crates/core/src/agentic/persistence/session_branch.rs b/src/crates/core/src/agentic/persistence/session_branch.rs index 306be413f9..661dcf178c 100644 --- a/src/crates/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/core/src/agentic/persistence/session_branch.rs @@ -187,7 +187,13 @@ impl PersistenceManager { branched_metadata.tags = branched_metadata .tags .into_iter() - .filter(|tag| tag != "btw" && tag != "review" && tag != "deep_review") + .filter(|tag| { + tag != "btw" + && tag != "review" + && tag != "deep_review" + && tag != "miniapp" + && tag != "subagent" + }) .collect(); branched_metadata.custom_metadata = build_branch_custom_metadata( source_metadata.custom_metadata.as_ref(), @@ -195,7 +201,9 @@ impl PersistenceManager { &request.source_turn_id, source_turn_index, ); + branched_metadata.relationship = None; branched_metadata.todos = None; + branched_metadata.deep_review_run_manifest = None; branched_metadata.unread_completion = None; branched_metadata.needs_user_attention = None; @@ -246,6 +254,12 @@ mod tests { fn path(&self) -> &Path { &self.path } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } } impl Drop for TestWorkspace { @@ -278,8 +292,8 @@ mod tests { #[tokio::test] async fn branch_session_copies_turns_snapshots_and_lineage_metadata() { let workspace = TestWorkspace::new(); - let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); let mut source_session = Session::new( "Source Title".to_string(), @@ -332,6 +346,18 @@ mod tests { "parentSessionId": "legacy-parent", "preservedKey": "preserved-value" })); + source_metadata.relationship = Some(serde_json::from_value(serde_json::json!({ + "kind": "deep_review", + "parentSessionId": "structured-parent", + "parentRequestId": "structured-request", + "parentDialogTurnId": "structured-turn", + "parentTurnIndex": 4 + })) + .expect("relationship should deserialize")); + source_metadata.deep_review_run_manifest = Some(serde_json::json!({ + "reviewMode": "deep", + "coreReviewers": [{ "subagentId": "ReviewBusinessLogic" }] + })); source_metadata.todos = Some(serde_json::json!([{ "id": "todo-1" }])); source_metadata.unread_completion = Some("completed".to_string()); source_metadata.needs_user_attention = Some("ask_user".to_string()); @@ -383,6 +409,8 @@ mod tests { assert_eq!(branched_metadata.session_name, "Source Title"); assert_eq!(branched_metadata.session_kind, SessionKind::Standard); assert_eq!(branched_metadata.tags, vec!["kept".to_string()]); + assert!(branched_metadata.relationship.is_none()); + assert!(branched_metadata.deep_review_run_manifest.is_none()); assert!(branched_metadata.todos.is_none()); assert!(branched_metadata.unread_completion.is_none()); assert!(branched_metadata.needs_user_attention.is_none()); diff --git a/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs b/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs deleted file mode 100644 index 3121a2e583..0000000000 --- a/src/crates/core/src/agentic/persistence/session_workspace_maintenance.rs +++ /dev/null @@ -1,277 +0,0 @@ -use super::PersistenceManager; -use crate::util::errors::BitFunResult; -use dashmap::{DashMap, DashSet}; -use log::info; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::Mutex; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SessionWorkspaceMaintenanceReport { - pub scanned_sessions: usize, - pub hidden_sessions: usize, - pub deleted_sessions: usize, - pub skipped: bool, -} - -pub struct SessionWorkspaceMaintenanceService { - persistence_manager: Arc, - cleaned_workspaces: DashSet, - workspace_locks: DashMap>>, -} - -impl SessionWorkspaceMaintenanceService { - pub fn new(persistence_manager: Arc) -> Self { - Self { - persistence_manager, - cleaned_workspaces: DashSet::new(), - workspace_locks: DashMap::new(), - } - } - - pub async fn ensure_workspace_maintained( - &self, - workspace_path: &Path, - ) -> BitFunResult { - let workspace_key = workspace_path.to_path_buf(); - - if self.cleaned_workspaces.contains(&workspace_key) { - return Ok(SessionWorkspaceMaintenanceReport { - skipped: true, - ..Default::default() - }); - } - - let workspace_lock = self - .workspace_locks - .entry(workspace_key.clone()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone(); - let _guard = workspace_lock.lock().await; - - if self.cleaned_workspaces.contains(&workspace_key) { - return Ok(SessionWorkspaceMaintenanceReport { - skipped: true, - ..Default::default() - }); - } - - let report = self.run_workspace_maintenance(workspace_path).await?; - self.cleaned_workspaces.insert(workspace_key); - - Ok(report) - } - - async fn run_workspace_maintenance( - &self, - workspace_path: &Path, - ) -> BitFunResult { - if !workspace_path.exists() { - return Ok(SessionWorkspaceMaintenanceReport::default()); - } - - let all_metadata = self - .persistence_manager - .list_session_metadata_including_internal(workspace_path) - .await?; - let hidden_session_ids = all_metadata - .iter() - .filter(|metadata| metadata.should_hide_from_user_lists()) - .map(|metadata| metadata.session_id.clone()) - .collect::>(); - - let mut report = SessionWorkspaceMaintenanceReport { - scanned_sessions: all_metadata.len(), - hidden_sessions: hidden_session_ids.len(), - deleted_sessions: 0, - skipped: false, - }; - - for session_id in hidden_session_ids { - self.persistence_manager - .delete_session(workspace_path, &session_id) - .await?; - report.deleted_sessions += 1; - } - - if report.deleted_sessions > 0 { - info!( - "Workspace session maintenance removed hidden sessions: workspace_path={}, scanned_sessions={}, hidden_sessions={}, deleted_sessions={}", - workspace_path.display(), - report.scanned_sessions, - report.hidden_sessions, - report.deleted_sessions - ); - } - - Ok(report) - } -} - -#[cfg(test)] -mod tests { - use super::SessionWorkspaceMaintenanceService; - use crate::agentic::core::SessionKind; - use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::PathManager; - use crate::service::session::SessionMetadata; - use crate::service::workspace_runtime::WorkspaceRuntimeService; - use std::path::{Path, PathBuf}; - use std::sync::Arc; - use uuid::Uuid; - - struct TestWorkspace { - path: PathBuf, - } - - impl TestWorkspace { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "bitfun-session-maintenance-test-{}", - Uuid::new_v4() - )); - std::fs::create_dir_all(&path).expect("test workspace should be created"); - Self { path } - } - - fn path(&self) -> &Path { - &self.path - } - } - - impl Drop for TestWorkspace { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.path); - } - } - - #[tokio::test] - async fn workspace_maintenance_removes_hidden_sessions_once() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let maintenance = SessionWorkspaceMaintenanceService::new(persistence_manager.clone()); - - let visible = SessionMetadata::new( - Uuid::new_v4().to_string(), - "Visible Session".to_string(), - "agent".to_string(), - "model".to_string(), - ); - - let mut legacy_hidden = SessionMetadata::new( - Uuid::new_v4().to_string(), - "Subagent: stale task".to_string(), - "agent".to_string(), - "model".to_string(), - ); - legacy_hidden.created_by = Some("session-parent".to_string()); - - let mut subagent_hidden = SessionMetadata::new( - Uuid::new_v4().to_string(), - "Subagent: fresh task".to_string(), - "agent".to_string(), - "model".to_string(), - ); - subagent_hidden.session_kind = SessionKind::Subagent; - - for metadata in [&visible, &legacy_hidden, &subagent_hidden] { - persistence_manager - .save_session_metadata(workspace.path(), metadata) - .await - .expect("metadata should save"); - } - - let first_report = maintenance - .ensure_workspace_maintained(workspace.path()) - .await - .expect("maintenance should succeed"); - - assert_eq!(first_report.scanned_sessions, 3); - assert_eq!(first_report.hidden_sessions, 2); - assert_eq!(first_report.deleted_sessions, 2); - assert!(!first_report.skipped); - - let raw_after_cleanup = persistence_manager - .list_session_metadata_including_internal(workspace.path()) - .await - .expect("raw metadata should load"); - assert_eq!(raw_after_cleanup.len(), 1); - assert_eq!(raw_after_cleanup[0].session_id, visible.session_id); - - let visible_after_cleanup = persistence_manager - .list_session_metadata(workspace.path()) - .await - .expect("visible metadata should load"); - assert_eq!(visible_after_cleanup.len(), 1); - assert_eq!(visible_after_cleanup[0].session_id, visible.session_id); - - let second_report = maintenance - .ensure_workspace_maintained(workspace.path()) - .await - .expect("second maintenance should succeed"); - assert!(second_report.skipped); - assert_eq!(second_report.deleted_sessions, 0); - } - - #[tokio::test] - async fn legacy_hidden_sessions_are_migrated_then_cleaned_after_runtime_ensure() { - let workspace = TestWorkspace::new(); - let path_manager = Arc::new(PathManager::new().expect("path manager")); - let runtime_service = WorkspaceRuntimeService::new(path_manager.clone()); - let persistence_manager = - Arc::new(PersistenceManager::new(path_manager.clone()).expect("persistence manager")); - let maintenance = SessionWorkspaceMaintenanceService::new(persistence_manager.clone()); - - let mut legacy_hidden = SessionMetadata::new( - Uuid::new_v4().to_string(), - "Subagent: stale task".to_string(), - "agent".to_string(), - "model".to_string(), - ); - legacy_hidden.created_by = Some("session-parent".to_string()); - - persistence_manager - .save_session_metadata(workspace.path(), &legacy_hidden) - .await - .expect("metadata should save"); - - let runtime_context = runtime_service.context_for_local_workspace(workspace.path()); - let legacy_sessions_root = workspace.path().join(".bitfun").join("sessions"); - std::fs::create_dir_all(&legacy_sessions_root).expect("legacy sessions root should exist"); - std::fs::rename( - runtime_context.sessions_dir.join(&legacy_hidden.session_id), - legacy_sessions_root.join(&legacy_hidden.session_id), - ) - .expect("session directory should move back to legacy location"); - let _ = std::fs::remove_dir_all(&runtime_context.runtime_root); - - runtime_service - .ensure_local_workspace_runtime(workspace.path()) - .await - .expect("runtime ensure should migrate legacy sessions"); - - let report = maintenance - .ensure_workspace_maintained(workspace.path()) - .await - .expect("maintenance should succeed"); - - assert_eq!(report.hidden_sessions, 1); - assert_eq!(report.deleted_sessions, 1); - assert!( - !runtime_context - .sessions_dir - .join(&legacy_hidden.session_id) - .exists(), - "hidden session should be deleted from migrated runtime storage" - ); - assert!( - !legacy_sessions_root - .join(&legacy_hidden.session_id) - .exists(), - "legacy session directory should not remain after migration and cleanup" - ); - } -} diff --git a/src/crates/core/src/agentic/round_preempt.rs b/src/crates/core/src/agentic/round_preempt.rs index b5f59550e4..936a5d9a6e 100644 --- a/src/crates/core/src/agentic/round_preempt.rs +++ b/src/crates/core/src/agentic/round_preempt.rs @@ -3,8 +3,8 @@ //! The [`DialogRoundPreemptSource`] is implemented by [`DialogScheduler`](super::scheduler::DialogScheduler) //! and read by [`ExecutionEngine`](super::execution::ExecutionEngine) after each completed model round. //! -//! In addition, the [`DialogRoundSteeringSource`] trait is read by the engine at the same -//! round boundary to retrieve any pending user "steering" messages that should be injected +//! In addition, the [`DialogRoundInjectionSource`] trait is read by the engine at the same +//! round boundary to retrieve any pending round injections that should be injected //! into the current dialog turn (Codex-style mid-turn injection) without ending the turn. use std::sync::atomic::{AtomicBool, Ordering}; @@ -65,17 +65,32 @@ impl DialogRoundPreemptSource for SessionRoundYieldFlags { } } -// ── Mid-turn user "steering" injection ───────────────────────────────────── +// ── Round-boundary injection ──────────────────────────────────────────────── -/// A user-authored message to inject into the currently running dialog turn at the -/// next model-round boundary. Produced by `submit_steering` on the scheduler/coordinator -/// and consumed by [`ExecutionEngine`](super::execution::ExecutionEngine) before each new round. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoundInjectionKind { + UserSteering, + BackgroundSubagentResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoundInjectionTarget { + /// Only inject into the exact targeted running turn. If that turn already + /// finished, the injection is ignored. + ExactTurn(String), + /// Inject into whichever turn is currently running for the session. + CurrentRunningTurn, +} + +/// A message to inject into the currently running dialog turn at the next +/// model-round boundary. Produced by the scheduler/coordinator and consumed by +/// [`ExecutionEngine`](super::execution::ExecutionEngine) before each new round. #[derive(Debug, Clone)] -pub struct SteeringMessage { +pub struct RoundInjection { pub id: String, - /// The dialog turn this steering targets. Steering messages whose `turn_id` does not - /// match the running turn are ignored (e.g. user steered a turn that already finished). - pub turn_id: String, + pub kind: RoundInjectionKind, + /// Injection target routing policy. + pub target: RoundInjectionTarget, pub content: String, /// Original (pre-rendering) text from the user, for UI display when the rendered /// `content` differs (e.g. when wrapped with a system reminder envelope). @@ -83,53 +98,53 @@ pub struct SteeringMessage { pub created_at: SystemTime, } -/// Observes whether any user steering messages are pending for a given (session, turn). -pub trait DialogRoundSteeringSource: Send + Sync { - /// Check whether the given running turn has pending steering without +/// Observes whether any round injections are pending for a given (session, turn). +pub trait DialogRoundInjectionSource: Send + Sync { + /// Check whether the given running turn has pending injections without /// consuming it. This lets tool execution stop at a safe boundary while the /// execution engine remains responsible for draining and injecting the /// messages into the next model round. fn has_pending(&self, session_id: &str, turn_id: &str) -> bool; - /// Drain all pending steering messages targeted at the given dialog turn. + /// Drain all pending injections targeted at the given dialog turn. /// Implementations must be safe to call concurrently from multiple round boundaries. - fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec; + fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec; } /// Used when no scheduler is wired (e.g. tests, isolated execution). -pub struct NoopDialogRoundSteeringSource; +pub struct NoopDialogRoundInjectionSource; -impl DialogRoundSteeringSource for NoopDialogRoundSteeringSource { +impl DialogRoundInjectionSource for NoopDialogRoundInjectionSource { fn has_pending(&self, _session_id: &str, _turn_id: &str) -> bool { false } - fn take_pending(&self, _session_id: &str, _turn_id: &str) -> Vec { + fn take_pending(&self, _session_id: &str, _turn_id: &str) -> Vec { Vec::new() } } #[derive(Clone)] -pub struct DialogRoundSteeringInterrupt { +pub struct DialogRoundInjectionInterrupt { session_id: String, turn_id: String, - source: Arc, + source: Arc, } -impl std::fmt::Debug for DialogRoundSteeringInterrupt { +impl std::fmt::Debug for DialogRoundInjectionInterrupt { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DialogRoundSteeringInterrupt") + f.debug_struct("DialogRoundInjectionInterrupt") .field("session_id", &self.session_id) .field("turn_id", &self.turn_id) .finish_non_exhaustive() } } -impl DialogRoundSteeringInterrupt { +impl DialogRoundInjectionInterrupt { pub fn new( session_id: String, turn_id: String, - source: Arc, + source: Arc, ) -> Self { Self { session_id, @@ -143,35 +158,37 @@ impl DialogRoundSteeringInterrupt { } } -/// Per-session FIFO buffer of user steering messages keyed by `session_id`. -/// Messages are appended via [`SessionSteeringBuffer::push`] and drained at round boundaries. +/// Per-session FIFO buffer of round injections keyed by `session_id`. +/// Messages are appended via [`SessionRoundInjectionBuffer::push`] and drained at round boundaries. #[derive(Debug, Default)] -pub struct SessionSteeringBuffer { - inner: dashmap::DashMap>, +pub struct SessionRoundInjectionBuffer { + inner: dashmap::DashMap>, } -impl SessionSteeringBuffer { - pub fn push(&self, session_id: &str, message: SteeringMessage) { +impl SessionRoundInjectionBuffer { + pub fn push(&self, session_id: &str, message: RoundInjection) { self.inner .entry(session_id.to_string()) .or_default() .push(message); } - /// Drain all messages whose `turn_id` matches `turn_id`. Messages targeting a different - /// turn are dropped (the targeted turn is no longer running), matching Codex semantics - /// where steering is bound to a specific in-flight turn. - pub fn drain_for_turn(&self, session_id: &str, turn_id: &str) -> Vec { + /// Drain all messages eligible for the currently running turn. Exact-turn + /// injections that target a different turn are retained until the targeted + /// turn consumes them or the session is cleared. + pub fn drain_for_turn(&self, session_id: &str, turn_id: &str) -> Vec { let Some(mut entry) = self.inner.get_mut(session_id) else { return Vec::new(); }; let mut taken = Vec::new(); let mut keep = Vec::new(); for msg in entry.drain(..) { - if msg.turn_id == turn_id { - taken.push(msg); - } else { - keep.push(msg); + match &msg.target { + RoundInjectionTarget::ExactTurn(target_turn_id) if target_turn_id == turn_id => { + taken.push(msg); + } + RoundInjectionTarget::CurrentRunningTurn => taken.push(msg), + RoundInjectionTarget::ExactTurn(_) => keep.push(msg), } } *entry = keep; @@ -181,7 +198,12 @@ impl SessionSteeringBuffer { pub fn has_pending_for_turn(&self, session_id: &str, turn_id: &str) -> bool { self.inner .get(session_id) - .map(|entry| entry.iter().any(|msg| msg.turn_id == turn_id)) + .map(|entry| { + entry.iter().any(|msg| match &msg.target { + RoundInjectionTarget::ExactTurn(target_turn_id) => target_turn_id == turn_id, + RoundInjectionTarget::CurrentRunningTurn => true, + }) + }) .unwrap_or(false) } @@ -195,12 +217,12 @@ impl SessionSteeringBuffer { } } -impl DialogRoundSteeringSource for SessionSteeringBuffer { +impl DialogRoundInjectionSource for SessionRoundInjectionBuffer { fn has_pending(&self, session_id: &str, turn_id: &str) -> bool { self.has_pending_for_turn(session_id, turn_id) } - fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec { + fn take_pending(&self, session_id: &str, turn_id: &str) -> Vec { self.drain_for_turn(session_id, turn_id) } } @@ -209,10 +231,22 @@ impl DialogRoundSteeringSource for SessionSteeringBuffer { mod steering_tests { use super::*; - fn msg(turn_id: &str, content: &str) -> SteeringMessage { - SteeringMessage { + fn exact_turn_msg(turn_id: &str, content: &str) -> RoundInjection { + RoundInjection { id: uuid::Uuid::new_v4().to_string(), - turn_id: turn_id.to_string(), + kind: RoundInjectionKind::UserSteering, + target: RoundInjectionTarget::ExactTurn(turn_id.to_string()), + content: content.to_string(), + display_content: content.to_string(), + created_at: SystemTime::now(), + } + } + + fn current_turn_msg(content: &str) -> RoundInjection { + RoundInjection { + id: uuid::Uuid::new_v4().to_string(), + kind: RoundInjectionKind::BackgroundSubagentResult, + target: RoundInjectionTarget::CurrentRunningTurn, content: content.to_string(), display_content: content.to_string(), created_at: SystemTime::now(), @@ -221,10 +255,10 @@ mod steering_tests { #[test] fn drain_for_turn_returns_only_matching_turn_messages_in_fifo_order() { - let buf = SessionSteeringBuffer::default(); - buf.push("s1", msg("turn_a", "first")); - buf.push("s1", msg("turn_b", "for_b_only")); - buf.push("s1", msg("turn_a", "second")); + let buf = SessionRoundInjectionBuffer::default(); + buf.push("s1", exact_turn_msg("turn_a", "first")); + buf.push("s1", exact_turn_msg("turn_b", "for_b_only")); + buf.push("s1", exact_turn_msg("turn_a", "second")); assert!(buf.has_pending_for_turn("s1", "turn_a")); assert!(buf.has_pending_for_turn("s1", "turn_b")); @@ -246,17 +280,30 @@ mod steering_tests { #[test] fn drain_for_turn_on_empty_session_returns_empty() { - let buf = SessionSteeringBuffer::default(); + let buf = SessionRoundInjectionBuffer::default(); assert!(buf.drain_for_turn("missing", "turn_a").is_empty()); } #[test] fn clear_drops_all_pending_for_session() { - let buf = SessionSteeringBuffer::default(); - buf.push("s1", msg("turn_a", "x")); - buf.push("s1", msg("turn_b", "y")); + let buf = SessionRoundInjectionBuffer::default(); + buf.push("s1", exact_turn_msg("turn_a", "x")); + buf.push("s1", exact_turn_msg("turn_b", "y")); buf.clear("s1"); assert_eq!(buf.pending_count("s1"), 0); assert!(buf.drain_for_turn("s1", "turn_a").is_empty()); } + + #[test] + fn current_running_turn_messages_are_delivered_to_active_turn() { + let buf = SessionRoundInjectionBuffer::default(); + buf.push("s1", current_turn_msg("background result")); + + assert!(buf.has_pending_for_turn("s1", "turn_a")); + + let drained = buf.drain_for_turn("s1", "turn_a"); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].content, "background result"); + assert_eq!(buf.pending_count("s1"), 0); + } } diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 0837e27527..96fbbb3ae7 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -18,17 +18,18 @@ use crate::service::config::{ subscribe_config_updates, ConfigUpdateEvent, }; use crate::service::session::{ - DialogTurnData, DialogTurnKind, ModelRoundData, SessionMetadata, TextItemData, TurnStatus, - UserMessageData, + DialogTurnData, DialogTurnKind, ModelRoundData, SessionMetadata, SessionRelationship, + SessionRelationshipKind, TextItemData, TurnStatus, UserMessageData, }; use crate::service::snapshot::ensure_snapshot_manager_for_workspace; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::sanitize_plain_model_output; use crate::util::timing::elapsed_ms_u64; use dashmap::DashMap; use log::{debug, error, info, warn}; use serde_json::json; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -75,594 +76,264 @@ pub struct ResolvedSessionTitle { pub title: String, pub method: SessionTitleMethod, } +/// Session manager +pub struct SessionManager { + /// Active sessions in memory + sessions: Arc>, -#[cfg(test)] -mod tests { - use super::{SessionManager, SessionManagerConfig}; - use crate::agentic::core::{ProcessingPhase, Session, SessionConfig, SessionState}; - use crate::agentic::persistence::PersistenceManager; - use crate::agentic::session::SessionContextStore; - use crate::infrastructure::PathManager; - use crate::service::session::{DialogTurnData, UserMessageData}; - use std::path::{Path, PathBuf}; - use std::sync::Arc; - use std::time::Duration; - use uuid::Uuid; + /// Runtime cache of session_id -> effective workspace path. + /// Populated on session create/restore and used to restore evicted sessions + /// or resolve workspace-bound operations that only receive a session_id. + /// This cache is intentionally retained across memory eviction, but should + /// be cleared when a session is explicitly deleted. + session_workspace_index: Arc>, - struct TestWorkspace { - path: PathBuf, - } + /// Sub-components + context_store: Arc, + evidence_ledger: Arc, + persistence_manager: Arc, - impl TestWorkspace { - fn new() -> Self { - let path = std::env::temp_dir() - .join(format!("bitfun-session-restore-test-{}", Uuid::new_v4())); - std::fs::create_dir_all(&path).expect("test workspace should be created"); - Self { path } - } + /// Configuration + config: SessionManagerConfig, +} - fn path(&self) -> &Path { - &self.path +#[derive(Clone)] +struct SessionAutoSaveSnapshot { + session_id: String, + updated_at: SystemTime, + last_activity_at: SystemTime, + session: Session, +} + +#[derive(Clone)] +struct SessionCleanupCandidate { + session_id: String, + updated_at: SystemTime, + last_activity_at: SystemTime, +} + +impl SessionManager { + async fn resolve_model_context_window(model_id: &str) -> Option { + let trimmed = model_id.trim(); + if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + return None; } + + let config_service = get_global_config_service().await.ok()?; + let ai_config: crate::service::config::types::AIConfig = + config_service.get_config(Some("ai")).await.ok()?; + let resolved_model_id = ai_config.resolve_model_selection(trimmed)?; + + ai_config + .models + .iter() + .find(|model| model.id == resolved_model_id) + .and_then(|model| model.context_window) + .map(|tokens| tokens as usize) } - impl Drop for TestWorkspace { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.path); + fn normalize_session_title_input(title: &str) -> BitFunResult { + let trimmed = title.trim(); + if trimmed.is_empty() { + return Err(BitFunError::validation( + "Session title must not be empty".to_string(), + )); } - } - fn test_manager(persistence_manager: Arc) -> SessionManager { - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - SessionManagerConfig { - max_active_sessions: 100, - session_idle_timeout: Duration::from_secs(3600), - auto_save_interval: Duration::from_secs(300), - enable_persistence: true, - }, - ) + Ok(trimmed.to_string()) } - fn in_memory_test_manager() -> SessionManager { - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - SessionManager::new( - Arc::new(SessionContextStore::new()), - persistence_manager, - SessionManagerConfig { - max_active_sessions: 100, - session_idle_timeout: Duration::from_secs(3600), - auto_save_interval: Duration::from_secs(300), - enable_persistence: false, - }, - ) + fn normalize_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") } - #[tokio::test] - async fn reset_session_state_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + fn truncate_chars(value: &str, max_length: usize) -> String { + value.chars().take(max_length).collect() + } - manager.reset_session_state_if_processing(&session_id, "turn-1"); + fn fallback_session_title(user_message: &str, max_length: usize) -> String { + let max_length = max_length.max(1); + let normalized = Self::normalize_whitespace(user_message); - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!( - session.state, - SessionState::Processing { - ref current_turn_id, - .. - } if current_turn_id == "turn-2" - )); - } + if normalized.is_empty() { + return Self::truncate_chars("New Session", max_length); + } - #[tokio::test] - async fn reset_session_state_if_processing_resets_the_matching_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + let truncated_chars: Vec = normalized.chars().take(max_length).collect(); + if normalized.chars().count() <= max_length { + return truncated_chars.iter().collect(); + } - manager.reset_session_state_if_processing(&session_id, "turn-1"); + let sentence_break_chars = ['。', '!', '?', ';', '.', '!', '?']; + let break_chars = ['。', '!', '?', ';', '.', '!', '?', ',', ',', ' ']; + let min_break_index = max_length / 2; + let mut best_break_index: Option = None; - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!(session.state, SessionState::Idle)); - } + for (idx, ch) in truncated_chars.iter().enumerate() { + if break_chars.contains(ch) && idx > min_break_index { + best_break_index = Some(idx); + } + } - #[tokio::test] - async fn update_session_state_for_turn_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); + if let Some(idx) = best_break_index { + let candidate: String = truncated_chars[..=idx].iter().collect(); + if candidate + .chars() + .last() + .map(|ch| sentence_break_chars.contains(&ch)) + .unwrap_or(false) + { + return candidate; + } - let updated = manager - .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) - .await - .expect("conditional state update should not fail"); + return format!("{}...", candidate.trim_end()); + } - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(!updated); - assert!(matches!( - session.state, - SessionState::Processing { - ref current_turn_id, - .. - } if current_turn_id == "turn-2" - )); + let truncated: String = truncated_chars.iter().collect(); + format!("{truncated}...") } - #[tokio::test] - async fn update_session_state_for_turn_if_processing_updates_matching_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, + fn paginate_messages( + messages: &[Message], + limit: usize, + before_message_id: Option<&str>, + ) -> (Vec, bool) { + if messages.is_empty() { + return (vec![], false); + } + + let end_idx = if let Some(before_id) = before_message_id { + messages.iter().position(|m| m.id == before_id).unwrap_or(0) + } else { + messages.len() }; - manager.sessions.insert(session_id.clone(), session); - let updated = manager - .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) - .await - .expect("conditional state update should not fail"); + if end_idx == 0 { + return (vec![], false); + } - let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(updated); - assert!(matches!(session.state, SessionState::Idle)); + let start_idx = end_idx.saturating_sub(limit); + let has_more = start_idx > 0; + + (messages[start_idx..end_idx].to_vec(), has_more) } - #[tokio::test] - async fn restore_session_resets_processing_state_without_marking_unread_completion() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Legacy processing session".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ); - session.state = SessionState::Processing { - current_turn_id: "turn-1".to_string(), - phase: ProcessingPhase::Thinking, - }; - - persistence_manager - .save_session(workspace.path(), &session) - .await - .expect("session should save"); - persistence_manager - .save_session_state(workspace.path(), &session_id, &session.state) - .await - .expect("processing state should save"); - - let manager = test_manager(persistence_manager.clone()); - let restored = manager - .restore_session(workspace.path(), &session_id) - .await - .expect("session should restore"); - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session_id) - .await - .expect("metadata should load") - .expect("metadata should exist"); - - assert!(matches!(restored.state, SessionState::Idle)); - assert_eq!(metadata.unread_completion, None); + fn session_workspace_from_config(config: &SessionConfig) -> Option { + config.workspace_path.as_ref().map(PathBuf::from) } - #[tokio::test] - async fn rollback_context_deletes_persisted_turns_from_target() { - let workspace = TestWorkspace::new(); - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let manager = test_manager(persistence_manager.clone()); - let session = manager - .create_session( - "Rollback session".to_string(), - "agent".to_string(), - SessionConfig { - workspace_path: Some(workspace.path().to_string_lossy().to_string()), - ..Default::default() - }, - ) - .await - .expect("session should create"); - - for index in 0..3 { - let turn = DialogTurnData::new( - format!("turn-{index}"), - index, - session.session_id.clone(), - UserMessageData { - id: format!("turn-{index}-user"), - content: format!("prompt {index}"), - timestamp: index as u64, - metadata: None, - }, - ); - persistence_manager - .save_dialog_turn(workspace.path(), &turn) - .await - .expect("turn should save"); - } - - { - let mut active = manager - .sessions - .get_mut(&session.session_id) - .expect("session should be active"); - active.dialog_turn_ids = vec![ - "turn-0".to_string(), - "turn-1".to_string(), - "turn-2".to_string(), - ]; + fn should_persist_session_kind(kind: SessionKind) -> bool { + match kind { + SessionKind::Standard | SessionKind::Subagent => true, + SessionKind::EphemeralChild => false, } - persistence_manager - .save_turn_context_snapshot( - workspace.path(), - &session.session_id, - 0, - &[crate::agentic::core::Message::user("prompt 0".to_string())], - ) - .await - .expect("snapshot 0 should save"); - persistence_manager - .save_turn_context_snapshot( - workspace.path(), - &session.session_id, - 1, - &[ - crate::agentic::core::Message::user("prompt 0".to_string()), - crate::agentic::core::Message::user("prompt 1".to_string()), - ], - ) - .await - .expect("snapshot 1 should save"); - - manager - .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) - .await - .expect("rollback should succeed"); - - let turns = persistence_manager - .load_session_turns(workspace.path(), &session.session_id) - .await - .expect("turns should load"); - assert_eq!(turns.len(), 1); - assert_eq!(turns[0].user_message.content, "prompt 0"); - assert!(persistence_manager - .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) - .await - .expect("snapshot load should succeed") - .is_none()); - - manager.sessions.remove(&session.session_id); - let restored = manager - .restore_session(workspace.path(), &session.session_id) - .await - .expect("session should restore"); - assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); - assert_eq!( - manager - .context_store - .get_context_messages(&session.session_id) - .len(), - 1 - ); - - let metadata = persistence_manager - .load_session_metadata(workspace.path(), &session.session_id) - .await - .expect("metadata should load") - .expect("metadata should exist"); - assert_eq!(metadata.turn_count, 1); } - #[test] - fn build_messages_from_turns_skips_model_invisible_turns() { - use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; - - let turns = vec![ - DialogTurnData::new( - "turn-1".to_string(), - 0, - "session-1".to_string(), - UserMessageData { - id: "user-1".to_string(), - content: "hello".to_string(), - timestamp: 1, - metadata: None, - }, - ), - DialogTurnData::new_with_kind( - DialogTurnKind::ManualCompaction, - "turn-2".to_string(), - 1, - "session-1".to_string(), - UserMessageData { - id: "user-2".to_string(), - content: "/compact".to_string(), - timestamp: 2, - metadata: None, - }, - ), - DialogTurnData::new_with_kind( - DialogTurnKind::LocalCommand, - "turn-3".to_string(), - 2, - "session-1".to_string(), - UserMessageData { - id: "user-3".to_string(), - content: "# Session Usage Report".to_string(), - timestamp: 3, - metadata: Some(serde_json::json!({ - "localCommandKind": "usage_report", - "modelVisible": false - })), - }, - ), - ]; + fn should_persist_session(session: &Session) -> bool { + Self::should_persist_session_kind(session.kind) + } - let messages = SessionManager::build_messages_from_turns(&turns); + fn same_session_version( + session: &Session, + updated_at: SystemTime, + last_activity_at: SystemTime, + ) -> bool { + session.updated_at == updated_at && session.last_activity_at == last_activity_at + } - assert_eq!(messages.len(), 1); - assert!(messages[0].is_actual_user_message()); + fn collect_auto_save_snapshots( + sessions: &DashMap, + ) -> Vec { + sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + if !Self::should_persist_session(session) { + return None; + } + Some(SessionAutoSaveSnapshot { + session_id: session.session_id.clone(), + updated_at: session.updated_at, + last_activity_at: session.last_activity_at, + session: session.clone(), + }) + }) + .collect() } - #[test] - fn fallback_session_title_uses_sentence_break_when_available() { - let title = SessionManager::fallback_session_title( - "Fix the flaky integration test. Add logging for retries.", - 20, - ); + fn auto_save_snapshot_is_current( + sessions: &DashMap, + snapshot: &SessionAutoSaveSnapshot, + ) -> bool { + sessions + .get(&snapshot.session_id) + .map(|session| { + Self::same_session_version(&session, snapshot.updated_at, snapshot.last_activity_at) + }) + .unwrap_or(false) + } - assert_eq!(title, "Fix the flaky..."); + fn auto_save_interval(interval: Duration) -> time::Interval { + time::interval_at(time::Instant::now() + interval, interval) } - #[test] - fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { - let title = SessionManager::fallback_session_title( - "Implement session title generation fallback", - 12, - ); + fn is_session_expired(session: &Session, now: SystemTime, timeout: Duration) -> bool { + now.duration_since(session.last_activity_at) + .map(|idle_duration| idle_duration > timeout) + .unwrap_or(false) + } - assert_eq!(title, "Implement..."); + fn collect_expired_session_candidates( + sessions: &DashMap, + now: SystemTime, + timeout: Duration, + ) -> Vec { + sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + if !Self::is_session_expired(session, now, timeout) { + return None; + } + Some(SessionCleanupCandidate { + session_id: session.session_id.clone(), + updated_at: session.updated_at, + last_activity_at: session.last_activity_at, + }) + }) + .collect() } - #[test] - fn fallback_session_title_uses_default_for_blank_input() { - let title = SessionManager::fallback_session_title(" ", 20); + fn cleanup_candidate_matches_session( + session: &Session, + candidate: &SessionCleanupCandidate, + now: SystemTime, + timeout: Duration, + ) -> bool { + Self::same_session_version(session, candidate.updated_at, candidate.last_activity_at) + && Self::is_session_expired(session, now, timeout) + } - assert_eq!(title, "New Session"); + fn cleanup_snapshot_for_candidate( + sessions: &DashMap, + candidate: &SessionCleanupCandidate, + now: SystemTime, + timeout: Duration, + ) -> Option { + sessions.get(&candidate.session_id).and_then(|session| { + Self::cleanup_candidate_matches_session(&session, candidate, now, timeout) + .then(|| session.clone()) + }) } - #[tokio::test] - async fn records_subagent_partial_timeout_in_evidence_ledger() { - let persistence_manager = Arc::new( - PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) - .expect("persistence manager"), - ); - let manager = test_manager(persistence_manager); - - let event = manager.record_subagent_partial_timeout( - "session-a", - "turn-a", - "ReviewSecurity", - "Found token logging before timeout.", - Some("timeout"), - ); - - assert!(!event.event_id.is_empty()); - let events = manager.evidence_events_for_turn("session-a", "turn-a"); - assert_eq!(events, vec![event.clone()]); - let summary = manager.evidence_summary_for_session("session-a", 10); - assert_eq!(summary.partial_subagent_results.len(), 1); - assert_eq!(summary.partial_subagent_results[0].event_id, event.event_id); - } -} - -/// Session manager -pub struct SessionManager { - /// Active sessions in memory - sessions: Arc>, - - /// Persistent index of session_id -> effective workspace path. - /// Populated on session create/restore; NOT cleared on memory eviction. - /// Allows commands that only receive a session_id (e.g. update_session_model_id) - /// to restore an evicted session without requiring the caller to supply a path. - session_workspace_index: Arc>, - - /// Sub-components - context_store: Arc, - evidence_ledger: Arc, - persistence_manager: Arc, - - /// Configuration - config: SessionManagerConfig, -} - -impl SessionManager { - async fn resolve_model_context_window(model_id: &str) -> Option { - let trimmed = model_id.trim(); - if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { - return None; - } - - let config_service = get_global_config_service().await.ok()?; - let ai_config: crate::service::config::types::AIConfig = - config_service.get_config(Some("ai")).await.ok()?; - let resolved_model_id = ai_config.resolve_model_selection(trimmed)?; - - ai_config - .models - .iter() - .find(|model| model.id == resolved_model_id) - .and_then(|model| model.context_window) - .map(|tokens| tokens as usize) - } - - fn normalize_session_title_input(title: &str) -> BitFunResult { - let trimmed = title.trim(); - if trimmed.is_empty() { - return Err(BitFunError::validation( - "Session title must not be empty".to_string(), - )); - } - - Ok(trimmed.to_string()) - } - - fn normalize_whitespace(value: &str) -> String { - value.split_whitespace().collect::>().join(" ") - } - - fn truncate_chars(value: &str, max_length: usize) -> String { - value.chars().take(max_length).collect() - } - - fn fallback_session_title(user_message: &str, max_length: usize) -> String { - let max_length = max_length.max(1); - let normalized = Self::normalize_whitespace(user_message); - - if normalized.is_empty() { - return Self::truncate_chars("New Session", max_length); - } - - let truncated_chars: Vec = normalized.chars().take(max_length).collect(); - if normalized.chars().count() <= max_length { - return truncated_chars.iter().collect(); - } - - let sentence_break_chars = ['。', '!', '?', ';', '.', '!', '?']; - let break_chars = ['。', '!', '?', ';', '.', '!', '?', ',', ',', ' ']; - let min_break_index = max_length / 2; - let mut best_break_index: Option = None; - - for (idx, ch) in truncated_chars.iter().enumerate() { - if break_chars.contains(ch) && idx > min_break_index { - best_break_index = Some(idx); - } - } - - if let Some(idx) = best_break_index { - let candidate: String = truncated_chars[..=idx].iter().collect(); - if candidate - .chars() - .last() - .map(|ch| sentence_break_chars.contains(&ch)) - .unwrap_or(false) - { - return candidate; - } - - return format!("{}...", candidate.trim_end()); - } - - let truncated: String = truncated_chars.iter().collect(); - format!("{truncated}...") - } - - fn paginate_messages( - messages: &[Message], - limit: usize, - before_message_id: Option<&str>, - ) -> (Vec, bool) { - if messages.is_empty() { - return (vec![], false); - } - - let end_idx = if let Some(before_id) = before_message_id { - messages.iter().position(|m| m.id == before_id).unwrap_or(0) - } else { - messages.len() - }; - - if end_idx == 0 { - return (vec![], false); - } - - let start_idx = end_idx.saturating_sub(limit); - let has_more = start_idx > 0; - - (messages[start_idx..end_idx].to_vec(), has_more) - } - - fn session_workspace_from_config(config: &SessionConfig) -> Option { - config.workspace_path.as_ref().map(PathBuf::from) - } - - fn should_persist_session_kind(kind: SessionKind) -> bool { - !matches!(kind, SessionKind::Subagent) - } - - fn should_persist_session(session: &Session) -> bool { - Self::should_persist_session_kind(session.kind) - } - - pub fn should_persist_session_id(&self, session_id: &str) -> bool { - self.config.enable_persistence - && self - .sessions - .get(session_id) - .map(|session| Self::should_persist_session(&session)) - .unwrap_or(true) - } + pub fn should_persist_session_id(&self, session_id: &str) -> bool { + self.config.enable_persistence + && self + .sessions + .get(session_id) + .map(|session| Self::should_persist_session(&session)) + .unwrap_or(true) + } /// Resolve the effective storage path for a session's workspace. async fn effective_workspace_path_from_config(config: &SessionConfig) -> Option { @@ -705,6 +376,87 @@ impl SessionManager { Self::effective_workspace_path_from_config(&config).await } + /// Resolve the logical workspace path bound to a session. + /// + /// This prefers the in-memory session config, then the persisted metadata + /// reachable via the session workspace index, and finally scans tracked + /// workspaces known to the global workspace service ordered by recent access. + pub async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + if let Some(workspace_path) = self + .get_session(session_id) + .and_then(|session| session.config.workspace_path) + .filter(|path| !path.is_empty()) + { + return Some(PathBuf::from(workspace_path)); + } + + let indexed_workspace_path = self + .session_workspace_index + .get(session_id) + .map(|entry| entry.clone()); + if let Some(workspace_path) = indexed_workspace_path { + match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await + { + Ok(Some(metadata)) => { + if let Some(bound_workspace) = + metadata.workspace_path.filter(|path| !path.is_empty()) + { + return Some(PathBuf::from(bound_workspace)); + } + return Some(workspace_path); + } + Ok(None) => {} + Err(err) => { + debug!( + "Failed to load indexed session metadata while resolving workspace: session_id={} workspace={} error={}", + session_id, + workspace_path.display(), + err + ); + } + } + } + + let workspace_service = get_global_workspace_service()?; + let mut workspaces = workspace_service.list_workspace_infos().await; + workspaces.sort_by(|left, right| right.last_accessed.cmp(&left.last_accessed)); + let candidates: Vec = workspaces + .into_iter() + .map(|workspace| workspace.root_path) + .collect(); + + for workspace_path in candidates { + match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await + { + Ok(Some(metadata)) => { + if let Some(bound_workspace) = + metadata.workspace_path.filter(|path| !path.is_empty()) + { + return Some(PathBuf::from(bound_workspace)); + } + return Some(workspace_path); + } + Ok(None) => {} + Err(err) => { + debug!( + "Failed to load session metadata while resolving workspace: session_id={} workspace={} error={}", + session_id, + workspace_path.display(), + err + ); + } + } + } + + None + } + fn build_messages_from_turns(turns: &[DialogTurnData]) -> Vec { let mut messages = Vec::new(); @@ -1485,6 +1237,39 @@ impl SessionManager { Ok(()) } + fn derive_last_user_dialog_agent_type_from_turns( + turns: &[DialogTurnData], + fallback_agent_type: Option<&str>, + ) -> Option { + // New turns persist their mode on the turn itself. For older persisted + // sessions that predate this field, fall back to the session default + // only when at least one surviving user dialog turn exists. + turns + .iter() + .rev() + .find(|turn| turn.kind == DialogTurnKind::UserDialog) + .and_then(|turn| { + turn.agent_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .or_else(|| { + if turns + .iter() + .any(|turn| turn.kind == DialogTurnKind::UserDialog) + { + fallback_agent_type + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + } else { + None + } + }) + } + /// Update session model id (in-memory + persistence) pub async fn update_session_model_id( &self, @@ -1683,6 +1468,7 @@ impl SessionManager { session_id, elapsed_ms_u64(memory_stage_started_at) ); + self.session_workspace_index.remove(session_id); info!( "Session deletion completed: session_id={}, workspace_path={}, duration_ms={}", @@ -1700,9 +1486,60 @@ impl SessionManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { - // Check if session is already in memory - let session_already_in_memory = self.sessions.contains_key(session_id); + self.restore_session_internal(workspace_path, session_id, false) + .await + } + + pub async fn restore_internal_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.restore_session_internal(workspace_path, session_id, true) + .await + } + + async fn restore_session_internal( + &self, + workspace_path: &Path, + session_id: &str, + include_internal: bool, + ) -> BitFunResult { + let (session, _) = self + .restore_session_with_turns_internal(workspace_path, session_id, include_internal) + .await?; + Ok(session) + } + + /// Restore the persisted session header and turns needed by the UI view + /// without loading runtime context snapshots or inserting the session into + /// the in-memory coordinator state. + pub async fn restore_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_view_internal(workspace_path, session_id, false) + .await + } + pub async fn restore_internal_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_view_internal(workspace_path, session_id, true) + .await + } + + async fn restore_session_view_internal( + &self, + workspace_path: &Path, + session_id: &str, + include_internal: bool, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + let storage_path_started_at = Instant::now(); let session_storage_path = { let ws = workspace_path.to_string_lossy().to_string(); let tmp_config = SessionConfig { @@ -1713,39 +1550,167 @@ impl SessionManager { .await .unwrap_or_else(|| workspace_path.to_path_buf()) }; + debug!( + "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + let metadata_started_at = Instant::now(); if self .persistence_manager .load_session_metadata(&session_storage_path, session_id) .await? - .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) { return Err(BitFunError::NotFound(format!( "Session not found: {}", session_id ))); } + debug!( + "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); - // 1. Load session from storage - let mut session = self + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self .persistence_manager - .load_session(&session_storage_path, session_id) + .load_session_with_turns(&session_storage_path, session_id) .await?; + debug!( + "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); - // Lazy migration: if the persisted model_id is no longer usable - // (model deleted or disabled while the session was on disk), repoint - // it to "auto" before the session re-enters memory. The next request - // will pick a model via the normal auto/agent/default pipeline. - if let Some(persisted_model_id) = session.config.model_id.as_deref() { - let trimmed = persisted_model_id.trim(); - let needs_migration = if trimmed.is_empty() { - false - } else if let Ok(config_service) = get_global_config_service().await { - match config_service - .get_config::(Some("ai")) - .await - { - Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), + if !matches!(session.state, SessionState::Idle) { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during view restore: session_id={}, state={:?} -> Idle", + session_id, old_state + ); + } + + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + if session.dialog_turn_ids != persisted_turn_ids { + debug!( + "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + persisted_turn_ids.len() + ); + session.dialog_turn_ids = persisted_turn_ids; + } + + debug!( + "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", + session_id, + session.session_name, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) + ); + + Ok((session, persisted_turns)) + } + + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_with_turns_internal(workspace_path, session_id, false) + .await + } + + pub async fn restore_internal_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.restore_session_with_turns_internal(workspace_path, session_id, true) + .await + } + + async fn restore_session_with_turns_internal( + &self, + workspace_path: &Path, + session_id: &str, + include_internal: bool, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + // Check if session is already in memory + let session_already_in_memory = self.sessions.contains_key(session_id); + + let storage_path_started_at = Instant::now(); + let session_storage_path = { + let ws = workspace_path.to_string_lossy().to_string(); + let tmp_config = SessionConfig { + workspace_path: Some(ws), + ..Default::default() + }; + Self::effective_workspace_path_from_config(&tmp_config) + .await + .unwrap_or_else(|| workspace_path.to_path_buf()) + }; + debug!( + "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + + let metadata_started_at = Instant::now(); + if self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + debug!( + "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); + + // 1. Load session and turns from storage in one pass + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self + .persistence_manager + .load_session_with_turns(&session_storage_path, session_id) + .await?; + debug!( + "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); + + // Lazy migration: if the persisted model_id is no longer usable + // (model deleted or disabled while the session was on disk), repoint + // it to "auto" before the session re-enters memory. The next request + // will pick a model via the normal auto/agent/default pipeline. + if let Some(persisted_model_id) = session.config.model_id.as_deref() { + let trimmed = persisted_model_id.trim(); + let needs_migration = if trimmed.is_empty() { + false + } else if let Ok(config_service) = get_global_config_service().await { + match config_service + .get_config::(Some("ai")) + .await + { + Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), Err(_) => false, } } else { @@ -1791,15 +1756,16 @@ impl SessionManager { // // This compensates for the fact that persistence is not transactional across // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. - let persisted_turns = self - .persistence_manager - .load_session_turns(&session_storage_path, session_id) - .await?; let persisted_turn_ids: Vec = persisted_turns .iter() .map(|turn| turn.turn_id.clone()) .collect(); + session.last_user_dialog_agent_type = Self::derive_last_user_dialog_agent_type_from_turns( + &persisted_turns, + Some(session.agent_type.as_str()), + ); let mut latest_turn_index: Option = None; + let context_snapshot_started_at = Instant::now(); let mut messages = match self .persistence_manager .load_latest_turn_context_snapshot(&session_storage_path, session_id) @@ -1811,6 +1777,13 @@ impl SessionManager { } None => Self::build_messages_from_turns(&persisted_turns), }; + debug!( + "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", + session_id, + latest_turn_index, + messages.len(), + elapsed_ms_u64(context_snapshot_started_at) + ); if let Some(snapshot_turn_index) = latest_turn_index { let delta_start = snapshot_turn_index.saturating_add(1); @@ -1840,8 +1813,15 @@ impl SessionManager { self.context_store.delete_session(session_id); } + let context_replace_started_at = Instant::now(); self.context_store .replace_context(session_id, messages.clone()); + debug!( + "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", + session_id, + messages.len(), + elapsed_ms_u64(context_replace_started_at) + ); let recoverable_turn_count = latest_turn_index .map(|turn_index| turn_index + 1) @@ -1885,12 +1865,14 @@ impl SessionManager { let context_msg_count = self.context_store.get_context_messages(session_id).len(); - info!( - "Session restored: session_id={}, session_name={}, messages={}, context_messages={}", + debug!( + "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", session_id, session.session_name, messages.len(), - context_msg_count + context_msg_count, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) ); // Do not infer unread completion from persisted runtime state during restore. @@ -1904,7 +1886,7 @@ impl SessionManager { self.session_workspace_index .insert(session_id.to_string(), session_storage_path.clone()); - Ok(session) + Ok((session, persisted_turns)) } /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) @@ -1938,12 +1920,34 @@ impl SessionManager { // 2) Restore the in-memory context cache. self.context_store.replace_context(session_id, messages); + let last_user_dialog_agent_type = if target_turn == 0 { + None + } else { + let surviving_turns = self + .persistence_manager + .load_session_turns(workspace_path, session_id) + .await?; + let kept_turns = surviving_turns + .into_iter() + .take(target_turn) + .collect::>(); + let fallback_agent_type = self + .sessions + .get(session_id) + .map(|session| session.agent_type.clone()); + Self::derive_last_user_dialog_agent_type_from_turns( + &kept_turns, + fallback_agent_type.as_deref(), + ) + }; + // 3) Truncate session turn list & persist // IMPORTANT: keep the DashMap guard scope short -- do NOT hold it across .await. let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { if session.dialog_turn_ids.len() > target_turn { session.dialog_turn_ids.truncate(target_turn); } + session.last_user_dialog_agent_type = last_user_dialog_agent_type; session.state = SessionState::Idle; session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); @@ -2003,7 +2007,12 @@ impl SessionManager { state: session.state.clone(), } }) - .filter(|summary| !matches!(summary.kind, SessionKind::Subagent)) + .filter(|summary| { + !matches!( + summary.kind, + SessionKind::Subagent | SessionKind::EphemeralChild + ) + }) .collect(); Ok(summaries) } @@ -2029,23 +2038,17 @@ impl SessionManager { .await } - // ============ Dialog Turn Management ============ - - #[allow(clippy::too_many_arguments)] - async fn start_persisted_turn( + pub async fn merge_session_custom_metadata( &self, session_id: &str, - kind: DialogTurnKind, - user_input: String, - turn_id: Option, - context_message: Option, - processing_phase: ProcessingPhase, - user_message_metadata: Option, - ) -> BitFunResult { - let session = self - .get_session(session_id) - .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; - let workspace_path = Self::effective_workspace_path_from_config(&session.config) + patch: serde_json::Value, + ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + return Ok(()); + } + + let workspace_path = self + .effective_session_workspace_path(session_id) .await .ok_or_else(|| { BitFunError::Validation(format!( @@ -2054,109 +2057,421 @@ impl SessionManager { )) })?; - let turn_index = session.dialog_turn_ids.len(); - let turn_id = new_turn_id(turn_id); - - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.dialog_turn_ids.push(turn_id.clone()); - session.state = SessionState::Processing { - current_turn_id: turn_id.clone(), - phase: processing_phase, - }; - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } - - if let Some(message) = context_message { - self.context_store - .add_message(session_id, message.with_turn_id(turn_id.clone())); - } - - if self.should_persist_session_id(session_id) { - let turn_data = DialogTurnData::new_with_kind( - kind, - turn_id.clone(), - turn_index, - session_id.to_string(), - UserMessageData { - id: format!("{}-user", turn_id), - content: user_input, - timestamp: SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64, - metadata: user_message_metadata, - }, - ); - - // Clone the session data out of the DashMap guard before awaiting I/O. - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - // Ref guard released -- DashMap shard lock is free. - if let Some(session) = session_snapshot { + let mut metadata = match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + { + Some(metadata) => metadata, + None => { + let session = self + .sessions + .get(session_id) + .map(|value| value.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; self.persistence_manager .save_session(&workspace_path, &session) .await?; + self.persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))? } - self.persistence_manager - .save_dialog_turn(&workspace_path, &turn_data) - .await?; - } + }; - self.persist_context_snapshot_for_turn_best_effort(session_id, turn_index, "turn_started") - .await; + metadata.custom_metadata = Some(match (metadata.custom_metadata.take(), patch) { + (Some(serde_json::Value::Object(mut existing)), serde_json::Value::Object(patch_obj)) => { + for (key, value) in patch_obj { + existing.insert(key, value); + } + serde_json::Value::Object(existing) + } + (_, value) => value, + }); - Ok(turn_id) + self.persistence_manager + .save_session_metadata(&workspace_path, &metadata) + .await } - /// Start a new dialog turn - /// turn_id: Optional frontend-specified ID, if None then backend generates - /// Returns: turn_id - pub async fn start_dialog_turn( + pub async fn merge_session_relationship( &self, session_id: &str, - user_input: String, - turn_id: Option, - image_contexts: Option>, - user_message_metadata: Option, - ) -> BitFunResult { - let user_message = - if let Some(images) = image_contexts.as_ref().filter(|v| !v.is_empty()).cloned() { - Message::user_multimodal(user_input.clone(), images) - .with_semantic_kind(MessageSemanticKind::ActualUserInput) - } else { - Message::user(user_input.clone()) - .with_semantic_kind(MessageSemanticKind::ActualUserInput) - }; + relationship: SessionRelationship, + ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + return Ok(()); + } - let turn_id = self - .start_persisted_turn( - session_id, - DialogTurnKind::UserDialog, - user_input, - turn_id, - Some(user_message), - ProcessingPhase::Starting, - user_message_metadata, - ) - .await?; + let workspace_path = self + .effective_session_workspace_path(session_id) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; - debug!("Starting dialog turn: turn_id={}", turn_id); + let mut metadata = match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + { + Some(metadata) => metadata, + None => { + let session = self + .sessions + .get(session_id) + .map(|value| value.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + self.persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))? + } + }; - Ok(turn_id) + metadata.relationship = Some(relationship); + self.persistence_manager + .save_session_metadata(&workspace_path, &metadata) + .await } - /// Start a persisted maintenance turn that should not enter model-visible context. - pub async fn start_maintenance_turn( + pub async fn persist_session_lineage( &self, session_id: &str, - display_message: String, - turn_id: Option, - user_message_metadata: Option, - ) -> BitFunResult { - let turn_id = self - .start_persisted_turn( - session_id, + relationship: SessionRelationship, + ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + return Ok(()); + } + + let workspace_path = self + .effective_session_workspace_path(session_id) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; + + let mut metadata = match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + { + Some(metadata) => metadata, + None => { + let session = self + .sessions + .get(session_id) + .map(|value| value.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + self.persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))? + } + }; + + metadata.relationship = Some(relationship); + + if let Some(serde_json::Value::Object(mut custom_metadata)) = metadata.custom_metadata.take() { + for key in [ + "kind", + "parentSessionId", + "parentRequestId", + "parentDialogTurnId", + "parentTurnIndex", + "parentToolCallId", + "subagentType", + ] { + custom_metadata.remove(key); + } + metadata.custom_metadata = (!custom_metadata.is_empty()) + .then_some(serde_json::Value::Object(custom_metadata)); + } + + self.persistence_manager + .save_session_metadata(&workspace_path, &metadata) + .await + } + + pub async fn collect_hidden_subagent_cascade_for_parent_turns( + &self, + workspace_path: &Path, + parent_session_id: &str, + parent_dialog_turn_ids: &HashSet, + ) -> BitFunResult> { + if parent_session_id.trim().is_empty() || parent_dialog_turn_ids.is_empty() { + return Ok(Vec::new()); + } + + let metadata_list = self + .persistence_manager + .list_session_metadata_including_internal(workspace_path) + .await?; + + let mut child_session_ids_by_parent: HashMap> = HashMap::new(); + let mut root_session_ids = Vec::new(); + + for metadata in metadata_list { + let Some((relationship_kind, relationship_parent_session_id, parent_dialog_turn_id)) = + extract_subagent_relationship(&metadata) + else { + continue; + }; + + if relationship_kind != SessionRelationshipKind::Subagent { + continue; + } + + child_session_ids_by_parent + .entry(relationship_parent_session_id.clone()) + .or_default() + .push(metadata.session_id.clone()); + + if relationship_parent_session_id == parent_session_id + && parent_dialog_turn_ids.contains(&parent_dialog_turn_id) + { + root_session_ids.push(metadata.session_id); + } + } + + let mut visited = HashSet::new(); + let mut ordered_session_ids = Vec::new(); + + fn visit( + session_id: &str, + child_session_ids_by_parent: &HashMap>, + visited: &mut HashSet, + ordered_session_ids: &mut Vec, + ) { + if !visited.insert(session_id.to_string()) { + return; + } + + if let Some(child_session_ids) = child_session_ids_by_parent.get(session_id) { + for child_session_id in child_session_ids { + visit( + child_session_id, + child_session_ids_by_parent, + visited, + ordered_session_ids, + ); + } + } + + ordered_session_ids.push(session_id.to_string()); + } + + for root_session_id in root_session_ids { + visit( + &root_session_id, + &child_session_ids_by_parent, + &mut visited, + &mut ordered_session_ids, + ); + } + + Ok(ordered_session_ids) + } + + pub async fn set_session_deep_review_run_manifest( + &self, + session_id: &str, + deep_review_run_manifest: Option, + ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + return Ok(()); + } + + let workspace_path = self + .effective_session_workspace_path(session_id) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; + + let mut metadata = match self + .persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + { + Some(metadata) => metadata, + None => { + let session = self + .sessions + .get(session_id) + .map(|value| value.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + self.persistence_manager + .load_session_metadata(&workspace_path, session_id) + .await? + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))? + } + }; + + metadata.deep_review_run_manifest = deep_review_run_manifest; + self.persistence_manager + .save_session_metadata(&workspace_path, &metadata) + .await + } + + // ============ Dialog Turn Management ============ + + #[allow(clippy::too_many_arguments)] + async fn start_persisted_turn( + &self, + session_id: &str, + kind: DialogTurnKind, + agent_type: Option, + user_input: String, + turn_id: Option, + context_message: Option, + processing_phase: ProcessingPhase, + user_message_metadata: Option, + ) -> BitFunResult { + let session = self + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + let workspace_path = Self::effective_workspace_path_from_config(&session.config) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; + + let turn_index = session.dialog_turn_ids.len(); + let turn_id = new_turn_id(turn_id); + + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.dialog_turn_ids.push(turn_id.clone()); + if kind == DialogTurnKind::UserDialog { + session.last_user_dialog_agent_type = agent_type.clone(); + } + session.state = SessionState::Processing { + current_turn_id: turn_id.clone(), + phase: processing_phase, + }; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } + + if let Some(message) = context_message { + self.context_store + .add_message(session_id, message.with_turn_id(turn_id.clone())); + } + + if self.should_persist_session_id(session_id) { + let turn_data = DialogTurnData::new_with_kind( + kind, + turn_id.clone(), + turn_index, + session_id.to_string(), + if kind == DialogTurnKind::UserDialog { + agent_type.clone() + } else { + None + }, + UserMessageData { + id: format!("{}-user", turn_id), + content: user_input, + timestamp: SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + metadata: user_message_metadata, + }, + ); + + // Clone the session data out of the DashMap guard before awaiting I/O. + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + // Ref guard released -- DashMap shard lock is free. + if let Some(session) = session_snapshot { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + self.persistence_manager + .save_dialog_turn(&workspace_path, &turn_data) + .await?; + } + + self.persist_context_snapshot_for_turn_best_effort(session_id, turn_index, "turn_started") + .await; + + Ok(turn_id) + } + + /// Start a new dialog turn + /// turn_id: Optional frontend-specified ID, if None then backend generates + /// Returns: turn_id + pub async fn start_dialog_turn( + &self, + session_id: &str, + agent_type: String, + user_input: String, + turn_id: Option, + image_contexts: Option>, + user_message_metadata: Option, + ) -> BitFunResult { + let user_message = + if let Some(images) = image_contexts.as_ref().filter(|v| !v.is_empty()).cloned() { + Message::user_multimodal(user_input.clone(), images) + .with_semantic_kind(MessageSemanticKind::ActualUserInput) + } else { + Message::user(user_input.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput) + }; + + let turn_id = self + .start_persisted_turn( + session_id, + DialogTurnKind::UserDialog, + Some(agent_type), + user_input, + turn_id, + Some(user_message), + ProcessingPhase::Starting, + user_message_metadata, + ) + .await?; + + debug!("Starting dialog turn: turn_id={}", turn_id); + + Ok(turn_id) + } + + /// Start a persisted maintenance turn that should not enter model-visible context. + pub async fn start_maintenance_turn( + &self, + session_id: &str, + display_message: String, + turn_id: Option, + user_message_metadata: Option, + ) -> BitFunResult { + let turn_id = self + .start_persisted_turn( + session_id, DialogTurnKind::ManualCompaction, + None, display_message, turn_id, None, @@ -2853,167 +3168,1238 @@ impl SessionManager { BitFunError::AIClient(format!("Failed to get AI client factory: {}", e)) })?; - let ai_client = ai_client_factory - .get_client_by_func_agent("session-title-func-agent") + let ai_client = ai_client_factory + .get_client_by_func_agent("session-title-func-agent") + .await + .map_err(|e| BitFunError::AIClient(format!("Failed to get AI client: {}", e)))?; + + let response = ai_client + .send_message(messages, None) + .await + .map_err(|e| BitFunError::ai(format!("AI call failed: {}", e)))?; + + let title = sanitize_plain_model_output(&response.text); + if title.is_empty() { + return Ok(None); + } + + // Truncate title + let final_title = if title.chars().count() > max_length { + title.chars().take(max_length).collect::() + } else { + title + }; + + Ok(Some(final_title)) + } + + /// Generate a concise session title, using AI first and falling back to a local heuristic. + pub async fn resolve_session_title( + &self, + user_message: &str, + max_length: Option, + allow_ai: bool, + ) -> ResolvedSessionTitle { + let max_length = max_length.unwrap_or(20).max(1); + + if allow_ai { + match self + .try_generate_session_title_with_ai(user_message, max_length) + .await + { + Ok(Some(title)) => { + return ResolvedSessionTitle { + title, + method: SessionTitleMethod::Ai, + }; + } + Ok(None) => { + warn!("AI session title generation returned empty output; using fallback"); + } + Err(error) => { + warn!("AI session title generation failed; using fallback: {error}"); + } + } + } + + ResolvedSessionTitle { + title: Self::fallback_session_title(user_message, max_length), + method: SessionTitleMethod::Fallback, + } + } + + /// Generate session title + /// + /// Generate a concise and accurate session title based on user message content. + pub async fn generate_session_title( + &self, + user_message: &str, + max_length: Option, + ) -> BitFunResult { + Ok(self + .resolve_session_title(user_message, max_length, true) + .await + .title) + } + + // ============ Background Tasks ============ + + /// Start auto-save task + fn spawn_auto_save_task(&self) { + let sessions = self.sessions.clone(); + let persistence = self.persistence_manager.clone(); + let interval = self.config.auto_save_interval; + + tokio::spawn(async move { + let mut ticker = Self::auto_save_interval(interval); + + loop { + ticker.tick().await; + + for snapshot in Self::collect_auto_save_snapshots(&sessions) { + if !Self::auto_save_snapshot_is_current(&sessions, &snapshot) { + continue; + } + if let Some(workspace_path) = + Self::effective_workspace_path_from_config(&snapshot.session.config).await + { + if !Self::auto_save_snapshot_is_current(&sessions, &snapshot) { + continue; + } + if let Err(e) = persistence + .save_session(&workspace_path, &snapshot.session) + .await + { + error!( + "Failed to auto-save session: session_id={}, error={}", + snapshot.session_id, e + ); + } + } + } + } + }); + + debug!("Auto-save task started"); + } + + /// Start cleanup task for expired sessions + fn spawn_cleanup_task(&self) { + let sessions = self.sessions.clone(); + let timeout = self.config.session_idle_timeout; + let persistence = self.persistence_manager.clone(); + let enable_persistence = self.config.enable_persistence; + let context_store = self.context_store.clone(); + + tokio::spawn(async move { + let mut ticker = time::interval(Duration::from_secs(60)); + + loop { + ticker.tick().await; + + let now = SystemTime::now(); + let candidates = Self::collect_expired_session_candidates(&sessions, now, timeout); + + for candidate in candidates { + debug!( + "Cleaning up expired session: session_id={}", + candidate.session_id + ); + + let cleanup_now = SystemTime::now(); + let Some(session) = Self::cleanup_snapshot_for_candidate( + &sessions, + &candidate, + cleanup_now, + timeout, + ) else { + continue; + }; + + if enable_persistence && Self::should_persist_session(&session) { + if let Some(workspace_path) = + Self::effective_workspace_path_from_config(&session.config).await + { + if Self::cleanup_snapshot_for_candidate( + &sessions, + &candidate, + SystemTime::now(), + timeout, + ) + .is_some() + { + let _ = persistence.save_session(&workspace_path, &session).await; + } + } + } + + let removal_now = SystemTime::now(); + if sessions + .remove_if(&candidate.session_id, |_, session| { + Self::cleanup_candidate_matches_session( + session, + &candidate, + removal_now, + timeout, + ) + }) + .is_some() + { + context_store.delete_session(&candidate.session_id); + } + } + } + }); + + debug!("Cleanup task started"); + } +} + +fn extract_subagent_relationship( + metadata: &SessionMetadata, +) -> Option<(SessionRelationshipKind, String, String)> { + let relationship = metadata.relationship.as_ref(); + let custom_metadata = metadata.custom_metadata.as_ref(); + + let relationship_kind = relationship + .and_then(|value| value.kind.clone()) + .or_else(|| { + custom_metadata + .and_then(|value| value.get("kind")) + .and_then(|value| value.as_str()) + .and_then(|value| match value { + "subagent" => Some(SessionRelationshipKind::Subagent), + _ => None, + }) + })?; + + let parent_session_id = relationship + .and_then(|value| value.parent_session_id.clone()) + .or_else(|| { + custom_metadata + .and_then(|value| value.get("parentSessionId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + })?; + + let parent_dialog_turn_id = relationship + .and_then(|value| value.parent_dialog_turn_id.clone()) + .or_else(|| { + custom_metadata + .and_then(|value| value.get("parentDialogTurnId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + })?; + + Some((relationship_kind, parent_session_id, parent_dialog_turn_id)) +} + +#[cfg(test)] +mod tests { + use super::{SessionManager, SessionManagerConfig}; + use crate::agentic::core::{Message, ProcessingPhase, Session, SessionConfig, SessionState}; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::SessionContextStore; + use crate::infrastructure::PathManager; + use crate::service::remote_ssh::workspace_state::local_workspace_roots_equal; + use crate::service::session::{ + DialogTurnData, ModelRoundData, SessionKind, SessionMetadata, SessionRelationship, + SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, UserMessageData, + }; + use dashmap::try_result::TryResult; + use serde_json::json; + use std::collections::HashSet; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = std::env::temp_dir() + .join(format!("bitfun-session-restore-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + + fn path_manager(&self) -> Arc { + Arc::new(PathManager::with_user_root_for_tests( + self.path.join("user-root"), + )) + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn test_manager(persistence_manager: Arc) -> SessionManager { + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: true, + }, + ) + } + + fn in_memory_test_manager() -> SessionManager { + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + }, + ) + } + + #[tokio::test] + async fn auto_save_interval_waits_before_first_tick() { + let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); + let started = tokio::time::Instant::now(); + + ticker.tick().await; + + assert!(started.elapsed() >= Duration::from_millis(30)); + } + + #[tokio::test] + async fn auto_save_snapshot_collection_releases_session_map_guards() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session = manager + .create_session( + "Auto-save snapshot".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let snapshots = SessionManager::collect_auto_save_snapshots(&manager.sessions); + assert!(snapshots + .iter() + .any(|snapshot| snapshot.session_id == session.session_id)); + + match manager.sessions.try_get_mut(&session.session_id) { + TryResult::Present(_) => {} + TryResult::Absent => panic!("session should remain present"), + TryResult::Locked => panic!("snapshot collection should not retain session map guards"), + }; + } + + #[tokio::test] + async fn reset_session_state_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!( + session.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-2" + )); + } + + #[tokio::test] + async fn reset_session_state_if_processing_resets_the_matching_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!(session.state, SessionState::Idle)); + } + + #[tokio::test] + async fn update_session_state_for_turn_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + let updated = manager + .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + .await + .expect("conditional state update should not fail"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(!updated); + assert!(matches!( + session.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == "turn-2" + )); + } + + #[tokio::test] + async fn update_session_state_for_turn_if_processing_updates_matching_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + let updated = manager + .update_session_state_for_turn_if_processing(&session_id, "turn-1", SessionState::Idle) + .await + .expect("conditional state update should not fail"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(updated); + assert!(matches!(session.state, SessionState::Idle)); + } + + #[tokio::test] + async fn restore_session_resets_processing_state_without_marking_unread_completion() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Legacy processing session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + persistence_manager + .save_session_state(workspace.path(), &session_id, &session.state) + .await + .expect("processing state should save"); + + let manager = test_manager(persistence_manager.clone()); + let restored = manager + .restore_session(workspace.path(), &session_id) + .await + .expect("session should restore"); + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + + assert!(matches!(restored.state, SessionState::Idle)); + assert_eq!(metadata.unread_completion, None); + } + + #[tokio::test] + async fn ephemeral_child_session_is_kept_in_memory_without_persisting() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + + let session = manager + .create_session_with_id_and_details( + Some(Uuid::new_v4().to_string()), + "Side thread".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + Some("session-parent".to_string()), + SessionKind::EphemeralChild, + ) + .await + .expect("ephemeral child session should create"); + + assert!(manager.get_session(&session.session_id).is_some()); + assert!(persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata lookup should succeed") + .is_none()); + } + + #[tokio::test] + async fn persist_session_lineage_updates_structured_relationship_and_clears_legacy_projection() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + + let session = manager + .create_session_with_id_and_details( + Some(Uuid::new_v4().to_string()), + "Review child".to_string(), + "CodeReview".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + Some("session-parent".to_string()), + SessionKind::Standard, + ) + .await + .expect("session should create"); + + manager + .merge_session_custom_metadata( + &session.session_id, + json!({ + "kind": "review", + "parentSessionId": "stale-parent", + "parentRequestId": "stale-request", + "parentDialogTurnId": "stale-turn", + "parentTurnIndex": 1, + "parentToolCallId": "stale-tool", + "subagentType": "stale-subagent", + "preservedKey": "preserved-value", + }), + ) + .await + .expect("legacy compatibility metadata should seed"); + + manager + .persist_session_lineage( + &session.session_id, + SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-1".to_string()), + parent_request_id: Some("request-1".to_string()), + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: None, + subagent_type: None, + }, + ) + .await + .expect("lineage should persist"); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata lookup should succeed") + .expect("metadata should exist"); + + assert_eq!( + metadata.relationship, + Some(SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-1".to_string()), + parent_request_id: Some("request-1".to_string()), + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: None, + subagent_type: None, + }) + ); + + let custom_metadata = metadata + .custom_metadata + .expect("non-lineage custom metadata should remain"); + assert_eq!(custom_metadata["preservedKey"], "preserved-value"); + assert!(custom_metadata.get("kind").is_none()); + assert!(custom_metadata.get("parentSessionId").is_none()); + assert!(custom_metadata.get("parentRequestId").is_none()); + assert!(custom_metadata.get("parentDialogTurnId").is_none()); + assert!(custom_metadata.get("parentTurnIndex").is_none()); + assert!(custom_metadata.get("parentToolCallId").is_none()); + assert!(custom_metadata.get("subagentType").is_none()); + } + + #[tokio::test] + async fn collect_hidden_subagent_cascade_for_parent_turns_returns_post_order_matches() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + + let mut matched_root = SessionMetadata::new( + "child-root".to_string(), + "Subagent: root".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + matched_root.session_kind = SessionKind::Subagent; + matched_root.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: Some("tool-1".to_string()), + subagent_type: Some("Explore".to_string()), + }); + persistence_manager + .save_session_metadata(workspace.path(), &matched_root) + .await + .expect("matched root should save"); + + let mut matched_grandchild = SessionMetadata::new( + "grandchild".to_string(), + "Subagent: grandchild".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + matched_grandchild.session_kind = SessionKind::Subagent; + matched_grandchild.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("child-root".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("child-turn".to_string()), + parent_turn_index: None, + parent_tool_call_id: Some("tool-child".to_string()), + subagent_type: Some("Explore".to_string()), + }); + persistence_manager + .save_session_metadata(workspace.path(), &matched_grandchild) + .await + .expect("grandchild should save"); + + let mut unmatched_root = SessionMetadata::new( + "child-other-turn".to_string(), + "Subagent: other turn".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + unmatched_root.session_kind = SessionKind::Subagent; + unmatched_root.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-1".to_string()), + parent_turn_index: Some(1), + parent_tool_call_id: Some("tool-2".to_string()), + subagent_type: Some("Explore".to_string()), + }); + persistence_manager + .save_session_metadata(workspace.path(), &unmatched_root) + .await + .expect("unmatched root should save"); + + let mut visible_review_child = SessionMetadata::new( + "review-child".to_string(), + "Review child".to_string(), + "DeepReview".to_string(), + "model".to_string(), + ); + visible_review_child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-2".to_string()), + parent_turn_index: Some(2), + parent_tool_call_id: None, + subagent_type: None, + }); + persistence_manager + .save_session_metadata(workspace.path(), &visible_review_child) + .await + .expect("visible review child should save"); + + let matched_turn_ids = HashSet::from(["turn-2".to_string()]); + let cascade = manager + .collect_hidden_subagent_cascade_for_parent_turns( + workspace.path(), + "parent-session", + &matched_turn_ids, + ) + .await + .expect("cascade lookup should succeed"); + + assert_eq!( + cascade, + vec!["grandchild".to_string(), "child-root".to_string()] + ); + } + + #[tokio::test] + async fn restore_session_view_loads_turns_without_restoring_runtime_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Large history".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 0, + &[Message::user("snapshot prompt".to_string())], + ) + .await + .expect("context snapshot should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(turns.len(), 1); + assert!(manager.get_session(&session_id).is_none()); + assert!(manager + .context_store + .get_context_messages(&session_id) + .is_empty()); + } + + #[tokio::test] + async fn restore_session_view_preserves_full_visible_tool_result_payload() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "History with tool output".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let visible_output = "complete visible output ".repeat(128); + let assistant_output = "assistant visible summary ".repeat(16); + let mut turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "show full output".to_string(), + timestamp: 1, + metadata: None, + }, + ); + turn.model_rounds.push(ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ToolItemData { + id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + tool_call: ToolCallData { + id: "call-1".to_string(), + input: json!({ "command": "printf output" }), + }, + tool_result: Some(ToolResultData { + result: json!({ + "stdout": visible_output, + "nested": { + "stderr": "also visible", + }, + }), + success: true, + result_for_assistant: Some(assistant_output.clone()), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: Some("completed".to_string()), + interruption_reason: None, + }], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + let restored_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should be preserved"); + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!( + restored_result.result["stdout"].as_str(), + Some(visible_output.as_str()) + ); + assert_eq!( + restored_result.result["nested"]["stderr"].as_str(), + Some("also visible") + ); + assert_eq!( + restored_result.result_for_assistant.as_deref(), + Some(assistant_output.as_str()) + ); + assert!(manager.get_session(&session_id).is_none()); + } + + #[tokio::test] + async fn rollback_context_deletes_persisted_turns_from_target() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Rollback session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + for index in 0..3 { + let mut turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + turn.agent_type = Some(if index == 0 { + "agentic".to_string() + } else { + "Plan".to_string() + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active"); + active.dialog_turn_ids = vec![ + "turn-0".to_string(), + "turn-1".to_string(), + "turn-2".to_string(), + ]; + active.last_user_dialog_agent_type = Some("Plan".to_string()); + } + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session.session_id, + 0, + &[crate::agentic::core::Message::user("prompt 0".to_string())], + ) + .await + .expect("snapshot 0 should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session.session_id, + 1, + &[ + crate::agentic::core::Message::user("prompt 0".to_string()), + crate::agentic::core::Message::user("prompt 1".to_string()), + ], + ) + .await + .expect("snapshot 1 should save"); + + manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) + .await + .expect("rollback should succeed"); + + let turns = persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].user_message.content, "prompt 0"); + assert_eq!(turns[0].agent_type.as_deref(), Some("agentic")); + assert!(persistence_manager + .load_turn_context_snapshot(workspace.path(), &session.session_id, 1) + .await + .expect("snapshot load should succeed") + .is_none()); + + manager.sessions.remove(&session.session_id); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("session should restore"); + assert_eq!(restored.dialog_turn_ids, vec!["turn-0".to_string()]); + assert_eq!( + restored.last_user_dialog_agent_type.as_deref(), + Some("agentic") + ); + assert_eq!( + manager + .context_store + .get_context_messages(&session.session_id) + .len(), + 1 + ); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) .await - .map_err(|e| BitFunError::AIClient(format!("Failed to get AI client: {}", e)))?; + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 1); + } - let response = ai_client - .send_message(messages, None) + #[tokio::test] + async fn rollback_to_empty_history_clears_last_user_dialog_agent_type() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Rollback empty history".to_string(), + "Plan".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) .await - .map_err(|e| BitFunError::ai(format!("AI call failed: {}", e)))?; + .expect("session should create"); - let title = sanitize_plain_model_output(&response.text); - if title.is_empty() { - return Ok(None); + let mut turn = DialogTurnData::new( + "turn-0".to_string(), + 0, + session.session_id.clone(), + UserMessageData { + id: "turn-0-user".to_string(), + content: "plan prompt".to_string(), + timestamp: 0, + metadata: None, + }, + ); + turn.agent_type = Some("Plan".to_string()); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active"); + active.dialog_turn_ids = vec!["turn-0".to_string()]; + active.last_user_dialog_agent_type = Some("Plan".to_string()); } - // Truncate title - let final_title = if title.chars().count() > max_length { - title.chars().take(max_length).collect::() - } else { - title - }; + manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 0) + .await + .expect("rollback should succeed"); - Ok(Some(final_title)) + let active = manager + .get_session(&session.session_id) + .expect("session should remain in memory"); + assert_eq!(active.agent_type, "Plan"); + assert_eq!(active.last_user_dialog_agent_type, None); } - /// Generate a concise session title, using AI first and falling back to a local heuristic. - pub async fn resolve_session_title( - &self, - user_message: &str, - max_length: Option, - allow_ai: bool, - ) -> ResolvedSessionTitle { - let max_length = max_length.unwrap_or(20).max(1); - - if allow_ai { - match self - .try_generate_session_title_with_ai(user_message, max_length) - .await - { - Ok(Some(title)) => { - return ResolvedSessionTitle { - title, - method: SessionTitleMethod::Ai, - }; - } - Ok(None) => { - warn!("AI session title generation returned empty output; using fallback"); - } - Err(error) => { - warn!("AI session title generation failed; using fallback: {error}"); - } - } - } + #[tokio::test] + async fn delete_session_removes_workspace_cache_entry() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session = manager + .create_session( + "Cached session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); - ResolvedSessionTitle { - title: Self::fallback_session_title(user_message, max_length), - method: SessionTitleMethod::Fallback, - } - } + assert_eq!( + manager + .session_workspace_index + .get(&session.session_id) + .as_deref() + .map(|entry| local_workspace_roots_equal(entry, workspace.path())), + Some(true) + ); - /// Generate session title - /// - /// Generate a concise and accurate session title based on user message content. - pub async fn generate_session_title( - &self, - user_message: &str, - max_length: Option, - ) -> BitFunResult { - Ok(self - .resolve_session_title(user_message, max_length, true) + manager + .delete_session(workspace.path(), &session.session_id) .await - .title) - } - - // ============ Background Tasks ============ + .expect("session should delete"); - /// Start auto-save task - fn spawn_auto_save_task(&self) { - let sessions = self.sessions.clone(); - let persistence = self.persistence_manager.clone(); - let interval = self.config.auto_save_interval; + assert!(manager + .session_workspace_index + .get(&session.session_id) + .is_none()); + } - tokio::spawn(async move { - let mut ticker = time::interval(interval); + #[test] + fn build_messages_from_turns_skips_model_invisible_turns() { + use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; - loop { - ticker.tick().await; + let turns = vec![ + DialogTurnData::new( + "turn-1".to_string(), + 0, + "session-1".to_string(), + UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ), + DialogTurnData::new_with_kind( + DialogTurnKind::ManualCompaction, + "turn-2".to_string(), + 1, + "session-1".to_string(), + None, + UserMessageData { + id: "user-2".to_string(), + content: "/compact".to_string(), + timestamp: 2, + metadata: None, + }, + ), + DialogTurnData::new_with_kind( + DialogTurnKind::LocalCommand, + "turn-3".to_string(), + 2, + "session-1".to_string(), + None, + UserMessageData { + id: "user-3".to_string(), + content: "# Session Usage Report".to_string(), + timestamp: 3, + metadata: Some(serde_json::json!({ + "localCommandKind": "usage_report", + "modelVisible": false + })), + }, + ), + ]; - for entry in sessions.iter() { - let session = entry.value(); - if !Self::should_persist_session(session) { - continue; - } - if let Some(workspace_path) = - Self::effective_workspace_path_from_config(&session.config).await - { - if let Err(e) = persistence.save_session(&workspace_path, session).await { - error!( - "Failed to auto-save session: session_id={}, error={}", - session.session_id, e - ); - } - } - } - } - }); + let messages = SessionManager::build_messages_from_turns(&turns); - debug!("Auto-save task started"); + assert_eq!(messages.len(), 1); + assert!(messages[0].is_actual_user_message()); } - /// Start cleanup task for expired sessions - fn spawn_cleanup_task(&self) { - let sessions = self.sessions.clone(); - let timeout = self.config.session_idle_timeout; - let persistence = self.persistence_manager.clone(); - let enable_persistence = self.config.enable_persistence; - let context_store = self.context_store.clone(); + #[test] + fn fallback_session_title_uses_sentence_break_when_available() { + let title = SessionManager::fallback_session_title( + "Fix the flaky integration test. Add logging for retries.", + 20, + ); - tokio::spawn(async move { - let mut ticker = time::interval(Duration::from_secs(60)); + assert_eq!(title, "Fix the flaky..."); + } - loop { - ticker.tick().await; + #[test] + fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { + let title = SessionManager::fallback_session_title( + "Implement session title generation fallback", + 12, + ); - let now = SystemTime::now(); - let mut expired_sessions = Vec::new(); + assert_eq!(title, "Implement..."); + } - for entry in sessions.iter() { - let session = entry.value(); - if let Ok(idle_duration) = now.duration_since(session.last_activity_at) { - if idle_duration > timeout { - expired_sessions.push(session.session_id.clone()); - } - } - } + #[test] + fn fallback_session_title_uses_default_for_blank_input() { + let title = SessionManager::fallback_session_title(" ", 20); - for session_id in expired_sessions { - debug!("Cleaning up expired session: session_id={}", session_id); + assert_eq!(title, "New Session"); + } - // Save before deleting - if enable_persistence { - if let Some(session) = sessions.get(&session_id) { - if !Self::should_persist_session(&session) { - context_store.delete_session(&session_id); - sessions.remove(&session_id); - continue; - } - if let Some(workspace_path) = - Self::effective_workspace_path_from_config(&session.config).await - { - let _ = persistence.save_session(&workspace_path, &session).await; - } - } - } + #[tokio::test] + async fn records_subagent_partial_timeout_in_evidence_ledger() { + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); - context_store.delete_session(&session_id); - sessions.remove(&session_id); - } - } - }); + let event = manager.record_subagent_partial_timeout( + "session-a", + "turn-a", + "ReviewSecurity", + "Found token logging before timeout.", + Some("timeout"), + ); - debug!("Cleanup task started"); + assert!(!event.event_id.is_empty()); + let events = manager.evidence_events_for_turn("session-a", "turn-a"); + assert_eq!(events, vec![event.clone()]); + let summary = manager.evidence_summary_for_session("session-a", 10); + assert_eq!(summary.partial_subagent_results.len(), 1); + assert_eq!(summary.partial_subagent_results[0].event_id, event.event_id); } } diff --git a/src/crates/core/src/agentic/tools/catalog_provider.rs b/src/crates/core/src/agentic/tools/catalog_provider.rs new file mode 100644 index 0000000000..5204105f0e --- /dev/null +++ b/src/crates/core/src/agentic/tools/catalog_provider.rs @@ -0,0 +1,289 @@ +use crate::agentic::agents::{get_agent_registry, AgentToolPolicyOverrides}; +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::registry::{get_global_tool_registry, GET_TOOL_SPEC_TOOL_NAME}; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_agent_tools::{ + ContextualToolManifest, ContextualVisibleTools, GetToolSpecCatalogProvider, + GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, +}; +use serde_json::Value; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ProductToolCatalogProvider; + +pub(crate) type ProductGetToolSpecRuntime<'a> = + GetToolSpecRuntime<'a, dyn Tool, ToolUseContext, ProductToolCatalogProvider>; + +pub(crate) type ProductToolCatalogRuntime<'a> = + ToolCatalogRuntime<'a, dyn Tool, ToolUseContext, ProductToolCatalogProvider>; + +#[async_trait::async_trait] +impl ToolCatalogSnapshotProvider for ProductToolCatalogProvider { + async fn tool_snapshot(&self) -> Vec> { + let registry = get_global_tool_registry(); + let registry = registry.read().await; + registry.get_all_tools() + } +} + +#[async_trait::async_trait] +impl GetToolSpecCatalogProvider for ProductToolCatalogProvider { + async fn collapsed_tools_for_get_tool_spec( + &self, + context: Option<&ToolUseContext>, + ) -> Result>, String> { + match context { + Some(context) => self + .contextual_collapsed_tools(context) + .await + .map_err(|error| error.to_string()), + None => Ok(self.default_collapsed_tools().await), + } + } +} + +impl ProductToolCatalogProvider { + async fn default_collapsed_tools(&self) -> Vec> { + let registry = get_global_tool_registry(); + let registry = registry.read().await; + registry + .get_all_tools() + .into_iter() + .filter(|tool| tool.default_exposure() == ToolExposure::Collapsed) + .collect() + } + + async fn contextual_collapsed_tools( + &self, + context: &ToolUseContext, + ) -> BitFunResult>> { + let agent_type = context.agent_type.as_deref().ok_or_else(|| { + BitFunError::Validation("GetToolSpec requires agent type context".to_string()) + })?; + let workspace_root = context.workspace_root(); + let agent_registry = get_agent_registry(); + let policy = agent_registry + .get_agent_tool_policy(agent_type, workspace_root) + .await; + let visible_tools = product_tool_catalog_runtime(self) + .visible_tools(&policy.allowed_tools, &policy.exposure_overrides, context) + .await; + Ok(visible_tools.collapsed_tools) + } +} + +pub(crate) fn product_get_tool_spec_runtime( + provider: &ProductToolCatalogProvider, +) -> ProductGetToolSpecRuntime<'_> { + GetToolSpecRuntime::new(provider, GET_TOOL_SPEC_TOOL_NAME) +} + +pub(crate) fn product_tool_catalog_runtime( + provider: &ProductToolCatalogProvider, +) -> ProductToolCatalogRuntime<'_> { + ToolCatalogRuntime::new(provider, GET_TOOL_SPEC_TOOL_NAME) +} + +pub(crate) async fn resolve_product_visible_tools( + allowed_tools: &[String], + exposure_overrides: &AgentToolPolicyOverrides, + context: &ToolUseContext, +) -> ContextualVisibleTools { + let provider = ProductToolCatalogProvider; + product_tool_catalog_runtime(&provider) + .visible_tools(allowed_tools, exposure_overrides, context) + .await +} + +pub(crate) async fn resolve_product_tool_manifest( + allowed_tools: &[String], + exposure_overrides: &AgentToolPolicyOverrides, + context: &ToolUseContext, +) -> ContextualToolManifest { + let provider = ProductToolCatalogProvider; + product_tool_catalog_runtime(&provider) + .tool_manifest(allowed_tools, exposure_overrides, context) + .await +} + +pub(crate) async fn resolve_product_readonly_enabled_tools() -> Vec> { + let provider = ProductToolCatalogProvider; + product_tool_catalog_runtime(&provider) + .readonly_enabled_tools() + .await +} + +pub(crate) async fn build_product_get_tool_spec_catalog_description( + context: Option<&ToolUseContext>, +) -> String { + let provider = ProductToolCatalogProvider; + product_get_tool_spec_runtime(&provider) + .catalog_description(context) + .await +} + +pub(crate) async fn resolve_product_get_tool_spec_results( + input: &Value, + context: &ToolUseContext, + get_tool_spec_tool_name: &str, +) -> Result, GetToolSpecExecutionError> { + let provider = ProductToolCatalogProvider; + GetToolSpecRuntime::new(&provider, get_tool_spec_tool_name) + .call_results(input, &context.unlocked_collapsed_tools, context) + .await +} + +#[cfg(test)] +mod tests { + use super::{ + resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, + resolve_product_tool_manifest, ProductToolCatalogProvider, + }; + use crate::agentic::agents::AgentToolPolicyOverrides; + use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::tools::registry::create_tool_registry; + use crate::agentic::tools::ToolRuntimeRestrictions; + use bitfun_agent_tools::{GetToolSpecCatalogProvider, ToolCatalogSnapshotProvider, ToolResult}; + use serde_json::json; + use std::collections::HashMap; + + fn tool_context(agent_type: Option<&str>) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: agent_type.map(str::to_string), + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + fn context_without_agent_type() -> ToolUseContext { + tool_context(None) + } + + #[tokio::test] + async fn product_catalog_provider_reads_global_registry_snapshot() { + let provider = ProductToolCatalogProvider; + + let snapshot_names = provider + .tool_snapshot() + .await + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + let expected_builtin_names = create_tool_registry().get_tool_names(); + assert!( + snapshot_names.starts_with(&expected_builtin_names), + "product catalog provider must preserve global registry snapshot order" + ); + } + + #[tokio::test] + async fn product_catalog_provider_default_get_tool_spec_catalog_matches_registry() { + let provider = ProductToolCatalogProvider; + + let collapsed_names = provider + .collapsed_tools_for_get_tool_spec(None) + .await + .expect("default collapsed catalog") + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + let expected_builtin_collapsed_names = create_tool_registry().get_collapsed_tool_names(); + assert!( + collapsed_names.starts_with(&expected_builtin_collapsed_names), + "GetToolSpec default catalog must preserve collapsed registry order" + ); + } + + #[tokio::test] + async fn product_catalog_provider_context_requires_agent_type() { + let provider = ProductToolCatalogProvider; + + let result = provider + .collapsed_tools_for_get_tool_spec(Some(&context_without_agent_type())) + .await; + let error = match result { + Ok(_) => { + panic!("contextual catalog without agent_type should keep existing validation") + } + Err(error) => error, + }; + + assert!( + error.contains("GetToolSpec requires agent type context"), + "unexpected validation error: {error}" + ); + } + + #[tokio::test] + async fn product_catalog_facade_resolves_manifest_from_same_provider_owner() { + let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; + + let manifest = resolve_product_tool_manifest( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &tool_context(Some("agentic")), + ) + .await; + + assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", "WebFetch", "GetToolSpec"], + "product manifest facade must preserve prompt-visible definition order" + ); + } + + #[tokio::test] + async fn product_catalog_facade_resolves_get_tool_spec_results_from_same_provider_owner() { + let results = resolve_product_get_tool_spec_results( + &json!({ "tool_name": "WebFetch" }), + &tool_context(Some("agentic")), + "GetToolSpec", + ) + .await + .expect("WebFetch should resolve through product GetToolSpec runtime facade"); + + assert_eq!(results.len(), 1); + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected normal tool result"); + }; + + assert_eq!(data["tool_name"], "WebFetch"); + assert_eq!(data["input_schema"]["type"], "object"); + } + + #[tokio::test] + async fn product_catalog_facade_resolves_readonly_enabled_tools_from_same_provider_owner() { + let readonly_names = resolve_product_readonly_enabled_tools() + .await + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + let mut expected_readonly_names = Vec::new(); + for tool in create_tool_registry().get_all_tools() { + if tool.is_readonly() && tool.is_enabled().await { + expected_readonly_names.push(tool.name().to_string()); + } + } + + assert_eq!( + readonly_names, expected_readonly_names, + "product readonly catalog facade must preserve registry snapshot order" + ); + } +} diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index fe36b36d13..22a5d9a9be 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -1,26 +1,27 @@ //! Tool framework - Tool interface definition and execution context +use crate::agentic::WorkspaceBinding; use crate::agentic::coordination::get_global_coordinator; use crate::agentic::session::EvidenceLedgerCheckpoint; use crate::agentic::tools::post_call_hooks; use crate::agentic::tools::restrictions::{ - is_local_path_within_root, is_remote_posix_path_within_root, ToolPathOperation, - ToolRuntimeRestrictions, + ToolPathOperation, ToolRuntimeRestrictions, is_local_path_within_root, + is_remote_posix_path_within_root, }; use crate::agentic::tools::workspace_paths::{ build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, parse_bitfun_runtime_uri, }; use crate::agentic::workspace::WorkspaceServices; -use crate::agentic::WorkspaceBinding; use crate::infrastructure::get_path_manager_arc; use crate::service::git::{GitDiffParams, GitService}; use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; -use crate::service::{get_workspace_runtime_service_arc, WorkspaceRuntimeContext}; +use crate::service::{WorkspaceRuntimeContext, get_workspace_runtime_service_arc}; use crate::util::errors::BitFunResult; use async_trait::async_trait; pub use bitfun_agent_tools::{ - DynamicMcpToolInfo, DynamicToolInfo, ToolPathBackend, ToolPathResolution, ToolRenderOptions, - ToolResult, ValidationResult, + DynamicMcpToolInfo, DynamicToolInfo, PortableToolContextProvider, ToolContextFacts, + ToolExposure, ToolPathBackend, ToolPathResolution, ToolRenderOptions, ToolResult, + ToolWorkspaceKind, ValidationResult, }; use log::warn; use serde_json::Value; @@ -29,11 +30,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio_util::sync::CancellationToken; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolExposure { - Expanded, - Collapsed, -} /// Tool use context #[derive(Debug, Clone)] pub struct ToolUseContext { @@ -67,6 +63,31 @@ impl ToolUseContext { .unwrap_or(false) } + pub fn to_tool_context_facts(&self) -> ToolContextFacts { + let workspace_kind = self.workspace.as_ref().map(|workspace| { + if workspace.is_remote() { + ToolWorkspaceKind::Remote + } else { + ToolWorkspaceKind::Local + } + }); + + ToolContextFacts { + tool_call_id: self.tool_call_id.clone(), + agent_type: self.agent_type.clone(), + session_id: self.session_id.clone(), + dialog_turn_id: self.dialog_turn_id.clone(), + workspace_kind, + workspace_root: self.workspace.as_ref().map(|workspace| { + workspace + .session_identity + .logical_workspace_path() + .to_string() + }), + runtime_tool_restrictions: self.runtime_tool_restrictions.clone(), + } + } + pub fn ws_fs(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceFileSystem> { self.workspace_services.as_ref().map(|s| s.fs.as_ref()) } @@ -262,13 +283,12 @@ impl ToolUseContext { self.is_remote(), )?; - let is_within_workspace = if self.is_remote() { - is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) - } else { - is_local_path_within_root(Path::new(&resolved_path), Path::new(&workspace_root_owned))? - }; - - if !is_within_workspace { + // Remote SSH workspaces stay contained to the opened project tree. Local desktop + // sessions may use any host path the OS user can access (Bash already has the same + // reach); optional `path_policy` roots still apply via `enforce_path_operation`. + if self.is_remote() + && !is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) + { return Err(crate::util::errors::BitFunError::tool(format!( "Path '{}' resolves outside current workspace '{}': {}", path, workspace_root_owned, resolved_path @@ -439,12 +459,21 @@ impl ToolUseContext { } } +impl PortableToolContextProvider for ToolUseContext { + fn tool_context_facts(&self) -> ToolContextFacts { + self.to_tool_context_facts() + } +} + #[cfg(test)] mod path_resolution_tests { use super::ToolUseContext; - use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; - use std::collections::HashMap; + use crate::agentic::tools::{ + PortableToolContextProvider, ToolRuntimeRestrictions, ToolWorkspaceKind, + }; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; fn local_context(root: &str) -> ToolUseContext { @@ -480,12 +509,191 @@ mod path_resolution_tests { } #[test] - fn workspace_path_resolution_rejects_absolute_paths_outside_local_workspace() { + fn tool_context_facts_preserve_portable_fields_without_runtime_handles() { + let context = ToolUseContext { + tool_call_id: Some("call-1".to_string()), + agent_type: Some("Agentic".to_string()), + session_id: Some("session-1".to_string()), + dialog_turn_id: Some("turn-1".to_string()), + workspace: Some(WorkspaceBinding::new(None, PathBuf::from("/repo/project"))), + unlocked_collapsed_tools: vec!["WebFetch".to_string()], + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from(["Read".to_string()]), + denied_tool_names: BTreeSet::from(["Bash".to_string()]), + path_policy: Default::default(), + }, + workspace_services: None, + }; + + let facts = context.to_tool_context_facts(); + + assert_eq!(facts.tool_call_id.as_deref(), Some("call-1")); + assert_eq!(facts.agent_type.as_deref(), Some("Agentic")); + assert_eq!(facts.session_id.as_deref(), Some("session-1")); + assert_eq!(facts.dialog_turn_id.as_deref(), Some("turn-1")); + assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Local)); + assert_eq!(facts.workspace_root.as_deref(), Some("/repo/project")); + assert!(facts.runtime_tool_restrictions.is_tool_allowed("Read")); + assert!(!facts.runtime_tool_restrictions.is_tool_allowed("Bash")); + + let value = serde_json::to_value(&facts).expect("serialize context facts"); + assert!(value.get("unlockedCollapsedTools").is_none()); + assert!(value.get("computer_use_host").is_none()); + assert!(value.get("workspace_services").is_none()); + assert!(value.get("cancellation_token").is_none()); + } + + #[test] + fn tool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated() { + let mut custom_data = HashMap::new(); + custom_data.insert( + "checkpoint".to_string(), + serde_json::json!({ "kind": "runtime-only" }), + ); + + let context = ToolUseContext { + tool_call_id: Some("call-runtime".to_string()), + agent_type: Some("Agentic".to_string()), + session_id: Some("session-runtime".to_string()), + dialog_turn_id: Some("turn-runtime".to_string()), + workspace: Some(WorkspaceBinding::new(None, PathBuf::from("/repo/runtime"))), + unlocked_collapsed_tools: vec!["WebFetch".to_string(), "Git".to_string()], + custom_data, + computer_use_host: None, + cancellation_token: Some(tokio_util::sync::CancellationToken::new()), + runtime_tool_restrictions: ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from(["Read".to_string(), "GetToolSpec".to_string()]), + denied_tool_names: BTreeSet::from(["Bash".to_string()]), + path_policy: Default::default(), + }, + workspace_services: None, + }; + + let facts = PortableToolContextProvider::tool_context_facts(&context); + + assert_eq!(facts.tool_call_id.as_deref(), Some("call-runtime")); + assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Local)); + assert_eq!(facts.workspace_root.as_deref(), Some("/repo/runtime")); + assert!(facts.runtime_tool_restrictions.is_tool_allowed("Read")); + assert!( + facts + .runtime_tool_restrictions + .is_tool_allowed("GetToolSpec") + ); + assert!(!facts.runtime_tool_restrictions.is_tool_allowed("Bash")); + + let value = serde_json::to_value(&facts).expect("serialize runtime context facts"); + for runtime_only_field in [ + "unlockedCollapsedTools", + "customData", + "computerUseHost", + "cancellationToken", + "workspaceServices", + ] { + assert!( + value.get(runtime_only_field).is_none(), + "{runtime_only_field} must remain outside portable facts" + ); + } + } + + #[test] + fn tool_context_facts_use_normalized_remote_workspace_identity() { + let session_identity = workspace_session_identity( + "/home/wsp//projects/test/", + Some("conn-1"), + Some("ssh.dev"), + ) + .expect("remote identity"); + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: Some("session-remote".to_string()), + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + Some("workspace-remote".to_string()), + PathBuf::from("/home/wsp//projects/test/"), + "conn-1".to_string(), + "Dev SSH".to_string(), + session_identity, + )), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + }; + + let facts = context.to_tool_context_facts(); + + assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Remote)); + assert_eq!( + facts.workspace_root.as_deref(), + Some("/home/wsp/projects/test") + ); + + let value = serde_json::to_value(&facts).expect("serialize remote context facts"); + assert!(value.get("connectionId").is_none()); + assert!(value.get("connectionName").is_none()); + assert!(value.get("workspace_services").is_none()); + } + + #[test] + fn tool_use_context_implements_portable_context_provider() { + fn assert_provider() {} + assert_provider::(); + let context = local_context("/repo/project"); + let facts = PortableToolContextProvider::tool_context_facts(&context); + + assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Local)); + assert_eq!(facts.workspace_root.as_deref(), Some("/repo/project")); + } + + #[test] + fn workspace_path_resolution_allows_absolute_paths_outside_local_workspace() { + let context = local_context("/repo/project"); + + let resolved = context + .resolve_workspace_tool_path("/tmp/pr_body.md") + .expect("local sessions may resolve paths outside the workspace root"); + + assert_eq!(PathBuf::from(resolved), PathBuf::from("/tmp/pr_body.md")); + } + + #[test] + fn workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace() { + let session_identity = + workspace_session_identity("/home/wsp/projects/test", Some("conn-1"), Some("ssh.dev")) + .expect("remote identity"); + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + None, + PathBuf::from("/home/wsp/projects/test"), + "conn-1".to_string(), + "Dev SSH".to_string(), + session_identity, + )), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + }; + let err = context - .resolve_workspace_tool_path("/workspace") - .expect_err("placeholder absolute paths must not escape the current workspace"); + .resolve_workspace_tool_path("/tmp/pr_body.md") + .expect_err("remote sessions must stay within the workspace root"); assert!(err.to_string().contains("outside current workspace")); } @@ -715,7 +923,7 @@ mod shared_context_tests { use crate::agentic::tools::ToolRuntimeRestrictions; use crate::util::errors::BitFunResult; use async_trait::async_trait; - use serde_json::{json, Value}; + use serde_json::{Value, json}; use std::collections::HashMap; struct MeasurementReadTool; diff --git a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs index d558c6b89e..e457a8dd9e 100644 --- a/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -1222,8 +1222,7 @@ impl Tool for ControlHubTool { } fn short_description(&self) -> String { - "Control browser, terminal, and desktop helper domains through one tool." - .to_string() + "Control browser, terminal, and desktop helper domains through one tool.".to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index 4a629c83f1..768640e51e 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -117,15 +117,29 @@ impl CronTool { self.resolve_workspace(workspace.to_string_lossy().as_ref(), Some(context)) } - fn resolve_effective_workspace( + async fn resolve_effective_workspace_for_session( &self, - workspace: Option<&str>, + session_id: &str, context: &ToolUseContext, ) -> BitFunResult { - match workspace { - Some(workspace) => self.resolve_workspace(workspace, Some(context)), - None => self.resolve_workspace_from_context(context), + if let Some(coordinator) = get_global_coordinator() { + if let Some(resolved) = coordinator + .resolve_session_workspace_path(session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + { + return Ok(resolved); + } + } + + if context.session_id.as_deref() == Some(session_id) { + return self.resolve_workspace_from_context(context); } + + Err(BitFunError::tool(format!( + "Unable to resolve workspace for session '{}'", + session_id + ))) } fn resolve_effective_session_id( @@ -353,9 +367,9 @@ impl CronToolJobPatchInput { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct CronToolInput { action: CronAction, - workspace: Option, session_id: Option, job: Option, patch: Option, @@ -550,12 +564,11 @@ impl Tool for CronTool { Ok(r#"Manage scheduled jobs for agent sessions. Defaults: -- "workspace": defaults to the current workspace. - "session_id": defaults to the current session for "list" and "add". Actions: - "get_time": Return the current local time including timezone information. -- "list": List all jobs for the effective workspace + session scope. +- "list": List all jobs for the effective session scope. - "add": Create a job. Requires "job". When "job.name" is omitted, uses "Cron job". - "update": Update a job. Requires "job_id" and "patch". - "remove": Delete a job. Requires "job_id". @@ -597,10 +610,6 @@ Patch schema for "update": json!({ "type": "object", "properties": { - "workspace": { - "type": "string", - "description": "Optional absolute workspace path. Defaults to the current workspace." - }, "session_id": { "type": "string", "description": "Optional target session ID. Defaults to the current session for list/add." @@ -699,17 +708,6 @@ Patch schema for "update": } }; - if let Some(workspace) = parsed.workspace.as_deref() { - if let Err(message) = Self::validate_workspace_format(workspace, context) { - return ValidationResult { - result: false, - message: Some(message), - error_code: Some(400), - meta: None, - }; - } - } - if let Some(session_id) = parsed.session_id.as_deref() { if let Err(message) = Self::validate_session_id(session_id.trim()) { return ValidationResult { @@ -724,11 +722,11 @@ Patch schema for "update": match parsed.action { CronAction::GetTime => ValidationResult::default(), CronAction::List => { - if parsed.session_id.is_none() - && context + let has_effective_session = parsed.session_id.is_some() + || context .and_then(|tool_context| tool_context.session_id.as_deref()) - .is_none() - { + .is_some(); + if !has_effective_session { return ValidationResult { result: false, message: Some( @@ -739,7 +737,7 @@ Patch schema for "update": meta: None, }; } - if parsed.workspace.is_none() + if parsed.session_id.is_none() && context .and_then(|tool_context| tool_context.workspace_root()) .is_none() @@ -747,7 +745,7 @@ Patch schema for "update": return ValidationResult { result: false, message: Some( - "workspace is required for list when the current workspace is unavailable" + "the current workspace is required for list when session_id is omitted" .to_string(), ), error_code: Some(400), @@ -783,11 +781,11 @@ Patch schema for "update": }; } - if parsed.session_id.is_none() - && context + let has_effective_session = parsed.session_id.is_some() + || context .and_then(|tool_context| tool_context.session_id.as_deref()) - .is_none() - { + .is_some(); + if !has_effective_session { return ValidationResult { result: false, message: Some( @@ -798,7 +796,7 @@ Patch schema for "update": meta: None, }; } - if parsed.workspace.is_none() + if parsed.session_id.is_none() && context .and_then(|tool_context| tool_context.workspace_root()) .is_none() @@ -806,7 +804,7 @@ Patch schema for "update": return ValidationResult { result: false, message: Some( - "workspace is required for add when the current workspace is unavailable" + "the current workspace is required for add when session_id is omitted" .to_string(), ), error_code: Some(400), @@ -971,10 +969,11 @@ Patch schema for "update": CronAction::List => { let cron_service = get_global_cron_service() .ok_or_else(|| BitFunError::tool("cron service not initialized".to_string()))?; - let workspace = - self.resolve_effective_workspace(params.workspace.as_deref(), context)?; let session_id = self.resolve_effective_session_id(params.session_id.as_deref(), context)?; + let workspace = self + .resolve_effective_workspace_for_session(&session_id, context) + .await?; let mut jobs = cron_service .list_jobs_filtered(Some(&workspace), Some(&session_id)) .await; @@ -1004,10 +1003,11 @@ Patch schema for "update": CronAction::Add => { let cron_service = get_global_cron_service() .ok_or_else(|| BitFunError::tool("cron service not initialized".to_string()))?; - let workspace = - self.resolve_effective_workspace(params.workspace.as_deref(), context)?; let session_id = self.resolve_effective_session_id(params.session_id.as_deref(), context)?; + let workspace = self + .resolve_effective_workspace_for_session(&session_id, context) + .await?; let job = params .job .ok_or_else(|| BitFunError::tool("job is required for add".to_string()))?; @@ -1149,3 +1149,93 @@ Patch schema for "update": } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use serde_json::json; + use std::collections::HashMap; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: None, + } + } + + #[tokio::test] + async fn validate_list_allows_missing_workspace_when_session_id_present() { + let tool = CronTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + "session_id": "worker_1", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_add_allows_missing_workspace_when_session_id_present() { + let tool = CronTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "add", + "session_id": "worker_1", + "job": { + "payload": "hello", + "schedule": { + "kind": "every", + "every": 60 + } + } + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_rejects_legacy_workspace_field() { + let tool = CronTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + "session_id": "worker_1", + "workspace": "E:/Projects/OpenBitfun/BitFun", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert!( + validation + .message + .as_deref() + .unwrap_or_default() + .contains("unknown field") + ); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index c6641d108d..b6a1732b01 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,8 +1,8 @@ -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use tool_runtime::fs::edit_file::{apply_edit_to_content, edit_file}; pub struct FileEditTool; @@ -73,8 +73,9 @@ Usage: }, "old_string": { "type": "string", + "minLength": 1, "default": "", - "description": "The exact current text to replace. It must match the current file contents exactly, including whitespace and indentation, and must be unique unless replace_all is true. Copy it from a fresh Read result, excluding the line-number prefix. If this file was edited earlier in the turn, read the target area again before building old_string. Include stable surrounding context when a short snippet may appear multiple times." + "description": "The non-empty exact current text to replace. It must match the current file contents exactly, including whitespace and indentation, and must be unique unless replace_all is true. Copy it from a fresh Read result, excluding the line-number prefix. If this file was edited earlier in the turn, read the target area again before building old_string. Include stable surrounding context when a short snippet may appear multiple times." }, "new_string": { "type": "string", @@ -169,6 +170,23 @@ Usage: .get("new_string") .and_then(|v| v.as_str()) .unwrap_or(""); + if old_string.is_empty() { + return ValidationResult { + result: false, + message: Some("old_string cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + if old_string == new_string { + return ValidationResult { + result: false, + message: Some("new_string must be different from old_string".to_string()), + error_code: Some(400), + meta: None, + }; + } + let largest_lines = old_string.lines().count().max(new_string.lines().count()); let largest_bytes = old_string.len().max(new_string.len()); if largest_lines > LARGE_EDIT_SOFT_LINE_LIMIT || largest_bytes > LARGE_EDIT_SOFT_BYTE_LIMIT @@ -308,10 +326,12 @@ mod tests { fn edit_multiple_match_error_includes_unique_context_guidance() { let message = FileEditTool::enhance_edit_error( "src/lib.rs", - "`old_string` appears 2 times in file".to_string(), + "`old_string` appears 2 times in file\nMatched contexts:\n[match 1 starts at line 4]" + .to_string(), ); assert!(message.contains("old_string")); + assert!(message.contains("[match 1 starts at line 4]")); assert!(message.contains("include more surrounding context")); assert!(message.contains("replace_all only when every occurrence should change")); } diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 1cb522d718..720d0e66c9 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,10 +1,10 @@ -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::framework::{ Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::path::Path; use tokio::fs; @@ -114,9 +114,9 @@ impl FileWriteTool { #[cfg(test)] mod tests { use super::FileWriteTool; - use crate::agentic::WorkspaceBinding; - use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -204,12 +204,10 @@ mod tests { assert_eq!(data["success"], true); assert_eq!(data["bytes_written"], 0); assert_eq!(data["status"], "already_exists_same_content"); - assert!( - result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("do not call Write for this path again") - ); + assert!(result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("do not call Write for this path again")); } #[tokio::test] diff --git a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs index f3a6ce7139..d3d27818ba 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs @@ -1,16 +1,16 @@ //! GetToolSpec tool implementation -use crate::agentic::agents::get_agent_registry; +use crate::agentic::tools::catalog_provider::{ + build_product_get_tool_spec_catalog_description, product_get_tool_spec_runtime, + resolve_product_get_tool_spec_results, ProductGetToolSpecRuntime, ProductToolCatalogProvider, +}; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; -use crate::agentic::tools::resolve_visible_tools; -use crate::agentic::tools::registry::get_global_tool_registry; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use log::debug; -use serde_json::{json, Value}; -use std::sync::Arc; +use bitfun_agent_tools::{GetToolSpecExecutionError, GET_TOOL_SPEC_TOOL_NAME}; +use serde_json::Value; pub struct GetToolSpecTool; @@ -19,130 +19,8 @@ impl GetToolSpecTool { Self } - fn escape_xml_text(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - } - - fn render_collapsed_tools_description(&self, collapsed_tools_list: String) -> String { - format!( - r#"Read usage instructions for additional tools. - -You have access to an additional tools listed below. - - -{} - - -Before using one of these tools, first call GetToolSpec with its exact tool name to read its full description and input schema. - -After reading the returned definition, call the real tool directly using its own name. - -Do not call GetToolSpec again for a tool whose definition is already loaded in the current conversation. - -Example: -- Suppose the catalog includes a tool named `GetWeather` and you need to use it. -- First call `GetToolSpec` with `{{"tool_name":"GetWeather"}}` -- Then read the returned schema and call `GetWeather` itself with the appropriate arguments -"#, - collapsed_tools_list - ) - } - - async fn get_contextual_collapsed_tools( - &self, - context: &ToolUseContext, - ) -> BitFunResult>> { - let agent_type = context.agent_type.as_deref().ok_or_else(|| { - BitFunError::Validation("GetToolSpec requires agent type context".to_string()) - })?; - let workspace_root = context.workspace_root(); - let agent_registry = get_agent_registry(); - let policy = agent_registry - .get_agent_tool_policy(agent_type, workspace_root) - .await; - let visible_tools = - resolve_visible_tools(&policy.allowed_tools, &policy.exposure_overrides, context).await; - Ok(visible_tools.collapsed_tools) - } - async fn build_collapsed_tools_description(&self, context: Option<&ToolUseContext>) -> String { - let mut entries = Vec::new(); - - if let Some(context) = context { - if let Ok(collapsed_tools) = self.get_contextual_collapsed_tools(context).await { - for tool in collapsed_tools { - entries.push(format!("- {}", tool.name())); - } - } - } else { - let registry = get_global_tool_registry(); - let collapsed_tools = { - let registry = registry.read().await; - registry - .get_all_tools() - .into_iter() - .filter(|tool| { - tool.default_exposure() - == crate::agentic::tools::framework::ToolExposure::Collapsed - }) - .map(|tool| (tool.name().to_string(), tool.short_description())) - .collect::>() - }; - - for (tool_name, short_description) in collapsed_tools { - entries.push(format!("- {}: {}", tool_name, short_description)); - } - } - - let collapsed_tools_list = if entries.is_empty() { - "No additional tools are available.".to_string() - } else { - entries.join("\n") - }; - - self.render_collapsed_tools_description(collapsed_tools_list) - } - - async fn build_tool_detail( - &self, - tool_name: &str, - context: Option<&ToolUseContext>, - ) -> BitFunResult { - let context = context.ok_or_else(|| { - BitFunError::Validation("GetToolSpec requires execution context".to_string()) - })?; - let collapsed_tools = self.get_contextual_collapsed_tools(context).await?; - let tool = collapsed_tools - .into_iter() - .find(|tool| tool.name() == tool_name) - .ok_or_else(|| { - BitFunError::Validation(format!( - "Tool '{}' is not an available collapsed tool in the current context", - tool_name - )) - })?; - - if tool.name() == self.name() { - return Err(BitFunError::Validation(format!( - "Tool '{}' cannot inspect itself", - tool_name - ))); - } - - let description = tool - .description_with_context(Some(context)) - .await - .unwrap_or_else(|_| format!("Tool: {}", tool.name())); - let input_schema = tool.input_schema_for_model_with_context(Some(context)).await; - - Ok(json!({ - "tool_name": tool_name, - "description": description, - "input_schema": input_schema - })) + build_product_get_tool_spec_catalog_description(context).await } } @@ -152,10 +30,15 @@ impl Default for GetToolSpecTool { } } +fn with_runtime(operation: impl FnOnce(ProductGetToolSpecRuntime<'_>) -> Result) -> Result { + let provider = ProductToolCatalogProvider; + operation(product_get_tool_spec_runtime(&provider)) +} + #[async_trait] impl Tool for GetToolSpecTool { fn name(&self) -> &str { - "GetToolSpec" + GET_TOOL_SPEC_TOOL_NAME } async fn description(&self) -> BitFunResult { @@ -163,7 +46,7 @@ impl Tool for GetToolSpecTool { } fn short_description(&self) -> String { - "Discover collapsed tools and read their detailed definitions.".to_string() + with_runtime(|runtime| runtime.short_description()) } async fn description_with_context( @@ -174,37 +57,23 @@ impl Tool for GetToolSpecTool { } fn input_schema(&self) -> Value { - json!({ - "type": "object", - "additionalProperties": false, - "required": ["tool_name"], - "properties": { - "tool_name": { - "type": "string", - "description": "The exact tool name to read details for." - } - } - }) + with_runtime(|runtime| runtime.input_schema()) } fn is_readonly(&self) -> bool { - true + with_runtime(|runtime| runtime.is_readonly()) } - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - true + fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + with_runtime(|runtime| runtime.is_concurrency_safe(input)) } - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false + fn needs_permissions(&self, input: Option<&Value>) -> bool { + with_runtime(|runtime| runtime.needs_permissions(input)) } fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { - let tool_name = input - .get("tool_name") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - format!("Reading tool spec for '{}'.", tool_name) + with_runtime(|runtime| runtime.render_tool_use_message(input)) } async fn validate_input( @@ -212,25 +81,7 @@ impl Tool for GetToolSpecTool { input: &Value, _context: Option<&ToolUseContext>, ) -> ValidationResult { - let Some(tool_name) = input.get("tool_name").and_then(|v| v.as_str()) else { - return ValidationResult { - result: false, - message: Some("tool_name is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - }; - - if tool_name.is_empty() { - return ValidationResult { - result: false, - message: Some("tool_name is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - - ValidationResult::default() + with_runtime(|runtime| runtime.validate_input(input)) } async fn call_impl( @@ -238,50 +89,16 @@ impl Tool for GetToolSpecTool { input: &Value, context: &ToolUseContext, ) -> BitFunResult> { - let tool_name = input - .get("tool_name") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("tool_name is required".to_string()))?; - - if context - .unlocked_collapsed_tools - .iter() - .any(|loaded| loaded == tool_name) - { - return Ok(vec![ToolResult::Result { - data: json!({ - "tool_name": tool_name, - "already_loaded": true - }), - result_for_assistant: Some(format!( - "Tool '{}' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use '{}' directly.", - tool_name, tool_name - )), - image_attachments: None, - }]); - } + resolve_product_get_tool_spec_results(input, context, self.name()) + .await + .map_err(map_get_tool_spec_execution_error) + } +} - debug!("GetToolSpec reading tool: {}", tool_name); - let detail = self.build_tool_detail(tool_name, Some(context)).await?; - let description = detail - .get("description") - .and_then(|value| value.as_str()) - .unwrap_or(""); - let input_schema = detail - .get("input_schema") - .map(|value| value.to_string()) - .unwrap_or_else(|| "{}".to_string()); - let assistant_detail = format!( - "\n{}\n\n\n{}\n", - Self::escape_xml_text(description), - Self::escape_xml_text(&input_schema) - ); - - Ok(vec![ToolResult::Result { - data: detail, - result_for_assistant: Some(assistant_detail), - image_attachments: None, - }]) +fn map_get_tool_spec_execution_error(error: GetToolSpecExecutionError) -> BitFunError { + match error { + GetToolSpecExecutionError::MissingToolName => BitFunError::tool(error.to_string()), + GetToolSpecExecutionError::Detail(message) => BitFunError::Validation(message), } } @@ -358,10 +175,7 @@ mod tests { .await; assert!(description.contains(&format!("- {}: Concise catalog entry.", tool_name))); - assert!(!description.contains(&format!( - "- {}: Verbose description first line.", - tool_name - ))); + assert!(!description.contains(&format!("- {}: Verbose description first line.", tool_name))); } #[tokio::test] diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/core/src/agentic/tools/implementations/git_tool.rs index b100f7d1ce..ee305639cf 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/git_tool.rs @@ -727,6 +727,8 @@ impl Tool for GitTool { This tool provides a safe and convenient way to execute Git commands. It supports common Git operations like status, diff, log, add, commit, branch, checkout, pull, push, and more. +If this tool was collapsed earlier in the conversation, only call it after `GetToolSpec` has returned this definition. A failed direct call that says "Tool 'Git' is collapsed" means the next tool call should be `GetToolSpec` with `{"tool_name":"Git"}`; after that, retry `Git` with the schema below. + ## Supported Operations - **status**: Show working tree status @@ -832,7 +834,7 @@ When creating commits, use this format for the commit message: } fn short_description(&self) -> String { - "Inspect and operate on the Git repository.".to_string() + "Inspect and operate on the Git repository; load with GetToolSpec before first use when collapsed.".to_string() } fn default_exposure(&self) -> ToolExposure { @@ -845,12 +847,12 @@ When creating commits, use this format for the commit message: "properties": { "operation": { "type": "string", - "description": "Required Git operation/subcommand to perform (e.g., status, diff, log, add, commit, branch, checkout, pull, push). Do not omit this and do not place the subcommand in args.", + "description": "Required Git subcommand to perform. Use the bare subcommand only, such as \"status\", \"diff\", \"log\", \"add\", or \"commit\". Do not send a raw command like \"git status\". Do not omit this field, and do not place the subcommand in args.", "enum": ALLOWED_OPERATIONS }, "args": { "type": "string", - "description": "Only additional arguments for the selected Git operation (e.g., file paths, flags, options). Do not include the operation/subcommand itself here." + "description": "Only additional arguments for the selected operation: flags, refs, commit messages, or file paths. Examples: \"--staged\", \"--oneline -10\", \"-m \\\"message\\\"\", or \"-- src/file.rs\". Do not include \"git\" or repeat the operation/subcommand here." }, "working_directory": { "type": "string", @@ -1175,7 +1177,7 @@ mod tests { assert!(schema["properties"]["args"]["description"] .as_str() .unwrap() - .contains("Do not include the operation")); + .contains("Do not include \"git\" or repeat the operation")); let validation = tool .validate_input(&json!({"args": "--since=\"2026-05-02\" --oneline"}), None) diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index 4d44ee8005..9d6f24f4b4 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -1,19 +1,19 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::service::search::{ - ContentSearchOutputMode, ContentSearchRequest, WorkspaceSearchHit, WorkspaceSearchLine, get_global_workspace_search_service, remote_workspace_search_service_for_path, - workspace_search_feature_enabled, workspace_search_runtime_available, + workspace_search_feature_enabled, workspace_search_runtime_available, ContentSearchOutputMode, + ContentSearchRequest, WorkspaceSearchHit, WorkspaceSearchLine, }; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::HashSet; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Instant; use tool_runtime::search::grep_search::{ - GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, grep_search, + grep_search, GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, }; const DEFAULT_HEAD_LIMIT: usize = 250; @@ -551,8 +551,8 @@ fn apply_offset_and_limit(items: &mut Vec, offset: usize, head_limit: Op #[cfg(test)] mod tests { use super::{ - DEFAULT_HEAD_LIMIT, GrepTool, render_workspace_search_content_lines, - render_workspace_search_result_lines, + render_workspace_search_content_lines, render_workspace_search_result_lines, GrepTool, + DEFAULT_HEAD_LIMIT, }; use crate::infrastructure::{FileSearchOutcome, FileSearchResult, SearchMatchType}; use crate::service::search::{ diff --git a/src/crates/core/src/agentic/tools/implementations/log_tool.rs b/src/crates/core/src/agentic/tools/implementations/log_tool.rs index 2d4f32da80..9fe86f1a68 100644 --- a/src/crates/core/src/agentic/tools/implementations/log_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/log_tool.rs @@ -188,7 +188,8 @@ The tool will return the log content or analysis results that you can use to dia } fn short_description(&self) -> String { - "Read and analyze log files for debugging and monitoring.".to_string() + "Read and analyze log files for debugging and monitoring; mainly used in Debug mode." + .to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs index b7d239df5f..56d07829de 100644 --- a/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/core/src/agentic/tools/implementations/mcp_tools.rs @@ -594,8 +594,7 @@ impl Tool for GetMCPPromptTool { } fn short_description(&self) -> String { - "Fetch and render a named MCP prompt template from a connected MCP server." - .to_string() + "Fetch and render a named MCP prompt template from a connected MCP server.".to_string() } fn default_exposure(&self) -> ToolExposure { diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 37b78aa67d..77aec9889e 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -23,6 +23,7 @@ pub mod file_read_tool; pub mod file_write_tool; pub mod generative_ui_tool; pub mod get_file_diff_tool; +pub mod get_tool_spec_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; @@ -39,7 +40,6 @@ pub mod skills; pub mod task_tool; pub mod terminal_control_tool; pub mod todo_write_tool; -pub mod get_tool_spec_tool; pub mod util; pub mod web_tools; pub mod harmony_build_tool; @@ -59,6 +59,7 @@ pub use file_read_tool::FileReadTool; pub use file_write_tool::FileWriteTool; pub use generative_ui_tool::GenerativeUITool; pub use get_file_diff_tool::GetFileDiffTool; +pub use get_tool_spec_tool::GetToolSpecTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; @@ -76,5 +77,4 @@ pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; pub use todo_write_tool::TodoWriteTool; -pub use get_tool_spec_tool::GetToolSpecTool; pub use web_tools::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index 0e4cd8627d..b106b1ef0d 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -71,38 +71,6 @@ impl SessionControlTool { Ok(()) } - fn resolve_workspace(&self, workspace: &str) -> BitFunResult { - let workspace = workspace.trim(); - if workspace.is_empty() { - return Err(BitFunError::tool( - "workspace is required and cannot be empty".to_string(), - )); - } - - let path = Path::new(workspace); - if !path.is_absolute() { - return Err(BitFunError::tool( - "workspace must be an absolute path".to_string(), - )); - } - - let resolved = normalize_path(workspace); - let path = Path::new(&resolved); - if !path.exists() { - return Err(BitFunError::tool(format!( - "Workspace does not exist: {}", - resolved - ))); - } - if !path.is_dir() { - return Err(BitFunError::tool(format!( - "Workspace is not a directory: {}", - resolved - ))); - } - Ok(resolved) - } - fn default_session_name() -> String { "New Session".to_string() } @@ -126,6 +94,64 @@ impl SessionControlTool { Ok(format!("session-{}", creator_session_id)) } + fn validate_workspace_shape(&self, workspace: &str) -> ValidationResult { + if workspace.trim().is_empty() { + return ValidationResult { + result: false, + message: Some("workspace is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if !Path::new(workspace.trim()).is_absolute() { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + + ValidationResult::default() + } + + async fn resolve_effective_workspace( + &self, + action: SessionControlAction, + session_id: Option<&str>, + context: &ToolUseContext, + coordinator: &crate::agentic::coordination::ConversationCoordinator, + ) -> BitFunResult { + match action { + SessionControlAction::Cancel | SessionControlAction::Delete => { + let session_id = session_id.ok_or_else(|| { + BitFunError::tool(format!("session_id is required for {}", action.as_str())) + })?; + if let Some(resolved) = coordinator + .resolve_session_workspace_path(session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + { + return Ok(resolved); + } + Err(BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + session_id + ))) + } + SessionControlAction::Create | SessionControlAction::List => context + .workspace_root() + .map(|path| normalize_path(path.to_string_lossy().as_ref())) + .ok_or_else(|| { + BitFunError::tool(format!( + "workspace is required for {} when the current workspace is unavailable", + action.as_str() + )) + }), + } + } + fn validate_mutating_action_target( &self, action: SessionControlAction, @@ -167,18 +193,20 @@ impl SessionControlTool { } if let Some(tool_context) = context { - if let Ok(workspace) = self.resolve_workspace(&parsed.workspace) { - if self.current_workspace_session(tool_context, &workspace) == Some(session_id) { - return ValidationResult { - result: false, - message: Some(format!( - "cannot {} the current session from SessionControl", - action.as_str() - )), - error_code: Some(400), - meta: None, - }; - } + if context + .and_then(|value| value.session_id.as_deref()) + .is_some_and(|current_session_id| current_session_id == session_id) + && tool_context.workspace_root().is_some() + { + return ValidationResult { + result: false, + message: Some(format!( + "cannot {} the current session from SessionControl", + action.as_str() + )), + error_code: Some(400), + meta: None, + }; } } @@ -287,7 +315,7 @@ impl SessionControlAgentType { #[derive(Debug, Clone, Deserialize)] struct SessionControlInput { action: SessionControlAction, - workspace: String, + workspace: Option, session_id: Option, session_name: Option, agent_type: Option, @@ -309,10 +337,8 @@ Actions: - "delete": Delete an existing session by session_id. - "list": List all sessions. -Required inputs: -- "workspace": Absolute workspace path for the target session scope. - -Optional inputs: +Arguments: +- "workspace": Absolute workspace path. Required for create and list. Ignored for cancel and delete. - "session_name": Only used by create. Defaults to "New Session". - "agent_type": Only used by create. Defaults to "agentic". - "agentic": Coding-focused agent for implementation, debugging, and code changes. @@ -342,7 +368,7 @@ Optional inputs: }, "workspace": { "type": "string", - "description": "Required absolute workspace path." + "description": "Required absolute workspace path for create and list. Ignored for cancel and delete." }, "session_id": { "type": "string", @@ -358,7 +384,7 @@ Optional inputs: "description": "Optional agent type when creating a session. Defaults to agentic." } }, - "required": ["action", "workspace"], + "required": ["action"], "additionalProperties": false }) } @@ -388,26 +414,46 @@ Optional inputs: } }; - if parsed.workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if !Path::new(parsed.workspace.trim()).is_absolute() { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; + if let Some(workspace) = parsed.workspace.as_deref() { + let should_validate_workspace = matches!( + parsed.action, + SessionControlAction::Create | SessionControlAction::List + ); + if !should_validate_workspace { + // Ignore workspace for cancel/delete so callers cannot accidentally + // scope these actions to the wrong workspace. + return match parsed.action { + SessionControlAction::Cancel => self + .validate_mutating_action_target( + SessionControlAction::Cancel, + &parsed, + context, + ), + SessionControlAction::Delete => self + .validate_mutating_action_target( + SessionControlAction::Delete, + &parsed, + context, + ), + _ => ValidationResult::default(), + }; + } + let workspace_validation = self.validate_workspace_shape(workspace); + if !workspace_validation.result { + return workspace_validation; + } } match parsed.action { SessionControlAction::Create => { + if parsed.workspace.is_none() { + return ValidationResult { + result: false, + message: Some("workspace is required for create".to_string()), + error_code: Some(400), + meta: None, + }; + } if parsed.session_id.is_some() { return ValidationResult { result: false, @@ -445,6 +491,14 @@ Optional inputs: ); } SessionControlAction::List => { + if parsed.workspace.is_none() { + return ValidationResult { + result: false, + message: Some("workspace is required for list".to_string()), + error_code: Some(400), + meta: None, + }; + } if parsed.agent_type.is_some() { return ValidationResult { result: false, @@ -491,11 +545,8 @@ Optional inputs: match action { "create" => format!("Create session in {}", workspace), - "cancel" => format!( - "Cancel active turn for session {} in {}", - session_id, workspace - ), - "delete" => format!("Delete session {} in {}", session_id, workspace), + "cancel" => format!("Cancel active turn for session {}", session_id), + "delete" => format!("Delete session {}", session_id), "list" => format!("List sessions in {}", workspace), _ => format!("Manage sessions in {}", workspace), } @@ -508,13 +559,18 @@ Optional inputs: ) -> BitFunResult> { let params: SessionControlInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace)?; - let workspace_path = Path::new(&workspace); let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; match params.action { SessionControlAction::Create => { + let workspace = self.resolve_effective_workspace( + SessionControlAction::Create, + None, + context, + &coordinator, + ) + .await?; let session_name = params .session_name .clone() @@ -568,6 +624,14 @@ Optional inputs: BitFunError::tool("session_id is required for cancel".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + let workspace = self.resolve_effective_workspace( + SessionControlAction::Cancel, + Some(session_id), + context, + &coordinator, + ) + .await?; + let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( "cannot cancel the current session from SessionControl".to_string(), @@ -641,6 +705,14 @@ Optional inputs: BitFunError::tool("session_id is required for delete".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; + let workspace = self.resolve_effective_workspace( + SessionControlAction::Delete, + Some(session_id), + context, + &coordinator, + ) + .await?; + let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( "cannot delete the current session from SessionControl".to_string(), @@ -669,6 +741,14 @@ Optional inputs: }]) } SessionControlAction::List => { + let workspace = self.resolve_effective_workspace( + SessionControlAction::List, + None, + context, + &coordinator, + ) + .await?; + let workspace_path = Path::new(&workspace); let sessions = coordinator.list_sessions(workspace_path).await?; let current_session_id = self.current_workspace_session(context, &workspace); let result_for_assistant = @@ -698,6 +778,7 @@ mod tests { use serde_json::json; use std::collections::HashMap; use std::fs; + use std::path::PathBuf; use uuid::Uuid; fn empty_context() -> ToolUseContext { @@ -716,25 +797,36 @@ mod tests { } } - fn temp_workspace_path() -> String { - let path = std::env::temp_dir().join(format!( - "bitfun-session-control-tool-test-{}", - Uuid::new_v4() - )); - fs::create_dir_all(&path).expect("temp workspace should be created"); - path.to_string_lossy().to_string() + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temp workspace should be created"); + Self { path } + } + + fn as_string(&self) -> String { + self.path.to_string_lossy().to_string() + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } } #[tokio::test] async fn validate_cancel_requires_session_id() { let tool = SessionControlTool::new(); - let workspace = temp_workspace_path(); let validation = tool .validate_input( &json!({ "action": "cancel", - "workspace": workspace, }), Some(&empty_context()), ) @@ -750,13 +842,11 @@ mod tests { #[tokio::test] async fn validate_cancel_rejects_session_name() { let tool = SessionControlTool::new(); - let workspace = temp_workspace_path(); let validation = tool .validate_input( &json!({ "action": "cancel", - "workspace": workspace, "session_id": "worker_1", "session_name": "should-not-be-here", }), @@ -771,16 +861,51 @@ mod tests { ); } + #[tokio::test] + async fn validate_cancel_allows_missing_workspace() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "cancel", + "session_id": "worker_1", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_cancel_ignores_workspace_when_provided() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "cancel", + "session_id": "worker_1", + "workspace": "not-an-absolute-path", + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + #[tokio::test] async fn validate_list_rejects_session_id() { let tool = SessionControlTool::new(); - let workspace = temp_workspace_path(); + let workspace = TestTempDir::new("bitfun-session-control-tool-test"); let validation = tool .validate_input( &json!({ "action": "list", - "workspace": workspace, + "workspace": workspace.as_string(), "session_id": "worker_1", }), Some(&empty_context()), @@ -794,6 +919,26 @@ mod tests { ); } + #[tokio::test] + async fn validate_list_requires_workspace() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("workspace is required for list") + ); + } + #[test] fn render_message_for_cancel_is_specific() { let tool = SessionControlTool::new(); @@ -806,6 +951,6 @@ mod tests { &ToolRenderOptions { verbose: false }, ); - assert_eq!(message, "Cancel active turn for session worker_1 in /repo"); + assert_eq!(message, "Cancel active turn for session worker_1"); } } diff --git a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs index 826c0201ba..6f033500b1 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_history_tool.rs @@ -1,4 +1,4 @@ -use super::util::normalize_path; +use crate::agentic::coordination::get_global_coordinator; use crate::agentic::persistence::PersistenceManager; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, @@ -9,7 +9,6 @@ use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde::Deserialize; use serde_json::{json, Value}; -use std::path::Path; use std::sync::Arc; /// SessionHistory tool - export a grep-friendly transcript file for a session. @@ -47,39 +46,6 @@ impl SessionHistoryTool { Ok(()) } - fn resolve_workspace(&self, workspace: &str) -> BitFunResult { - let workspace = workspace.trim(); - if workspace.is_empty() { - return Err(BitFunError::tool( - "workspace is required and cannot be empty".to_string(), - )); - } - - let path = Path::new(workspace); - if !path.is_absolute() { - return Err(BitFunError::tool( - "workspace must be an absolute path".to_string(), - )); - } - - let resolved = normalize_path(workspace); - let path = Path::new(&resolved); - if !path.exists() { - return Err(BitFunError::tool(format!( - "Workspace does not exist: {}", - resolved - ))); - } - if !path.is_dir() { - return Err(BitFunError::tool(format!( - "Workspace is not a directory: {}", - resolved - ))); - } - - Ok(resolved) - } - fn resolve_session_id(&self, session_id: &str) -> BitFunResult { let session_id = session_id.trim().to_string(); Self::validate_session_id(&session_id).map_err(BitFunError::tool)?; @@ -89,7 +55,6 @@ impl SessionHistoryTool { #[derive(Debug, Clone, Deserialize)] struct SessionHistoryInput { - workspace: String, session_id: String, #[serde(default)] tools: Option, @@ -165,10 +130,6 @@ Examples: json!({ "type": "object", "properties": { - "workspace": { - "type": "string", - "description": "Required absolute workspace path." - }, "session_id": { "type": "string", "description": "Required session ID to export." @@ -193,7 +154,7 @@ Examples: "description": "Optional list of turn selectors. Supports index and start:end forms such as \":1\", \"-20:\", \"10:30\", or \"15\"." } }, - "required": ["workspace", "session_id"], + "required": ["session_id"], "additionalProperties": false }) } @@ -223,24 +184,6 @@ Examples: } }; - if parsed.workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - - if !Path::new(parsed.workspace.trim()).is_absolute() { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; - } - if parsed.session_id.trim().is_empty() { return ValidationResult { result: false, @@ -304,12 +247,23 @@ Examples: let params: SessionHistoryInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace)?; let session_id = self.resolve_session_id(¶ms.session_id)?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let workspace = coordinator + .resolve_session_workspace_path(&session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + session_id + )) + })?; let manager = PersistenceManager::new(Arc::new(PathManager::new()?))?; let transcript = manager .export_session_transcript( - Path::new(&workspace), + std::path::Path::new(&workspace), &session_id, &SessionTranscriptExportOptions { tools: params.tools.unwrap_or(false), @@ -336,3 +290,25 @@ Examples: }]) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn validate_allows_missing_workspace_when_session_id_present() { + let tool = SessionHistoryTool::new(); + + let validation = tool + .validate_input( + &json!({ + "session_id": "worker_1", + }), + None, + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index b25200ef76..911f658ff8 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -3,7 +3,7 @@ use crate::agentic::coordination::{ get_global_coordinator, get_global_scheduler, AgentSessionReplyRoute, DialogSubmissionPolicy, DialogTriggerSource, }; -use crate::agentic::core::PromptEnvelope; +use crate::agentic::core::{PromptEnvelope, SessionConfig}; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -90,6 +90,52 @@ impl SessionMessageTool { Ok(resolved) } + fn validate_workspace_shape( + &self, + workspace: &str, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let workspace = workspace.trim(); + if workspace.is_empty() { + return ValidationResult { + result: false, + message: Some("workspace is required and cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + match context { + Some(context) => { + let ws_ok = if context.is_remote() { + posix_style_path_is_absolute(workspace) + } else { + Path::new(workspace).is_absolute() + }; + if !ws_ok { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + } + None => { + if !Path::new(workspace).is_absolute() && !posix_style_path_is_absolute(workspace) { + return ValidationResult { + result: false, + message: Some("workspace must be an absolute path".to_string()), + error_code: Some(400), + meta: None, + }; + } + } + } + + ValidationResult::default() + } + fn sender_session_id<'a>(&self, context: &'a ToolUseContext) -> BitFunResult<&'a str> { context.session_id.as_deref().ok_or_else(|| { BitFunError::tool("SessionMessage requires a source session".to_string()) @@ -105,6 +151,13 @@ impl SessionMessageTool { }) } + fn creator_session_marker(&self, context: &ToolUseContext) -> BitFunResult { + let creator_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool("SessionMessage requires a source session".to_string()) + })?; + Ok(format!("session-{}", creator_session_id)) + } + fn format_forwarded_message(&self, message: &str) -> String { let mut envelope = PromptEnvelope::new(); envelope.push_system_reminder( @@ -134,28 +187,13 @@ impl SessionMessageAgentType { Self::Cowork => "Cowork", } } - - fn from_str(value: &str) -> Option { - if value.eq_ignore_ascii_case("agentic") { - Some(Self::Agentic) - } else if value.eq_ignore_ascii_case("plan") { - Some(Self::Plan) - } else if value.eq_ignore_ascii_case("cowork") { - Some(Self::Cowork) - } else { - None - } - } - - fn is_coding_mode(&self) -> bool { - matches!(self, Self::Agentic | Self::Plan) - } } #[derive(Debug, Clone, Deserialize)] struct SessionMessageInput { - workspace: String, - session_id: String, + workspace: Option, + session_id: Option, + session_name: Option, message: String, agent_type: Option, } @@ -169,20 +207,22 @@ impl Tool for SessionMessageTool { async fn description(&self) -> BitFunResult { Ok( r#"Asynchronously send a message to another agent session. When the target session finishes, its result is automatically sent back to you as a follow-up message. - -You must provide the target workspace as an absolute path, and you can optionally set agent_type to choose how the target session handles the request: + +Usage: +- Create a new session and send: omit "session_id", and provide "workspace", "session_name", "agent_type", and "message". +- Reusing an existing session: provide "session_id" and "message". You may omit "workspace"; the tool will resolve it from the target session when possible. + +Allowed agent types when creating a session: - "agentic": Coding-focused agent for implementation, debugging, and code changes. - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. - -When overriding an existing session's agent_type, only switching between "agentic" and "Plan" is allowed. It will not switch coding sessions to or from "Cowork"."# +"# .to_string(), ) } fn short_description(&self) -> String { - "Send a message to another agent session and receive the result asynchronously." - .to_string() + "Send a message to another agent session and receive the result asynchronously.".to_string() } fn default_exposure(&self) -> ToolExposure { @@ -195,11 +235,15 @@ When overriding an existing session's agent_type, only switching between "agenti "properties": { "workspace": { "type": "string", - "description": "Required absolute target workspace path." + "description": "Required absolute target workspace path when creating a new session. Optional when session_id is provided." }, "session_id": { "type": "string", - "description": "Target session ID." + "description": "Optional target session ID. Omit it to create a new session and send the message there." + }, + "session_name": { + "type": "string", + "description": "Required when session_id is omitted. Display name for the new session." }, "message": { "type": "string", @@ -208,10 +252,10 @@ When overriding an existing session's agent_type, only switching between "agenti "agent_type": { "type": "string", "enum": ["agentic", "Plan", "Cowork"], - "description": "Optional target agent type. Defaults to the target session's current agent type." + "description": "Required when session_id is omitted. Not allowed when sending to an existing session." } }, - "required": ["workspace", "session_id", "message"], + "required": ["message"], "additionalProperties": false }) } @@ -241,15 +285,6 @@ When overriding an existing session's agent_type, only switching between "agenti } }; - if let Err(message) = Self::validate_session_id(&parsed.session_id) { - return ValidationResult { - result: false, - message: Some(message), - error_code: Some(400), - meta: None, - }; - } - if parsed.message.trim().is_empty() { return ValidationResult { result: false, @@ -259,43 +294,95 @@ When overriding an existing session's agent_type, only switching between "agenti }; } - if parsed.workspace.trim().is_empty() { - return ValidationResult { - result: false, - message: Some("workspace is required and cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } + match parsed.session_id.as_deref() { + Some(session_id) => { + if let Err(message) = Self::validate_session_id(session_id) { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } - let Some(context) = context else { - if !Path::new(parsed.workspace.trim()).is_absolute() - && !posix_style_path_is_absolute(parsed.workspace.trim()) - { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, + if parsed.session_name.is_some() { + return ValidationResult { + result: false, + message: Some( + "session_name is only allowed when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if parsed.agent_type.is_some() { + return ValidationResult { + result: false, + message: Some( + "agent_type override is not allowed when session_id is provided" + .to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if let Some(workspace) = parsed.workspace.as_deref() { + let workspace_validation = self.validate_workspace_shape(workspace, context); + if !workspace_validation.result { + return workspace_validation; + } + } + } + None => { + if parsed + .session_name + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return ValidationResult { + result: false, + message: Some( + "session_name is required when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if parsed.agent_type.is_none() { + return ValidationResult { + result: false, + message: Some( + "agent_type is required when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + let Some(workspace) = parsed.workspace.as_deref() else { + return ValidationResult { + result: false, + message: Some( + "workspace is required when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; }; + let workspace_validation = self.validate_workspace_shape(workspace, context); + if !workspace_validation.result { + return workspace_validation; + } } + } + + let Some(context) = context else { return ValidationResult::default(); }; - let ws_ok = if context.is_remote() { - posix_style_path_is_absolute(parsed.workspace.trim()) - } else { - Path::new(parsed.workspace.trim()).is_absolute() - }; - if !ws_ok { - return ValidationResult { - result: false, - message: Some("workspace must be an absolute path".to_string()), - error_code: Some(400), - meta: None, - }; - } - let Some(source_session_id) = context.session_id.as_deref() else { return ValidationResult { result: false, @@ -307,15 +394,17 @@ When overriding an existing session's agent_type, only switching between "agenti }; }; - if source_session_id == parsed.session_id { - return ValidationResult { - result: false, - message: Some( - "SessionMessage cannot send a message to the same session".to_string(), - ), - error_code: Some(400), - meta: None, - }; + if let Some(target_session_id) = parsed.session_id.as_deref() { + if source_session_id == target_session_id { + return ValidationResult { + result: false, + message: Some( + "SessionMessage cannot send a message to the same session".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } } ValidationResult::default() @@ -325,13 +414,16 @@ When overriding an existing session's agent_type, only switching between "agenti let workspace = input .get("workspace") .and_then(|value| value.as_str()) - .unwrap_or("unknown workspace"); - let session_id = input - .get("session_id") - .and_then(|value| value.as_str()) - .unwrap_or("unknown"); - - format!("Send message to session {} in {}", session_id, workspace) + .unwrap_or("resolved workspace"); + if let Some(session_id) = input.get("session_id").and_then(|value| value.as_str()) { + format!("Send message to session {} in {}", session_id, workspace) + } else { + let session_name = input + .get("session_name") + .and_then(|value| value.as_str()) + .unwrap_or("new session"); + format!("Create session {} in {} and send message", session_name, workspace) + } } async fn call_impl( @@ -341,17 +433,7 @@ When overriding an existing session's agent_type, only switching between "agenti ) -> BitFunResult> { let params: SessionMessageInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace, context)?; - let workspace_path = Path::new(&workspace); let source_session_id = self.sender_session_id(context)?.to_string(); - let target_session_id = params.session_id.clone(); - - if source_session_id == target_session_id { - return Err(BitFunError::tool( - "SessionMessage cannot send a message to the same session".to_string(), - )); - } - let source_workspace = self.sender_workspace(context)?; let coordinator = get_global_coordinator() @@ -359,47 +441,98 @@ When overriding an existing session's agent_type, only switching between "agenti let scheduler = get_global_scheduler() .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; - let existing_sessions = coordinator.list_sessions(workspace_path).await?; - let target_session = existing_sessions - .iter() - .find(|session| session.session_id == target_session_id.as_str()) - .ok_or_else(|| { - BitFunError::NotFound(format!( - "Session '{}' not found in workspace '{}'", - target_session_id, workspace - )) - })?; - - let persisted_agent_type = target_session.agent_type.trim(); - let target_agent_type = if let Some(requested_agent_type) = params.agent_type.as_ref() { - let current_agent_type = if persisted_agent_type.is_empty() { - SessionMessageAgentType::Agentic - } else { - SessionMessageAgentType::from_str(persisted_agent_type).ok_or_else(|| { - BitFunError::tool(format!( - "SessionMessage agent_type override is only supported for sessions using 'agentic', 'Plan', or 'Cowork'. Current agent type is '{}'.", - persisted_agent_type - )) - })? - }; + let (target_session_id, target_agent_type, created_session_id, workspace) = + if let Some(target_session_id) = params.session_id.clone() { + if source_session_id == target_session_id { + return Err(BitFunError::tool( + "SessionMessage cannot send a message to the same session".to_string(), + )); + } - if requested_agent_type.as_str() != current_agent_type.as_str() - && !(requested_agent_type.is_coding_mode() && current_agent_type.is_coding_mode()) - { - return Err(BitFunError::tool(format!( - "SessionMessage only allows agent_type override between 'agentic' and 'Plan'. Cannot switch session '{}' from '{}' to '{}'.", - target_session_id, - current_agent_type.as_str(), - requested_agent_type.as_str() - ))); - } + let workspace = if let Some(workspace) = params.workspace.as_deref() { + self.resolve_workspace(workspace, context)? + } else { + coordinator + .resolve_session_workspace_path(&target_session_id) + .await + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + target_session_id + )) + })? + }; + let workspace_path = Path::new(&workspace); + let existing_sessions = coordinator.list_sessions(workspace_path).await?; + let target_session = existing_sessions + .iter() + .find(|session| session.session_id == target_session_id.as_str()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Session '{}' not found in workspace '{}'", + target_session_id, workspace + )) + })?; + + let persisted_agent_type = target_session.agent_type.trim(); + let target_agent_type = if persisted_agent_type.is_empty() { + "agentic".to_string() + } else { + persisted_agent_type.to_string() + }; - requested_agent_type.as_str().to_string() - } else if persisted_agent_type.is_empty() { - "agentic".to_string() - } else { - persisted_agent_type.to_string() - }; + (target_session_id, target_agent_type, None, workspace) + } else { + let workspace = self.resolve_workspace( + params.workspace.as_deref().ok_or_else(|| { + BitFunError::tool( + "workspace is required when session_id is omitted".to_string(), + ) + })?, + context, + )?; + let session_name = params + .session_name + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + BitFunError::tool( + "session_name is required when session_id is omitted".to_string(), + ) + })?; + let agent_type = params + .agent_type + .as_ref() + .ok_or_else(|| { + BitFunError::tool( + "agent_type is required when session_id is omitted".to_string(), + ) + })? + .as_str() + .to_string(); + let created_by = self.creator_session_marker(context)?; + let session = coordinator + .create_session_with_workspace_and_creator( + None, + session_name, + agent_type.clone(), + SessionConfig { + workspace_path: Some(workspace.clone()), + ..Default::default() + }, + workspace.clone(), + Some(created_by), + ) + .await?; + + ( + session.session_id.clone(), + session.agent_type.clone(), + Some(session.session_id), + workspace, + ) + }; let forwarded_message = self.format_forwarded_message(¶ms.message); @@ -428,12 +561,205 @@ When overriding an existing session's agent_type, only switching between "agenti "target_workspace": workspace.clone(), "target_session_id": target_session_id.clone(), "target_agent_type": target_agent_type.clone(), + "created_session_id": created_session_id.clone(), + }), + result_for_assistant: Some(if let Some(created_session_id) = created_session_id { + format!( + "Created session '{}' and accepted the message in workspace '{}' using agent type '{}'.", + created_session_id, workspace, target_agent_type + ) + } else { + format!( + "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", + target_session_id, workspace, target_agent_type + ) }), - result_for_assistant: Some(format!( - "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", - target_session_id, workspace, target_agent_type - )), image_attachments: None, }]) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use serde_json::json; + use std::collections::HashMap; + use std::fs; + use std::path::PathBuf; + use uuid::Uuid; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: None, + } + } + + fn session_context(session_id: &str) -> ToolUseContext { + ToolUseContext { + session_id: Some(session_id.to_string()), + ..empty_context() + } + } + + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temp workspace should be created"); + Self { path } + } + + fn as_string(&self) -> String { + self.path.to_string_lossy().to_string() + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[tokio::test] + async fn validate_existing_session_rejects_agent_type_override() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "session_id": "worker_1", + "message": "hello", + "agent_type": "Plan", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("agent_type override is not allowed when session_id is provided") + ); + } + + #[tokio::test] + async fn validate_new_session_requires_session_name() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "message": "hello", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session_name is required when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_new_session_requires_agent_type() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "message": "hello", + "session_name": "Worker Session", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("agent_type is required when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_new_session_accepts_create_and_send_shape() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_existing_session_allows_missing_workspace() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "session_id": "worker_1", + "message": "hello", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_new_session_requires_workspace() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("workspace is required when session_id is omitted") + ); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index ac50fb27e3..f928700190 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -269,10 +269,8 @@ impl Default for SkillTool { #[cfg(test)] mod tests { use super::SkillTool; - use crate::agentic::tools::implementations::skills::{ - registry::SkillRegistry, SkillLocation, - }; use crate::agentic::tools::framework::{Tool, ToolResult}; + use crate::agentic::tools::implementations::skills::{registry::SkillRegistry, SkillLocation}; use crate::agentic::workspace::{ WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceServices, WorkspaceShell, @@ -457,18 +455,18 @@ Use the remote project skill. async fn read_file_text(&self, path: &str) -> anyhow::Result { match path { - "/remote/project/.bitfun/skills/z-last/SKILL.md" => Ok( - "---\nname: z-last\ndescription: last\n---\n\nz\n".to_string(), - ), - "/remote/project/.bitfun/skills/a-first/SKILL.md" => Ok( - "---\nname: A-First\ndescription: first\n---\n\na\n".to_string(), - ), - "/remote/project/.bitfun/skills/dup-one/SKILL.md" => Ok( - "---\nname: dup\ndescription: dup one\n---\n\none\n".to_string(), - ), - "/remote/project/.bitfun/skills/dup-two/SKILL.md" => Ok( - "---\nname: dup\ndescription: dup two\n---\n\ntwo\n".to_string(), - ), + "/remote/project/.bitfun/skills/z-last/SKILL.md" => { + Ok("---\nname: z-last\ndescription: last\n---\n\nz\n".to_string()) + } + "/remote/project/.bitfun/skills/a-first/SKILL.md" => { + Ok("---\nname: A-First\ndescription: first\n---\n\na\n".to_string()) + } + "/remote/project/.bitfun/skills/dup-one/SKILL.md" => { + Ok("---\nname: dup\ndescription: dup one\n---\n\none\n".to_string()) + } + "/remote/project/.bitfun/skills/dup-two/SKILL.md" => { + Ok("---\nname: dup\ndescription: dup two\n---\n\ntwo\n".to_string()) + } _ => anyhow::bail!("not found: {}", path), } } diff --git a/src/crates/core/src/agentic/tools/implementations/skills/policy.rs b/src/crates/core/src/agentic/tools/implementations/skills/policy.rs index c7a912aa34..9012195488 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/policy.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/policy.rs @@ -9,6 +9,7 @@ use super::catalog::{builtin_skill_spec, BuiltinSkillGroup, BuiltinSkillId, Buil #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillModeId { Agentic, + Multitask, Cowork, Plan, Debug, @@ -23,6 +24,7 @@ impl SkillModeId { pub fn parse(mode_id: &str) -> Self { match mode_id.trim() { "agentic" => Self::Agentic, + "Multitask" => Self::Multitask, "Cowork" => Self::Cowork, "Plan" => Self::Plan, "debug" => Self::Debug, @@ -121,7 +123,7 @@ pub fn policy_for_mode(mode_id: &str) -> ModeSkillPolicy { match SkillModeId::parse(mode_id) { SkillModeId::Plan => PLAN_POLICY, SkillModeId::Debug => DEBUG_POLICY, - SkillModeId::Agentic | SkillModeId::Claw => AGENTIC_POLICY, + SkillModeId::Agentic | SkillModeId::Multitask | SkillModeId::Claw => AGENTIC_POLICY, SkillModeId::Cowork => COWORK_POLICY, SkillModeId::Team => TEAM_POLICY, SkillModeId::ComputerUse | SkillModeId::DeepResearch | SkillModeId::Other => { diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index ff9f445e19..b1d811a008 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -182,7 +182,9 @@ fn sort_resolved_skill_candidates(mut resolved: Vec) -> Vec) -> Vec { +fn sort_skill_candidates_for_resolution( + mut candidates: Vec, +) -> Vec { candidates.sort_by(|a, b| { skill_candidate_precedence(a) .cmp(&skill_candidate_precedence(b)) @@ -206,7 +208,8 @@ fn resolve_visible_skills(candidates: Vec) -> Vec { for candidate in sort_skill_candidates_for_resolution(candidates) { match by_name.get(&candidate.info.name) { Some(existing) - if skill_candidate_precedence(existing) <= skill_candidate_precedence(&candidate) => {} + if skill_candidate_precedence(existing) + <= skill_candidate_precedence(&candidate) => {} _ => { by_name.insert(candidate.info.name.clone(), candidate); } diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 0650602fcb..6e82b85487 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -523,6 +523,10 @@ impl Tool for TaskTool { "minimum": 0, "description": "Optional timeout for this subagent task in seconds. Use 0 or omit it to disable the timeout." }, + "run_in_background": { + "type": "boolean", + "description": "Optional. When true, start the subagent in the background and return immediately. The final result will be delivered back to the parent agent by steering if it is still running, or by starting a new turn if it is idle." + }, "retry": { "type": "boolean", "description": "DeepReview only: true when this Task call is a retry for the same reviewer role after partial_timeout or an explicit transient capacity failure in the current turn." @@ -710,6 +714,10 @@ impl Tool for TaskTool { } None => None, }; + let run_in_background = input + .get("run_in_background") + .and_then(Value::as_bool) + .unwrap_or(false); let is_retry = input.get("retry").and_then(Value::as_bool).unwrap_or(false); let requested_auto_retry = Self::is_deep_review_auto_retry(input); let is_auto_retry = is_retry && requested_auto_retry; @@ -1107,6 +1115,36 @@ impl Tool for TaskTool { values }); let prepared_prompt = prompt; + if run_in_background { + let parent_info = SubagentParentInfo { + tool_call_id: tool_call_id.clone(), + session_id: session_id.clone(), + dialog_turn_id: dialog_turn_id.clone(), + }; + let background_result = coordinator + .start_background_subagent( + subagent_type.clone(), + prepared_prompt.clone(), + parent_info, + Some(effective_workspace_path.clone()), + subagent_context.clone(), + model_id.clone(), + timeout_seconds, + ) + .await?; + return Ok(vec![ToolResult::Result { + data: json!({ + "status": "started", + "run_in_background": true, + "background_task_id": background_result.background_task_id, + }), + result_for_assistant: Some(format!( + "Background subagent '{}' started successfully.\nIts final result will be delivered back automatically to you when it is finished.", + subagent_type, background_result.background_task_id + )), + image_attachments: None, + }]); + } let mut provider_capacity_retry_reason: Option = None; let mut provider_capacity_queue_elapsed_ms = 0_u64; let mut provider_capacity_retry_attempts = 0_usize; diff --git a/src/crates/core/src/agentic/tools/manifest_resolver.rs b/src/crates/core/src/agentic/tools/manifest_resolver.rs index cd5f364dc1..dd98ff0496 100644 --- a/src/crates/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/core/src/agentic/tools/manifest_resolver.rs @@ -1,13 +1,12 @@ use crate::agentic::agents::AgentToolPolicyOverrides; -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; -use crate::agentic::tools::registry::{get_global_tool_registry, GET_TOOL_SPEC_TOOL_NAME}; +use crate::agentic::tools::catalog_provider::{ + resolve_product_tool_manifest, resolve_product_visible_tools, +}; +use crate::agentic::tools::framework::{Tool, ToolUseContext}; use crate::util::types::ToolDefinition; -use serde_json::json; -use std::collections::{HashMap, HashSet}; +use bitfun_agent_tools::{ContextualToolManifest, ContextualVisibleTools, ToolManifestDefinition}; use std::sync::Arc; -type ToolRef = Arc; - #[derive(Debug, Clone)] pub struct ResolvedToolManifest { pub allowed_tool_names: Vec, @@ -17,64 +16,38 @@ pub struct ResolvedToolManifest { #[derive(Clone)] pub struct ResolvedVisibleTools { - allowed_tool_names: Vec, pub expanded_tools: Vec>, - collapsed_tool_names: Vec, pub collapsed_tools: Vec>, } -fn build_visible_tools( - tool_snapshot: &[ToolRef], - allowed_tools: &[String], - exposure_overrides: &AgentToolPolicyOverrides, - available_tool_names: &HashSet, -) -> ResolvedVisibleTools { - let allowed_set: HashSet<&str> = allowed_tools.iter().map(String::as_str).collect(); - let mut allowed_tool_names = allowed_tools.to_vec(); - let mut expanded_tools = Vec::new(); - let mut collapsed_tool_names = Vec::new(); - let mut collapsed_tools = Vec::new(); - - for tool in tool_snapshot { - let tool_name = tool.name().to_string(); - if !available_tool_names.contains(&tool_name) || !allowed_set.contains(tool_name.as_str()) { - continue; - } - - let exposure = exposure_overrides - .get(&tool_name) - .copied() - .unwrap_or_else(|| tool.default_exposure()); - match exposure { - ToolExposure::Collapsed => { - collapsed_tool_names.push(tool_name); - collapsed_tools.push(tool.clone()); - } - ToolExposure::Expanded => expanded_tools.push(tool.clone()), - } +fn to_core_tool_definition(definition: ToolManifestDefinition) -> ToolDefinition { + ToolDefinition { + name: definition.name, + description: definition.description, + parameters: definition.parameters, } +} - if !collapsed_tool_names.is_empty() { - if !allowed_tool_names - .iter() - .any(|name| name == GET_TOOL_SPEC_TOOL_NAME) - { - allowed_tool_names.push(GET_TOOL_SPEC_TOOL_NAME.to_string()); - } - if let Some(tool) = tool_snapshot - .iter() - .find(|tool| tool.name() == GET_TOOL_SPEC_TOOL_NAME) - .cloned() - { - expanded_tools.push(tool); +impl From> for ResolvedVisibleTools { + fn from(value: ContextualVisibleTools) -> Self { + Self { + expanded_tools: value.expanded_tools, + collapsed_tools: value.collapsed_tools, } } +} - ResolvedVisibleTools { - allowed_tool_names, - expanded_tools, - collapsed_tool_names, - collapsed_tools, +impl From> for ResolvedToolManifest { + fn from(value: ContextualToolManifest) -> Self { + Self { + allowed_tool_names: value.allowed_tool_names, + tool_definitions: value + .tool_definitions + .into_iter() + .map(to_core_tool_definition) + .collect(), + collapsed_tool_names: value.collapsed_tool_names, + } } } @@ -83,25 +56,9 @@ pub async fn resolve_visible_tools( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ResolvedVisibleTools { - let registry = get_global_tool_registry(); - let tool_snapshot = { - let registry = registry.read().await; - registry.get_all_tools() - }; - - let mut available_tool_names = HashSet::new(); - for tool in &tool_snapshot { - if tool.is_available_in_context(Some(context)).await { - available_tool_names.insert(tool.name().to_string()); - } - } - - build_visible_tools( - &tool_snapshot, - allowed_tools, - exposure_overrides, - &available_tool_names, - ) + resolve_product_visible_tools(allowed_tools, exposure_overrides, context) + .await + .into() } pub async fn resolve_tool_manifest( @@ -109,82 +66,18 @@ pub async fn resolve_tool_manifest( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ResolvedToolManifest { - let visible_tools = resolve_visible_tools(allowed_tools, exposure_overrides, context).await; - - let mut tool_definitions = Vec::with_capacity( - visible_tools.expanded_tools.len() + visible_tools.collapsed_tools.len(), - ); - for tool in &visible_tools.expanded_tools { - let description = tool - .description_with_context(Some(context)) - .await - .unwrap_or_else(|_| format!("Tool: {}", tool.name())); - let parameters = tool - .input_schema_for_model_with_context(Some(context)) - .await; - - tool_definitions.push(ToolDefinition { - name: tool.name().to_string(), - description, - parameters, - }); - } - - for tool in &visible_tools.collapsed_tools { - let description = format!( - "{} [This tool is collapsed. Call `GetToolSpec` first with {{\"tool_name\":\"{}\"}} before using it.]", - tool.short_description(), - tool.name() - ); - - tool_definitions.push(ToolDefinition { - name: tool.name().to_string(), - description, - parameters: json!({ - "type": "object", - "properties": {}, - "additionalProperties": true - }), - }); - } - - let tool_ordering: HashMap = [ - ("Task", 1), - ("Bash", 2), - ("TerminalControl", 3), - ("Glob", 4), - ("Grep", 5), - ("Read", 6), - ("Edit", 7), - ("Write", 8), - ("Delete", 9), - ("WebFetch", 10), - ("WebSearch", 11), - ("TodoWrite", 12), - ("Skill", 13), - ("Log", 14), - ("GetToolSpec", 15), - ("ControlHub", 16), - ] - .into_iter() - .map(|(k, v)| (k.to_string(), v)) - .collect(); - tool_definitions.sort_by_key(|tool| tool_ordering.get(&tool.name).unwrap_or(&100)); - - ResolvedToolManifest { - allowed_tool_names: visible_tools.allowed_tool_names, - tool_definitions, - collapsed_tool_names: visible_tools.collapsed_tool_names, - } + resolve_product_tool_manifest(allowed_tools, exposure_overrides, context) + .await + .into() } #[cfg(test)] mod tests { use super::resolve_tool_manifest; use crate::agentic::agents::AgentToolPolicyOverrides; - use crate::agentic::tools::framework::{ToolExposure, ToolUseContext}; - use crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME; use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::tools::framework::{ToolExposure, ToolUseContext}; + use bitfun_agent_tools::GET_TOOL_SPEC_TOOL_NAME; use serde_json::json; use std::collections::HashMap; @@ -217,10 +110,12 @@ mod tests { assert!(manifest.collapsed_tool_names.is_empty()); assert_eq!(manifest.allowed_tool_names, allowed_tools); - assert!(!manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + assert!( + !manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) + ); } #[tokio::test] @@ -235,29 +130,43 @@ mod tests { .await; assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); - assert!(manifest - .allowed_tool_names - .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string())); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "Read")); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "WebFetch")); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + assert!( + manifest + .allowed_tool_names + .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string()) + ); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "Read") + ); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch") + ); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) + ); let stub = manifest .tool_definitions .iter() .find(|tool| tool.name == "WebFetch") .expect("WebFetch stub should exist"); - assert!(stub.description.contains("Call `GetToolSpec` first")); + assert!(stub.description.contains("First call `GetToolSpec`")); assert_eq!(stub.parameters["type"], json!("object")); - assert_eq!(stub.parameters["additionalProperties"], json!(true)); + assert_eq!(stub.parameters["additionalProperties"], json!(false)); + assert!( + stub.parameters["properties"]["tool_name"]["description"] + .as_str() + .unwrap() + .contains("{\"tool_name\":\"WebFetch\"}") + ); } #[tokio::test] @@ -307,19 +216,119 @@ mod tests { .iter() .find(|tool| tool.name == "WebFetch") .expect("collapsed WebFetch stub"); - assert!(web_fetch - .description - .contains("Call `GetToolSpec` first with {\"tool_name\":\"WebFetch\"}")); + assert!( + web_fetch + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") + ); assert_eq!( web_fetch.parameters, json!({ "type": "object", - "properties": {}, - "additionalProperties": true + "additionalProperties": false, + "properties": { + "tool_name": { + "type": "string", + "description": "Do not supply WebFetch arguments here while the tool is collapsed. Use GetToolSpec with {\"tool_name\":\"WebFetch\"} first." + } + } }) ); } + #[tokio::test] + async fn manifest_guard_preserves_get_tool_spec_unlock_surface_before_owner_migration() { + let allowed_tools = vec![ + "Read".to_string(), + "WebFetch".to_string(), + "GetFileDiff".to_string(), + "Git".to_string(), + ]; + + let manifest = resolve_tool_manifest( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &tool_context(), + ) + .await; + + assert_eq!( + manifest.allowed_tool_names, + vec![ + "Read".to_string(), + "WebFetch".to_string(), + "GetFileDiff".to_string(), + "Git".to_string(), + GET_TOOL_SPEC_TOOL_NAME.to_string(), + ], + "GetToolSpec insertion must preserve the runtime allowed-list contract" + ); + assert_eq!( + manifest.collapsed_tool_names, + vec![ + "GetFileDiff".to_string(), + "WebFetch".to_string(), + "Git".to_string() + ], + "collapsed unlock list must follow product registry snapshot order" + ); + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["Read", "WebFetch", "GetToolSpec", "GetFileDiff", "Git"], + "prompt-visible definitions must keep the current discovery insertion and policy order stable" + ); + + for tool_name in ["GetFileDiff", "WebFetch", "Git"] { + let stub = manifest + .tool_definitions + .iter() + .find(|tool| tool.name == tool_name) + .unwrap_or_else(|| panic!("{tool_name} stub should exist")); + assert!( + stub.description.contains(&format!( + "First call `GetToolSpec` with {{\"tool_name\":\"{tool_name}\"}}" + )), + "collapsed stub must point to the explicit GetToolSpec unlock flow" + ); + assert_eq!(stub.parameters["type"], json!("object")); + assert_eq!(stub.parameters["additionalProperties"], json!(false)); + assert_eq!( + stub.parameters["properties"]["tool_name"]["description"], + json!(format!( + "Do not supply {tool_name} arguments here while the tool is collapsed. Use GetToolSpec with {{\"tool_name\":\"{tool_name}\"}} first." + )) + ); + } + } + + #[tokio::test] + async fn manifest_preserves_explicit_get_tool_spec_runtime_contract() { + let allowed_tools = vec![GET_TOOL_SPEC_TOOL_NAME.to_string(), "WebFetch".to_string()]; + + let manifest = resolve_tool_manifest( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &tool_context(), + ) + .await; + + assert_eq!(manifest.allowed_tool_names, allowed_tools); + assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["WebFetch", "GetToolSpec", "GetToolSpec"], + "core runtime currently mirrors the pure policy contract when GetToolSpec is already allowed" + ); + } + #[tokio::test] async fn manifest_expands_tool_when_agent_override_requests_it() { let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; @@ -329,13 +338,17 @@ mod tests { let manifest = resolve_tool_manifest(&allowed_tools, &overrides, &tool_context()).await; assert!(manifest.collapsed_tool_names.is_empty()); - assert!(manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "WebFetch")); - assert!(!manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); + assert!( + manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch") + ); + assert!( + !manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) + ); } } diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index b00ef750c3..c0c0f6f788 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -1,6 +1,7 @@ //! Tool system - includes Tool interface, tool registry and tool executor pub mod browser_control; +pub(crate) mod catalog_provider; pub mod computer_use_capability; pub mod computer_use_host; pub mod computer_use_optimizer; @@ -13,14 +14,22 @@ pub mod pipeline; pub(crate) mod post_call_hooks; pub mod registry; pub mod restrictions; +pub(crate) mod runtime_assembly; +pub(crate) mod static_providers; +pub(crate) mod tool_adapter; pub mod user_input_manager; pub mod workspace_paths; pub use bitfun_agent_tools::input_validator; -pub use framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +pub use framework::{ + PortableToolContextProvider, Tool, ToolContextFacts, ToolResult, ToolUseContext, + ToolWorkspaceKind, ValidationResult, +}; pub use image_context::{ImageContextData, ImageContextProvider, ImageContextProviderRef}; pub use input_validator::InputValidator; -pub use manifest_resolver::{resolve_tool_manifest, resolve_visible_tools, ResolvedToolManifest, ResolvedVisibleTools}; +pub use manifest_resolver::{ + resolve_tool_manifest, resolve_visible_tools, ResolvedToolManifest, ResolvedVisibleTools, +}; pub use pipeline::*; pub use registry::{ create_tool_registry, get_all_registered_tool_names, get_all_registered_tools, get_all_tools, diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs index 0f4b2b33f8..2ff7bcdce4 100644 --- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs @@ -251,12 +251,11 @@ impl ToolStateManager { }, }; - let event_subagent_parent_info = task.context.subagent_parent_info.map(|info| info.into()); let event = AgenticEvent::ToolEvent { session_id: task.context.session_id, turn_id: task.context.dialog_turn_id, + round_id: task.context.round_id, tool_event, - subagent_parent_info: event_subagent_parent_info, }; let _ = self.event_queue.enqueue(event, None).await; @@ -311,6 +310,7 @@ mod tests { ToolExecutionContext { session_id: "session-1".to_string(), dialog_turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), agent_type: "agentic".to_string(), workspace: None, context_vars: HashMap::new(), diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 02a677e9ca..47f4d3da77 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -19,8 +19,8 @@ use log::{debug, error, info, warn}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::{Instant, SystemTime}; -use tokio::sync::{oneshot, RwLock as TokioRwLock}; -use tokio::time::{timeout, Duration}; +use tokio::sync::{RwLock as TokioRwLock, oneshot}; +use tokio::time::{Duration, timeout}; use tokio_util::sync::CancellationToken; /// A batch of tool tasks to execute together. @@ -1577,10 +1577,18 @@ impl ToolPipeline { #[cfg(test)] mod tests { use super::*; + use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::tools::ToolRuntimeRestrictions; use serde_json::json; use std::collections::HashMap; + fn test_tool_pipeline() -> ToolPipeline { + let registry = Arc::new(TokioRwLock::new(ToolRegistry::new())); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let state_manager = Arc::new(ToolStateManager::new(event_queue)); + ToolPipeline::new(registry, state_manager, None) + } + fn test_tool_task(tool_id: &str, tool_name: &str) -> ToolTask { ToolTask::new( ToolCall { @@ -1594,6 +1602,7 @@ mod tests { ToolExecutionContext { session_id: "session_1".to_string(), dialog_turn_id: "turn_1".to_string(), + round_id: "round_1".to_string(), agent_type: "agent".to_string(), workspace: None, context_vars: HashMap::new(), @@ -1643,12 +1652,14 @@ mod tests { result.result.result["provided_arguments"], serde_json::Value::String("{\"operation\":\"log\"".to_string()) ); - assert!(result - .result - .result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("Provided arguments: {\"operation\":\"log\"")); + assert!( + result + .result + .result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("Provided arguments: {\"operation\":\"log\"") + ); } #[test] @@ -1675,6 +1686,62 @@ mod tests { assert!(!assistant_text.contains("completed with error")); } + #[test] + fn pipeline_preserves_core_owned_tool_context_without_portable_runtime_leak() { + let pipeline = test_tool_pipeline(); + let mut task = test_tool_task("tool_context_1", "WebFetch"); + task.context + .context_vars + .insert("turn_index".to_string(), "7".to_string()); + task.context + .context_vars + .insert("primary_model_provider".to_string(), "openai".to_string()); + task.context.context_vars.insert( + "primary_model_supports_image_understanding".to_string(), + "true".to_string(), + ); + task.context.collapsed_tools = vec!["WebFetch".to_string()]; + task.context.unlocked_collapsed_tools = vec!["WebFetch".to_string()]; + task.context.runtime_tool_restrictions = ToolRuntimeRestrictions { + allowed_tool_names: ["WebFetch"].into_iter().map(str::to_string).collect(), + denied_tool_names: ["Bash"].into_iter().map(str::to_string).collect(), + path_policy: Default::default(), + }; + + let context = pipeline.build_tool_use_context(&task, CancellationToken::new()); + + assert_eq!(context.tool_call_id.as_deref(), Some("tool_context_1")); + assert_eq!(context.agent_type.as_deref(), Some("agent")); + assert_eq!(context.session_id.as_deref(), Some("session_1")); + assert_eq!(context.dialog_turn_id.as_deref(), Some("turn_1")); + assert_eq!(context.unlocked_collapsed_tools, vec!["WebFetch"]); + assert!(context.cancellation_token.is_some()); + assert!( + context + .runtime_tool_restrictions + .is_tool_allowed("WebFetch") + ); + assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert_eq!(context.custom_data["turn_index"], json!(7)); + assert_eq!( + context.custom_data["primary_model_provider"], + json!("openai") + ); + assert_eq!( + context.custom_data["primary_model_supports_image_understanding"], + json!(true) + ); + + let facts = context.to_tool_context_facts(); + let value = serde_json::to_value(&facts).expect("serialize context facts"); + assert_eq!(value["toolCallId"], "tool_context_1"); + assert_eq!(value["sessionId"], "session_1"); + assert!(value.get("unlockedCollapsedTools").is_none()); + assert!(value.get("customData").is_none()); + assert!(value.get("cancellationToken").is_none()); + assert!(value.get("workspaceServices").is_none()); + } + #[test] fn collapsed_tool_requires_tool_catalog_unlock() { let mut task = test_tool_task("tool_1", "WebFetch"); @@ -1683,9 +1750,10 @@ mod tests { let err = ToolPipeline::validate_collapsed_tool_usage(&task) .expect_err("collapsed tool should require GetToolSpec unlock"); - assert!(err - .to_string() - .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}")); + assert!( + err.to_string() + .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}") + ); } #[test] diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 2944d2bd2b..7b842cc855 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -2,7 +2,7 @@ use crate::agentic::core::{ToolCall, ToolExecutionState}; use crate::agentic::events::SubagentParentInfo as EventSubagentParentInfo; -use crate::agentic::round_preempt::DialogRoundSteeringInterrupt; +use crate::agentic::round_preempt::DialogRoundInjectionInterrupt; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; @@ -50,21 +50,12 @@ impl From for EventSubagentParentInfo { } } -impl From for bitfun_agent_stream::SubagentParentInfo { - fn from(info: SubagentParentInfo) -> Self { - Self { - tool_call_id: info.tool_call_id, - session_id: info.session_id, - dialog_turn_id: info.dialog_turn_id, - } - } -} - /// Tool execution context #[derive(Debug, Clone)] pub struct ToolExecutionContext { pub session_id: String, pub dialog_turn_id: String, + pub round_id: String, pub agent_type: String, pub workspace: Option, pub context_vars: HashMap, @@ -77,8 +68,8 @@ pub struct ToolExecutionContext { pub allowed_tools: Vec, pub runtime_tool_restrictions: ToolRuntimeRestrictions, /// Optional cooperative interrupt used to stop remaining tool calls when a - /// user steering message is waiting for this turn. - pub steering_interrupt: Option, + /// round injection is waiting for this turn. + pub steering_interrupt: Option, pub workspace_services: Option, } diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index c8bfc2e518..7aa456eab1 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -1,29 +1,19 @@ //! Tool registry -use crate::agentic::tools::framework::{DynamicToolInfo, Tool, ToolExposure}; -use crate::agentic::tools::implementations::*; +use crate::agentic::tools::catalog_provider::resolve_product_readonly_enabled_tools; +use crate::agentic::tools::framework::{DynamicToolInfo, Tool}; use crate::util::errors::BitFunResult; use bitfun_agent_tools::{ - DynamicToolDescriptor, DynamicToolProvider, PortResult, ToolDecorator, - ToolRegistry as AgentToolRegistry, ToolRegistryItem, + DynamicToolDescriptor, DynamicToolProvider, PortResult, ToolDecoratorRef, + ToolRegistry as AgentToolRegistry, }; use log::{debug, info, trace, warn}; -use serde_json::Value; use std::sync::Arc; -type ToolRef = Arc; -type ToolDecoratorRef = Arc>; +pub(in crate::agentic::tools) type ToolRef = Arc; +pub(in crate::agentic::tools) type ProductToolDecoratorRef = ToolDecoratorRef; -pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; - -#[derive(Debug, Clone)] -struct SnapshotToolDecorator; - -impl ToolDecorator for SnapshotToolDecorator { - fn decorate(&self, tool: ToolRef) -> ToolRef { - crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) - } -} +pub use bitfun_agent_tools::GET_TOOL_SPEC_TOOL_NAME; /// Tool registry - manages all available tools (using IndexMap to maintain registration order) pub struct ToolRegistry { @@ -39,7 +29,8 @@ impl Default for ToolRegistry { impl ToolRegistry { /// Create a new tool registry pub fn new() -> Self { - Self::with_tool_decorator(Arc::new(SnapshotToolDecorator)) + crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly::default() + .create_registry() } /// Create a registry with an injected decoration boundary. @@ -47,14 +38,15 @@ impl ToolRegistry { /// The default production decorator preserves snapshot-aware wrapping while /// allowing future owner crates to replace this concrete service coupling /// through the `bitfun-runtime-ports` interface. - pub fn with_tool_decorator(tool_decorator: ToolDecoratorRef) -> Self { - let mut registry = Self { - inner: AgentToolRegistry::with_tool_decorator(tool_decorator), - }; + pub fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { + crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly::with_tool_decorator( + tool_decorator, + ) + .create_registry() + } - // Register all tools - registry.register_all_tools(); - registry + pub(in crate::agentic::tools) fn from_inner(inner: AgentToolRegistry) -> Self { + Self { inner } } /// Dynamically register MCP tools @@ -130,75 +122,6 @@ impl ToolRegistry { count } - /// Register all tools - fn register_all_tools(&mut self) { - // Basic tool set - self.register_tool(Arc::new(LSTool::new())); - self.register_tool(Arc::new(FileReadTool::new())); - self.register_tool(Arc::new(GlobTool::new())); - self.register_tool(Arc::new(GrepTool::new())); - self.register_tool(Arc::new(FileWriteTool::new())); - self.register_tool(Arc::new(FileEditTool::new())); - self.register_tool(Arc::new(DeleteFileTool::new())); - self.register_tool(Arc::new(BashTool::new())); - // TaskTool, execute subagent - self.register_tool(Arc::new(TaskTool::new())); - // Skill tool - self.register_tool(Arc::new(SkillTool::new())); - // AskUserQuestion tool - self.register_tool(Arc::new(AskUserQuestionTool::new())); - // TodoWrite tool - self.register_tool(Arc::new(TodoWriteTool::new())); - // CreatePlan tool - self.register_tool(Arc::new(CreatePlanTool::new())); - // Code review submit tool - self.register_tool(Arc::new(CodeReviewTool::new())); - - // GetToolSpec — the discovery entry point for collapsed tools. - self.register_tool(Arc::new(GetToolSpecTool::new())); - - // GetFileDiff tool - self.register_tool(Arc::new(GetFileDiffTool::new())); - // Log tool (debug mode only) - self.register_tool(Arc::new(LogTool::new())); - - // TerminalControl is now accessible via ControlHub's "terminal" domain, - // but we keep it registered separately for backward compatibility. - self.register_tool(Arc::new(TerminalControlTool::new())); - - self.register_tool(Arc::new(SessionControlTool::new())); - self.register_tool(Arc::new(SessionMessageTool::new())); - self.register_tool(Arc::new(SessionHistoryTool::new())); - - // Cron scheduled jobs tool - self.register_tool(Arc::new(CronTool::new())); - - // Web tool - self.register_tool(Arc::new(WebSearchTool::new())); - self.register_tool(Arc::new(WebFetchTool::new())); - - self.register_tool(Arc::new(ListMCPResourcesTool::new())); - self.register_tool(Arc::new(ReadMCPResourceTool::new())); - self.register_tool(Arc::new(ListMCPPromptsTool::new())); - self.register_tool(Arc::new(GetMCPPromptTool::new())); - - self.register_tool(Arc::new(GenerativeUITool::new())); - - // Git version control tool - self.register_tool(Arc::new(GitTool::new())); - - // MiniApp Agent tool (single InitMiniApp) - self.register_tool(Arc::new(InitMiniAppTool::new())); - - // ControlHub — unified browser/terminal/meta control entry point. - // Local desktop and OS/system Computer Use is exposed as a dedicated tool. - self.register_tool(Arc::new(ControlHubTool::new())); - self.register_tool(Arc::new(ComputerUseTool::new())); - - // Playbook — predefined step-by-step operation guides for common tasks. - self.register_tool(Arc::new(PlaybookTool::new())); - } - /// Register a single tool pub fn register_tool(&mut self, tool: ToolRef) { self.inner.register_tool(tool); @@ -214,16 +137,11 @@ impl ToolRegistry { } pub fn is_tool_collapsed(&self, name: &str) -> bool { - self.inner - .get_tool(name) - .is_some_and(|tool| tool.default_exposure() == ToolExposure::Collapsed) + self.inner.is_tool_collapsed(name) } pub fn get_collapsed_tool_names(&self) -> Vec { - self.get_tool_names() - .into_iter() - .filter(|name| self.is_tool_collapsed(name)) - .collect() + self.inner.get_collapsed_tool_names() } /// Get all tool names @@ -239,7 +157,6 @@ impl ToolRegistry { ); self.inner.get_all_tools() } - } #[async_trait::async_trait] @@ -249,35 +166,6 @@ impl DynamicToolProvider for ToolRegistry { } } -#[async_trait::async_trait] -impl ToolRegistryItem for dyn Tool { - fn name(&self) -> &str { - Tool::name(self) - } - - async fn description(&self) -> Result { - Tool::description(self) - .await - .map_err(|error| error.to_string()) - } - - fn input_schema(&self) -> Value { - Tool::input_schema(self) - } - - async fn input_schema_for_model(&self) -> Value { - Tool::input_schema_for_model(self).await - } - - fn dynamic_provider_id(&self) -> Option<&str> { - Tool::dynamic_provider_id(self) - } - - fn dynamic_tool_info(&self) -> Option { - Tool::dynamic_tool_info(self) - } -} - #[cfg(test)] mod tests { use super::create_tool_registry; @@ -286,8 +174,10 @@ mod tests { use crate::agentic::tools::framework::{ DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolResult, ToolUseContext, ValidationResult, }; + use crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly; + use crate::agentic::tools::static_providers::builtin_static_tool_providers; use async_trait::async_trait; - use bitfun_agent_tools::DynamicToolProvider; + use bitfun_agent_tools::{DynamicToolProvider, StaticToolProvider, ToolDecorator}; use serde_json::json; use serde_json::Value; use std::sync::Arc; @@ -379,6 +269,60 @@ mod tests { }) } + #[derive(Debug, Clone)] + struct MarkerToolDecorator; + + impl ToolDecorator for MarkerToolDecorator { + fn decorate(&self, tool: ToolRef) -> ToolRef { + Arc::new(DecoratedMarkerTool { + name: tool.name().to_string(), + exposure: tool.default_exposure(), + readonly: tool.is_readonly(), + }) + } + } + + struct DecoratedMarkerTool { + name: String, + exposure: crate::agentic::tools::framework::ToolExposure, + readonly: bool, + } + + #[async_trait] + impl Tool for DecoratedMarkerTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> crate::util::errors::BitFunResult { + Ok("decorated test tool".to_string()) + } + + fn short_description(&self) -> String { + "decorated test tool".to_string() + } + + fn default_exposure(&self) -> crate::agentic::tools::framework::ToolExposure { + self.exposure + } + + fn input_schema(&self) -> Value { + json!({ "type": "object" }) + } + + fn is_readonly(&self) -> bool { + self.readonly + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult> { + Ok(Vec::new()) + } + } + #[test] fn registry_includes_webfetch_tool() { let registry = create_tool_registry(); @@ -448,6 +392,132 @@ mod tests { ); } + #[test] + fn builtin_static_tool_providers_cover_registry_manifest_in_order() { + let provider_tools = builtin_static_tool_providers() + .into_iter() + .flat_map(|provider| provider.tools()) + .map(|tool| tool.name().to_string()) + .collect::>(); + + assert_eq!( + provider_tools, + create_tool_registry().get_tool_names(), + "provider-based assembly must preserve the existing builtin registry order" + ); + } + + #[test] + fn builtin_static_tool_providers_keep_owner_group_order() { + let provider_ids = builtin_static_tool_providers() + .into_iter() + .map(|provider| provider.provider_id()) + .collect::>(); + + assert_eq!( + provider_ids, + vec![ + "core.basic", + "core.agent", + "core.session", + "core.integration" + ], + "provider groups must stay stable until concrete tool-pack owners exist" + ); + } + + #[test] + fn builtin_static_tool_providers_follow_tool_pack_group_plan() { + let provider_ids = builtin_static_tool_providers() + .into_iter() + .map(|provider| provider.provider_id()) + .collect::>(); + let planned_provider_ids = bitfun_tool_packs::product_tool_provider_group_plan() + .iter() + .map(|group| group.provider_id()) + .collect::>(); + + assert_eq!( + provider_ids, planned_provider_ids, + "core must materialize provider groups from the tool-pack plan" + ); + } + + #[test] + fn product_tool_runtime_assembly_preserves_core_owned_registry_contract() { + let assembly = ProductToolRuntimeAssembly::default(); + + assert_eq!( + assembly.provider_group_ids(), + vec![ + "core.basic", + "core.agent", + "core.session", + "core.integration" + ], + "runtime assembly must keep core-owned provider group order explicit" + ); + + let assembled_registry = assembly.create_registry(); + let compatibility_registry = create_tool_registry(); + + assert_eq!( + assembled_registry.get_tool_names(), + compatibility_registry.get_tool_names(), + "runtime assembly must preserve legacy create_tool_registry output" + ); + assert_eq!( + assembled_registry.get_collapsed_tool_names(), + compatibility_registry.get_collapsed_tool_names(), + "runtime assembly must preserve product collapsed-tool catalog" + ); + + for tool_name in ["Write", "Edit", "Delete"] { + let tool = assembled_registry + .get_tool(tool_name) + .unwrap_or_else(|| panic!("{tool_name} tool should be registered")); + let assistant_text = tool.render_result_for_assistant(&json!({ + "success": true, + "file_path": "E:/Projects/demo.txt" + })); + + assert!( + assistant_text.contains("snapshot system"), + "runtime assembly must preserve snapshot wrapping for {tool_name}" + ); + } + } + + #[test] + fn product_tool_runtime_assembly_keeps_custom_decorator_provider_contract() { + let registry = + ProductToolRuntimeAssembly::with_tool_decorator(Arc::new(MarkerToolDecorator)) + .create_registry(); + let compatibility_registry = create_tool_registry(); + + assert_eq!( + registry.get_tool_names(), + compatibility_registry.get_tool_names(), + "custom decorator assembly must keep provider tool order stable" + ); + assert_eq!( + registry.get_collapsed_tool_names(), + compatibility_registry.get_collapsed_tool_names(), + "custom decorator assembly must keep collapsed exposure stable" + ); + + for tool_name in ["Write", "GetToolSpec", "WebFetch"] { + let tool = registry + .get_tool(tool_name) + .unwrap_or_else(|| panic!("{tool_name} tool should be registered")); + assert_eq!( + tool.short_description(), + "decorated test tool", + "custom decorator must be applied while preserving provider installation" + ); + } + } + #[test] fn registry_marks_collapsed_tools_for_get_tool_spec() { let registry = create_tool_registry(); @@ -458,6 +528,37 @@ mod tests { assert!(registry.is_tool_collapsed("Git")); } + #[test] + fn registry_preserves_collapsed_tool_manifest_for_owner_migration() { + let registry = create_tool_registry(); + + assert_eq!( + registry.get_collapsed_tool_names(), + vec![ + "GetFileDiff", + "Log", + "TerminalControl", + "SessionControl", + "SessionMessage", + "SessionHistory", + "Cron", + "WebSearch", + "WebFetch", + "ListMCPResources", + "ReadMCPResource", + "ListMCPPrompts", + "GetMCPPrompt", + "GenerativeUI", + "Git", + "InitMiniApp", + "ControlHub", + "ComputerUse", + "Playbook", + ], + "collapsed tool manifest must stay stable before moving registry or manifest ownership" + ); + } + #[tokio::test] async fn registry_preserves_readonly_tool_manifest_for_owner_migration() { let readonly_names = super::get_readonly_tools() @@ -686,16 +787,7 @@ pub async fn get_all_tools() -> Vec> { /// Get readonly tools pub async fn get_readonly_tools() -> BitFunResult>> { - let all_tools = get_all_tools().await; - let mut readonly_tools = Vec::new(); - - for tool in all_tools { - if tool.is_readonly() && tool.is_enabled().await { - readonly_tools.push(tool); - } - } - - Ok(readonly_tools) + Ok(resolve_product_readonly_enabled_tools().await) } /// Create default tool registry - factory function diff --git a/src/crates/core/src/agentic/tools/runtime_assembly.rs b/src/crates/core/src/agentic/tools/runtime_assembly.rs new file mode 100644 index 0000000000..b3ac0ec8da --- /dev/null +++ b/src/crates/core/src/agentic/tools/runtime_assembly.rs @@ -0,0 +1,63 @@ +//! Core-owned product tool runtime assembly. +//! +//! This module is the single core-side owner for assembling the product tool +//! registry while concrete tools, `ToolUseContext`, runtime manifest assembly, +//! and snapshot decoration remain core-owned. + +use crate::agentic::tools::registry::{ProductToolDecoratorRef, ToolRef, ToolRegistry}; +use crate::agentic::tools::static_providers::builtin_static_tool_providers; +#[cfg(test)] +use bitfun_agent_tools::StaticToolProvider; +use bitfun_agent_tools::{SnapshotToolDecorator, SnapshotToolWrapper, ToolRuntimeAssembly}; +use std::sync::Arc; + +#[derive(Clone)] +pub(in crate::agentic::tools) struct ProductToolRuntimeAssembly { + tool_decorator: ProductToolDecoratorRef, +} + +impl Default for ProductToolRuntimeAssembly { + fn default() -> Self { + Self::new() + } +} + +impl ProductToolRuntimeAssembly { + pub(in crate::agentic::tools) fn new() -> Self { + Self::with_tool_decorator(Arc::new(SnapshotToolDecorator::new(Arc::new( + ProductSnapshotToolWrapper, + )))) + } + + pub(in crate::agentic::tools) fn with_tool_decorator( + tool_decorator: ProductToolDecoratorRef, + ) -> Self { + Self { tool_decorator } + } + + #[cfg(test)] + pub(in crate::agentic::tools) fn provider_group_ids(&self) -> Vec<&'static str> { + builtin_static_tool_providers() + .iter() + .map(|provider| provider.provider_id()) + .collect() + } + + pub(in crate::agentic::tools) fn create_registry(&self) -> ToolRegistry { + let providers = builtin_static_tool_providers(); + let inner = ToolRuntimeAssembly::with_tool_decorator(self.tool_decorator.clone()) + .create_registry_from_static_providers(&providers); + ToolRegistry::from_inner(inner) + } +} + +#[derive(Debug, Clone)] +struct ProductSnapshotToolWrapper; + +impl SnapshotToolWrapper + for ProductSnapshotToolWrapper +{ + fn wrap_for_snapshot_tracking(&self, tool: ToolRef) -> ToolRef { + crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) + } +} diff --git a/src/crates/core/src/agentic/tools/static_providers.rs b/src/crates/core/src/agentic/tools/static_providers.rs new file mode 100644 index 0000000000..ae22d2a653 --- /dev/null +++ b/src/crates/core/src/agentic/tools/static_providers.rs @@ -0,0 +1,63 @@ +//! Core-owned static tool provider assembly. + +use crate::agentic::tools::framework::Tool; +use crate::agentic::tools::implementations::*; +use bitfun_agent_tools::StaticToolProviderGroup; +use bitfun_tool_packs::product_tool_provider_group_plan; +use std::sync::Arc; + +pub(crate) fn builtin_static_tool_providers() -> Vec> { + product_tool_provider_group_plan() + .iter() + .map(|group| { + StaticToolProviderGroup::new(group.provider_id(), materialize_tools(group.tool_names())) + }) + .collect() +} + +fn materialize_tools(tool_names: &[&str]) -> Vec> { + tool_names + .iter() + .map(|tool_name| materialize_tool(tool_name)) + .collect() +} + +fn materialize_tool(tool_name: &str) -> Arc { + match tool_name { + "LS" => Arc::new(LSTool::new()), + "Read" => Arc::new(FileReadTool::new()), + "Glob" => Arc::new(GlobTool::new()), + "Grep" => Arc::new(GrepTool::new()), + "Write" => Arc::new(FileWriteTool::new()), + "Edit" => Arc::new(FileEditTool::new()), + "Delete" => Arc::new(DeleteFileTool::new()), + "Bash" => Arc::new(BashTool::new()), + "Task" => Arc::new(TaskTool::new()), + "Skill" => Arc::new(SkillTool::new()), + "AskUserQuestion" => Arc::new(AskUserQuestionTool::new()), + "TodoWrite" => Arc::new(TodoWriteTool::new()), + "CreatePlan" => Arc::new(CreatePlanTool::new()), + "submit_code_review" => Arc::new(CodeReviewTool::new()), + "GetToolSpec" => Arc::new(GetToolSpecTool::new()), + "GetFileDiff" => Arc::new(GetFileDiffTool::new()), + "Log" => Arc::new(LogTool::new()), + "TerminalControl" => Arc::new(TerminalControlTool::new()), + "SessionControl" => Arc::new(SessionControlTool::new()), + "SessionMessage" => Arc::new(SessionMessageTool::new()), + "SessionHistory" => Arc::new(SessionHistoryTool::new()), + "Cron" => Arc::new(CronTool::new()), + "WebSearch" => Arc::new(WebSearchTool::new()), + "WebFetch" => Arc::new(WebFetchTool::new()), + "ListMCPResources" => Arc::new(ListMCPResourcesTool::new()), + "ReadMCPResource" => Arc::new(ReadMCPResourceTool::new()), + "ListMCPPrompts" => Arc::new(ListMCPPromptsTool::new()), + "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), + "GenerativeUI" => Arc::new(GenerativeUITool::new()), + "Git" => Arc::new(GitTool::new()), + "InitMiniApp" => Arc::new(InitMiniAppTool::new()), + "ControlHub" => Arc::new(ControlHubTool::new()), + "ComputerUse" => Arc::new(ComputerUseTool::new()), + "Playbook" => Arc::new(PlaybookTool::new()), + _ => panic!("unknown product tool provider plan entry: {tool_name}"), + } +} diff --git a/src/crates/core/src/agentic/tools/tool_adapter.rs b/src/crates/core/src/agentic/tools/tool_adapter.rs new file mode 100644 index 0000000000..f8c4f71322 --- /dev/null +++ b/src/crates/core/src/agentic/tools/tool_adapter.rs @@ -0,0 +1,74 @@ +//! Core product tool adapter for provider-neutral tool contracts. +//! +//! Keep these adapters in core until `ToolUseContext` and concrete tools have a +//! reviewed owner migration. Generic contracts live in `bitfun-agent-tools`; +//! this module only projects core-owned `Tool` behavior into those contracts. + +use crate::agentic::tools::framework::{DynamicToolInfo, Tool, ToolExposure, ToolUseContext}; +use bitfun_agent_tools::{ContextualToolManifestItem, ToolRegistryItem}; +use serde_json::Value; + +#[async_trait::async_trait] +impl ToolRegistryItem for dyn Tool { + fn name(&self) -> &str { + Tool::name(self) + } + + async fn description(&self) -> Result { + Tool::description(self) + .await + .map_err(|error| error.to_string()) + } + + fn input_schema(&self) -> Value { + Tool::input_schema(self) + } + + fn short_description(&self) -> String { + Tool::short_description(self) + } + + fn default_exposure(&self) -> ToolExposure { + Tool::default_exposure(self) + } + + fn is_readonly(&self) -> bool { + Tool::is_readonly(self) + } + + async fn is_enabled(&self) -> bool { + Tool::is_enabled(self).await + } + + async fn input_schema_for_model(&self) -> Value { + Tool::input_schema_for_model(self).await + } + + fn dynamic_provider_id(&self) -> Option<&str> { + Tool::dynamic_provider_id(self) + } + + fn dynamic_tool_info(&self) -> Option { + Tool::dynamic_tool_info(self) + } +} + +#[async_trait::async_trait] +impl ContextualToolManifestItem for dyn Tool { + async fn is_available_in_context(&self, context: &ToolUseContext) -> bool { + Tool::is_available_in_context(self, Some(context)).await + } + + async fn description_with_context(&self, context: &ToolUseContext) -> Result { + Tool::description_with_context(self, Some(context)) + .await + .map_err(|error| error.to_string()) + } + + async fn input_schema_for_model_with_context( + &self, + context: &ToolUseContext, + ) -> serde_json::Value { + Tool::input_schema_for_model_with_context(self, Some(context)).await + } +} diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index 17cd3ecba6..a16bad92b8 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -2,6 +2,9 @@ use super::types::{AICommitAnalysis, CommitMessageOptions, ProjectContext}; use crate::function_agents::common::{AgentError, AgentResult}; use crate::infrastructure::ai::AIClient; use crate::util::types::Message; +use bitfun_product_domains::function_agents::git_func_agent::{ + parse_commit_ai_response, prepare_commit_ai_prompt, +}; /** * AI service layer * @@ -10,9 +13,6 @@ use crate::util::types::Message; use log::{debug, error, warn}; use std::sync::Arc; -/// Prompt template constants (embedded at compile time) -const COMMIT_MESSAGE_PROMPT: &str = include_str!("prompts/commit_message.md"); - pub struct AIAnalysisService { ai_client: Arc, } @@ -46,11 +46,16 @@ impl AIAnalysisService { return Err(AgentError::invalid_input("Code changes are empty")); } - let processed_diff = self.truncate_diff_if_needed(diff_content, 50000); - - let prompt = self.build_commit_prompt(&processed_diff, project_context, options); + let prepared_prompt = prepare_commit_ai_prompt(diff_content, project_context, options); + if prepared_prompt.truncated { + warn!( + "Diff too large ({} chars), truncating to {} chars", + diff_content.len(), + 50_000 + ); + } - let ai_response = self.call_ai(&prompt).await?; + let ai_response = self.call_ai(&prepared_prompt.prompt).await?; self.parse_commit_response(&ai_response) } @@ -83,45 +88,77 @@ impl AIAnalysisService { } } - fn build_commit_prompt( - &self, - diff_content: &str, - project_context: &ProjectContext, - options: &CommitMessageOptions, - ) -> String { - super::utils::build_commit_prompt( - COMMIT_MESSAGE_PROMPT, - diff_content, - project_context, - options, - ) - } - fn parse_commit_response(&self, response: &str) -> AgentResult { - let json_str = crate::util::extract_json_from_ai_response(response) - .ok_or_else(|| AgentError::analysis_error("Cannot extract JSON from response"))?; - - let value: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| { - AgentError::analysis_error(format!("Failed to parse AI response: {}", e)) - })?; - - super::utils::parse_commit_analysis_value(&value).map_err(AgentError::analysis_error) + parse_commit_ai_response(response) } +} - fn truncate_diff_if_needed(&self, diff: &str, max_chars: usize) -> String { - if diff.len() <= max_chars { - return diff.to_string(); +#[cfg(test)] +mod tests { + use super::*; + use crate::function_agents::common::AgentErrorType; + use crate::util::types::AIConfig; + use bitfun_ai_adapters::types::ReasoningMode; + + fn test_service() -> AIAnalysisService { + AIAnalysisService { + ai_client: Arc::new(AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "http://127.0.0.1".to_string(), + request_url: "http://127.0.0.1".to_string(), + api_key: "test".to_string(), + model: "test-model".to_string(), + format: "openai".to_string(), + context_window: 8192, + max_tokens: None, + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + })), } + } - warn!( - "Diff too large ({} chars), truncating to {} chars", - diff.len(), - max_chars + #[test] + fn parse_commit_response_preserves_product_domain_response_policy() { + let service = test_service(); + let parsed = service + .parse_commit_response( + r#"The answer is: +```json +{ + "type": "refactor", + "title": "refactor(product-domains): add runtime baseline", + "body": "Keep behavior stable.", + "confidence": 0.91 +} +``` +"#, + ) + .unwrap(); + + assert_eq!( + parsed.title, + "refactor(product-domains): add runtime baseline" ); - - let mut truncated = diff.chars().take(max_chars - 100).collect::(); - truncated.push_str("\n\n... [content truncated] ..."); - - truncated + assert_eq!(parsed.body.as_deref(), Some("Keep behavior stable.")); + assert_eq!(parsed.confidence, 0.91); + + let missing_json = service.parse_commit_response("no json here").unwrap_err(); + assert_eq!(missing_json.error_type, AgentErrorType::AnalysisError); + assert_eq!(missing_json.message, "Cannot extract JSON from response"); + + let missing_title = service + .parse_commit_response(r#"{"type":"refactor","body":"missing title"}"#) + .unwrap_err(); + assert_eq!(missing_title.error_type, AgentErrorType::AnalysisError); + assert_eq!(missing_title.message, "Missing title field"); } } diff --git a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs index 85b435f2e5..d280df27ca 100644 --- a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs +++ b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs @@ -1,15 +1,16 @@ -use super::ai_service::AIAnalysisService; -use super::context_analyzer::ContextAnalyzer; use super::types::*; -use crate::function_agents::common::{AgentError, AgentResult}; +use crate::function_agents::common::AgentResult; +use crate::function_agents::port_adapters::{ + CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, +}; use crate::infrastructure::ai::AIClientFactory; -use crate::service::git::{GitDiffParams, GitService}; +use bitfun_product_domains::function_agents::ports::FunctionAgentRuntimeFacade; /** * Git Function Agent - commit message generator * * Uses AI to deeply analyze code changes and generate compliant commit messages */ -use log::{debug, info}; +use log::info; use std::path::Path; use std::sync::Arc; @@ -26,88 +27,11 @@ impl CommitGenerator { repo_path ); - let status = GitService::get_status(repo_path) + let git_adapter = CoreFunctionAgentGitAdapter::default(); + let ai_adapter = CoreFunctionAgentAiAdapter::new(factory); + let facade = FunctionAgentRuntimeFacade::new(&git_adapter, &ai_adapter); + facade + .generate_commit_message(repo_path.to_path_buf(), options) .await - .map_err(|e| AgentError::git_error(format!("Failed to get Git status: {}", e)))?; - - let changed_files: Vec = status.staged.iter().map(|f| f.path.clone()).collect(); - - if changed_files.is_empty() { - return Err(AgentError::invalid_input( - "Staging area is empty, please stage files first", - )); - } - - debug!( - "Staged files: count={}, files={:?}", - changed_files.len(), - changed_files - ); - - let diff_content = Self::get_full_diff(repo_path).await?; - - if diff_content.trim().is_empty() { - return Err(AgentError::invalid_input("Diff content is empty")); - } - - let project_context = ContextAnalyzer::analyze_project_context(repo_path) - .await - .unwrap_or_default(); // Fallback to default on failure - - debug!( - "Project context: type={}, tech_stack={:?}", - project_context.project_type, project_context.tech_stack - ); - - let ai_service = - AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?; - - let ai_analysis = ai_service - .generate_commit_message_ai(&diff_content, &project_context, &options) - .await?; - - debug!( - "AI analysis completed: commit_type={:?}, confidence={}", - ai_analysis.commit_type, ai_analysis.confidence - ); - - let changes_summary = super::utils::build_changes_summary_from_paths( - &changed_files, - status.staged.len(), - status.unstaged.len(), - ); - - let full_message = super::utils::assemble_commit_message( - &ai_analysis.title, - &ai_analysis.body, - &ai_analysis.breaking_changes, - ); - - Ok(CommitMessage { - title: ai_analysis.title, - body: ai_analysis.body, - footer: ai_analysis.breaking_changes, - full_message, - commit_type: ai_analysis.commit_type, - scope: ai_analysis.scope, - confidence: ai_analysis.confidence, - changes_summary, - }) - } - - async fn get_full_diff(repo_path: &Path) -> AgentResult { - let diff_params = GitDiffParams { - staged: Some(true), - stat: Some(false), - files: None, - ..Default::default() - }; - - let diff = GitService::get_diff(repo_path, &diff_params) - .await - .map_err(|e| AgentError::git_error(format!("Failed to get diff: {}", e)))?; - - debug!("Got staged diff: length={} chars", diff.len()); - Ok(diff) } } diff --git a/src/crates/core/src/function_agents/port_adapters.rs b/src/crates/core/src/function_agents/port_adapters.rs index a38c225b8c..2dc203a99c 100644 --- a/src/crates/core/src/function_agents/port_adapters.rs +++ b/src/crates/core/src/function_agents/port_adapters.rs @@ -1,29 +1,49 @@ //! Core adapters for product-domain function-agent ports. use std::path::{Path, PathBuf}; +use std::sync::Arc; use bitfun_product_domains::function_agents::ports::{ - FunctionAgentFuture, FunctionAgentGitPort, GitCommitSnapshot, StartchatGitSnapshot, + CommitAiAnalysisRequest, FunctionAgentAiPort, FunctionAgentFuture, FunctionAgentGitPort, + GitCommitSnapshot, StartchatGitSnapshot, StartchatTimeSnapshot, WorkStateAiAnalysisRequest, }; use bitfun_product_domains::function_agents::startchat_func_agent::AheadBehind; +use bitfun_product_domains::function_agents::{ + git_func_agent::AICommitAnalysis, startchat_func_agent::AIGeneratedAnalysis, +}; use crate::function_agents::common::{AgentError, AgentResult}; use crate::function_agents::git_func_agent::ContextAnalyzer; +use crate::infrastructure::ai::AIClientFactory; use crate::service::git::{GitDiffParams, GitService}; #[derive(Debug, Default, Clone)] pub struct CoreFunctionAgentGitAdapter; impl FunctionAgentGitPort for CoreFunctionAgentGitAdapter { - fn git_commit_snapshot(&self, repo_path: String) -> FunctionAgentFuture<'_, GitCommitSnapshot> { - Box::pin(async move { Self::build_git_commit_snapshot(PathBuf::from(repo_path)).await }) + fn git_commit_snapshot( + &self, + repo_path: PathBuf, + ) -> FunctionAgentFuture<'_, GitCommitSnapshot> { + Box::pin(async move { Self::build_git_commit_snapshot(repo_path).await }) } fn startchat_git_snapshot( &self, - repo_path: String, + repo_path: PathBuf, ) -> FunctionAgentFuture<'_, StartchatGitSnapshot> { - Box::pin(async move { Self::build_startchat_git_snapshot(PathBuf::from(repo_path)).await }) + Box::pin(async move { Self::build_startchat_git_snapshot(repo_path).await }) + } + + fn startchat_time_snapshot( + &self, + repo_path: PathBuf, + ) -> FunctionAgentFuture<'_, StartchatTimeSnapshot> { + Box::pin(async move { + Ok(StartchatTimeSnapshot { + last_commit_timestamp: git_last_commit_timestamp(&repo_path), + }) + }) } } @@ -57,12 +77,12 @@ impl CoreFunctionAgentGitAdapter { } async fn build_startchat_git_snapshot(repo_path: PathBuf) -> AgentResult { - let current_branch = git_stdout(&repo_path, &["branch", "--show-current"])? + let current_branch = git_stdout_lenient(&repo_path, &["branch", "--show-current"])? .trim() .to_string(); - let status_porcelain = git_stdout(&repo_path, &["status", "--porcelain"])?; - let unstaged_diff = git_stdout(&repo_path, &["diff", "HEAD"])?; - let staged_diff = git_stdout(&repo_path, &["diff", "--cached"])?; + let status_porcelain = git_stdout_lenient(&repo_path, &["status", "--porcelain"])?; + let unstaged_diff = git_stdout_lenient(&repo_path, &["diff", "HEAD"])?; + let staged_diff = git_stdout_lenient(&repo_path, &["diff", "--cached"])?; let unpushed_commits = git_unpushed_commits(&repo_path); let ahead_behind = git_ahead_behind(&repo_path); let last_commit_timestamp = git_last_commit_timestamp(&repo_path); @@ -79,27 +99,69 @@ impl CoreFunctionAgentGitAdapter { } } -fn git_stdout(repo_path: &Path, args: &[&str]) -> AgentResult { +#[derive(Clone)] +pub struct CoreFunctionAgentAiAdapter { + factory: Arc, +} + +impl CoreFunctionAgentAiAdapter { + pub fn new(factory: Arc) -> Self { + Self { factory } + } +} + +impl FunctionAgentAiPort for CoreFunctionAgentAiAdapter { + fn analyze_commit( + &self, + request: CommitAiAnalysisRequest, + ) -> FunctionAgentFuture<'_, AICommitAnalysis> { + let factory = self.factory.clone(); + Box::pin(async move { + let service = + crate::function_agents::git_func_agent::AIAnalysisService::new_with_agent_config( + factory, + "git-func-agent", + ) + .await?; + service + .generate_commit_message_ai( + &request.diff_content, + &request.project_context, + &request.options, + ) + .await + }) + } + + fn analyze_work_state( + &self, + request: WorkStateAiAnalysisRequest, + ) -> FunctionAgentFuture<'_, AIGeneratedAnalysis> { + let factory = self.factory.clone(); + Box::pin(async move { + let service = crate::function_agents::startchat_func_agent::AIWorkStateService::new_with_agent_config( + factory, + "startchat-func-agent", + ) + .await?; + service + .generate_complete_analysis( + &request.git_state, + &request.git_diff, + &request.language, + ) + .await + }) + } +} + +fn git_stdout_lenient(repo_path: &Path, args: &[&str]) -> AgentResult { let output = crate::util::process_manager::create_command("git") .args(args) .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to run git {:?}: {}", args, e)))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if !stderr.trim().is_empty() { - stderr.trim() - } else { - stdout.trim() - }; - return Err(AgentError::git_error(format!( - "git {:?} failed with status {}: {}", - args, output.status, detail - ))); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) } @@ -169,22 +231,53 @@ fn git_last_commit_timestamp(repo_path: &Path) -> Option { mod tests { use bitfun_product_domains::function_agents::ports::FunctionAgentGitPort; use std::fs; + use std::path::{Path, PathBuf}; use std::process::Command; use super::CoreFunctionAgentGitAdapter; + struct TestTempDir { + path: PathBuf, + } + + impl TestTempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "bitfun-function-agent-port-{}-{}", + label, + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + #[tokio::test] async fn git_adapter_builds_commit_snapshot_from_existing_core_git_services() { - let repo = temp_repo("commit-snapshot"); - init_git_repo(&repo); - fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap(); - fs::create_dir_all(repo.join("src")).unwrap(); - fs::write(repo.join("src/lib.rs"), "pub fn demo() {}\n").unwrap(); - git(&repo, &["add", "Cargo.toml", "src/lib.rs"]); + let repo = TestTempDir::new("commit-snapshot"); + init_git_repo(repo.path()); + fs::write( + repo.path().join("Cargo.toml"), + "[package]\nname = \"demo\"\n", + ) + .unwrap(); + fs::create_dir_all(repo.path().join("src")).unwrap(); + fs::write(repo.path().join("src/lib.rs"), "pub fn demo() {}\n").unwrap(); + git(repo.path(), &["add", "Cargo.toml", "src/lib.rs"]); let adapter = CoreFunctionAgentGitAdapter::default(); let snapshot = adapter - .git_commit_snapshot(repo.to_string_lossy().to_string()) + .git_commit_snapshot(repo.path().to_path_buf()) .await .unwrap(); @@ -196,20 +289,46 @@ mod tests { assert_eq!(snapshot.project_context.project_type, "rust-application"); } + #[tokio::test] + async fn git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate() { + let repo = TestTempDir::new("commit-snapshot-boundary"); + init_git_repo(repo.path()); + fs::write(repo.path().join("tracked.txt"), "base\n").unwrap(); + git(repo.path(), &["add", "tracked.txt"]); + git(repo.path(), &["commit", "-m", "initial"]); + + fs::write(repo.path().join("tracked.txt"), "base\nunstaged only\n").unwrap(); + fs::write(repo.path().join("staged.txt"), "staged only\n").unwrap(); + git(repo.path(), &["add", "staged.txt"]); + + let adapter = CoreFunctionAgentGitAdapter::default(); + let snapshot = adapter + .git_commit_snapshot(repo.path().to_path_buf()) + .await + .unwrap(); + + assert_eq!(snapshot.staged_paths, vec!["staged.txt".to_string()]); + assert_eq!(snapshot.staged_count, 1); + assert_eq!(snapshot.unstaged_count, 1); + assert!(snapshot.diff_content.contains("staged.txt")); + assert!(snapshot.diff_content.contains("staged only")); + assert!(!snapshot.diff_content.contains("unstaged only")); + } + #[tokio::test] async fn git_adapter_builds_startchat_snapshot_without_changing_git_semantics() { - let repo = temp_repo("startchat-snapshot"); - init_git_repo(&repo); - fs::write(repo.join("tracked.txt"), "base\n").unwrap(); - git(&repo, &["add", "tracked.txt"]); - git(&repo, &["commit", "-m", "initial"]); - fs::write(repo.join("tracked.txt"), "base\nchange\n").unwrap(); - fs::write(repo.join("staged.txt"), "staged\n").unwrap(); - git(&repo, &["add", "staged.txt"]); + let repo = TestTempDir::new("startchat-snapshot"); + init_git_repo(repo.path()); + fs::write(repo.path().join("tracked.txt"), "base\n").unwrap(); + git(repo.path(), &["add", "tracked.txt"]); + git(repo.path(), &["commit", "-m", "initial"]); + fs::write(repo.path().join("tracked.txt"), "base\nchange\n").unwrap(); + fs::write(repo.path().join("staged.txt"), "staged\n").unwrap(); + git(repo.path(), &["add", "staged.txt"]); let adapter = CoreFunctionAgentGitAdapter::default(); let snapshot = adapter - .startchat_git_snapshot(repo.to_string_lossy().to_string()) + .startchat_git_snapshot(repo.path().to_path_buf()) .await .unwrap(); @@ -224,25 +343,43 @@ mod tests { } #[tokio::test] - async fn git_adapter_rejects_startchat_snapshot_when_git_command_fails() { - let repo = temp_repo("not-a-git-repo"); + async fn git_adapter_startchat_snapshot_preserves_git_state_when_diff_has_no_head() { + let repo = TestTempDir::new("startchat-no-head-diff"); + init_git_repo(repo.path()); + fs::write(repo.path().join("new.txt"), "new\n").unwrap(); let adapter = CoreFunctionAgentGitAdapter::default(); - let result = adapter - .startchat_git_snapshot(repo.to_string_lossy().to_string()) - .await; + let snapshot = adapter + .startchat_git_snapshot(repo.path().to_path_buf()) + .await + .unwrap(); - assert!(result.is_err()); + assert_eq!(snapshot.current_branch, "main"); + assert!(snapshot.status_porcelain.contains("?? new.txt")); + assert!(snapshot.unstaged_diff.is_empty()); + assert!(snapshot.staged_diff.is_empty()); + assert_eq!(snapshot.unpushed_commits, 0); + assert!(snapshot.ahead_behind.is_none()); + assert!(snapshot.last_commit_timestamp.is_none()); } - fn temp_repo(label: &str) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!( - "bitfun-function-agent-port-{}-{}", - label, - uuid::Uuid::new_v4() - )); - fs::create_dir_all(&path).unwrap(); - path + #[tokio::test] + async fn git_adapter_startchat_snapshot_matches_legacy_empty_state_when_not_git_repo() { + let repo = TestTempDir::new("not-a-git-repo"); + + let adapter = CoreFunctionAgentGitAdapter::default(); + let snapshot = adapter + .startchat_git_snapshot(repo.path().to_path_buf()) + .await + .unwrap(); + + assert!(snapshot.current_branch.is_empty()); + assert!(snapshot.status_porcelain.is_empty()); + assert!(snapshot.unstaged_diff.is_empty()); + assert!(snapshot.staged_diff.is_empty()); + assert_eq!(snapshot.unpushed_commits, 0); + assert!(snapshot.ahead_behind.is_none()); + assert!(snapshot.last_commit_timestamp.is_none()); } fn init_git_repo(repo: &std::path::Path) { diff --git a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs index 096caa65ad..2429027b83 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs @@ -2,6 +2,9 @@ use super::types::*; use crate::function_agents::common::{AgentError, AgentResult, Language}; use crate::infrastructure::ai::AIClient; use crate::util::types::Message; +use bitfun_product_domains::function_agents::startchat_func_agent::{ + build_work_state_analysis_prompt, parse_work_state_analysis_response, +}; /** * AI analysis service * @@ -10,9 +13,6 @@ use crate::util::types::Message; use log::{debug, error, warn}; use std::sync::Arc; -/// Prompt template constants (embedded at compile time) -const WORK_STATE_ANALYSIS_PROMPT: &str = include_str!("prompts/work_state_analysis.md"); - pub struct AIWorkStateService { ai_client: Arc, } @@ -88,35 +88,15 @@ impl AIWorkStateService { git_diff: &str, language: &Language, ) -> String { - super::utils::build_complete_analysis_prompt( - WORK_STATE_ANALYSIS_PROMPT, - git_state, - git_diff, - language, - ) + build_work_state_analysis_prompt(git_state, git_diff, language) } fn parse_complete_analysis(&self, response: &str) -> AgentResult { - let json_str = crate::util::extract_json_from_ai_response(response).ok_or_else(|| { - error!( - "Failed to extract JSON from analysis response: {}", - response - ); - AgentError::internal_error("Failed to extract JSON from analysis response") + let parsed_analysis = parse_work_state_analysis_response(response).map_err(|error| { + error!("{}, response: {}", error.message, response); + error })?; - debug!("Parsing JSON response: length={}", json_str.len()); - - let parsed: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| { - error!( - "Failed to parse complete analysis response: {}, response: {}", - e, response - ); - AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e)) - })?; - - let parsed_analysis = super::utils::parse_complete_analysis_value(&parsed); - if parsed_analysis.predicted_actions_count < 3 { warn!( "AI generated insufficient predicted actions ({}), adding defaults", @@ -151,3 +131,83 @@ impl AIWorkStateService { Ok(parsed_analysis.analysis) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::function_agents::common::AgentErrorType; + use crate::util::types::AIConfig; + use bitfun_ai_adapters::types::ReasoningMode; + + fn test_service() -> AIWorkStateService { + AIWorkStateService { + ai_client: Arc::new(AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "http://127.0.0.1".to_string(), + request_url: "http://127.0.0.1".to_string(), + api_key: "test".to_string(), + model: "test-model".to_string(), + format: "openai".to_string(), + context_window: 8192, + max_tokens: None, + temperature: None, + top_p: None, + reasoning_mode: ReasoningMode::Default, + inline_think_in_text: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + thinking_budget_tokens: None, + custom_request_body: None, + custom_request_body_mode: None, + })), + } + } + + #[test] + fn parse_complete_analysis_preserves_product_domain_response_policy() { + let service = test_service(); + let analysis = service + .parse_complete_analysis( + r#"The answer is: +```json +{ + "summary": "Working on product-domain owner closure.", + "predicted_actions": [ + {"description": "Run checks", "priority": "High", "icon": "check", "is_reminder": false} + ], + "quick_actions": [ + {"title": "Status", "command": "git status", "icon": "git", "action_type": "ViewStatus"} + ] +} +``` +"#, + ) + .unwrap(); + + assert_eq!(analysis.summary, "Working on product-domain owner closure."); + assert_eq!(analysis.predicted_actions.len(), 3); + assert_eq!(analysis.quick_actions.len(), 1); + + let missing_json = service.parse_complete_analysis("no json here").unwrap_err(); + assert_eq!(missing_json.error_type, AgentErrorType::InternalError); + assert_eq!( + missing_json.message, + "Failed to extract JSON from analysis response" + ); + + let invalid_json = service + .parse_complete_analysis( + r#"```json +not json +```"#, + ) + .unwrap_err(); + assert_eq!(invalid_json.error_type, AgentErrorType::InternalError); + assert_eq!( + invalid_json.message, + "Failed to extract JSON from analysis response" + ); + } +} diff --git a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs index 955e74ed0b..ddbeb3dd31 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs @@ -1,13 +1,17 @@ use super::types::*; -use crate::function_agents::common::{AgentError, AgentResult}; +use crate::function_agents::common::AgentResult; +use crate::function_agents::port_adapters::{ + CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, +}; use crate::infrastructure::ai::AIClientFactory; +use bitfun_product_domains::function_agents::ports::FunctionAgentRuntimeFacade; use chrono::{Local, Timelike}; /** * Work state analyzer * * Analyzes the user's current work state, including Git status and file changes */ -use log::{debug, info}; +use log::info; use std::path::Path; use std::sync::Arc; @@ -21,229 +25,21 @@ impl WorkStateAnalyzer { ) -> AgentResult { info!("Analyzing work state: repo_path={:?}", repo_path); - let greeting = Self::generate_greeting(&options); - - let git_state = if options.analyze_git { - Self::analyze_git_state(repo_path).await.ok() - } else { - None - }; - - let git_diff = if git_state - .as_ref() - .is_some_and(|g| g.unstaged_files > 0 || g.staged_files > 0) - { - Self::get_git_diff(repo_path).await.unwrap_or_default() - } else { - String::new() - }; - - let time_info = Self::get_time_info(repo_path).await; - - let ai_analysis = - Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options) - .await?; - - debug!("AI complete analysis generation succeeded"); - let summary = ai_analysis.summary; - let ongoing_work = ai_analysis.ongoing_work; - let predicted_actions = if options.predict_next_actions { - ai_analysis.predicted_actions - } else { - Vec::new() - }; - let quick_actions = if options.include_quick_actions { - ai_analysis.quick_actions - } else { - Vec::new() - }; - - let current_state = CurrentWorkState { - summary, - git_state, - ongoing_work, - time_info, - }; - - Ok(WorkStateAnalysis { - greeting, - current_state, - predicted_actions, - quick_actions, - analyzed_at: Local::now().to_rfc3339(), - }) - } - - fn generate_greeting(_options: &WorkStateOptions) -> GreetingMessage { - // Frontend uses its own static greeting from i18n. - GreetingMessage { - title: String::new(), - subtitle: String::new(), - tagline: None, - } - } - - async fn get_git_diff(repo_path: &Path) -> AgentResult { - debug!("Getting Git diff"); - - let unstaged_output = crate::util::process_manager::create_command("git") - .arg("diff") - .arg("HEAD") - .current_dir(repo_path) - .output() - .map_err(|e| AgentError::git_error(format!("Failed to get git diff: {}", e)))?; - - let unstaged_diff = String::from_utf8_lossy(&unstaged_output.stdout); - - let staged_output = crate::util::process_manager::create_command("git") - .arg("diff") - .arg("--cached") - .current_dir(repo_path) - .output() - .map_err(|e| AgentError::git_error(format!("Failed to get staged diff: {}", e)))?; - - let staged_diff = String::from_utf8_lossy(&staged_output.stdout); - let diff = super::utils::combine_git_diffs(&unstaged_diff, &staged_diff); - - debug!("Git diff retrieved: length={} chars", diff.len()); - - Ok(diff) - } - - async fn generate_complete_analysis_with_ai( - factory: Arc, - git_state: &Option, - git_diff: &str, - options: &WorkStateOptions, - ) -> AgentResult { - use super::ai_service::AIWorkStateService; - - debug!("Starting AI complete analysis generation"); - - let ai_service = - AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?; - ai_service - .generate_complete_analysis(git_state, git_diff, &options.language) - .await - } - - async fn analyze_git_state(repo_path: &Path) -> AgentResult { - let current_branch = Self::get_current_branch(repo_path)?; - - let status_output = crate::util::process_manager::create_command("git") - .arg("status") - .arg("--porcelain") - .current_dir(repo_path) - .output() - .map_err(|e| AgentError::git_error(format!("Failed to get git status: {}", e)))?; - - let status_str = String::from_utf8_lossy(&status_output.stdout); - - let (unstaged_files, staged_files, modified_files) = - super::utils::parse_git_status_porcelain(&status_str); - - let unpushed_commits = Self::get_unpushed_commits(repo_path)?; - let ahead_behind = Self::get_ahead_behind(repo_path).ok(); - - Ok(GitWorkState { - current_branch, - unstaged_files, - staged_files, - unpushed_commits, - ahead_behind, - modified_files, - }) - } - - fn get_current_branch(repo_path: &Path) -> AgentResult { - let output = crate::util::process_manager::create_command("git") - .arg("branch") - .arg("--show-current") - .current_dir(repo_path) - .output() - .map_err(|e| AgentError::git_error(format!("Failed to get current branch: {}", e)))?; - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } - - fn get_unpushed_commits(repo_path: &Path) -> AgentResult { - let output = crate::util::process_manager::create_command("git") - .arg("log") - .arg("@{u}..") - .arg("--oneline") - .current_dir(repo_path) - .output(); - - if let Ok(output) = output { - if output.status.success() { - let count = String::from_utf8_lossy(&output.stdout).lines().count() as u32; - return Ok(count); - } - } - - Ok(0) - } - - fn get_ahead_behind(repo_path: &Path) -> AgentResult { - let output = crate::util::process_manager::create_command("git") - .arg("rev-list") - .arg("--left-right") - .arg("--count") - .arg("HEAD...@{u}") - .current_dir(repo_path) - .output() - .map_err(|e| AgentError::git_error(format!("Failed to get ahead/behind: {}", e)))?; - - if !output.status.success() { - return Err(AgentError::git_error("No upstream branch configured")); - } - - let result = String::from_utf8_lossy(&output.stdout); - let parts: Vec<&str> = result.split_whitespace().collect(); - - if parts.len() >= 2 { - let ahead = parts[0].parse().unwrap_or(0); - let behind = parts[1].parse().unwrap_or(0); - Ok(AheadBehind { ahead, behind }) - } else { - Err(AgentError::git_error("Failed to parse ahead/behind info")) - } - } - - async fn get_time_info(repo_path: &Path) -> TimeInfo { - let time_of_day = super::utils::time_of_day_for_hour(Local::now().hour()); - - let output = crate::util::process_manager::create_command("git") - .arg("log") - .arg("-1") - .arg("--format=%ct") - .current_dir(repo_path) - .output(); - - let (minutes_since_last_commit, last_commit_time_desc) = if let Ok(output) = output { - if output.status.success() { - let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if let Ok(timestamp) = timestamp_str.parse::() { - let now = Local::now().timestamp(); - let diff_seconds = now - timestamp; - let minutes = (diff_seconds / 60) as u64; - - // Don't format time description here, let frontend handle i18n - (Some(minutes), None) - } else { - (None, None) - } - } else { - (None, None) - } - } else { - (None, None) - }; - - TimeInfo { - minutes_since_last_commit, - last_commit_time_desc, - time_of_day, - } + let now = Local::now(); + let git_adapter = CoreFunctionAgentGitAdapter::default(); + let ai_adapter = CoreFunctionAgentAiAdapter::new(factory); + let facade = FunctionAgentRuntimeFacade::new(&git_adapter, &ai_adapter); + // Keep the legacy analyzed_at timing in core: assign it after AI analysis completes. + let mut analysis = facade + .analyze_work_state( + repo_path.to_path_buf(), + options, + now.timestamp(), + now.hour(), + String::new(), + ) + .await?; + analysis.analyzed_at = Local::now().to_rfc3339(); + Ok(analysis) } } diff --git a/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css b/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css index 32d66f3cb7..88111bbbf3 100644 --- a/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css +++ b/src/crates/core/src/miniapp/builtin/assets/pr-review/style.css @@ -84,7 +84,11 @@ button { cursor: pointer; } -button:disabled { +button:disabled, +.pr-btn[aria-disabled="true"], +.pr-icon-btn[aria-disabled="true"], +.pr-text-btn[aria-disabled="true"], +.pr-sync-tile[aria-disabled="true"] { cursor: not-allowed; opacity: 0.55; } @@ -104,6 +108,15 @@ button:disabled { overflow: hidden; } +.pr-shell--with-status { + grid-template-rows: auto auto minmax(0, 1fr); + row-gap: 8px; +} + +.pr-shell--with-status .pr-main-layout { + grid-row: 3; +} + .pr-command-bar { display: grid; grid-template-columns: minmax(180px, 0.45fr) minmax(520px, 1.55fr) minmax(300px, 0.75fr); @@ -119,6 +132,22 @@ button:disabled { box-shadow: 0 10px 26px var(--pr-shadow); } +.pr-command-bar--simple { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: "brand actions"; + align-items: center; + padding: 10px 12px; +} + +.pr-command-actions { + grid-area: actions; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + .pr-brand { grid-area: brand; display: flex; @@ -129,6 +158,7 @@ button:disabled { } .pr-brand-mark { + flex: 0 0 30px; width: 30px; height: 30px; display: grid; @@ -217,6 +247,17 @@ button:disabled { 100% { background-position: 0% 50%; } } +@keyframes pr-pulse-dot { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 5px color-mix(in srgb, var(--pr-info) 14%, transparent); + } + 50% { + opacity: 0.58; + box-shadow: 0 0 0 8px color-mix(in srgb, var(--pr-info) 6%, transparent); + } +} + .pr-repo-first::before { opacity: 0.15; } @@ -408,17 +449,59 @@ button:disabled { border-color: color-mix(in srgb, var(--pr-accent) 36%, var(--pr-line)); } +.pr-btn[aria-disabled="true"]:hover, +.pr-icon-btn[aria-disabled="true"]:hover { + background: var(--pr-surface-2); + border-color: var(--pr-line); +} + .pr-btn:active, .pr-icon-btn:active { transform: translateY(1px); } +.pr-btn[aria-disabled="true"]:active, +.pr-icon-btn[aria-disabled="true"]:active { + transform: none; +} + .pr-btn--primary { border-color: color-mix(in srgb, var(--pr-accent) 48%, var(--pr-line)); background: color-mix(in srgb, var(--pr-accent) 17%, var(--pr-surface-2)); color: var(--pr-text); } +.pr-btn--review { + border-color: color-mix(in srgb, var(--pr-accent) 62%, var(--pr-line)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 42%, var(--pr-surface-2)), color-mix(in srgb, var(--pr-info) 18%, var(--pr-surface-2))); + color: var(--pr-text); + box-shadow: 0 8px 20px color-mix(in srgb, var(--pr-accent) 15%, transparent); +} + +.pr-btn--review:hover { + border-color: color-mix(in srgb, var(--pr-accent) 76%, var(--pr-line)); + background: + linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 50%, var(--pr-surface-2)), color-mix(in srgb, var(--pr-info) 23%, var(--pr-surface-2))); +} + +.pr-btn--secondary { + color: var(--pr-text-2); + background: color-mix(in srgb, var(--pr-surface-2) 84%, transparent); +} + +.pr-btn--merge { + border-color: color-mix(in srgb, var(--pr-green) 38%, var(--pr-line)); + background: color-mix(in srgb, var(--pr-green) 13%, var(--pr-surface-2)); + color: color-mix(in srgb, var(--pr-green) 72%, var(--pr-text)); +} + +.pr-btn--danger { + border-color: color-mix(in srgb, var(--pr-red) 42%, var(--pr-line)); + background: color-mix(in srgb, var(--pr-red) 14%, var(--pr-surface-2)); + color: color-mix(in srgb, var(--pr-red) 78%, var(--pr-text)); +} + .pr-btn--compact { min-height: 30px; padding: 5px 9px; @@ -450,6 +533,12 @@ button:disabled { background: var(--pr-surface-2); } +.pr-text-btn[aria-disabled="true"]:hover { + color: var(--pr-muted); + border-color: transparent; + background: transparent; +} + .pr-token-badge, .pr-chip, .pr-count { @@ -497,6 +586,14 @@ button:disabled { min-height: 0; } +.pr-main-layout--no-composer { + grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1.5fr); +} + +.pr-main-layout--reviewing { + grid-template-columns: minmax(300px, 0.78fr) minmax(460px, 1.35fr) minmax(330px, 0.92fr); +} + .pr-sidebar, .pr-review-workspace, .pr-composer { @@ -581,6 +678,10 @@ button:disabled { padding: 12px 12px 0; } +.pr-sync-panel--settings { + padding: 0; +} + .pr-sync-tile { display: grid; gap: 4px; @@ -609,6 +710,17 @@ button:disabled { background: linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 12%, transparent), color-mix(in srgb, var(--pr-info) 7%, transparent)); } +.pr-sync-tile[aria-disabled="true"]:hover { + border-color: var(--pr-line-soft); + background: color-mix(in srgb, var(--pr-surface) 88%, var(--pr-bg)); +} + +.pr-sync-tile.is-active[aria-disabled="true"], +.pr-sync-tile.is-active[aria-disabled="true"]:hover { + border-color: color-mix(in srgb, var(--pr-accent) 56%, var(--pr-line)); + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-accent) 12%, transparent), color-mix(in srgb, var(--pr-info) 7%, transparent)); +} + .pr-queue-actions { align-items: center; justify-content: space-between; @@ -633,6 +745,11 @@ button:disabled { padding: 4px 7px; } +.pr-queue-actions--settings { + padding: 0; + justify-content: space-between; +} + .pr-list, .pr-source-list, .pr-review-list, @@ -657,6 +774,15 @@ button:disabled { padding: 11px; } +.pr-queue-item--compact { + gap: 6px; + padding: 10px; +} + +.pr-main-layout--reviewing .pr-queue-item--compact { + padding: 9px; +} + .pr-queue-item:hover, .pr-queue-item.is-active { border-color: color-mix(in srgb, var(--pr-accent) 56%, var(--pr-line)); @@ -673,6 +799,10 @@ button:disabled { overflow: hidden; } +.pr-queue-item--compact .pr-queue-title { + -webkit-line-clamp: 2; +} + .pr-queue-meta, .pr-queue-signals, .pr-meta-row { @@ -854,11 +984,32 @@ button:disabled { margin: 0; } +.pr-shell-status { + margin: 0; +} + +.pr-shell-status .pr-status { + display: flex; + align-items: center; + min-height: 34px; + font-size: 13px; + font-weight: 720; +} + .pr-status--error { color: var(--pr-red); border-color: color-mix(in srgb, var(--pr-red) 35%, var(--pr-line)); } +.pr-status--busy { + color: var(--pr-text); + border-color: color-mix(in srgb, var(--pr-accent) 42%, var(--pr-line)); + background: + linear-gradient(90deg, color-mix(in srgb, var(--pr-accent) 18%, var(--pr-surface)), color-mix(in srgb, var(--pr-info) 10%, var(--pr-bg))), + var(--pr-surface); + box-shadow: 0 8px 22px color-mix(in srgb, var(--pr-accent) 12%, transparent); +} + .pr-empty { display: grid; place-items: center; @@ -905,10 +1056,16 @@ button:disabled { .pr-pr-actions { align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + flex: 0 0 auto; justify-content: flex-end; } +.pr-pr-actions .pr-btn { + min-height: 32px; + padding-inline: 9px; +} + .pr-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -946,6 +1103,52 @@ button:disabled { font-weight: 740; } +.pr-lifecycle-panel { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-surface) 92%, var(--pr-bg)), color-mix(in srgb, var(--pr-accent) 5%, var(--pr-bg))); +} + +.pr-lifecycle-panel .pr-section-head { + margin-bottom: 0; +} + +.pr-lifecycle-panel .pr-muted { + margin: 4px 0 0; +} + +.pr-lifecycle-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.pr-lifecycle-tag { + min-height: 24px; + max-width: 100%; + border-color: color-mix(in srgb, var(--pr-accent) 22%, var(--pr-line-soft)); + background: color-mix(in srgb, var(--pr-bg) 74%, var(--pr-surface)); +} + +.pr-lifecycle-warnings { + display: flex; + flex-wrap: wrap; + gap: 6px; + color: var(--pr-amber); + font-size: 11px; + line-height: 1.4; +} + +.pr-lifecycle-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + .pr-section-head { display: flex; justify-content: space-between; @@ -1014,18 +1217,24 @@ button:disabled { .pr-diff-panel { min-width: 0; + max-height: min(560px, 56vh); + overflow: auto; + overscroll-behavior: contain; border: 1px solid var(--pr-line-soft); border-radius: 8px; - overflow: hidden; background: var(--pr-diff-bg); } .pr-diff-head { + position: sticky; + top: 0; + z-index: 1; display: flex; justify-content: space-between; gap: 10px; padding: 9px 10px; border-bottom: 1px solid var(--pr-line-soft); + background: var(--pr-diff-bg); } .pr-diff-head strong { @@ -1078,31 +1287,62 @@ button:disabled { } .pr-fold summary { + list-style: none; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; cursor: pointer; color: var(--pr-text-2); font-size: 13px; font-weight: 720; } -.pr-fold summary span { - margin-left: 8px; - color: var(--pr-muted); - font-size: 11px; - font-weight: 400; +.pr-fold summary::-webkit-details-marker { + display: none; } -.pr-overview-fold summary { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; +.pr-fold summary::before { + content: ""; + width: 0; + height: 0; + flex: 0 0 auto; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid var(--pr-muted); + transition: transform 140ms ease, border-left-color 140ms ease; +} + +.pr-fold[open] > summary::before { + transform: rotate(90deg); + border-left-color: var(--pr-accent); +} + +.pr-fold-title { + flex: 0 0 auto; + min-width: max-content; + color: var(--pr-text-2); + font-size: 13px; + font-weight: 720; + white-space: nowrap; } -.pr-overview-fold summary span { +.pr-fold-meta { + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--pr-muted); + font-size: 11px; + font-weight: 400; +} + +.pr-overview-fold summary { + justify-content: flex-start; +} + +.pr-overview-fold .pr-fold-meta { margin-left: 0; } @@ -1110,6 +1350,19 @@ button:disabled { margin-top: 10px; } +.pr-files-fold summary { + justify-content: flex-start; +} + +.pr-files-fold .pr-fold-meta { + margin-left: auto; + text-align: right; +} + +.pr-files-fold .pr-files-layout { + margin-top: 10px; +} + .pr-review-row { display: grid; gap: 7px; @@ -1120,6 +1373,69 @@ button:disabled { font-size: 12px; } +.pr-ci-list { + display: grid; + gap: 7px; +} + +.pr-ci-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 82%, var(--pr-surface)); +} + +.pr-ci-row strong, +.pr-ci-row span { + display: block; +} + +.pr-ci-row strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pr-text); + font-size: 12px; +} + +.pr-ci-row span { + color: var(--pr-muted); + font-size: 11px; +} + +.pr-ci-row .pr-text-btn { + white-space: nowrap; +} + +.pr-ci-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--pr-muted); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-muted) 10%, transparent); +} + +.pr-ci-dot.is-ok { + background: var(--pr-green); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-green) 12%, transparent); +} + +.pr-ci-dot.is-warn { + background: var(--pr-amber); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-amber) 14%, transparent); +} + +.pr-ci-dot.is-bad { + background: var(--pr-red); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--pr-red) 13%, transparent); +} + .pr-review-row p { margin: 0; color: var(--pr-text-2); @@ -1128,15 +1444,33 @@ button:disabled { } .pr-manual-comment { - margin-top: 10px; display: grid; gap: 8px; - padding: 10px; + padding: 12px; border: 1px solid var(--pr-line-soft); border-radius: 8px; background: color-mix(in srgb, var(--pr-bg) 76%, var(--pr-surface)); } +.pr-manual-comment-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.pr-manual-comment .pr-textarea { + min-height: 56px; +} + +.pr-manual-comment.is-expanded .pr-textarea { + min-height: 260px; +} + +.pr-manual-comment .pr-btn { + justify-self: flex-start; +} + .pr-composer { padding-bottom: 12px; } @@ -1150,6 +1484,39 @@ button:disabled { margin: 0 12px 12px; } +.pr-language-control { + display: grid; + grid-template-columns: minmax(0, 1fr) 124px; + gap: 10px; + align-items: center; + margin: 0 12px 12px; + padding: 10px; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 80%, var(--pr-surface)); +} + +.pr-language-control span { + min-width: 0; +} + +.pr-language-control strong, +.pr-language-control small { + display: block; +} + +.pr-language-control strong { + color: var(--pr-text); + font-size: 12px; +} + +.pr-language-control small { + margin-top: 3px; + color: var(--pr-muted); + font-size: 11px; + line-height: 1.35; +} + .pr-live-status { margin: 0 12px 12px; display: grid; @@ -1164,6 +1531,11 @@ button:disabled { box-shadow: 0 8px 22px color-mix(in srgb, var(--pr-accent) 12%, transparent); } +.pr-composer.is-reviewing .pr-live-status { + border-color: color-mix(in srgb, var(--pr-info) 48%, var(--pr-line)); + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-info) 17%, var(--pr-surface)), color-mix(in srgb, var(--pr-accent) 11%, var(--pr-bg))); +} + .pr-live-status-dot { width: 9px; height: 9px; @@ -1172,6 +1544,10 @@ button:disabled { box-shadow: 0 0 0 5px color-mix(in srgb, var(--pr-accent) 15%, transparent); } +.pr-composer.is-reviewing .pr-live-status-dot { + animation: pr-pulse-dot 1.1s ease-in-out infinite; +} + .pr-live-status strong, .pr-live-status span { display: block; @@ -1189,6 +1565,37 @@ button:disabled { line-height: 1.45; } +.pr-reviewing-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--pr-info) 42%, var(--pr-line)); + border-left-width: 3px; + border-radius: 8px; + background: linear-gradient(135deg, color-mix(in srgb, var(--pr-info) 16%, var(--pr-surface)), color-mix(in srgb, var(--pr-accent) 9%, var(--pr-bg))); + box-shadow: 0 10px 26px color-mix(in srgb, var(--pr-info) 12%, transparent); +} + +.pr-reviewing-banner strong, +.pr-reviewing-banner span { + display: block; +} + +.pr-reviewing-banner strong { + color: var(--pr-text); + font-size: 13px; +} + +.pr-reviewing-banner span { + margin-top: 3px; + color: var(--pr-text-2); + font-size: 11px; + line-height: 1.45; +} + .pr-draft-list { padding: 0 12px; } @@ -1269,6 +1676,14 @@ button:disabled { min-width: 0; } +.pr-draft-path-row { + min-width: 0; +} + +.pr-draft-path-row .pr-file-link { + max-width: 100%; +} + .pr-file-link { display: inline-flex; align-items: center; @@ -1360,6 +1775,15 @@ button:disabled { box-shadow: 0 30px 80px rgba(0, 0, 0, 0.46); } +.pr-modal--lifecycle { + width: min(620px, calc(100vw - 40px)); +} + +.pr-settings-modal { + width: min(980px, calc(100vw - 40px)); + overflow: hidden; +} + .pr-modal-head, .pr-modal-body, .pr-modal-foot { @@ -1367,6 +1791,10 @@ button:disabled { } .pr-modal-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; border-bottom: 1px solid var(--pr-line-soft); } @@ -1375,11 +1803,79 @@ button:disabled { font-size: 16px; } +.pr-modal-head p { + margin: 4px 0 0; + color: var(--pr-muted); + font-size: 12px; + line-height: 1.45; +} + .pr-modal-body p { color: var(--pr-text-2); line-height: 1.55; } +.pr-settings-body { + max-height: calc(100vh - 170px); + overflow: auto; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.pr-settings-body .pr-card--sources, +.pr-settings-section, +.pr-settings-body .pr-url-card, +.pr-settings-body .pr-access { + min-width: 0; + grid-area: auto; + border: 1px solid var(--pr-line-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--pr-bg) 80%, var(--pr-surface)); +} + +.pr-settings-section { + display: grid; + gap: 10px; + padding: 11px; +} + +.pr-confirm-summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 12px 0; +} + +.pr-confirm-summary div { + min-width: 0; + display: grid; + gap: 4px; + padding: 9px; + border: 1px solid var(--pr-line-soft); + border-radius: 7px; + background: color-mix(in srgb, var(--pr-bg) 74%, var(--pr-surface)); +} + +.pr-confirm-summary span { + color: var(--pr-muted); + font-size: 11px; + font-weight: 650; +} + +.pr-confirm-summary strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--pr-text); + font-size: 12px; + white-space: nowrap; +} + +.pr-lifecycle-warnings--modal { + margin-top: 12px; +} + .pr-modal-foot { display: flex; justify-content: flex-end; @@ -1420,6 +1916,12 @@ button:disabled { "access"; } + .pr-command-bar--simple { + grid-template-areas: + "brand" + "actions"; + } + .pr-sidebar, .pr-composer, .pr-review-workspace { @@ -1435,7 +1937,8 @@ button:disabled { .pr-open-strip, .pr-pr-header, - .pr-pr-actions { + .pr-pr-actions, + .pr-command-actions { align-items: stretch; flex-direction: column; } @@ -1446,10 +1949,18 @@ button:disabled { .pr-top-watch-form, .pr-access-row, .pr-source-row, - .pr-sync-panel { + .pr-sync-panel, + .pr-language-control, + .pr-settings-body, + .pr-confirm-summary { grid-template-columns: 1fr; } + .pr-reviewing-banner { + align-items: stretch; + flex-direction: column; + } + .pr-access-row .pr-btn, .pr-access-row .pr-token-badge, .pr-refresh-now { diff --git a/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js b/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js index eef7b9f005..5a8ec6c677 100644 --- a/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js +++ b/src/crates/core/src/miniapp/builtin/assets/pr-review/ui.js @@ -21,6 +21,11 @@ const I18N = { 'en-US': { title: 'PR Review Inbox', subtitle: 'Watch repositories, open PRs, review diffs, compose feedback, and publish with confirmation.', + manageSources: 'Manage sources', + sourceSettingsTitle: 'Sources and queue settings', + sourceSettingsSubtitle: 'Adjust watched repositories, queue mode, private access, or open a single PR.', + queueSettingsTitle: 'Queue settings', + queueSettingsHint: 'Choose which PRs appear in the review queue. The main workspace stays focused on review.', queueModeAll: 'Repository PRs', queueModeMine: 'Needs my review', queueModeAllHint: 'Sync open PRs from watched repositories.', @@ -70,7 +75,7 @@ const I18N = { manualToken: 'Manual token', repoAddedSyncing: 'Repository added. Syncing queue...', advancedProviders: 'Advanced provider settings', - errorRepoNotFound: 'Repository not found or not accessible. Check the repository path or authorize GitHub CLI.', + errorSourceUnavailable: 'Could not refresh {repo}. The repository or PR may be unavailable, the provider API may be incompatible, or your account may not have access. Check Manage sources or authorize the provider.', workspaceDiscovering: 'Detecting repositories from the current workspace...', workspaceDiscovered: 'Added {count} repository from the current workspace.', workspaceDiscoveredMany: 'Added {count} repositories from the current workspace.', @@ -81,6 +86,17 @@ const I18N = { repositoryFirstHint: 'Add the repo you care about, then sync its review queue.', singlePrFallback: 'Inspect one PR', singlePrFallbackHint: 'Use this when the repository is not watched yet.', + directOpenBusySync: 'Queue sync is running. Wait a moment before opening a single PR.', + directOpenBusyReview: 'AI review is running. Stop or finish it before opening another PR.', + directOpenBusyPublish: 'Review publishing is in progress. Finish it before opening another PR.', + directOpenBusyLifecycle: 'A PR lifecycle action is in progress. Finish it before opening another PR.', + directOpenBusyGeneric: 'Another PR action is in progress. Finish it before opening a single PR.', + busyActionSync: 'Queue sync is already running. Wait a moment before starting another action.', + busyActionReview: 'AI review is running. Stop or finish it before using this action.', + busyActionPublish: 'Review publishing is in progress. Finish it before using this action.', + busyActionLifecycle: 'A PR lifecycle action is in progress. Finish it before using this action.', + busyActionGeneric: 'Another PR action is in progress. Finish it before using this action.', + publishedItemLocked: 'Published review items cannot be deleted from BitFun.', owner: 'Owner', repo: 'Repository', providerName: 'Display name', @@ -104,6 +120,8 @@ const I18N = { ciDetails: 'CI details', existingReview: 'Existing discussion', ciFolded: 'CI is folded by default. Open it when status needs attention.', + ciFreshnessHint: 'CI is fetched when this PR is opened, refreshed, or auto-synced.', + ciOpenDetails: 'Open details', noCi: 'No CI status returned.', noBody: 'No description.', noFiles: 'No changed files returned by the provider.', @@ -111,14 +129,20 @@ const I18N = { manualComment: 'Manual comment', manualCommentPlaceholder: 'Write a PR-level comment or paste a finding here.', addManualComment: 'Add to review', + expandComment: 'Expand', + collapseComment: 'Collapse', composer: 'Review composer', composerHint: 'Draft, edit, select, then publish. Nothing is sent without confirmation.', modeFast: 'Fast scan', modeFocused: 'Focused', modeDeep: 'Deep', + reviewLanguage: 'Review language', + reviewLanguageEn: 'English', + reviewLanguageZh: 'Chinese', + reviewLanguageHint: 'Used for AI review comments.', summaryComment: 'Summary comment', inlineComment: 'Inline comment', - reviewDecision: 'Review decision', + reviewDecision: 'Overall review', decisionComment: 'Comment', decisionApprove: 'Approve', decisionRequestChanges: 'Request changes', @@ -126,15 +150,18 @@ const I18N = { publishSelected: 'Publish selected', publishConfirmTitle: 'Publish selected review items?', publishConfirmBody: 'These comments will be posted to the provider. This action cannot be undone from BitFun.', + overwriteDraftTitle: 'Replace current review draft?', + overwriteDraftBody: '{count} unpublished review items will be deleted before a new AI review starts. Published comments are kept as context for the next review.', + confirmStartReview: 'Start new AI review', publishStaleTitle: 'PR head changed', publishStaleBody: 'The draft was created for an older head. Refresh or confirm that you want to publish against the latest head.', staleConfirm: 'I understand the PR head changed', publishNow: 'Publish now', cancel: 'Cancel', - markReviewed: 'Mark current head reviewed', - audit: 'Publish audit', + audit: 'Action audit', statusReady: 'Ready', statusRefreshing: 'Syncing queue...', + statusPartialSync: 'Refreshed what BitFun could access. {count} source could not be refreshed.', statusAssignedNeedsToken: 'Assigned-review sync needs a session token for the selected provider.', statusNoSubscriptions: 'Add a watched repository or paste a PR URL first.', statusNoActiveSubscriptions: 'All watched repositories are paused. Re-enable one or paste a PR URL.', @@ -150,22 +177,106 @@ const I18N = { reviewDetailAi: 'AI is analyzing the diff and existing discussion.', reviewDetailAiWait: 'Still analyzing; large diffs can take a little while.', reviewDetailBuild: 'Building an editable review draft.', + reviewingBannerTitle: 'AI review is running', + reviewingBannerBody: 'BitFun is reading the diff and preparing editable findings. You can keep browsing the PR or stop this run.', cancelReview: 'Cancel review', reviewCancelled: 'Review cancelled', statusPublishing: 'Publishing review...', statusSaved: 'Saved', statusPublished: 'Review published', - statusReviewed: 'Current head marked reviewed', + statusPublishFailed: 'No review item was published. Check the action audit and try again.', errorParse: 'Could not identify a PR from this URL.', errorNetwork: 'Provider request failed', newPrTitle: 'New reviewable PR', newHeadTitle: 'New commits on reviewed PR', + newPrBatchTitle: 'Review queue updated', + newPrBatchBody: '{count} PRs need attention.', + newHeadBatchBody: '{count} reviewed PRs have new commits.', publicRead: 'Public read', privateAction: 'Private and write actions', draftStatus: 'Draft', readyStatus: 'Ready', + prDraftStatus: 'Draft PR', + readyForReviewStatus: 'Ready for review', + lifecyclePanel: 'PR lifecycle', + lifecyclePanelHint: 'Status is checked before actions.', + lifecycleUnsupported: 'This provider does not support this action from BitFun.', + refreshLifecycle: 'Refresh status', + refreshingLifecycle: 'Refreshing PR lifecycle status...', + lifecycleUpdating: 'Updating PR lifecycle...', + lifecycleState: 'PR state', + lifecyclePermission: 'Permission', + lifecycleFreshness: 'Head', + lifecycleChecks: 'Checks', + lifecycleReviews: 'Reviews', + lifecycleMergeability: 'Mergeability', + lifecycleTokenReady: 'Authorized', + lifecycleTokenMissing: 'Authorization needed', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: 'Head unavailable', + lifecycleOpen: 'Open', + lifecycleClosed: 'Closed', + lifecycleMerged: 'Merged', + lifecycleMergeable: 'Mergeable', + lifecycleBlocked: 'Blocked', + lifecycleUnknown: 'Unknown', + lifecycleChecking: 'Checking', + lifecycleChecksPassing: 'Passing', + lifecycleChecksPending: 'Pending', + lifecycleChecksFailing: 'Failing', + lifecycleChecksMissing: 'No checks', + lifecycleReviewApproved: 'Approved', + lifecycleReviewChanges: 'Changes requested', + lifecycleReviewCommented: 'Comments only', + lifecycleReviewMissing: 'No review signal', + lifecycleBlockDraft: 'Draft PRs cannot be merged.', + lifecycleBlockClosed: 'Only open PRs can be updated.', + lifecycleBlockMerged: 'This PR is already merged.', + lifecycleBlockHead: 'The provider did not return a head SHA.', + lifecycleBlockToken: 'Authorize this provider before lifecycle actions.', + lifecycleBlockMergeable: 'The provider does not report this PR as mergeable.', + lifecycleWarnChecks: 'Checks are not passing.', + lifecycleWarnReviews: 'Review state needs attention.', + lifecycleGuideToken: 'Use GitHub CLI authorization or paste a token before changing PR state.', + lifecycleGuideDraft: 'Mark the PR ready before merging.', + lifecycleGuideClosed: 'Open the provider page to reopen or inspect the closed PR.', + lifecycleGuideHead: 'Refresh the PR so BitFun can verify the latest head commit.', + lifecycleGuideMergeable: 'Open the provider page to resolve conflicts or branch-protection blocks.', + lifecycleGuideChecking: 'Refresh again after the provider finishes mergeability checks.', + lifecycleGuideChecks: 'Open CI details, fix failing checks, or wait for pending checks.', + lifecycleGuideReviews: 'Resolve requested changes or collect an approval before merging.', + lifecycleAutoAuthHint: 'BitFun will try to authorize with GitHub CLI before this action.', + lifecycleAuthFailed: 'Authorization is required. Use GitHub CLI authorization or paste a token in Manage sources.', + markPrReady: 'Mark ready', + convertPrDraft: 'Convert to draft', + mergePr: 'Merge PR', + mergeMethod: 'Merge method', + mergeMethodMerge: 'Merge commit', + mergeMethodSquash: 'Squash merge', + mergeMethodRebase: 'Rebase merge', + lifecycleConfirmReadyTitle: 'Mark this PR ready for review?', + lifecycleConfirmDraftTitle: 'Convert this PR to a draft?', + lifecycleConfirmMergeTitle: 'Merge this PR?', + lifecycleConfirmReadyBody: 'The provider-side PR state will become ready for review.', + lifecycleConfirmDraftBody: 'The provider-side PR state will become draft. Reviewers may stop seeing it as ready.', + lifecycleConfirmMergeBody: 'This will merge the selected PR on the provider. BitFun will use the expected head SHA shown below.', + lifecycleConfirmWarning: 'Warnings', + expectedHeadSha: 'Expected head', + confirmLifecycle: 'Confirm action', + lifecycleActionReady: 'Ready state', + lifecycleActionDraft: 'Draft state', + lifecycleActionMerge: 'Merge', + lifecycleReadyDone: 'PR marked ready for review', + lifecycleDraftDone: 'PR converted to draft', + lifecycleMergeDone: 'PR merged', + lifecycleHeadChanged: 'The PR head changed. Refresh the PR before taking this action.', + lifecycleMergeBlocked: 'Merge is blocked: {reason}', + lifecycleActionUnsupported: 'This lifecycle action is not supported for the selected provider.', overviewHint: 'Expand for full description.', noActionableFindings: 'No actionable findings were generated. Add a manual comment or edit this review decision before publishing.', + publishNoDraftItems: 'Generate or add a review item before publishing.', + publishSelectItemFirst: 'Select at least one draft review item before publishing.', + suggestedFix: 'Suggested fix', binary: 'binary', large: 'large', stale: 'stale', @@ -177,6 +288,11 @@ const I18N = { 'zh-CN': { title: 'PR 审核台', subtitle: '监听仓库、打开 PR、查看变更、组织意见,并在二次确认后发布 Review。', + manageSources: '管理来源', + sourceSettingsTitle: '来源与队列设置', + sourceSettingsSubtitle: '调整监听仓库、队列范围、私有授权,或单独打开某个 PR。', + queueSettingsTitle: '队列设置', + queueSettingsHint: '选择哪些 PR 进入审核队列。主界面只保留审核工作流。', queueModeAll: '仓库全部 PR', queueModeMine: '待我审核', queueModeAllHint: '从已监听仓库同步打开状态的 PR。', @@ -226,7 +342,7 @@ const I18N = { manualToken: '手动 Token', repoAddedSyncing: '已添加监听仓库,正在同步队列...', advancedProviders: '高级平台设置', - errorRepoNotFound: '仓库不存在或当前无权访问,请检查仓库路径或授权 GitHub CLI。', + errorSourceUnavailable: '无法刷新 {repo}。仓库或 PR 可能不可用、接口可能不兼容,或当前账号无权访问。请在“管理来源”中检查仓库地址;如为私有仓库,请先完成授权。', workspaceDiscovering: '正在从当前工作区识别仓库...', workspaceDiscovered: '已从当前工作区添加 {count} 个仓库。', workspaceDiscoveredMany: '已从当前工作区添加 {count} 个仓库。', @@ -237,6 +353,17 @@ const I18N = { repositoryFirstHint: '先添加要关注的仓库,再同步它的审核队列。', singlePrFallback: '单独检视一个 PR', singlePrFallbackHint: '当这个仓库暂时不需要监听时使用。', + directOpenBusySync: '正在同步队列,请稍后再单独打开 PR。', + directOpenBusyReview: 'AI 审核正在进行,请先完成或中止后再打开其他 PR。', + directOpenBusyPublish: 'Review 正在发布,请完成后再打开其他 PR。', + directOpenBusyLifecycle: 'PR 状态操作正在执行,请完成后再打开其他 PR。', + directOpenBusyGeneric: '当前有 PR 操作正在进行,请完成后再单独打开 PR。', + busyActionSync: '队列正在同步,请稍后再执行其他操作。', + busyActionReview: 'AI 审核正在进行,请先完成或中止后再执行此操作。', + busyActionPublish: 'Review 正在发布,请完成后再执行此操作。', + busyActionLifecycle: 'PR 状态操作正在执行,请完成后再执行此操作。', + busyActionGeneric: '当前有 PR 操作正在进行,请完成后再执行此操作。', + publishedItemLocked: '已发布的 Review 项不能在 BitFun 中删除。', owner: 'Owner', repo: '仓库', providerName: '显示名称', @@ -260,6 +387,8 @@ const I18N = { ciDetails: 'CI 详情', existingReview: '已有讨论', ciFolded: 'CI 默认折叠,只有需要定位状态时再展开。', + ciFreshnessHint: 'CI 会在打开 PR、刷新或自动同步时重新获取。', + ciOpenDetails: '打开详情', noCi: '代码平台没有返回 CI 状态。', noBody: '没有描述。', noFiles: '代码平台没有返回变更文件。', @@ -267,14 +396,20 @@ const I18N = { manualComment: '手写评论', manualCommentPlaceholder: '在这里写 PR 级评论,或粘贴你已经发现的问题。', addManualComment: '加入 Review', + expandComment: '展开', + collapseComment: '收起', composer: 'Review 编辑器', composerHint: '生成、编辑、选择,再发布。未经确认不会提交到代码平台。', modeFast: '快速扫读', modeFocused: '重点审核', modeDeep: '深度审核', + reviewLanguage: '审核语言', + reviewLanguageEn: '英文', + reviewLanguageZh: '中文', + reviewLanguageHint: '用于 AI 生成的审核意见。', summaryComment: '总结评论', inlineComment: '行内评论', - reviewDecision: 'Review 结论', + reviewDecision: '整体 Review', decisionComment: '评论', decisionApprove: '通过', decisionRequestChanges: '要求修改', @@ -282,15 +417,18 @@ const I18N = { publishSelected: '发布选中项', publishConfirmTitle: '确认发布选中的 Review 内容?', publishConfirmBody: '这些评论会提交到代码平台。BitFun 无法替你撤回这个操作。', + overwriteDraftTitle: '替换当前审核草稿?', + overwriteDraftBody: '新的 AI 审核开始前,会删除 {count} 条未发布审核意见。已发布的意见会作为下一轮审核上下文保留。', + confirmStartReview: '开始新的 AI 审核', publishStaleTitle: 'PR head 已变化', publishStaleBody: '草稿基于旧 head 生成。请刷新,或明确确认要基于最新 head 继续发布。', staleConfirm: '我确认 PR head 已变化', publishNow: '立即发布', cancel: '取消', - markReviewed: '标记当前 head 已审', - audit: '发布审计', + audit: '操作记录', statusReady: '就绪', statusRefreshing: '正在同步队列...', + statusPartialSync: '已刷新 BitFun 可访问的内容。{count} 个来源暂时无法刷新。', statusAssignedNeedsToken: '同步待我审核需要当前代码平台的会话 Token。', statusNoSubscriptions: '请先添加监听仓库,或粘贴一个 PR 链接。', statusNoActiveSubscriptions: '已添加的监听仓库都处于暂停状态,请重新开启一个仓库或粘贴 PR 链接。', @@ -306,22 +444,106 @@ const I18N = { reviewDetailAi: 'AI 正在分析 diff 和已有讨论。', reviewDetailAiWait: '仍在分析中,大型 diff 可能需要稍等。', reviewDetailBuild: '正在整理可编辑的审核草稿。', + reviewingBannerTitle: 'AI 正在审核', + reviewingBannerBody: 'BitFun 正在读取 diff 并整理可编辑问题。你可以继续浏览 PR,也可以中止本次审核。', cancelReview: '中止审核', reviewCancelled: '审核已中止', statusPublishing: '正在发布 Review...', statusSaved: '已保存', statusPublished: 'Review 已发布', - statusReviewed: '已标记当前 head 已审', + statusPublishFailed: '没有 Review 内容发布成功。请查看操作记录后重试。', errorParse: '无法从这个链接识别 PR。', errorNetwork: '代码平台请求失败', newPrTitle: '新的可审核 PR', newHeadTitle: '已审 PR 有新提交', + newPrBatchTitle: '审核队列已更新', + newPrBatchBody: '{count} 个 PR 需要关注。', + newHeadBatchBody: '{count} 个已审核 PR 有新提交。', publicRead: '公开读取', privateAction: '私有与写入操作', draftStatus: '草稿', readyStatus: '可审', + prDraftStatus: '草稿 PR', + readyForReviewStatus: '可审 PR', + lifecyclePanel: 'PR 生命周期', + lifecyclePanelHint: '操作前会先校验状态。', + lifecycleUnsupported: '当前代码平台不支持在 BitFun 中执行这个操作。', + refreshLifecycle: '刷新状态', + refreshingLifecycle: '正在刷新 PR 生命周期状态...', + lifecycleUpdating: '正在更新 PR 生命周期...', + lifecycleState: 'PR 状态', + lifecyclePermission: '权限', + lifecycleFreshness: 'Head', + lifecycleChecks: '检查', + lifecycleReviews: 'Review', + lifecycleMergeability: '可合并性', + lifecycleTokenReady: '已授权', + lifecycleTokenMissing: '需要授权', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: '未返回 Head', + lifecycleOpen: '打开', + lifecycleClosed: '已关闭', + lifecycleMerged: '已合并', + lifecycleMergeable: '可合并', + lifecycleBlocked: '受阻', + lifecycleUnknown: '未知', + lifecycleChecking: '检查中', + lifecycleChecksPassing: '通过', + lifecycleChecksPending: '等待中', + lifecycleChecksFailing: '失败', + lifecycleChecksMissing: '无检查', + lifecycleReviewApproved: '已批准', + lifecycleReviewChanges: '要求修改', + lifecycleReviewCommented: '仅评论', + lifecycleReviewMissing: '无 Review 信号', + lifecycleBlockDraft: '草稿 PR 不能合并。', + lifecycleBlockClosed: '只有打开状态的 PR 可以更新。', + lifecycleBlockMerged: '这个 PR 已经合并。', + lifecycleBlockHead: '代码平台未返回 head SHA。', + lifecycleBlockToken: '执行生命周期操作前需要先授权。', + lifecycleBlockMergeable: '代码平台未确认这个 PR 可合并。', + lifecycleWarnChecks: '检查尚未全部通过。', + lifecycleWarnReviews: 'Review 状态需要关注。', + lifecycleGuideToken: '先使用 GitHub CLI 授权,或粘贴 Token,再变更 PR 状态。', + lifecycleGuideDraft: '先将 PR 标记为可审,再执行合并。', + lifecycleGuideClosed: '打开代码平台页面,重新打开或检查这个已关闭 PR。', + lifecycleGuideHead: '刷新 PR,让 BitFun 确认最新 head 提交。', + lifecycleGuideMergeable: '打开代码平台页面处理冲突或分支保护阻塞。', + lifecycleGuideChecking: '等待代码平台完成可合并性检查后再刷新。', + lifecycleGuideChecks: '展开 CI 详情,修复失败检查或等待检查完成。', + lifecycleGuideReviews: '处理要求修改的意见,或先获得批准后再合并。', + lifecycleAutoAuthHint: 'BitFun 会先尝试通过 GitHub CLI 自动授权。', + lifecycleAuthFailed: '需要授权。请在管理来源中使用 GitHub CLI 授权,或粘贴 Token。', + markPrReady: '标记可审', + convertPrDraft: '转为草稿', + mergePr: '合并 PR', + mergeMethod: '合并方式', + mergeMethodMerge: '创建合并提交', + mergeMethodSquash: 'Squash 合并', + mergeMethodRebase: 'Rebase 合并', + lifecycleConfirmReadyTitle: '将这个 PR 标记为可审?', + lifecycleConfirmDraftTitle: '将这个 PR 转为草稿?', + lifecycleConfirmMergeTitle: '合并这个 PR?', + lifecycleConfirmReadyBody: '代码平台上的 PR 状态会变为可审。', + lifecycleConfirmDraftBody: '代码平台上的 PR 状态会变为草稿,审核者可能不再把它视为待审。', + lifecycleConfirmMergeBody: '这会在代码平台合并当前选中的 PR。BitFun 会使用下方显示的预期 head SHA。', + lifecycleConfirmWarning: '风险提示', + expectedHeadSha: '预期 Head', + confirmLifecycle: '确认执行', + lifecycleActionReady: '可审状态', + lifecycleActionDraft: '草稿状态', + lifecycleActionMerge: '合并', + lifecycleReadyDone: '已标记为可审 PR', + lifecycleDraftDone: '已转为草稿 PR', + lifecycleMergeDone: 'PR 已合并', + lifecycleHeadChanged: 'PR head 已变化,请刷新后再执行该操作。', + lifecycleMergeBlocked: '合并被阻止:{reason}', + lifecycleActionUnsupported: '当前代码平台不支持这个生命周期操作。', overviewHint: '展开查看完整描述。', noActionableFindings: '没有生成可操作问题。发布前可以添加手写评论,或编辑这条 Review 结论。', + publishNoDraftItems: '请先生成或添加一条 Review 内容,再发布。', + publishSelectItemFirst: '请至少选中一条 Review 草稿后再发布。', + suggestedFix: '建议处理', binary: '二进制', large: '过大', stale: '已过期', @@ -333,6 +555,11 @@ const I18N = { 'zh-TW': { title: 'PR 審核台', subtitle: '監聽倉庫、開啟 PR、檢視變更、組織意見,並在二次確認後發布 Review。', + manageSources: '管理來源', + sourceSettingsTitle: '來源與佇列設定', + sourceSettingsSubtitle: '調整監聽倉庫、佇列範圍、私有授權,或單獨開啟某個 PR。', + queueSettingsTitle: '佇列設定', + queueSettingsHint: '選擇哪些 PR 進入審核佇列。主介面只保留審核工作流。', queueModeAll: '倉庫全部 PR', queueModeMine: '待我審核', queueModeAllHint: '從已監聽倉庫同步開啟狀態的 PR。', @@ -382,7 +609,7 @@ const I18N = { manualToken: '手動 Token', repoAddedSyncing: '已新增監聽倉庫,正在同步佇列...', advancedProviders: '進階平台設定', - errorRepoNotFound: '倉庫不存在或目前無權存取,請檢查倉庫路徑或授權 GitHub CLI。', + errorSourceUnavailable: '無法重新整理 {repo}。倉庫或 PR 可能無法使用、介面可能不相容,或目前帳號無權存取。請在「管理來源」中檢查倉庫地址;如為私有倉庫,請先完成授權。', workspaceDiscovering: '正在從目前工作區識別倉庫...', workspaceDiscovered: '已從目前工作區新增 {count} 個倉庫。', workspaceDiscoveredMany: '已從目前工作區新增 {count} 個倉庫。', @@ -393,6 +620,17 @@ const I18N = { repositoryFirstHint: '先新增要關注的倉庫,再同步它的審核佇列。', singlePrFallback: '單獨檢視一個 PR', singlePrFallbackHint: '當這個倉庫暫時不需要監聽時使用。', + directOpenBusySync: '正在同步佇列,請稍後再單獨開啟 PR。', + directOpenBusyReview: 'AI 審核正在進行,請先完成或中止後再開啟其他 PR。', + directOpenBusyPublish: 'Review 正在發布,請完成後再開啟其他 PR。', + directOpenBusyLifecycle: 'PR 狀態操作正在執行,請完成後再開啟其他 PR。', + directOpenBusyGeneric: '目前有 PR 操作正在進行,請完成後再單獨開啟 PR。', + busyActionSync: '佇列正在同步,請稍後再執行其他操作。', + busyActionReview: 'AI 審核正在進行,請先完成或中止後再執行此操作。', + busyActionPublish: 'Review 正在發布,請完成後再執行此操作。', + busyActionLifecycle: 'PR 狀態操作正在執行,請完成後再執行此操作。', + busyActionGeneric: '目前有 PR 操作正在進行,請完成後再執行此操作。', + publishedItemLocked: '已發布的 Review 項不能在 BitFun 中刪除。', owner: 'Owner', repo: '倉庫', providerName: '顯示名稱', @@ -416,6 +654,8 @@ const I18N = { ciDetails: 'CI 詳情', existingReview: '既有討論', ciFolded: 'CI 預設摺疊,只有需要定位狀態時再展開。', + ciFreshnessHint: 'CI 會在開啟 PR、重新整理或自動同步時重新取得。', + ciOpenDetails: '開啟詳情', noCi: '程式碼平台沒有回傳 CI 狀態。', noBody: '沒有描述。', noFiles: '程式碼平台沒有回傳變更檔案。', @@ -423,14 +663,20 @@ const I18N = { manualComment: '手寫評論', manualCommentPlaceholder: '在這裡寫 PR 級評論,或貼上你已經發現的問題。', addManualComment: '加入 Review', + expandComment: '展開', + collapseComment: '收起', composer: 'Review 編輯器', composerHint: '產生、編輯、選擇,再發布。未經確認不會提交到程式碼平台。', modeFast: '快速掃讀', modeFocused: '重點審核', modeDeep: '深度審核', + reviewLanguage: '審核語言', + reviewLanguageEn: '英文', + reviewLanguageZh: '中文', + reviewLanguageHint: '用於 AI 產生的審核意見。', summaryComment: '總結評論', inlineComment: '行內評論', - reviewDecision: 'Review 結論', + reviewDecision: '整體 Review', decisionComment: '評論', decisionApprove: '通過', decisionRequestChanges: '要求修改', @@ -438,15 +684,18 @@ const I18N = { publishSelected: '發布選取項', publishConfirmTitle: '確認發布選取的 Review 內容?', publishConfirmBody: '這些評論會提交到程式碼平台。BitFun 無法替你撤回這個操作。', + overwriteDraftTitle: '取代目前審核草稿?', + overwriteDraftBody: '新的 AI 審核開始前,會刪除 {count} 條未發布審核意見。已發布的意見會作為下一輪審核上下文保留。', + confirmStartReview: '開始新的 AI 審核', publishStaleTitle: 'PR head 已變更', publishStaleBody: '草稿基於舊 head 產生。請重新整理,或明確確認要基於最新 head 繼續發布。', staleConfirm: '我確認 PR head 已變更', publishNow: '立即發布', cancel: '取消', - markReviewed: '標記目前 head 已審', - audit: '發布審計', + audit: '操作記錄', statusReady: '就緒', statusRefreshing: '正在同步佇列...', + statusPartialSync: '已重新整理 BitFun 可存取的內容。{count} 個來源暫時無法重新整理。', statusAssignedNeedsToken: '同步待我審核需要目前程式碼平台的工作階段 Token。', statusNoSubscriptions: '請先新增監聽倉庫,或貼上一個 PR 連結。', statusNoActiveSubscriptions: '已新增的監聽倉庫都處於暫停狀態,請重新開啟一個倉庫或貼上 PR 連結。', @@ -462,22 +711,106 @@ const I18N = { reviewDetailAi: 'AI 正在分析 diff 和既有討論。', reviewDetailAiWait: '仍在分析中,大型 diff 可能需要稍等。', reviewDetailBuild: '正在整理可編輯的審核草稿。', + reviewingBannerTitle: 'AI 正在審核', + reviewingBannerBody: 'BitFun 正在讀取 diff 並整理可編輯問題。你可以繼續瀏覽 PR,也可以中止本次審核。', cancelReview: '中止審核', reviewCancelled: '審核已中止', statusPublishing: '正在發布 Review...', statusSaved: '已儲存', statusPublished: 'Review 已發布', - statusReviewed: '已標記目前 head 已審', + statusPublishFailed: '沒有 Review 內容發布成功。請查看操作記錄後重試。', errorParse: '無法從這個連結識別 PR。', errorNetwork: '程式碼平台請求失敗', newPrTitle: '新的可審核 PR', newHeadTitle: '已審 PR 有新提交', + newPrBatchTitle: '審核佇列已更新', + newPrBatchBody: '{count} 個 PR 需要關注。', + newHeadBatchBody: '{count} 個已審核 PR 有新提交。', publicRead: '公開讀取', privateAction: '私有與寫入操作', draftStatus: '草稿', readyStatus: '可審', + prDraftStatus: '草稿 PR', + readyForReviewStatus: '可審 PR', + lifecyclePanel: 'PR 生命週期', + lifecyclePanelHint: '操作前會先校驗狀態。', + lifecycleUnsupported: '目前程式碼平台不支援在 BitFun 中執行此操作。', + refreshLifecycle: '重新整理狀態', + refreshingLifecycle: '正在重新整理 PR 生命週期狀態...', + lifecycleUpdating: '正在更新 PR 生命週期...', + lifecycleState: 'PR 狀態', + lifecyclePermission: '權限', + lifecycleFreshness: 'Head', + lifecycleChecks: '檢查', + lifecycleReviews: 'Review', + lifecycleMergeability: '可合併性', + lifecycleTokenReady: '已授權', + lifecycleTokenMissing: '需要授權', + lifecycleHeadReady: 'Head {sha}', + lifecycleHeadMissing: '未返回 Head', + lifecycleOpen: '開啟', + lifecycleClosed: '已關閉', + lifecycleMerged: '已合併', + lifecycleMergeable: '可合併', + lifecycleBlocked: '受阻', + lifecycleUnknown: '未知', + lifecycleChecking: '檢查中', + lifecycleChecksPassing: '通過', + lifecycleChecksPending: '等待中', + lifecycleChecksFailing: '失敗', + lifecycleChecksMissing: '無檢查', + lifecycleReviewApproved: '已核准', + lifecycleReviewChanges: '要求修改', + lifecycleReviewCommented: '僅評論', + lifecycleReviewMissing: '無 Review 訊號', + lifecycleBlockDraft: '草稿 PR 不能合併。', + lifecycleBlockClosed: '只有開啟狀態的 PR 可以更新。', + lifecycleBlockMerged: '這個 PR 已經合併。', + lifecycleBlockHead: '程式碼平台未返回 head SHA。', + lifecycleBlockToken: '執行生命週期操作前需要先授權。', + lifecycleBlockMergeable: '程式碼平台未確認這個 PR 可合併。', + lifecycleWarnChecks: '檢查尚未全部通過。', + lifecycleWarnReviews: 'Review 狀態需要關注。', + lifecycleGuideToken: '先使用 GitHub CLI 授權,或貼上 Token,再變更 PR 狀態。', + lifecycleGuideDraft: '先將 PR 標記為可審,再執行合併。', + lifecycleGuideClosed: '開啟程式碼平台頁面,重新開啟或檢查這個已關閉 PR。', + lifecycleGuideHead: '重新整理 PR,讓 BitFun 確認最新 head 提交。', + lifecycleGuideMergeable: '開啟程式碼平台頁面處理衝突或分支保護阻塞。', + lifecycleGuideChecking: '等待程式碼平台完成可合併性檢查後再重新整理。', + lifecycleGuideChecks: '展開 CI 詳情,修復失敗檢查或等待檢查完成。', + lifecycleGuideReviews: '處理要求修改的意見,或先取得核准後再合併。', + lifecycleAutoAuthHint: 'BitFun 會先嘗試透過 GitHub CLI 自動授權。', + lifecycleAuthFailed: '需要授權。請在管理來源中使用 GitHub CLI 授權,或貼上 Token。', + markPrReady: '標記可審', + convertPrDraft: '轉為草稿', + mergePr: '合併 PR', + mergeMethod: '合併方式', + mergeMethodMerge: '建立合併提交', + mergeMethodSquash: 'Squash 合併', + mergeMethodRebase: 'Rebase 合併', + lifecycleConfirmReadyTitle: '將這個 PR 標記為可審?', + lifecycleConfirmDraftTitle: '將這個 PR 轉為草稿?', + lifecycleConfirmMergeTitle: '合併這個 PR?', + lifecycleConfirmReadyBody: '程式碼平台上的 PR 狀態會變為可審。', + lifecycleConfirmDraftBody: '程式碼平台上的 PR 狀態會變為草稿,審核者可能不再把它視為待審。', + lifecycleConfirmMergeBody: '這會在程式碼平台合併目前選中的 PR。BitFun 會使用下方顯示的預期 head SHA。', + lifecycleConfirmWarning: '風險提示', + expectedHeadSha: '預期 Head', + confirmLifecycle: '確認執行', + lifecycleActionReady: '可審狀態', + lifecycleActionDraft: '草稿狀態', + lifecycleActionMerge: '合併', + lifecycleReadyDone: '已標記為可審 PR', + lifecycleDraftDone: '已轉為草稿 PR', + lifecycleMergeDone: 'PR 已合併', + lifecycleHeadChanged: 'PR head 已變化,請重新整理後再執行該操作。', + lifecycleMergeBlocked: '合併被阻止:{reason}', + lifecycleActionUnsupported: '目前程式碼平台不支援這個生命週期操作。', overviewHint: '展開查看完整描述。', noActionableFindings: '沒有生成可操作問題。發布前可以新增手寫評論,或編輯這條 Review 結論。', + publishNoDraftItems: '請先產生或新增一條 Review 內容,再發布。', + publishSelectItemFirst: '請至少選取一條 Review 草稿後再發布。', + suggestedFix: '建議處理', binary: '二進位', large: '過大', stale: '已過期', @@ -519,8 +852,10 @@ const state = { selectedFilePath: null, directUrl: '', mode: 'focused_review', + reviewLanguage: 'en', queueMode: 'all', drafts: {}, + publishedReviewContext: {}, audit: [], lastReviewedHeads: {}, notifiedKeys: [], @@ -533,10 +868,17 @@ const state = { busy: null, status: null, error: null, + startupSyncing: false, reviewProgress: null, cancelReviewRequested: false, + filesExpanded: false, + manualCommentExpanded: false, + manualCommentDraft: '', activeProviderId: 'github', confirm: null, + draftOverwriteConfirm: null, + lifecycleConfirm: null, + settingsOpen: false, }, volatile: { sessionTokens: {}, @@ -565,6 +907,30 @@ function restoreReviewWorkspaceScroll(position) { }); } +function readPaneScrolls() { + return ['.pr-sidebar', '.pr-review-workspace', '.pr-composer'].map((selector) => { + const node = document.querySelector(selector); + if (!(node instanceof HTMLElement)) return null; + return { + selector, + top: node.scrollTop, + left: node.scrollLeft, + }; + }).filter(Boolean); +} + +function restorePaneScrolls(positions) { + if (!positions?.length) return; + window.requestAnimationFrame(() => { + for (const position of positions) { + const node = document.querySelector(position.selector); + if (!(node instanceof HTMLElement)) continue; + node.scrollTop = position.top; + node.scrollLeft = position.left; + } + }); +} + function t(key, params = {}) { const table = I18N[state.locale] || I18N['en-US']; const fallback = I18N['en-US'][key] || key; @@ -607,6 +973,28 @@ function profileById(id) { return state.data.profiles.find((profile) => profile.id === id) || state.data.profiles[0]; } +function sourceLabel(source) { + const profileId = source?.providerId || source?.id || ''; + const profile = profileId ? profileById(profileId) : null; + const providerName = source?.displayName || profile?.displayName || source?.id || profile?.id || t('provider'); + if (!(source?.owner && source?.repo)) return providerName; + const repoName = `${source.owner}/${source.repo}`; + const number = Number.isFinite(Number(source?.number)) ? `#${source.number}` : ''; + return `${providerName} ${repoName}${number}`; +} + +function sourceAccessError(source, error) { + const friendly = new Error(t('errorSourceUnavailable', { repo: sourceLabel(source) })); + friendly.status = Number(error?.status || 0); + return friendly; +} + +function preferAccessError(firstError, secondError) { + return [401, 403, 404].includes(Number(secondError?.status || 0)) + ? secondError + : firstError; +} + function activeProfile() { return profileById(state.ui.activeProviderId); } @@ -622,7 +1010,7 @@ function setReviewProgress(stageKey, detail = '', progressPct = 8) { progressPct: Math.max(0, Math.min(100, Number(progressPct) || 0)), cancelled: state.ui.cancelReviewRequested, }; - render(); + render({ preservePaneScroll: true }); } function modeLabel(mode) { @@ -732,7 +1120,6 @@ async function netJson(url, options = {}) { : String(body || `${status}`); const error = new Error(`${t('errorNetwork')}: ${message}`); error.status = status; - error.body = body; throw error; } return body; @@ -773,12 +1160,14 @@ async function loadStorage() { subscriptions, items: Array.isArray(parsed.items) ? parsed.items : [], drafts: parsed.drafts && typeof parsed.drafts === 'object' ? parsed.drafts : {}, + publishedReviewContext: parsed.publishedReviewContext && typeof parsed.publishedReviewContext === 'object' ? parsed.publishedReviewContext : {}, audit: Array.isArray(parsed.audit) ? parsed.audit : [], lastReviewedHeads: parsed.lastReviewedHeads || {}, notifiedKeys: Array.isArray(parsed.notifiedKeys) ? parsed.notifiedKeys : [], dismissedWorkspaceRepos: Array.isArray(parsed.dismissedWorkspaceRepos) ? parsed.dismissedWorkspaceRepos : [], workspaceAutoListenDoneFor: parsed.workspaceAutoListenDoneFor || '', queueMode: parsed.queueMode || 'all', + reviewLanguage: ['en', 'zh'].includes(parsed.reviewLanguage) ? parsed.reviewLanguage : 'en', }; state.ui.activeProviderId = state.data.profiles[0]?.id || 'github'; } catch (error) { @@ -814,13 +1203,13 @@ function setBusy(key, statusKey) { render(); } -async function finish(statusKey) { +async function finish(statusKey, renderOptions = {}) { state.ui.busy = null; state.ui.status = statusKey ? t(statusKey) : null; state.ui.reviewProgress = null; state.ui.cancelReviewRequested = false; await saveStorage(); - render(); + render(renderOptions); } function setError(error) { @@ -1112,9 +1501,19 @@ async function applyWorkspaceDiscoveredRepositories({ force = false, sync = true } async function refreshQueueOnOpen() { - await applyWorkspaceDiscoveredRepositories({ sync: false }); - if (activeSubscriptions().length) { - void syncQueue('all'); + state.ui.startupSyncing = true; + state.ui.status = t('statusRefreshing'); + state.ui.error = null; + render({ preservePaneScroll: true }); + try { + await applyWorkspaceDiscoveredRepositories({ sync: false }); + if (activeSubscriptions().length) { + await syncQueue('all'); + } + } finally { + state.ui.startupSyncing = false; + if (state.ui.busy === 'refresh') state.ui.busy = null; + render({ preservePaneScroll: true }); } } @@ -1172,6 +1571,86 @@ function normalizeChecks(statusBody, checksBody) { return [...statusChecks, ...checkRuns]; } +function summarizeCheckState(checks = []) { + if (!checks.length) return 'missing'; + const states = checks.map((check) => String(check.conclusion || check.status || '').toLowerCase()); + if (states.some((stateValue) => ['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required'].includes(stateValue))) { + return 'failing'; + } + if (states.some((stateValue) => ['pending', 'queued', 'in_progress', 'requested', 'waiting', 'expected'].includes(stateValue))) { + return 'pending'; + } + if (states.every((stateValue) => ['success', 'neutral', 'skipped', 'completed'].includes(stateValue))) { + return 'passing'; + } + return 'unknown'; +} + +function summarizeReviewState(reviewSummary = {}) { + if (reviewSummary.changesRequested > 0) return 'changes_requested'; + if (reviewSummary.approvals > 0) return 'approved'; + if (reviewSummary.comments > 0) return 'commented'; + return 'missing'; +} + +function shortSha(value) { + const sha = String(value || '').trim(); + return sha ? sha.slice(0, 12) : ''; +} + +function buildMergeReadiness({ pr, checks, reviewSummary, profile }) { + const stateName = String(pr.state || pr.status || '').toLowerCase(); + const merged = Boolean(pr.merged || pr.merged_at); + const isOpen = stateName === 'open' || stateName === 'opened'; + const isDraft = Boolean(pr.draft || pr.work_in_progress); + const headSha = pr.head?.sha || pr.head_sha || pr.sha || pr.diff_refs?.head_sha || ''; + const mergeableRaw = pr.mergeable; + const mergeableStateRaw = String(pr.mergeable_state || pr.detailed_merge_status || pr.merge_status || '').toLowerCase(); + const checkState = summarizeCheckState(checks); + const reviewState = summarizeReviewState(reviewSummary); + const blockers = []; + const warnings = []; + + if (merged) blockers.push('lifecycleBlockMerged'); + if (!merged && !isOpen) blockers.push('lifecycleBlockClosed'); + if (isDraft) blockers.push('lifecycleBlockDraft'); + if (!headSha) blockers.push('lifecycleBlockHead'); + if (!hasToken(profile)) blockers.push('lifecycleBlockToken'); + if (mergeableRaw === false || ['dirty', 'blocked', 'cannot_be_merged', 'conflicts'].includes(mergeableStateRaw)) { + blockers.push('lifecycleBlockMergeable'); + } + if ((mergeableRaw == null && !['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) || mergeableStateRaw === 'checking' || mergeableStateRaw === 'unknown') { + blockers.push('lifecycleChecking'); + } + if (['failing', 'pending', 'unknown', 'missing'].includes(checkState)) warnings.push('lifecycleWarnChecks'); + if (['changes_requested', 'missing'].includes(reviewState)) warnings.push('lifecycleWarnReviews'); + + let mergeability = 'unknown'; + if (mergeableRaw === true || ['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) { + mergeability = 'mergeable'; + } else if (mergeableRaw === false || ['dirty', 'blocked', 'cannot_be_merged', 'conflicts'].includes(mergeableStateRaw)) { + mergeability = 'blocked'; + } else if ((mergeableRaw == null && !['clean', 'unstable', 'can_be_merged'].includes(mergeableStateRaw)) || mergeableStateRaw === 'checking') { + mergeability = 'checking'; + } + + return { + state: merged ? 'merged' : isOpen ? 'open' : 'closed', + isDraft, + headSha, + expectedHeadSha: headSha, + mergeability, + mergeableState: mergeableStateRaw || String(mergeableRaw ?? 'unknown'), + checkState, + reviewState, + permissionState: hasToken(profile) ? 'ready' : 'missing', + blockers, + warnings, + canMerge: blockers.length === 0, + refreshedAt: new Date().toISOString(), + }; +} + async function fetchGithubSnapshot(identity) { const profile = profileById(identity.providerId); const base = normalizeBaseUrl(profile.apiBaseUrl); @@ -1198,6 +1677,8 @@ async function fetchGithubSnapshot(identity) { statusResult.status === 'fulfilled' ? statusResult.value : null, checksResult.status === 'fulfilled' ? checksResult.value : null, ); + const reviewSummary = summarizeReviews(reviews); + const mergeReadiness = buildMergeReadiness({ pr, checks, reviewSummary, profile }); return { identity: { providerId: profile.id, @@ -1206,11 +1687,16 @@ async function fetchGithubSnapshot(identity) { repo: identity.repo, number: identity.number, }, + queueOrigin: identity.queueOrigin || 'direct', + nodeId: pr.node_id || '', url: pr.html_url || identity.url || `${normalizeBaseUrl(profile.webBaseUrl)}/${identity.owner}/${identity.repo}/pull/${identity.number}`, title: pr.title || `#${identity.number}`, body: pr.body || '', author: pr.user?.login || '', state: pr.state || '', + merged: Boolean(pr.merged || pr.merged_at), + closedAt: pr.closed_at || '', + mergedAt: pr.merged_at || '', isDraft: Boolean(pr.draft), baseBranch: pr.base?.ref || '', headBranch: pr.head?.ref || '', @@ -1220,11 +1706,17 @@ async function fetchGithubSnapshot(identity) { files, checks, reviews, - reviewSummary: summarizeReviews(reviews), + reviewSummary, + mergeReadiness, providerCapabilities: { publishSummaryComment: true, publishInlineComment: true, publishReviewDecision: true, + refreshMergeReadiness: true, + transitionDraftState: true, + mergePullRequest: true, + closePullRequest: false, + reopenPullRequest: false, }, }; } @@ -1238,10 +1730,14 @@ async function fetchCompatibleSnapshot(identity) { pr = await requestWithAuthRetry(profile, () => netJson(`${base}/repos/${ownerRepo}/pulls/${identity.number}`, { headers: providerHeaders(profile), })); - } catch { - pr = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${identity.owner}/${identity.repo}`)}/merge_requests/${identity.number}`, { - headers: providerHeaders(profile), - })); + } catch (firstError) { + try { + pr = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${identity.owner}/${identity.repo}`)}/merge_requests/${identity.number}`, { + headers: providerHeaders(profile), + })); + } catch (secondError) { + throw preferAccessError(firstError, secondError); + } } const headers = providerHeaders(profile); @@ -1264,6 +1760,7 @@ async function fetchCompatibleSnapshot(identity) { const files = rawFiles.map(normalizeFile); const reviews = rawReviews.map((item) => normalizeReview(item)); const headSha = pr.head?.sha || pr.head_sha || pr.sha || pr.diff_refs?.head_sha || ''; + const reviewSummary = summarizeReviews(reviews); return { identity: { providerId: profile.id, @@ -1272,6 +1769,7 @@ async function fetchCompatibleSnapshot(identity) { repo: identity.repo, number: identity.number, }, + queueOrigin: identity.queueOrigin || 'direct', url: pr.html_url || pr.web_url || identity.url || `${normalizeBaseUrl(profile.webBaseUrl)}/${identity.owner}/${identity.repo}/pull/${identity.number}`, title: pr.title || `#${identity.number}`, body: pr.body || pr.description || '', @@ -1286,20 +1784,46 @@ async function fetchCompatibleSnapshot(identity) { files, checks: [], reviews, - reviewSummary: summarizeReviews(reviews), + reviewSummary, + mergeReadiness: { + state: pr.merged || pr.merged_at ? 'merged' : (String(pr.state || pr.status || '').toLowerCase() === 'closed' ? 'closed' : 'open'), + isDraft: Boolean(pr.draft || pr.work_in_progress), + headSha, + expectedHeadSha: headSha, + mergeability: 'unknown', + checkState: 'missing', + reviewState: summarizeReviewState(reviewSummary), + permissionState: hasToken(profile) ? 'ready' : 'missing', + blockers: ['lifecycleActionUnsupported'], + warnings: [], + canMerge: false, + refreshedAt: new Date().toISOString(), + }, providerCapabilities: { publishSummaryComment: true, publishInlineComment: true, publishReviewDecision: false, + refreshMergeReadiness: false, + transitionDraftState: false, + mergePullRequest: false, + closePullRequest: false, + reopenPullRequest: false, }, }; } async function fetchSnapshot(identity) { const profile = profileById(identity.providerId); - return profile.kind === 'github' - ? fetchGithubSnapshot(identity) - : fetchCompatibleSnapshot(identity); + try { + return profile.kind === 'github' + ? fetchGithubSnapshot(identity) + : fetchCompatibleSnapshot(identity); + } catch (error) { + if ([401, 403, 404].includes(Number(error?.status || 0))) { + throw sourceAccessError(identity, error); + } + throw error; + } } async function listRepositoryPullRequests(subscription) { @@ -1314,8 +1838,8 @@ async function listRepositoryPullRequests(subscription) { headers: providerHeaders(profile), })); } catch (error) { - if (Number(error?.status || 0) === 404) { - throw new Error(`${t('errorRepoNotFound')} (${subscription.owner}/${subscription.repo})`); + if ([401, 403, 404].includes(Number(error?.status || 0))) { + throw sourceAccessError(subscription, error); } throw error; } @@ -1326,16 +1850,21 @@ async function listRepositoryPullRequests(subscription) { repo: subscription.repo, number: Number(row.number || row.iid || row.id), url: row.html_url || row.web_url || '', + queueOrigin: 'watch', })).filter((row) => Number.isFinite(row.number)); } try { raw = await requestWithAuthRetry(profile, () => netJson(`${base}/repos/${ownerRepo}/pulls?state=open&per_page=20`, { headers: providerHeaders(profile), })); - } catch { - raw = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${subscription.owner}/${subscription.repo}`)}/merge_requests?state=opened&per_page=20`, { - headers: providerHeaders(profile), - })); + } catch (firstError) { + try { + raw = await requestWithAuthRetry(profile, () => netJson(`${base}/projects/${encodeURIComponent(`${subscription.owner}/${subscription.repo}`)}/merge_requests?state=opened&per_page=20`, { + headers: providerHeaders(profile), + })); + } catch (secondError) { + throw sourceAccessError(subscription, preferAccessError(firstError, secondError)); + } } const rows = Array.isArray(raw) ? raw : raw?.values || raw?.items || []; return rows.slice(0, 20).map((row) => ({ @@ -1345,6 +1874,7 @@ async function listRepositoryPullRequests(subscription) { repo: subscription.repo, number: Number(row.number || row.iid || row.id), url: row.html_url || row.web_url || '', + queueOrigin: 'watch', })).filter((row) => Number.isFinite(row.number)); } @@ -1355,31 +1885,57 @@ async function listReviewRequested(profile) { const headers = providerHeaders(profile); const query = encodeURIComponent('is:pr is:open review-requested:@me archived:false'); const result = await netJson(`${base}/search/issues?q=${query}&per_page=20`, { headers }); - return (result.items || []).map((item) => parsePrUrl(item.html_url)).filter(Boolean); + return (result.items || []) + .map((item) => parsePrUrl(item.html_url)) + .filter(Boolean) + .map((item) => ({ ...item, queueOrigin: 'assigned' })); } try { const result = await netJson(`${normalizeBaseUrl(profile.apiBaseUrl)}/user/review_requests`, { headers: providerHeaders(profile), }); - return (Array.isArray(result) ? result : result?.items || []).map((item) => parsePrUrl(item.html_url || item.web_url || item.url)).filter(Boolean); + return (Array.isArray(result) ? result : result?.items || []) + .map((item) => parsePrUrl(item.html_url || item.web_url || item.url)) + .filter(Boolean) + .map((item) => ({ ...item, queueOrigin: 'assigned' })); } catch { return []; } } -function mergeItems(nextItems) { +function isOpenReviewItem(item) { + const stateName = String(item?.state || '').toLowerCase(); + return !item?.merged && (stateName === 'open' || stateName === 'opened' || !stateName); +} + +function mergeItems(nextItems, { dropMissing = false } = {}) { const previous = new Map(state.data.items.map((item) => [itemKey(item), item])); const merged = new Map(); for (const item of nextItems) { - merged.set(itemKey(item), { ...previous.get(itemKey(item)), ...item, stale: false }); + const existing = previous.get(itemKey(item)); + merged.set(itemKey(item), { + ...existing, + ...item, + queueOrigin: item.queueOrigin || existing?.queueOrigin || 'direct', + stale: false, + }); } for (const item of state.data.items) { - if (!merged.has(itemKey(item))) merged.set(itemKey(item), { ...item, stale: true }); + if (merged.has(itemKey(item))) continue; + if (!dropMissing || item.queueOrigin === 'direct') { + merged.set(itemKey(item), { ...item, stale: dropMissing }); + } } state.data.items = Array.from(merged.values()).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + if (state.data.selectedKey && !state.data.items.some((item) => itemKey(item) === state.data.selectedKey)) { + state.data.selectedKey = null; + state.data.selectedFilePath = null; + resetSelectedPrTransientUi(); + } if (!state.data.selectedKey && state.data.items[0]) { state.data.selectedKey = itemKey(state.data.items[0]); state.data.selectedFilePath = state.data.items[0].files?.[0]?.path || null; + resetSelectedPrTransientUi(); } } @@ -1412,15 +1968,49 @@ async function notifyNewWork(previousItems, nextItems) { } } } - for (const notice of notifications.slice(0, 4)) { + if (!notifications.length) return; + for (const notice of notifications) { + notified.add(notice.key); + } + state.data.notifiedKeys = Array.from(notified).slice(-200); + if (shouldSuppressSystemNotification()) { + return; + } + const digest = buildNotificationDigest(notifications); + for (const notice of digest) { try { await app.notifications.system(notice.title, notice.body); - notified.add(notice.key); } catch { break; } } - state.data.notifiedKeys = Array.from(notified).slice(-200); +} + +function shouldSuppressSystemNotification() { + return document.visibilityState === 'visible' || document.hasFocus(); +} + +function buildNotificationDigest(notifications) { + const newItems = notifications.filter((notice) => notice.key.startsWith('new:')); + const newHeads = notifications.filter((notice) => notice.key.startsWith('head:')); + const digest = []; + if (newItems.length === 1) digest.push(newItems[0]); + if (newItems.length > 1) { + digest.push({ + key: 'new:digest', + title: t('newPrBatchTitle'), + body: t('newPrBatchBody', { count: newItems.length }), + }); + } + if (newHeads.length === 1) digest.push(newHeads[0]); + if (newHeads.length > 1) { + digest.push({ + key: 'head:digest', + title: t('newHeadTitle'), + body: t('newHeadBatchBody', { count: newHeads.length }), + }); + } + return digest.slice(0, 2); } async function syncQueue(mode = state.data.queueMode) { @@ -1444,20 +2034,24 @@ async function syncQueue(mode = state.data.queueMode) { try { const previousItems = [...state.data.items]; const identities = []; + const sourceErrors = []; + let refreshedSourceCount = 0; if (mode === 'all') { for (const subscription of subscriptions) { try { identities.push(...await listRepositoryPullRequests(subscription)); + refreshedSourceCount += 1; } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(error); } } } else { for (const item of state.data.profiles.filter((profileItem) => profileItem.enabled && hasToken(profileItem))) { try { identities.push(...await listReviewRequested(item)); + refreshedSourceCount += 1; } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(sourceAccessError(item, error)); } } } @@ -1465,14 +2059,31 @@ async function syncQueue(mode = state.data.queueMode) { const snapshots = []; for (const identity of Array.from(unique.values()).slice(0, 30)) { try { - snapshots.push(await fetchSnapshot(identity)); + const snapshot = await fetchSnapshot(identity); + if (isOpenReviewItem(snapshot)) snapshots.push(snapshot); } catch (error) { - state.ui.error = String(error?.message || error); + sourceErrors.push(error); } } - mergeItems(snapshots); + const hadSourceError = sourceErrors.length > 0; + mergeItems(snapshots, { dropMissing: !hadSourceError }); await notifyNewWork(previousItems, snapshots); - await finish('statusReady'); + if (hadSourceError) { + state.ui.busy = null; + if (refreshedSourceCount > 0) { + state.ui.error = null; + state.ui.status = t('statusPartialSync', { count: sourceErrors.length }); + } else { + state.ui.error = String(sourceErrors[0]?.message || sourceErrors[0]); + state.ui.status = null; + } + state.ui.reviewProgress = null; + state.ui.cancelReviewRequested = false; + await saveStorage(); + render({ preservePaneScroll: true }); + } else { + await finish('statusReady'); + } resetPollTimer(); } catch (error) { setError(error); @@ -1494,6 +2105,7 @@ async function openDirectUrl() { const byKey = new Map(state.data.items.map((item) => [itemKey(item), item])); byKey.set(itemKey(snapshot), snapshot); state.data.items = Array.from(byKey.values()); + if (state.data.selectedKey !== itemKey(snapshot)) resetSelectedPrTransientUi(); state.data.selectedKey = itemKey(snapshot); state.data.selectedFilePath = snapshot.files?.[0]?.path || null; state.ui.activeProviderId = identity.providerId; @@ -1512,6 +2124,50 @@ function selectedDraft() { return snapshot ? state.data.drafts[snapshotKey(snapshot)] || null : null; } +function unpublishedDraftOperations(draft) { + return (draft?.operations || []).filter((operation) => !operation.published); +} + +function publishedReviewContextFor(snapshot) { + const key = snapshotKey(snapshot); + const context = state.data.publishedReviewContext?.[key]; + return Array.isArray(context) ? context.slice(0, 20) : []; +} + +function recordPublishedReviewContext(snapshot, draft, results) { + const key = snapshotKey(snapshot); + const byOperationId = new Map((results || []).map((result) => [result.operationId, result])); + const publishedItems = (draft?.operations || []) + .filter((operation) => { + const result = byOperationId.get(operation.id); + return result?.status === 'success' && result.providerOperationId !== 'skipped'; + }) + .map((operation) => ({ + id: operation.id, + kind: operation.kind, + path: operation.path || '', + position: operation.position || null, + body: String(operation.body || '').slice(0, 1200), + decision: operation.decision || '', + headSha: draft.headSha || snapshot.headSha || '', + publishedAt: new Date().toISOString(), + })); + if (!publishedItems.length) return; + const previous = publishedReviewContextFor(snapshot); + state.data.publishedReviewContext = { + ...(state.data.publishedReviewContext || {}), + [key]: [...publishedItems, ...previous].slice(0, 30), + }; +} + +function resetSelectedPrTransientUi() { + state.ui.filesExpanded = false; + state.ui.focusedDiffPath = null; + state.ui.focusedDiffPosition = null; + state.ui.manualCommentDraft = ''; + state.ui.manualCommentExpanded = false; +} + function recommendMode(snapshot) { const lines = snapshot.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); const security = snapshot.files.some((file) => /auth|permission|crypto|secret|token|security/i.test(file.path)); @@ -1521,9 +2177,26 @@ function recommendMode(snapshot) { return 'fast_check'; } +function reviewLanguageName() { + return state.data.reviewLanguage === 'zh' ? 'Simplified Chinese' : 'English'; +} + function localSummary(snapshot) { const lines = snapshot.files.reduce((sum, file) => sum + file.additions + file.deletions, 0); const topFiles = snapshot.files.slice(0, 8).map((file) => `- ${file.path} (+${file.additions}/-${file.deletions})`).join('\n'); + if (state.data.reviewLanguage === 'zh') { + return [ + `Review 草稿:"${snapshot.title}"。`, + '', + `变更文件:${snapshot.files.length}。变更行数:${lines}。`, + snapshot.checks.length ? `CI:${snapshot.checks.map((check) => `${check.name}:${check.conclusion || check.status}`).join(', ')}` : 'CI:代码平台未返回状态。', + '', + '建议关注:', + topFiles || '- 代码平台未返回可审核文件。', + '', + '发布前请编辑确认。', + ].join('\n'); + } return [ `Review draft for "${snapshot.title}".`, '', @@ -1545,12 +2218,23 @@ function reviewPrompt(snapshot, mode) { deletions: file.deletions, patch: (file.patch || '').slice(0, mode === 'deep_review' ? 12000 : 5000), })); + const previousPublishedFindings = publishedReviewContextFor(snapshot).map((item) => ({ + kind: item.kind, + path: item.path, + position: item.position, + body: item.body, + decision: item.decision, + headSha: item.headSha, + })); return [ 'You are reviewing a pull request. Return JSON only with actionable review items.', + `Write all review comments in ${reviewLanguageName()}.`, 'Do not create a general summary comment. The reviewed author does not need a recap unless there is a principle-level concern about the PR direction.', 'Use summaryComment only when the issue is a principle-level PR direction concern that cannot be tied to a specific file or diff line.', - `Depth: ${modeLabel(mode)}. Prefer concrete functionality direction, implementation risks, and missing tests.`, - 'Schema: {"findings":[{"path":"src/file.ts","position":12,"body":"specific issue"}],"summaryComment":"","decision":"comment","decisionBody":""}.', + `Depth: ${modeLabel(mode)}. Prefer concrete functionality direction, implementation risks, missing tests, and a practical suggested fix for each issue.`, + 'Previous published review comments are provided as context. Do not repeat previous published review comments as new findings.', + 'You may disagree with or refine those earlier comments when the current diff or discussion shows they were wrong, outdated, resolved, or need follow-up.', + 'Schema: {"findings":[{"path":"src/file.ts","position":12,"body":"specific issue","suggestion":"how to fix it"}],"summaryComment":"","decision":"comment","decisionBody":""}.', 'Use a 1-based diff position only when you can identify it from the patch. Omit findings that are not supported by the diff.', '', JSON.stringify({ @@ -1561,6 +2245,7 @@ function reviewPrompt(snapshot, mode) { head: snapshot.headBranch, ci: snapshot.checks, existingReviews: snapshot.reviews.slice(0, 12), + previousPublishedFindings, files, }, null, 2), ].join('\n'); @@ -1583,6 +2268,13 @@ function normalizeDecision(value) { return ['approve', 'request_changes', 'comment'].includes(value) ? value : 'comment'; } +function formatFindingBody(body, suggestion) { + const issue = String(body || '').trim(); + const fix = String(suggestion || '').trim(); + if (!fix) return issue; + return `${issue}\n\n${state.data.reviewLanguage === 'zh' ? '建议处理' : 'Suggested fix'}: ${fix}`; +} + function buildReviewOperations(snapshot, aiText, mode) { const parsed = extractJsonObject(aiText); const timestamp = snapshot.headSha || Date.now(); @@ -1606,6 +2298,7 @@ function buildReviewOperations(snapshot, aiText, mode) { for (const finding of findings.slice(0, 12)) { const path = String(finding?.path || '').trim(); const body = String(finding?.body || '').trim(); + const suggestion = String(finding?.suggestion || finding?.suggestedFix || '').trim(); const position = Number(finding?.position || 0); if (!path || !body || !validPaths.has(path) || !Number.isFinite(position) || position <= 0) { continue; @@ -1615,15 +2308,16 @@ function buildReviewOperations(snapshot, aiText, mode) { kind: 'inline_comment', path, position, - body, + body: formatFindingBody(body, suggestion), selected: true, stale: false, published: false, }); } + const hasInlineFindings = operations.some((operation) => operation.kind === 'inline_comment'); const decisionBody = String(parsed.decisionBody || '').trim(); - if (decisionBody) { + if (decisionBody && !hasInlineFindings) { operations.push({ id: `decision-${timestamp}`, kind: 'review_decision', @@ -1652,9 +2346,26 @@ function buildReviewOperations(snapshot, aiText, mode) { return operations; } +function requestGenerateDraft() { + const snapshot = selectedSnapshot(); + if (!snapshot) return; + const draft = selectedDraft(); + const unpublished = unpublishedDraftOperations(draft); + if (unpublished.length) { + state.ui.draftOverwriteConfirm = { + key: snapshotKey(snapshot), + count: unpublished.length, + }; + render(); + return; + } + void generateDraft(); +} + async function generateDraft() { const snapshot = selectedSnapshot(); if (!snapshot) return; + state.ui.draftOverwriteConfirm = null; state.ui.cancelReviewRequested = false; setBusy('draft', 'statusGenerating'); try { @@ -1696,7 +2407,7 @@ async function generateDraft() { operations: buildReviewOperations(snapshot, reviewText, mode), }; state.data.drafts[snapshotKey(snapshot)] = draft; - await finish('statusReady'); + await finish('statusReady', { preservePaneScroll: true }); } catch (error) { setError(error); } @@ -1725,6 +2436,7 @@ async function addManualComment() { published: false, }); state.data.drafts[key] = draft; + state.ui.manualCommentDraft = ''; input.value = ''; await finish('statusSaved'); } @@ -1879,17 +2591,274 @@ async function confirmPublish() { message: result.message || null, timestamp: new Date().toISOString(), })); + recordPublishedReviewContext(snapshot, draft, results); state.data.audit = [...auditEntries, ...state.data.audit].slice(0, 50); - state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; + const publishedCount = results.filter((result) => result.status === 'success').length; + if (publishedCount > 0) { + state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; + } state.ui.confirm = null; - await finish('statusPublished'); + if (publishedCount > 0) { + await finish('statusPublished'); + } else { + await saveStorage(); + setError(t('statusPublishFailed')); + } +} + +function upsertSnapshot(snapshot) { + if (!snapshot) return; + const byKey = new Map(state.data.items.map((item) => [itemKey(item), item])); + byKey.set(snapshotKey(snapshot), { + ...byKey.get(snapshotKey(snapshot)), + ...snapshot, + stale: false, + }); + state.data.items = Array.from(byKey.values()).sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); + state.data.selectedKey = snapshotKey(snapshot); + state.data.selectedFilePath = state.data.selectedFilePath || snapshot.files?.[0]?.path || null; } -async function markReviewed() { +function githubGraphqlUrl(profile) { + const base = normalizeBaseUrl(profile.apiBaseUrl); + if (base === 'https://api.github.com') return `${base}/graphql`; + if (base.endsWith('/api/v3')) return `${base.slice(0, -'/api/v3'.length)}/api/graphql`; + return `${base}/graphql`; +} + +async function githubGraphql(profile, query, variables = {}) { + const result = await netJson(githubGraphqlUrl(profile), { + method: 'POST', + headers: providerHeaders(profile, true), + body: JSON.stringify({ query, variables }), + }); + if (Array.isArray(result?.errors) && result.errors.length) { + const message = result.errors.map((error) => error.message).filter(Boolean).join('; '); + throw new Error(`${t('errorNetwork')}: ${message || 'GraphQL error'}`); + } + return result?.data || {}; +} + +async function refreshSelectedLifecycleStatus() { const snapshot = selectedSnapshot(); if (!snapshot) return; - state.data.lastReviewedHeads[snapshotKey(snapshot)] = snapshot.headSha; - await finish('statusReviewed'); + setBusy('lifecycle-refresh', 'refreshingLifecycle'); + try { + const fresh = await fetchSnapshot(snapshot.identity); + upsertSnapshot(fresh); + await finish('statusReady'); + } catch (error) { + setError(error); + } +} + +function lifecycleActionBlockers(action, snapshot) { + const caps = snapshot?.providerCapabilities || {}; + const readiness = snapshot?.mergeReadiness || {}; + if (action === 'merge') { + if (!caps.mergePullRequest) return ['lifecycleActionUnsupported']; + const blockers = [...(readiness.blockers || ['lifecycleUnknown'])]; + if (!hasToken(profileById(snapshot?.identity?.providerId)) && !blockers.includes('lifecycleBlockToken')) { + blockers.push('lifecycleBlockToken'); + } + return blockers; + } + if (!caps.transitionDraftState) return ['lifecycleActionUnsupported']; + const blockers = []; + if (readiness.state === 'merged') blockers.push('lifecycleBlockMerged'); + if (readiness.state === 'closed') blockers.push('lifecycleBlockClosed'); + if (!readiness.headSha && !snapshot?.headSha) blockers.push('lifecycleBlockHead'); + if (!snapshot?.nodeId) blockers.push('lifecycleUnknown'); + if (!hasToken(profileById(snapshot.identity.providerId))) blockers.push('lifecycleBlockToken'); + return blockers; +} + +function hardLifecycleBlockers(blockers) { + return blockers.filter((key) => key !== 'lifecycleBlockToken'); +} + +function lifecycleButtonTitle(action, blockers, profile) { + const unique = Array.from(new Set(blockers || [])); + if (unique.includes('lifecycleBlockToken')) { + const authHint = profile?.kind === 'github' ? t('lifecycleAutoAuthHint') : t('lifecycleGuideToken'); + const other = unique.filter((key) => key !== 'lifecycleBlockToken').map((key) => t(key)); + return [authHint, ...other].filter(Boolean).join(' · '); + } + if (unique.length) return unique.map((key) => t(key)).join(' · '); + if (action === 'ready') return t('lifecycleConfirmReadyBody'); + if (action === 'draft') return t('lifecycleConfirmDraftBody'); + return t('lifecycleConfirmMergeBody'); +} + +function lifecycleActionLabel(action) { + if (action === 'ready') return t('lifecycleActionReady'); + if (action === 'draft') return t('lifecycleActionDraft'); + if (action === 'merge') return t('lifecycleActionMerge'); + return action; +} + +function lifecycleConfirmTitleKey(action) { + if (action === 'ready') return 'lifecycleConfirmReadyTitle'; + if (action === 'draft') return 'lifecycleConfirmDraftTitle'; + return 'lifecycleConfirmMergeTitle'; +} + +function lifecycleConfirmBodyKey(action) { + if (action === 'ready') return 'lifecycleConfirmReadyBody'; + if (action === 'draft') return 'lifecycleConfirmDraftBody'; + return 'lifecycleConfirmMergeBody'; +} + +async function requestLifecycleAction(action) { + const snapshot = selectedSnapshot(); + if (!snapshot) return; + const profile = profileById(snapshot.identity.providerId); + if (!profile || !await ensureProfileToken(profile)) { + state.ui.settingsOpen = true; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleAuthFailed'); + render(); + return; + } + setBusy('lifecycle-check', 'refreshingLifecycle'); + try { + const fresh = await fetchSnapshot(snapshot.identity); + upsertSnapshot(fresh); + if (snapshot.headSha && fresh.headSha && snapshot.headSha !== fresh.headSha) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleHeadChanged'); + render(); + return; + } + const blockers = lifecycleActionBlockers(action, fresh); + if (blockers.length) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = action === 'merge' + ? t('lifecycleMergeBlocked', { reason: blockers.map((key) => t(key)).join(', ') }) + : blockers.map((key) => t(key)).join(', '); + render(); + return; + } + state.ui.busy = null; + state.ui.status = null; + state.ui.error = null; + state.ui.lifecycleConfirm = { + action, + identity: fresh.identity, + title: fresh.title, + owner: fresh.identity.owner, + repo: fresh.identity.repo, + number: fresh.identity.number, + baseBranch: fresh.baseBranch, + headBranch: fresh.headBranch, + expectedHeadSha: fresh.headSha, + warnings: action === 'merge' ? (fresh.mergeReadiness?.warnings || []) : [], + defaultMergeMethod: 'merge', + }; + render(); + } catch (error) { + setError(error); + } +} + +async function transitionGithubDraftState(profile, snapshot, action) { + const mutation = action === 'ready' + ? `mutation MarkPullRequestReady($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { + pullRequest { isDraft } + } + }` + : `mutation ConvertPullRequestToDraft($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { + pullRequest { isDraft } + } + }`; + await githubGraphql(profile, mutation, { pullRequestId: snapshot.nodeId }); +} + +async function mergeGithubPullRequest(profile, snapshot, mergeMethod, expectedHeadSha) { + const base = normalizeBaseUrl(profile.apiBaseUrl); + const ownerRepo = `${encodeURIComponent(snapshot.identity.owner)}/${encodeURIComponent(snapshot.identity.repo)}`; + return netJson(`${base}/repos/${ownerRepo}/pulls/${snapshot.identity.number}/merge`, { + method: 'PUT', + headers: providerHeaders(profile, true), + body: JSON.stringify({ + sha: expectedHeadSha, + merge_method: mergeMethod, + }), + }); +} + +async function confirmLifecycleAction() { + const confirm = state.ui.lifecycleConfirm; + if (!confirm) return; + const profile = profileById(confirm.identity.providerId); + const mergeMethod = document.getElementById('merge-method')?.value || confirm.defaultMergeMethod || 'merge'; + setBusy('lifecycle', 'lifecycleUpdating'); + try { + const fresh = await fetchSnapshot(confirm.identity); + upsertSnapshot(fresh); + if (confirm.expectedHeadSha && fresh.headSha && confirm.expectedHeadSha !== fresh.headSha) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = t('lifecycleHeadChanged'); + render(); + return; + } + const blockers = lifecycleActionBlockers(confirm.action, fresh); + if (blockers.length) { + state.ui.lifecycleConfirm = null; + state.ui.busy = null; + state.ui.status = null; + state.ui.error = confirm.action === 'merge' + ? t('lifecycleMergeBlocked', { reason: blockers.map((key) => t(key)).join(', ') }) + : blockers.map((key) => t(key)).join(', '); + render(); + return; + } + if (confirm.action === 'merge') { + await mergeGithubPullRequest(profile, fresh, mergeMethod, confirm.expectedHeadSha); + } else { + await transitionGithubDraftState(profile, fresh, confirm.action); + } + const auditEntry = { + id: `${snapshotKey(fresh)}:lifecycle:${confirm.action}:${Date.now()}`, + providerId: profile.id, + owner: fresh.identity.owner, + repo: fresh.identity.repo, + number: fresh.identity.number, + actionType: `lifecycle:${confirm.action}`, + actionLabel: lifecycleActionLabel(confirm.action), + status: 'success', + headSha: confirm.expectedHeadSha, + timestamp: new Date().toISOString(), + }; + state.data.audit = [auditEntry, ...state.data.audit].slice(0, 50); + state.ui.lifecycleConfirm = null; + try { + upsertSnapshot(await fetchSnapshot(fresh.identity)); + } catch { + upsertSnapshot({ + ...fresh, + merged: confirm.action === 'merge' ? true : fresh.merged, + isDraft: confirm.action === 'draft' ? true : confirm.action === 'ready' ? false : fresh.isDraft, + }); + } + await finish(confirm.action === 'merge' + ? 'lifecycleMergeDone' + : confirm.action === 'draft' + ? 'lifecycleDraftDone' + : 'lifecycleReadyDone'); + } catch (error) { + state.ui.lifecycleConfirm = null; + setError(error); + } } function resetPollTimer() { @@ -1904,77 +2873,203 @@ function resetPollTimer() { function renderStatus() { if (state.ui.error) return `
${esc(state.ui.error)}
`; - if (state.ui.status) return `
${esc(state.ui.status)}
`; + if (state.ui.status) { + const busyClass = state.ui.startupSyncing || state.ui.busy === 'refresh' ? ' pr-status--busy' : ''; + return `
${esc(state.ui.status)}
`; + } return ''; } +function shouldShowShellStatus() { + return Boolean(state.ui.status && (state.ui.startupSyncing || state.ui.busy === 'refresh')); +} + +function directOpenBusyReason() { + if (!state.ui.busy) return ''; + if (state.ui.busy === 'refresh') return t('directOpenBusySync'); + if (state.ui.busy === 'draft') return t('directOpenBusyReview'); + if (state.ui.busy === 'publish' || state.ui.busy === 'stale-check') return t('directOpenBusyPublish'); + if (String(state.ui.busy).startsWith('lifecycle')) return t('directOpenBusyLifecycle'); + return t('directOpenBusyGeneric'); +} + +function busyActionReason(action) { + if (!state.ui.busy) return ''; + if (action === 'open-direct') return directOpenBusyReason(); + if (state.ui.busy === 'refresh') return t('busyActionSync'); + if (state.ui.busy === 'draft') return t('busyActionReview'); + if (state.ui.busy === 'publish' || state.ui.busy === 'stale-check') return t('busyActionPublish'); + if (String(state.ui.busy).startsWith('lifecycle')) return t('busyActionLifecycle'); + return t('busyActionGeneric'); +} + +function actionAvailabilityAttrs(disabledReason = '', enabledTitle = '') { + const title = disabledReason || enabledTitle; + const attrs = [`aria-disabled="${disabledReason ? 'true' : 'false'}"`]; + if (title) attrs.push(`title="${esc(title)}"`); + if (disabledReason) attrs.push(`data-disabled-reason="${esc(disabledReason)}"`); + return attrs.join(' '); +} + +function disabledActionAttrs(disabledReason = '') { + return actionAvailabilityAttrs(disabledReason); +} + +function busyActionAttrs(action) { + return disabledActionAttrs(busyActionReason(action)); +} + +function renderShellStatus() { + if (!shouldShowShellStatus()) return ''; + return `
${renderStatus()}
`; +} + function renderCommandBar() { - const profile = activeProfile(); + const activeRepoCount = activeSubscriptions().length; + const modeLabelText = state.data.queueMode === 'mine' ? t('syncMineTitle') : t('syncAllTitle'); return ` -
+
PR

${esc(t('title'))}

-

${esc(t('subtitle'))}

+

${esc(modeLabelText)} · ${esc(activeRepoCount ? t('autoSync') : t('queueEmpty'))}

-
-
- ${esc(t('repositoryFirst'))} - ${esc(t('repositoryFirstHint'))} -
-
- - - -
+
+ +
-
-
- ${esc(t('singlePrFallback'))} - ${esc(t('singlePrFallbackHint'))} -
-
- - -
+
+ `; +} + +function renderWatchRepositoryCard() { + return ` +
+
+ ${esc(t('repositoryFirst'))} + ${esc(t('repositoryFirstHint'))} +
+
+ + + +
+
+ `; +} + +function renderDirectOpenCard() { + const disabledAttrs = busyActionAttrs('open-direct'); + return ` +
+
+ ${esc(t('singlePrFallback'))} + ${esc(t('singlePrFallbackHint'))} +
+
+ + +
+
+ `; +} + +function renderAccessCard() { + const profile = activeProfile(); + return ` +
+
+ ${esc(t('privateAction'))} + ${esc(t('authAutoHint'))}
-
-
- ${esc(t('privateAction'))} - ${esc(t('authAutoHint'))} +
+ + ${profile?.kind === 'github' ? `` : ''} + ${esc(hasToken(profile) ? t('tokenReady') : t('tokenMissing'))} +
+
+ ${esc(t('manualToken'))} + +
+
+ `; +} + +function renderQueueSettingsCard() { + const mode = state.data.queueMode; + return ` +
+
+ ${esc(t('queueSettingsTitle'))} + ${esc(t('queueSettingsHint'))} +
+
+ + +
+
+ + +
+
+ `; +} + +function renderSettingsModal() { + if (!state.ui.settingsOpen) return ''; + return ` +
+
+
+ +
+ + `; } @@ -1983,8 +3078,7 @@ function renderQueuePanel() { const activeRepoCount = activeSubscriptions().length; return `