diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccce13c0d6..092320f5bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,15 @@ jobs: - name: Audit i18n resources run: pnpm run i18n:audit + - name: Validate theme color audit contract + run: pnpm run theme:color-audit:test + + - name: Audit theme color governance + run: pnpm run theme:color-audit + + - name: Validate theme visual governance contract + run: pnpm run theme:visual-contract + - name: Lint web UI run: pnpm run lint:web diff --git a/.github/workflows/cli-package-manual.yml b/.github/workflows/cli-package-manual.yml new file mode 100644 index 0000000000..3e15fa3767 --- /dev/null +++ b/.github/workflows/cli-package-manual.yml @@ -0,0 +1,210 @@ +name: CLI Package Manual + +on: + workflow_dispatch: + inputs: + platform: + description: "Platform to build." + required: true + default: linux-arm64 + type: choice + options: + - linux-arm64 + - linux-x64 + - macos-arm64 + - macos-x64 + - all + ubuntu_version: + description: "Ubuntu runner baseline for Linux builds. Ignored for macOS." + required: true + default: "22.04" + type: choice + options: + - "22.04" + - "24.04" + ref_name: + description: "Branch, tag, or commit SHA to build. Leave empty to use the selected workflow ref." + required: false + type: string + +permissions: + contents: read + +concurrency: + group: cli-package-manual-${{ inputs.platform }}-${{ inputs.ubuntu_version }}-${{ inputs.ref_name || github.ref }} + cancel-in-progress: true + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + short_sha: ${{ steps.meta.outputs.short_sha }} + checkout_ref: ${{ steps.meta.outputs.checkout_ref }} + matrix: ${{ steps.meta.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref_name != '' && inputs.ref_name || github.ref }} + fetch-depth: 0 + + - name: Resolve build metadata + id: meta + shell: bash + env: + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_UBUNTU_VERSION: ${{ inputs.ubuntu_version }} + INPUT_REF_NAME: ${{ inputs.ref_name }} + run: | + set -euo pipefail + + VERSION="$(jq -r '.version' package.json)" + FULL_SHA="$(git rev-parse HEAD)" + SHORT_SHA="$(git rev-parse --short HEAD)" + CHECKOUT_REF="$FULL_SHA" + + case "${INPUT_UBUNTU_VERSION}" in + 22.04) + LINUX_X64_RUNNER="ubuntu-22.04" + LINUX_ARM64_RUNNER="ubuntu-22.04-arm" + LINUX_NAME_SUFFIX="ubuntu2204" + ;; + 24.04) + LINUX_X64_RUNNER="ubuntu-24.04" + LINUX_ARM64_RUNNER="ubuntu-24.04-arm" + LINUX_NAME_SUFFIX="ubuntu2404" + ;; + *) + echo "Unsupported ubuntu_version: ${INPUT_UBUNTU_VERSION}" >&2 + exit 1 + ;; + esac + + case "${INPUT_PLATFORM}" in + linux-arm64) + MATRIX="{\"platform\":[{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + ;; + linux-x64) + MATRIX="{\"platform\":[{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + ;; + macos-arm64) + MATRIX='{"platform":[{"os":"macos-15","name":"macos-arm64","target":"aarch64-apple-darwin","can_smoke_test":true}]}' + ;; + macos-x64) + MATRIX='{"platform":[{"os":"macos-15-intel","name":"macos-x64","target":"x86_64-apple-darwin","can_smoke_test":true}]}' + ;; + all) + MATRIX="{\"platform\":[{\"os\":\"macos-15\",\"name\":\"macos-arm64\",\"target\":\"aarch64-apple-darwin\",\"can_smoke_test\":true},{\"os\":\"macos-15-intel\",\"name\":\"macos-x64\",\"target\":\"x86_64-apple-darwin\",\"can_smoke_test\":true},{\"os\":\"${LINUX_X64_RUNNER}\",\"name\":\"linux-x64-${LINUX_NAME_SUFFIX}\",\"target\":\"x86_64-unknown-linux-gnu\",\"can_smoke_test\":true},{\"os\":\"${LINUX_ARM64_RUNNER}\",\"name\":\"linux-arm64-${LINUX_NAME_SUFFIX}\",\"target\":\"aarch64-unknown-linux-gnu\",\"can_smoke_test\":true}]}" + ;; + *) + echo "Unsupported platform: ${INPUT_PLATFORM}" >&2 + exit 1 + ;; + esac + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + + build: + name: Build (${{ matrix.platform.name }}) + runs-on: ${{ matrix.platform.os }} + needs: prepare + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare.outputs.matrix) }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + build-essential \ + libssl-dev \ + libxcb1-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform.target }} + + - name: Cache Rust build + uses: swatinem/rust-cache@v2 + with: + shared-key: "cli-manual-v1-${{ matrix.platform.name }}" + cache-bin: false + + - name: Build bitfun-cli + shell: bash + run: | + set -euo pipefail + cargo build --release \ + --target ${{ matrix.platform.target }} \ + -p bitfun-cli + + - name: Smoke test (--version) + if: matrix.platform.can_smoke_test == true + shell: bash + run: | + set -euo pipefail + BIN="target/${{ matrix.platform.target }}/release/bitfun-cli" + "$BIN" --version + "$BIN" --help > /dev/null + + - name: Stage tarball + id: stage + shell: bash + env: + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.platform.target }} + run: | + set -euo pipefail + + STAGE_DIR="$(pwd)/dist-cli/bitfun-cli-${VERSION}-${TARGET}" + mkdir -p "$STAGE_DIR" + + cp "target/${TARGET}/release/bitfun-cli" "$STAGE_DIR/" + cp LICENSE "$STAGE_DIR/" || true + cp README.md "$STAGE_DIR/" || true + + if [[ -d "src/apps/cli/themes" ]]; then + cp -R "src/apps/cli/themes" "$STAGE_DIR/themes" + fi + if [[ -d "src/apps/cli/prompts" ]]; then + cp -R "src/apps/cli/prompts" "$STAGE_DIR/prompts" + fi + + ARCHIVE="bitfun-cli-${VERSION}-${TARGET}.tar.gz" + tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$ARCHIVE" > "${ARCHIVE}.sha256" + else + shasum -a 256 "$ARCHIVE" > "${ARCHIVE}.sha256" + fi + + echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" + echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bitfun-cli-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.short_sha }}-${{ matrix.platform.name }} + if-no-files-found: error + path: | + ${{ steps.stage.outputs.archive }} + ${{ steps.stage.outputs.checksum }} diff --git a/README.md b/README.md index 860bdd2373..c55525a020 100644 --- a/README.md +++ b/README.md @@ -16,116 +16,73 @@ --- -## What BitFun Is +## Local AI Workbench Built Around the Code Agent -**BitFun is a desktop-grade Agent runtime (Local Agent Runtime) and a ready-to-use suite of desktop Agent applications.** +BitFun is a local AI workbench built around a Code Agent designed for long-horizon tasks, engineering execution, and token economy. -- It is the **foundation**—a Rust core plus a Tauri shell, with sessions, tools, memory, MCP, LSP, and remote-control protocols built in, designed for long-running use; +It can understand complex context, call tools, wait for results, correct deviations, and keep long-horizon tasks moving until they reach a deliverable state. Coding, research, office work, documents, desktop operations, and extensible workflows all happen in the same local desktop environment. -- It is the **product**—install once and you get four official Agents out of the box: Code, Cowork, Computer Use, and Personal Assistant, covering almost every mainstream Agent capability shape in the industry today. - -> **One install: use it as an Agent, or use it as a Runtime.** - -BitFun aims to pack **the coding power of Code Agents, the office productivity of Cowork, the assistant experience of OpenClaw, the control surface of Computer Use, and more**—the most popular Agent capabilities in the industry—into one desktop app, with the full protocol stack (Agentic runtime, tools, memory, MCP, Skills, context compression, remote control) ready by default. You can use it immediately, or define **your own domain Agents** on top of it. - - -![readme_hero](./png/readme_hero.png) +Core goal: move AI from iterative Agent Loop execution into a productivity system that can autonomously complete long-horizon work. +![readme_hero](./png/readme_hero_CN.png) --- -## Why BitFun +## Agent Core Metrics -- **One app, almost every mainstream Agent capability in the industry**: Code / Cowork / Computer Use / document collaboration / generative UI / Mini App / MCP / remote control … No juggling multiple tools or paying for separate subscriptions for each. -- **Download and run—no DIY assembly**: MCP / LSP / filesystem / terminal / Git / remote SSH are all built in; configure your model and go, without spending time wiring the protocol stack from scratch. -- **Your data stays on your machine**: Sessions, memory, and working directories live under `.bitfun/sessions/`, portable, exportable, and auditable; nothing is forced to the cloud—suitable for privacy and compliance scenarios. -- **Deeply customizable, with no gap from a single Markdown file to a full-repo fork**: ~90% of domain needs are covered with one `.md`; missing a tool? a UI? want to change the product? Have the Code Agent do it inside BitFun—**the way you customize it is by using it**. -- **Control the desktop from your phone**: Pair by QR code, or use Telegram, Feishu Bot, or WeChat Bot as remote entry points. The Agent works on the desktop; you check progress on the go. -- **A desktop app you can actually live with**: Rust core + Tauri shell—fast cold start, low idle footprint, fine to leave running in the background for a long time. -- **Self-improving**: 97%+ of the code was produced by BitFun’s built-in Code Agent via Vibe Coding, so it naturally fits AI-assisted development. +The data below evaluates BitFun's core Agent capabilities. All measurements use **Deepseek-V4-Pro** and are grouped into completion results, token economy, and other experience metrics. ---- +> The current numbers are BitFun's initial evaluation results, with each case run once. Benchmarks can fluctuate with task sampling, model versions, runtime environment, and single-run variance, so these scores are meant as an initial sanity signal that the current Agent is already reasonably capable, not as a fixed ranking claim or final ceiling. We will keep optimizing and release full benchmark details later. -## What's New +### 1. Completion Results -BitFun combines **flashgrep** with **ripgrep** into an enhanced code-search pipeline. On very large repositories such as Chromium, search time drops by up to about **94.6%**, with an average speedup of about **36.1×**, significantly reducing the time you spend exploring a project. +BitFun leads Open Code and Claude Code on both **SWE-Bench-Pro** and **SWE-Bench-Verified**. SWE-Bench-Pro focuses on complex software engineering, while SWE-Bench-Verified focuses on human-verified GitHub issue fixes. -![flashgrep feature](./png/feat_flashgrep.png) +![Agent benchmark scores](./png/agent_benchmark_scores.svg) ---- - -## Cutting Edge · Ready Out of the Box +Benchmark references: [SWE-Bench-Pro](https://labs.scale.com/leaderboard/swe_bench_pro_public) / [SWE-Bench-Verified](https://www.swebench.com/verified.html) -New paradigms appear almost weekly in the Agent space. BitFun’s pace is: **when we see something great, we ship it on the desktop and make it work seamlessly with what you already have.** +### 2. Token Economy +Agent economy needs to be evaluated across end-to-end token consumption, execution time, and KV Cache reuse. The current snapshot first covers KV Cache behavior from the same SWE-Bench-Pro round: BitFun's average KV Cache hit rate was **98.67%**. The follow-up full benchmark report will add the broader cost and latency metrics. -![first_screen_screenshot](./png/first_screen_screenshot.png) +![KV Cache hit rate distribution](./png/kv_cache_hit_rate.svg) -Below is BitFun’s **official Agent and capability inventory**, plus how we track the industry’s latest Agent patterns. Zero extra setup—download and use: - -| Capability | Description | -| --- | --- | -| **Code Agent** | Four modes: Agentic (autonomous read / edit / run / verify) / Plan (plan first, then execute) / Debug (instrument → gather evidence → root cause) / Review (repo-standard review) | -| **Deep Review** | A parallel Code Review Team for higher-risk code changes, with reviewer roles, a quality gate, and user-approved remediation | -| **Session usage report** | Type `/usage` in chat to view recorded runtime, token usage, and model/tool/file summaries for the current session. | -| **Cowork Agent** | Native PDF / DOCX / XLSX / PPTX workflows; extend on demand from the Skill marketplace | -| **Document collaboration** | Write and ask in the document; the AI rewrites, continues, summarizes, and lays out text directly in paragraphs | -| **Personal Assistant** | Long-term memory and personality; schedules Code / Cowork / Computer Use / custom Agents as needed | -| **Remote control / IM** | Phone QR pairing, Telegram, Feishu Bot, WeChat Bot for remote commands with live progress | -| **MCP / MCP App** | One-click hookup for external tools; MCP can also be packaged as installable Apps | -| **Generative UI** | On-demand interactive UI components during chat, embedded in the message stream for immediate use | -| **Mini App** | One sentence to a standalone runnable app—generate, run, one-click package for desktop | -| **Markdown-defined Agents** | Write a `.md` file and run it in the Runtime right away for most domain customization | -| **Long-term memory** | Accumulates across sessions; readable by any Agent | -| **Self-iteration** | Code Agent can change BitFun’s own repository | -| **⋯⋯** | Next trends in progress—open an Issue with requests | +### 3. Other Experience Metrics ---- +Beyond cost, Agent experience also depends on how quickly it can retrieve context in very large engineering projects. For tens-of-millions-line repositories such as Chromium, BitFun uses **flashgrep** to reduce search time by up to about **94.6%**, with an average speedup of about **36.1x**. -## How to Customize Your BitFun +![flashgrep search speed](./png/flashgrep_search_speed.svg) -Different depths of customization map to different-effort paths. Pick from light to heavy as needed: +--- -| Tier | Approach | Best for | Effort | -| --- | --- | --- | --- | -| **L1** | **Markdown custom Agents** | Swap prompts + pick tool bundles to define a **new Agent capability**—covers most domain needs | Write one `.md` file | -| **L2** | **Mini App** | Capabilities that need UI (panels, forms, visualization, business flows) | One sentence to generate; run immediately | -| **L3** | **Source-level tools** | New tools, model adapters, protocols—give your custom Agent a `tool` BitFun doesn’t ship yet | Use BitFun’s Code Agent to edit BitFun’s own source | -| **L4** | **Free-form source changes** | Rebrand, rebuild UI, change session model, ship a totally different product | Fork the whole repo—naturally fits Vibe Coding | +## Two Core Scenarios, One Extensible Agent Desktop -### Example: Code Agent vs Cowork Agent is a small difference +You can hand two kinds of complex work to BitFun: shipping code in real repositories and turning source material into office deliverables. When a task needs the browser, desktop apps, the terminal, or a remote environment, it can enter the real workspace; when your workflow needs more, you can extend it with custom Agents, MCP, Skills, and Mini Apps. -In BitFun, an Agent = **a prompt (system role + behavior constraints) + the set of tools it may call**. The official Code Agent and Cowork Agent differ only in those two dimensions: +### Core Scenarios -| | Code Agent | Cowork Agent | +| Scenario | Delivery goal | Typical capabilities | | --- | --- | --- | -| **Prompt** | Role and norms for repo work; four operating modes | Role and document workflows for knowledge work | -| **Tooling** | Files / terminal / Git / LSP / build & test | PDF / DOCX / XLSX / PPTX / Skill marketplace | -| **Shared foundation** | Same sessions, memory, MCP, remote control, UI, model adapters | Same sessions, memory, MCP, remote control, UI, model adapters | - -**So if you want a “legal review Agent,” a “research literature Agent,” or an “ops incident Agent”—L1 is enough**: +| **Coding** | Move from a real repository to a mergeable result. | Agentic, Plan, Debug, testing, Git, Deep Review, long-horizon tasks, and benchmarks. | +| **Office Work** | Move from source material to deliverable documents. | Research, PPT, DOCX, XLSX, PDF, summarization, writing, meeting notes, and reports. | -1. Write a Markdown file defining role / guardrails / workflow -2. From the tool registry, enable what it should use (files, browser, specific MCP …) -3. If a specific tool is missing—use **L3**: open BitFun and have the Code Agent add it in source -4. If the Agent needs a dedicated UI—use **L2**: one sentence to spin up a Mini App -5. If you want a completely different product—use **L4**: fork the repo and have the Code Agent help you reshape it +### Shared Capabilities -**Key point**: For L3 and L4 you never leave BitFun—**open BitFun, tell the Code Agent what to change, and it shows you the diff**. **The way you customize it is by using it.** +- **Desktop execution layer**: Computer Use, browser operation, desktop apps, the filesystem, terminals, remote workspaces, and Mini Apps let the Agent enter real work environments. +- **Customization layer**: MCP, Skills, custom Agents, Mini Apps, and source-level extension let BitFun keep growing around your tools, roles, and interfaces. -> From one Markdown file to a full fork, there is no discontinuity. That is what “a self-improving foundation” means. +![first_screen_screenshot](./png/first_screen_screenshot.png) --- -## Platform Support +## Ready Out of the Box -Desktop is built on Tauri for Windows / macOS / Linux; remote control works from mobile browsers, Telegram, Feishu, and WeChat. - ---- +### Download directly -## Quick Start +Go to [Releases](https://github.com/GCWing/BitFun/releases) to download the latest desktop installer. After installation, configure your model and start using BitFun. -### Build from source +### Run from source **Prerequisites:** @@ -162,46 +119,42 @@ cd src/mobile-web && npm run build 3. use deveco app to build ``` -For more details, see the [Contributing guide](./CONTRIBUTING.md). +For more development details, see [CONTRIBUTING.md](./CONTRIBUTING.md). --- -## Project structure at a glance +## Customize Your BitFun -``` -src/crates/interfaces/ # Product protocol interfaces such as ACP -src/crates/assembly/ # Compatibility facade and product capability assembly -src/crates/adapters/ # AI, API, transport, and WebDriver adapters -src/crates/services/ # Reusable OS, terminal, MCP, remote, git, and filesystem services -src/crates/execution/ # Agent, harness, stream, typed-service, and tool primitives -src/crates/contracts/ # Stable DTOs, events, runtime ports, and product domains -src/apps/desktop # Tauri desktop host -src/apps/server # Web server runtime -src/apps/cli # CLI runtime -src/web-ui # Shared desktop / Web frontend -``` +BitFun's extension paths progress continuously from light to deep customization: + +| Tier | Path | Best for | +| --- | --- | --- | +| **L1** | Custom Agent | Defining roles, flows, constraints, and tool bundles. | +| **L2** | MCP / Skills | Connecting external tools, professional capabilities, and workflows. | +| **L3** | Mini App | Generating dedicated interfaces, forms, panels, or visualizations for tasks. | +| **L4** | Source-level customization | Changing tools, adapters, UI, Runtime, or product shape. | -Design principle: **keep product logic platform-agnostic and expose it through adapters**. See [AGENTS.md](./AGENTS.md). +You can use BitFun's Code Agent to extend BitFun itself. --- ## Contributing -We welcome great ideas and code; we are maximally open to AI-generated code. Please submit PRs directly to the `main` branch; we review and merge there. +Stars, Issues, and PRs are welcome. We especially care about: -**Contribution directions we care about most:** +1. Code Agent, Deep Review, debugging, and long-task execution capabilities +2. Cowork, research, document, and desktop workflows +3. MCP, Skills, Mini App, LSP plugins, and new domain Agents +4. Runtime stability, performance, context efficiency, and verifiability -1. **Runtime core**: session model, tool registry, memory system, protocol adapters -2. **Reference Agents**: capabilities and experience for Code / Cowork / Personal Assistant -3. **Ecosystem**: Skills, MCP, LSP plugins, Mini App templates, and new vertical Agents -4. Ideas / creativity (features, interaction, visuals)—Issues welcome +Please submit PRs directly to the `main` branch. For more details, see [CONTRIBUTING.md](./CONTRIBUTING.md). --- ## Disclaimer -1. This project is spare-time exploration and research into next-generation human–machine collaboration, not a commercial profit-making project. -2. More than 97% was built with Vibe Coding. Code feedback is welcome; refactoring and optimization via AI is encouraged. +1. This project is spare-time exploration and research into next-generation human-machine collaboration, not a commercial profit-making project. +2. This project is 97%+ built through Vibe Coding. Code feedback is welcome, and AI-assisted refactoring and optimization are encouraged. 3. This project depends on and references many open-source projects. Thanks to all open-source authors. **If your rights are affected, please contact us for remediation.** --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 6badd2ef26..d78961b5e5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,124 +16,77 @@ --- -## BitFun 是什么 +## 以 Code Agent 为核心的本地 AI 工作台 -**BitFun 是一个桌面级 Agent 运行时(Local Agent Runtime),同时也是一套开箱即用的桌面 Agent 应用。** +BitFun 基于一个面向长程任务、强调工程执行与 Token 经济性的 Code Agent 打造本地 AI 工作台。 -- 它是**基座**——Rust 内核 + Tauri 外壳,内置会话、工具、记忆、MCP、LSP、远程控制协议,为长期运行而生; -- 它是**产品**——下载安装就拥有 Code / Cowork / Computer Use / 个人助理四大官方 Agent,几乎覆盖了当前业界所有主流 Agent 能力形态。 - -> **一次安装,既能当 Agent 用,也能当 Runtime 做。** - -BitFun 的野心是把 **Code Agent 的编码力、Cowork 的办公力、OpenClaw 的助理体验、Computer Use 的操控力等等** 这些业界最受欢迎的 Agent 能力,装进同一个桌面端,并把底层协议栈(Agentic RunTime、工具、记忆、MCP、Skill、上下文压缩、远程控制)全部默认就绪——你拿来就能用,也可以基于它定义**你自己的领域 Agent**。 +它能理解复杂上下文、调用工具、等待结果、修正偏差,把长程任务持续推进到可交付状态;编码、调研、办公、文档、桌面操作和可扩展工作流,都在同一个本地桌面环境里展开。 +核心目标:让 AI 从“Agent Loop 的迭代执行”进化成“可自主完成长期工作”的生产力系统。 ![readme_hero_CN](./png/readme_hero_CN.png) --- -## 为什么选 BitFun - -- **一个应用,几乎覆盖全部业界主流 Agent 能力**:Code / Cowork / Computer Use / 文档协作 / 生成式 UI / Mini App / MCP / 远程控制 …… 不用在多个工具之间切换,也不用各配一个订阅。 -- **下载即用,不做拼装工**:MCP / LSP / 文件系统 / 终端 / Git / 远程 SSH 全部内置,模型配好就能开跑,省掉自己从零搭建协议栈的时间。 -- **数据在你自己机器上**:会话、记忆、工作目录都存在 `.bitfun/sessions/` 下,可迁移、可导出、可审计;没有强制上云,隐私与合规场景都能用。 -- **极致可定制,从一个 Markdown 到整仓 fork 没有断点**:90% 的领域化需求一个 `.md` 就能搞定;缺工具?缺界面?要改产品?在 BitFun 里直接让 Code Agent 动手——**你定制它的方式,就是用它本身**。 -- **手机也能指挥桌面**:扫码、Telegram、飞书 Bot、微信 Bot 都是远控入口。Agent 在桌面上干活,你在路上看进度。 -- **真正能装机长用的桌面应用**:Rust 内核 + Tauri 外壳,冷启动快、常驻资源低,长时间后台运行也不心疼电脑。 -- **会自我迭代**:97%+ 代码由 BitFun 内置 Code Agent 通过 Vibe Coding 完成,天然亲和AI开发。 - ---- +## Agent 核心指标 -## 最新特性 +下面的数据用于观察 BitFun Agent 的核心能力。统一使用 **Deepseek-V4-Pro**,分为完成效果、Token 经济和其他体验指标三个部分。 -BitFun 通过引入 flashgrep 与 ripgrep 联动形成增强版本的检索链路,在 Chromium 这类超大代码仓库中将代码搜索耗时最高降低约 94.6%、平均加速约 36.1×,显著缩短项目探索时间。 +> 当前数据为每个 case 跑 1 次得到的 BitFun 初始评测结果。评测会受到任务抽样、模型版本、运行环境和单次执行偶然性的影响,存在一定波动;这组数据仅用于说明当前 Agent 已具备可用的基础竞争力,并不代表固定排名或最终上限。后续会持续优化并放出完整评测详情。 -![flashgrep 检索增强](./png/feat_flashgrep.png) +### 1. 完成效果 ---- +BitFun 在 **SWE-Bench-Pro** 和 **SWE-Bench-Verified** 上均领先 Open Code 与 Claude Code。SWE-Bench-Pro 关注复杂软件工程,SWE-Bench-Verified 关注人工验证的 GitHub issue 修复。 -## 紧追前沿 · 开箱即用 +![Agent benchmark scores](./png/agent_benchmark_scores.svg) -Agent 领域几乎每周都有新范式出现。BitFun 的节奏是——**看到好东西,就把它装进桌面,并让它和已有能力无缝协同**。 +评测集说明:[SWE-Bench-Pro](https://labs.scale.com/leaderboard/swe_bench_pro_public) / [SWE-Bench-Verified](https://www.swebench.com/verified.html) +### 2. Token 经济 -![first_screen_screenshot](./png/first_screen_screenshot_CN.png) +Agent 执行是否经济,需要综合评估端到端 Token 消耗、执行耗时和 KV Cache 复用。当前先展示同一轮 SWE-Bench-Pro 中的 KV Cache 观察:BitFun 的平均 KV Cache 命中率为 **98.67%**。后续完整评测会继续补充更完整的成本与耗时指标。 -以下是 BitFun 已装箱的**官方 Agent 和能力清单**和对业界最前沿 Agent 范式的复现进度。零配置,下载即用: +![KV Cache hit rate distribution](./png/kv_cache_hit_rate.svg) +### 3. 其他体验指标 -| 能力 | 说明 | -| --------------------- | ------------------------------------------------------------------------- | -| **Code Agent** | 四种模式:Agentic(自主读改跑验证)/ Plan(先规划后执行)/ Debug(插桩取证 → 根因定位)/ Review(基于仓库规范审核) | -| **深度审核** | 面向高风险代码变更的并行代码审核团队,内置专项审核员、质量把关和用户确认后的修复流程 | -| **会话用量报告** | 在聊天中输入 `/usage`,查看当前会话的记录耗时、Token 用量和模型/工具/文件摘要。 | -| **Cowork Agent** | PDF / DOCX / XLSX / PPTX 原生处理能力,可从 Skill 市场按需扩展 | -| **文档协作** | 在文档里边写边问,AI 直接在段落上改写、续写、总结、排版 | -| **个人助理** | 长期记忆、个性设定,按需调度 Code / Cowork / Computer Use / 自定义 Agent | -| **远程控制 / IM 接入** | 手机扫码、Telegram、飞书 Bot、微信 Bot 远程下达指令,实时查看进度 | -| **MCP / MCP App** | 任意外部工具一键接入,MCP 也能打包成可安装的 App | -| **生成式 UI** | 对话过程中按需生成可交互 UI 组件,嵌在消息流里直接用 | -| **Mini App** | 一句话生成独立可运行的应用,即生即跑,一键打包成桌面端 | -| **Markdown 定义 Agent** | 写一个 `.md` 文件,立即在 Runtime 里跑起来,满足大多数领域化需求 | -| **长期记忆 + 项目上下文** | 跨会话积累,任意 Agent 可读 | -| **自我迭代** | Code Agent 直接改 BitFun 自己的仓库 | -| **⋯⋯** | 下一个热点持续跟进中,欢迎 Issue 提需求 | +成本之外,Agent 体验还取决于它能否在超大工程里快速找回上下文。面对 Chromium 这类千万行级代码仓库,BitFun 通过 **flashgrep** 最高降低约 **94.6%** 搜索耗时,平均加速约 **36.1x**。 +![flashgrep search speed](./png/flashgrep_search_speed.svg) --- -## 怎么定制自己的 BitFun - -不同深度的定制需求,对应不同成本的扩展路径。按"从轻到重"依次选择即可: - - -| 层级 | 方式 | 适合做什么 | 改动成本 | -| ------ | ---------------------- | ----------------------------------------------------- | ------------------------------------ | -| **L1** | **Markdown 自定义 Agent** | 换提示词 + 挑选工具组合,即可定义一个**新的 Agent 能力**,满足大多数领域化需求 | 写一个 `.md` 文件 | -| **L2** | **Mini App** | 需要用界面交互的能力(面板、表单、可视化、业务流程) | 一句话生成,即生即跑 | -| **L3** | **源码级添加工具** | 新工具、新模型适配、新协议接入——给自定义 Agent 补齐它需要但 BitFun 还没有的 `tool` | 用 BitFun 的 Code Agent 改 BitFun 自己的源码 | -| **L4** | **自由改源码** | 换品牌、重做 UI、改会话模型、做完全不一样的产品 | 整仓 fork,天然亲和 Vibe Coding 开发模式 | - +## 两个核心场景,一套可扩展 Agent 桌面 -### 一个例子:Code Agent 和 Cowork Agent 的差别其实很小 +你可以把两类复杂工作交给 BitFun 推进:在真实仓库里完成编码交付,在资料和文件中完成办公交付。遇到需要浏览器、桌面软件、终端或远程环境的任务时,它可以进入真实工作现场;需要接入你的工具链时,也可以继续扩展 Agent 自定义、MCP、Skills 和 Mini App。 -在 BitFun 里,一个 Agent = **一段提示词(系统角色 + 行为约束)+ 一组它能调用的工具**。官方的 Code Agent 和 Cowork Agent 区别就仅在于此: +### 核心场景 +| 场景 | 目标交付 | 典型能力 | +| --- | --- | --- | +| **编码** | 从真实仓库推进到可合并结果。 | Agentic、Plan、Debug、测试、Git、Deep Review、长程任务、Benchmark。 | +| **办公** | 从资料推进到可交付文档。 | Research、PPT、DOCX、XLSX、PDF、总结、写作、会议纪要、报告。 | -| | Code Agent | Cowork Agent | -| -------- | --------------------------- | ----------------------------------- | -| **提示词** | 面向仓库工作的角色、规范、四种工作模式 | 面向知识工作的角色、文档处理流程 | -| **工具集** | 文件 / 终端 / Git / LSP / 构建与测试 | PDF / DOCX / XLSX / PPTX / Skill 市场 | -| **共用底盘** | 同一套会话、记忆、MCP、远控、UI、模型适配 | 同一套会话、记忆、MCP、远控、UI、模型适配 | +### 通用能力 +- **桌面执行底座**:Computer Use、浏览器操作、桌面应用、文件系统、终端、远程工作区和 Mini App,让 Agent 能进入真实工作环境。 +- **可定制化扩展**:MCP、Skills、Agent 自定义、Mini App 和源码级扩展,让 BitFun 可以按你的工具链、角色和界面继续生长。 -**所以,如果你想做一个"法律审阅 Agent"、"科研文献 Agent"或者"运维应急 Agent"——L1 就够了**: - -1. 写一个 Markdown,定好它的角色 / 禁区 / 工作流程 -2. 从工具注册表里勾上它该用的工具(文件、浏览器、特定 MCP……) -3. 如果缺了一个特定工具 —— 走 **L3**,打开 BitFun 让 Code Agent 帮你加进源码 -4. 如果这个 Agent 需要一个专属界面 —— 走 **L2**,一句话生成一个 Mini App -5. 如果你要做一个完全不一样的产品 —— 走 **L4**,fork 整个仓库,让 Code Agent 陪你改 - -**关键点**:L3 和 L4 都不用你离开 BitFun——**打开 BitFun,对 Code Agent 说你要改什么,它就改给你看**。**你定制它的方式,就是用它本身** - -> 从一个 Markdown 文件到完整 fork,中间没有断点。这正是"会自我迭代的基座"的含义。 +![first_screen_screenshot_CN](./png/first_screen_screenshot_CN.png) --- -## 平台支持 +## 开箱即用 -桌面端基于 Tauri,支持 Windows / macOS / Linux;远程控制支持手机浏览器、Telegram、飞书、微信。 - ---- +### 直接下载 -## 快速开始 +前往 [Releases](https://github.com/GCWing/BitFun/releases) 下载最新桌面端安装包,安装后配置模型即可开始使用。 -### 从源码构建 +### 从源码运行 **前置依赖:** -- [Node.js](https://nodejs.org/)(推荐 LTS 版本) +- [Node.js](https://nodejs.org/)(推荐 LTS) - [pnpm](https://pnpm.io/) - [Rust 工具链](https://rustup.rs/) - [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(桌面端开发需要) @@ -165,46 +118,40 @@ cp target/aarch64-unknow-linux-ohos/release/libbitfun_desktop_lib.so src/apps/oh ## 3. 使用 DevEco Studio 构建完整应用 ``` -更多详情请参阅[贡献指南](./CONTRIBUTING_CN.md)。 +更多开发说明见 [CONTRIBUTING_CN.md](./CONTRIBUTING_CN.md)。 --- -## 项目结构一览 +## 定制你的 BitFun -``` -src/crates/interfaces/ # ACP 等产品协议接口 -src/crates/assembly/ # 兼容门面与产品能力组装 -src/crates/adapters/ # AI、API、transport 与 WebDriver adapter -src/crates/services/ # OS、terminal、MCP、remote、git 与 filesystem service -src/crates/execution/ # Agent、harness、stream、typed-service 与 tool 原语 -src/crates/contracts/ # 稳定 DTO、事件、runtime ports 与产品领域契约 -src/apps/desktop # Tauri 桌面宿主 -src/apps/server # Web 服务端运行时 -src/apps/cli # CLI 运行时 -src/web-ui # 桌面 / Web 共用前端 -``` +BitFun 的扩展路径从轻到重连续展开: + +| 层级 | 方式 | 适合场景 | +| --- | --- | --- | +| **L1** | Agent 自定义 | 定义角色、流程、约束和工具组合。 | +| **L2** | MCP / Skills | 接入外部工具、专业能力和工作流。 | +| **L3** | Mini App | 为任务生成专属界面、表单、面板或可视化。 | +| **L4** | 源码级改造 | 修改工具、适配器、UI、Runtime 或产品形态。 | -架构原则:**产品逻辑保持平台无关,通过适配器对外暴露**。详见 [AGENTS-CN.md](./AGENTS-CN.md)。 +你可以用 BitFun 的 Code Agent 来扩展 BitFun 本身。 --- ## 贡献 -欢迎大家贡献好的创意和代码,我们对 AI 生成代码抱有最大的接纳程度。请将 PR 直接提交至 `main` 分支,我们会在 `main` 上直接评审与合并。 +欢迎 Star、Issue 和 PR。我们尤其关注: -**我们重点关注的贡献方向:** +1. Code Agent、Deep Review、调试和长任务执行能力 +2. Cowork、调研、文档和桌面工作流 +3. MCP、Skills、Mini App、LSP 插件和新领域 Agent +4. Runtime 稳定性、性能、上下文效率和可验证性 -1. **Runtime 内核**:会话模型、工具注册、记忆系统、协议适配 -2. **样板 Agent**:Code / Cowork / 个人助理 的能力与体验 -3. **生态扩展**:Skill、MCP、LSP 插件、Mini App 模板,以及新的垂域 Agent -4. 想法 / 创意(功能、交互、视觉),欢迎提 Issue +请将 PR 直接提交至 `main` 分支。更多说明见 [CONTRIBUTING_CN.md](./CONTRIBUTING_CN.md)。 --- ## 声明 1. 本项目为业余时间探索、研究构建下一代人机协同交互,非商用盈利项目。 -2. 本项目 97%+ 由 Vibe Coding 完成,代码问题也欢迎指正,可通过 AI 进行重构优化。 -3. 本项目依赖和参考了众多开源软件,感谢所有开源作者。**如侵犯您的相关权益请联系我们整改。** - ---- +2. 本项目 97%+ 由 Vibe Coding 完成,代码问题欢迎指正,也欢迎通过 AI 进行重构优化。 +3. 本项目依赖和参考了众多开源软件。感谢所有开源作者。如侵犯您的相关权益,请联系我们整改。 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 2e858576ae..84de76283d 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -30,12 +30,17 @@ Agent Runtime SDK 的发布边界以调用方能力为准,而不是以物理 c 因此,SDK readiness 的最低标准是: -- 公共 façade 只暴露 builder、runner、request/response DTO、event stream、typed error 和 registry API。 +- 公共 facade 只暴露 builder、runner、request/response DTO、event stream、typed error 和 registry API。 - 所有 DTO 可序列化,所有 runtime handle 通过 typed port 注入,不进入 wire contract。 - `bitfun-agent-runtime`、Tool primitives、Runtime Services 和 Harness 能通过 fake provider 独立测试。 - SDK minimal feature 不牵引 Desktop、Tauri、Git provider、MCP client、AI HTTP client、remote SSH 或产品 UI。 - 完整产品能力只能通过 Product Assembly 或兼容 `bitfun-core/product-full` 组装,不反向污染 SDK API。 +SDK 公共 API 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前 API version 为 v1 preview: +小版本更新可以增加可选 builder hook、DTO 字段或 registry 查询能力,但不得改变既有端口语义、 +错误分类、session / turn 标识含义或默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更, +必须提升 API version 并提供兼容迁移路径。 + 只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有 concrete service manager、读取产品命令 registry 或依赖全局 mutable state,SDK 发布边界就不成立。 @@ -276,7 +281,7 @@ Remote ports 的边界: - runtime events。 - post-turn processor。 -公共 façade: +公共 facade: ```rust pub struct AgentRuntimeBuilder { @@ -285,22 +290,33 @@ pub struct AgentRuntimeBuilder { pub struct AgentRunRequest { pub session: SessionSelector, - pub input: AgentInput, - pub cancellation: CancellationToken, + pub message: String, + pub turn_id: Option, + pub source: Option, + pub attachments: Vec, + pub metadata: serde_json::Map, } pub struct AgentRunHandle { - pub session_id: SessionId, - pub turn_id: TurnId, - pub events: AgentEventStream, + pub session_id: String, + pub turn_id: String, + pub agent_type: Option, + pub accepted: bool, + pub events: Option, } impl AgentRuntimeBuilder { + pub fn with_submission_port(self, port: Arc) -> Self; + pub fn with_session_management_port(self, port: Arc) -> Self; + pub fn with_dialog_turn_port(self, port: Arc) -> Self; + pub fn with_lifecycle_delivery_port(self, port: Arc) -> Self; + pub fn with_cancellation_port(self, port: Arc) -> Self; pub fn with_services(self, services: RuntimeServices) -> Self; - pub fn with_tools(self, tools: Arc) -> Self; - pub fn with_harnesses(self, harnesses: Arc) -> Self; - pub fn with_agents(self, agents: Arc) -> Self; - pub fn with_hooks(self, hooks: RuntimeHookRegistry) -> Self; + pub fn with_event_stream(self, events: AgentEventStream) -> Self; + pub fn with_tool_registry(self, registry: Arc) -> Self; + pub fn with_harness_registry(self, registry: Arc) -> Self; + pub fn with_hook_registry(self, hooks: RuntimeHookRegistry) -> Self; + pub fn with_agent_registry(self, agents: Arc) -> Self; pub fn build(self) -> Result; } @@ -309,8 +325,11 @@ impl AgentRuntime { } ``` -该 façade 是目标 API 形态。它必须只接收已组装的 typed parts,不负责创建 +该 facade 是目标 API 形态。它必须只接收已组装的 typed parts,不负责创建 filesystem、terminal、MCP、AI client、Remote provider 或产品命令。 +当前 v1 preview API 以 message / attachment / metadata 作为最小输入形态;若后续需要把 +model-round cancellation token、structured AgentInput 或更复杂的 event cursor 纳入公开 SDK, +必须提升 SDK API version 并保留旧路径兼容。 旧路径兼容约束: @@ -966,7 +985,7 @@ Product 测试: ### 5.4 目标态判定口径 - `bitfun-agent-runtime` 能在不依赖 `bitfun-core` 的情况下构建 runtime kernel。 -- Agent Runtime SDK façade 能通过 fake model provider、fake runtime services、fake tool provider 和 fake +- Agent Runtime SDK facade 能通过 fake model provider、fake runtime services、fake tool provider 和 fake harness provider 完成最小 session / turn / event stream 流程。 - `bitfun-runtime-services` 提供 typed service injection,并由 boundary check 保护。 - `tool-contracts`、`tool-provider-groups` 和 `tool-execution` 分别承担 tool contract、provider group plan 和低层 execution helper;具体 tool 通过 Product Assembly 注册。 diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 59275e75e6..660e258406 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -38,7 +38,7 @@ Tauri handle 或任何产品形态的 concrete manager。在该目标达成前 但不能通过下沉 UI、命令或协议逻辑来换取复用。 - `bitfun-core` 保留兼容 facade 和 `product-full` 组装边界;新 owner crate 不得依赖回 `bitfun-core`。 -- 对外 SDK API 必须是稳定、窄口径、可版本化的 façade,不得把 `bitfun-core`、`product-full`、全量 +- 对外 SDK API 必须是稳定、窄口径、可版本化的 facade,不得把 `bitfun-core`、`product-full`、全量 service bundle 或产品内部 manager 暴露给调用方。 - Hook 是受控扩展点,Event 是事实通知。能改变行为的 hook 必须有顺序、timeout、错误策略和等价保护。 - feature group 是构建边界,CapabilitySet 是产品运行时能力边界;两者必须由 Product Assembly @@ -159,7 +159,7 @@ product commands 都是扩展点,但目前没有统一表达它们分别属于 ### 4.9 SDK 发布边界不足 已有 `bitfun-agent-runtime`、`bitfun-runtime-services`、`tool-contracts`、`tool-execution`、`bitfun-harness` -和 `runtime-ports` 等 SDK 候选原语,但缺少可对外承诺的统一 runtime façade、稳定错误模型、事件流协议、 +和 `runtime-ports` 等 SDK 候选原语,但缺少可对外承诺的统一 runtime facade、稳定错误模型、事件流协议、 provider 注册边界、持久化/恢复契约和最小依赖构建形态。如果外部调用方仍需要直接理解 `bitfun-core`、 `product-full`、concrete service manager 或产品命令路径,说明 SDK 边界尚未完成。 diff --git a/docs/architecture/theme-token-optimization.md b/docs/architecture/theme-token-optimization.md index f85d44891b..8defb48b40 100644 --- a/docs/architecture/theme-token-optimization.md +++ b/docs/architecture/theme-token-optimization.md @@ -1,6 +1,6 @@ # 主题与颜色 Token 优化方案 -> 基线:`gcwing/main` 的 `b5f4f131`,扫描日期为 2026-06-15。 +> 当前基线:`gcwing/main` 的 `8d85e236`,扫描日期为 2026-06-18。 本文档用于梳理 BitFun 前端主题、硬编码颜色、重复 token、近似色冗余、 命名漂移和后续治理方案。目标不是把所有看起来相近的颜色都合并,而是让 @@ -53,63 +53,184 @@ ## 当前现状 -基于最新 `gcwing/main` 的扫描结果,当前颜色系统已经具备一定抽象,但分散 -程度较高,重复和命名漂移明显。 +基于最新 `gcwing/main` 的扫描结果,当前 PR 已把普通 app/component 层的 raw color +literal、token-equivalent app literal 和普通组件 near color pair 收敛到 0。剩余色值 +全部落在明确 owner 的专用域:theme preset/runtime、token contract、boundary fallback、 +Mermaid、Monaco/editor、Prism syntax、terminal ANSI、language identity 和 UI exception +registry。 + +`colorScopes.exception`、`colorDomainScopes.uiException` 和 `colorDomainScopes.boundaryFallback` +的数值上升不是新增游离色,而是把原先散在 service/component 文件中的身份色、review team +角色色、Prism palette、截图兜底色和 Monaco theme palette 归入显式 owner 后的结果。 | 指标 | 当前基线 | | --- | ---: | -| 扫描的前端文件数 | 1711 | -| 包含颜色字面量的文件数 | 297 | -| 颜色字面量出现次数 | 5572 | -| 唯一颜色字面量数量 | 1532 | -| 组件或非 token 文件中的颜色出现次数 | 3735 | -| 包含组件或非 token 颜色的文件数 | 272 | -| `var(--token, fallback-color)` 出现次数 | 2847 | - -债务集中在少数高频 UI 区域: - -| 区域 | 文件 | 非 token 颜色出现次数 | -| --- | --- | ---: | -| Flow Chat 输入区 | `src/web-ui/src/flow_chat/components/ChatInput.scss` | 158 | -| Toolbar mode | `src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.scss` | 94 | -| Code editor | `src/web-ui/src/component-library/components/CodeEditor/CodeEditor.scss` | 86 | -| Profile nursery view | `src/web-ui/src/app/scenes/profile/views/NurseryView.scss` | 78 | -| Generative widget frame | `src/web-ui/src/tools/generative-widget/GenerativeWidgetFrame.tsx` | 78 | -| Select 组件 | `src/web-ui/src/component-library/components/Select/Select.scss` | 70 | -| Code review tool card | `src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss` | 70 | -| Stream text | `src/web-ui/src/component-library/components/StreamText/StreamText.scss` | 66 | -| Snapshot diff viewer | `src/web-ui/src/flow_chat/tool-cards/SnapshotFullscreenDiffViewer.css` | 64 | -| Workspace manager | `src/web-ui/src/tools/workspace/components/WorkspaceManager.css` | 64 | - -重复最多的原始色值主要是应用强调色、状态色、白色半透明叠层和暗色表面叠层: - -| 色值 | 次数 | 推测角色 | +| 扫描的生产前端文件数 | 1526 | +| 忽略的测试文件数 | 213 | +| 包含颜色字面量的文件数 | 26 | +| 颜色字面量出现次数 | 1718 | +| 唯一颜色字面量数量 | 913 | +| 组件或非 token 文件中的颜色出现次数 | 0 | +| 组件或非 token 唯一颜色数量 | 0 | +| App UI 颜色出现次数 | 0 | +| App UI 唯一颜色数量 | 0 | +| `var(--token, fallback)` 出现次数 | 25 | +| fallback 唯一 token 数 | 7 | +| token-equivalent app literal 出现次数 | 0 | +| token-equivalent app literal 唯一颜色数量 | 0 | +| 普通组件肉眼不可区分 near color pair | 0 | +| 普通组件需证据复核的 near color pair | 0 | + +当前审计未发现 CSS 变量契约层面的硬错误: + +| 契约指标 | 当前值 | +| --- | ---: | +| unresolved CSS vars | 0 | +| fallback-only unresolved vars | 0 | +| unregistered dynamic families | 0 | +| stale registered dynamic families | 0 | +| non-contract cross-file vars | 0 | +| non-contract dynamic inputs | 0 | +| non-contract component-private vars | 0 | + +本轮补充了机器可校验的治理契约,用于把“可删除债务”和“必须保留的兼容/边界” +分开: + +| 治理契约指标 | 当前值 | 说明 | | --- | ---: | --- | -| `#60a5fa` | 162 | blue accent / focus / info | -| `rgba(255, 255, 255, 0.08)` | 115 | 暗色主题 subtle overlay | -| `rgba(255, 255, 255, 0.1)` | 112 | 暗色主题 hover/elevated overlay | -| `#f59e0b` | 112 | warning | -| `#ffffff` | 107 | white / inverse text | -| `#ef4444` | 110 | error / danger | -| `#22c55e` | 77 | success | -| `#3b82f6` | 70 | primary / info | -| `rgba(255, 255, 255, 0.05)` | 70 | 暗色主题低强度 overlay | -| `rgba(255, 255, 255, 0.06)` | 68 | 暗色主题低强度 overlay | - -fallback 也已经形成了第二套分散色板。高频 fallback token 如下: +| compatibility alias contracts | 63 | 显式列出历史别名、canonical 目标、owner、保留原因和退场条件 | +| compatibility alias 使用 key | 68 | 包含 `--radius-*`、`--spacing-*` 展开的实际使用 key;这些 key 不能直接删除 | +| compatibility alias 使用次数 | 609 | 当前仍是重要兼容面,后续迁移应逐步降低并同步降低 baseline | +| stale compatibility alias contracts | 0 | 防止 registry 保留已经没有静态/runtime 定义的旧 key | +| compatibility alias family contracts | 2 | `--radius-* -> --size-radius-*`、`--spacing-* -> --size-gap-*` | +| stale compatibility alias family contracts | 0 | 防止动态 family 或 canonical family 失配 | +| missing compatibility alias family canonicals | 0 | 防止新增 `--radius-x` / `--spacing-x` 但缺少对应 canonical key | +| fallback token contracts | 7 | 每个 `var(--token, fallback)` 边界 fallback 都有 owner、reason 和 boundary | +| uncontracted fallback tokens | 0 | 防止新增未解释的 fallback key | +| stale fallback token contracts | 0 | 防止已删除 fallback 继续留在 registry 中 | +| color domain contracts | 13 | 每个专用域都有 owner、reason 和 merge policy | +| active uncontracted color domains | 0 | 防止新增专用域但没有 owner/合并策略 | + +剩余颜色集中在几个专用域;普通 app UI 不再保留 raw color literal: + +| 区域 | 当前出现次数 | 当前唯一色数 | 说明 | +| --- | ---: | ---: | --- | +| Theme presets | 1033 | 611 | 主题个性与 palette 映射,不作为普通 app literal 直接合并 | +| Token contracts | 268 | 159 | `tokens.scss` 等静态契约根 | +| Editor | 56 | 53 | Monaco/editor 专用域,不能直接泛化到 app token;组件装饰色已迁出 raw literal | +| Mermaid | 139 | 95 | Mermaid 专用渲染域 | +| Theme runtime | 54 | 45 | `ThemeService.ts` 运行时注入 | +| Language identity | 52 | 50 | 语言身份色,已集中到 identity registry | +| Terminal | 38 | 30 | terminal/ANSI 专用域 | +| Boundary fallback | 22 | 22 | iframe/miniapp/截图兜底值,不作为普通 app token | +| Visual effects | 0 | 0 | StreamText/TextStroke raw literal 已迁出普通组件层 | +| UI exception registry | 38 | 34 | 已归档的 UI 例外色,包含 review team、agent capability、template context、insights 和 inspector 等固定身份色 | +| Generated widget | 0 | 0 | 颜色默认值已迁到 boundary fallback registry | +| App UI | 0 | 0 | 普通 app/component raw color 已清零;后续新增必须先进入 token/exception 决策 | +| Syntax | 18 | 17 | Prism syntax palette,保留为专用渲染域 | + +剩余高频文件均为专用 palette 或集中 registry: + +| 文件 | 颜色出现次数 | 后续处理策略 | +| --- | ---: | --- | +| `src/web-ui/src/tools/editor/themes/bitfun-dark.theme.ts` | 47 | Monaco theme palette;不拆散到普通 app token | +| `src/web-ui/src/shared/theme/languageIdentityAccents.ts` | 52 | 内置 language/file identity registry;调用方复用常量 | +| `src/web-ui/src/shared/theme/uiExceptionAccents.ts` | 38 | 固定 UI 身份/角色色 registry;新增必须说明 owner/role | +| `src/web-ui/src/tools/terminal/utils/xtermTheme.ts` | 36 | terminal ANSI palette;不与 app semantic color 合并 | +| `src/web-ui/src/shared/theme/themeBoundaryFallbacks.ts` | 22 | isolated surface 和截图兜底值;集中 owner | +| `src/web-ui/src/shared/theme/syntaxHighlightAccents.ts` | 18 | Prism syntax palette;不泛化到 app token | + +当前 fallback token 都已进入 allowlist,但仍需要逐项决策是否保留边界 fallback: | fallback token | 次数 | | --- | ---: | -| `--color-accent-500` | 147 | -| `--color-warning` | 127 | -| `--color-error` | 117 | -| `--color-success` | 109 | -| `--color-text-muted` | 77 | -| `--color-text-primary` | 75 | -| `--color-text-secondary` | 71 | -| `--border-subtle` | 55 | -| `--element-bg-subtle` | 39 | -| `--color-primary` | 41 | +| `--surface-stagger-index` | 12 | +| `--mission-control-group-color` | 6 | +| `--char-index` | 3 | +| `--gallery-grid-min` | 1 | +| `--gallery-skeleton-height` | 1 | +| `--primary-color` | 1 | +| `--scene-viewport-border-width` | 1 | + +fallback 决策表: + +| fallback token | 决策 | 依据 | 后续动作 | +| --- | --- | --- | --- | +| `--surface-stagger-index` | 保留 | 运行时 inline 动画序号,`0` 是安全首帧/无动画默认值 | 不迁移为颜色 token;保持 allowlist | +| `--mission-control-group-color` | 保留 | 分组身份色由数据或 inline style 驱动,静态删除会丢失未设置分组色时的 accent 兜底 | 后续 content-canvas token 抽取时复核是否改为组件根默认值 | +| `--char-index` | 保留 | StreamText 每字符动画偏移,`0` fallback 是无序号渲染的安全默认值 | 不迁移为颜色 token;保持 allowlist | +| `--gallery-grid-min` | 保留 | runtime layout sizing 输入,不属于颜色债务;`320px` 保持 responsive grid 下限 | 保持 allowlist,后续只在 layout token 方案中处理 | +| `--gallery-skeleton-height` | 保留 | runtime skeleton sizing 输入,不属于颜色债务;`140px` 保持占位高度稳定 | 保持 allowlist,后续只在 layout token 方案中处理 | +| `--primary-color` | 延后 | Markdown 嵌入内容可覆盖 primary accent,边界语义不同于全局 app primary | Markdown token 抽取时决定是否转为 `--markdown-primary-color` contract | +| `--scene-viewport-border-width` | 保留 | viewport border width 是 runtime layout override,`1px` fallback 保持默认边界可见 | 保持 allowlist,后续只在 scene layout token 方案中处理 | + +阶段状态: + +| 阶段 | 状态 | 当前判断 | +| --- | --- | --- | +| Phase 0:基线与工具 | 已完成主体 | 审计脚本可区分测试文件、fallback token、dynamic families 和 exception domains | +| Phase 1:canonical token 契约 | 本轮强化 | compatibility alias registry 已记录 63 个显式 alias 和 2 个 alias family,包含 canonical 目标、owner、保留原因和退场条件 | +| Phase 2:精确重复合并 | 本轮完成 | token-equivalent app literal 已从 12/10 清零;截图兜底、language identity 和 review/agent/insights 固定色已迁入显式 registry | +| Phase 3:legacy fallback 迁移 | 本轮强化 | fallback unique token 保持 7,且全部进入 fallback contract registry;新增未登记 fallback 会被审计报告和 baseline 拦截 | +| Phase 4:组件 token 抽取 | 本轮完成 | CodeEditor、StreamText、ChatInputPixelPet、ReferencesPanel、AgentCompanion、tool-card、editor 组件装饰色已抽为组件私有 RGB channel 或复用 contract token | +| Phase 5:近似色合并 | 本轮完成 | 普通组件 near pair 已清零;极近似视觉色只在不相邻或不承担状态差异时合并,Monaco/terminal/Mermaid/syntax 专用 palette 不强行合并 | +| Phase 6:防回退约束 | 本轮强化 | baseline 已同步到 component/non-token=0、appUi=0、token-equivalent=0、nearPair=0,并新增 alias、fallback、domain contract 防回退指标 | + +Phase 5 决策记录: + +| pair | 决策 | 调用点 | 依据 | +| --- | --- | --- | --- | +| `#1f2024` -> `#202024` | merge | `ChatInputPixelPet.scss` panda body/decor;`bitfun-dark.theme.ts` editor subtle border | RGB distance = 1,非状态色,非相邻 surface 边界;panda 固定深色与 editor border 不在同一视觉层级承担区分 | +| `#6e7681` -> `#6e7781` | merge | `LanguageRegistry.ts` Plain Text identity;`prismTheme.ts` light comment | RGB distance = 1,均为 neutral muted 文本/identity 色,不表达状态严重程度或数据差异 | +| app UI / editor alpha raw values | merge to token/color-mix | `ContextMenu.scss`、`TiptapEditor.scss`、`GitDiffEditor.scss`、`AIModelConfig.scss`、`NurseryView.scss`、`AgentCard.scss`、`ImageViewer.scss` | 色相来自现有 accent/success/overlay/text/error contract,透明度仅表达层级;迁移为 token/color-mix 保留层级但移除游离 raw color | +| DiffEditor added/deleted alpha values | component-tokenize | `DiffEditor.scss` | `0.15/0.18/0.20/0.38` 表达统计徽标、行背景、强调行和字符级 diff 的层级差异,不能直接合并;改为 `--diff-editor-*` 组件 token 后保留层级并移除游离 raw rgba | +| `#ff8800` -> `#ff8c00` | merge | `StreamText.scss` rainbow/fire orange | RGB distance = 4,均为 visual-effect 暖橙,非相邻状态色,合并后不影响用户区分 | +| `#ffdd00` -> `#ffd700` | merge | `StreamText.scss` fire yellow;editor/reference yellow | RGB distance = 6,均为亮黄强调色,调用点不相邻,不承担不同业务状态 | +| `#7dd3fc` -> `#7DCFFF` | merge | `GenerativeWidgetToolCard.scss`;`bitfun-dark.theme.ts` editor link | RGB distance = 5,均为非状态 sky/cyan 强调,调用点跨 surface 且不相邻 | +| `#00b4d8` -> `#00add8` | merge | `StreamText.scss` ocean mid;Go language identity | RGB distance = 7,同为 cyan/blue identity/visual-effect 色,非错误/警告/状态强度 | +| `#141414` vs `#121214` | preserve | `LanguageRegistry.ts` reStructuredText identity;Flow Chat capture/editor fallback | RGB distance = 2.83,但 `#141414` 是已存在的 language identity,迁移到 registry 时保持原值;`#121214` 仅作为截图/边界兜底 | +| remaining near pairs | none in ordinary components | 无 | 审计口径下普通组件 near pair 已清零;后续只在专用 palette 自身重设计时处理 Monaco/terminal/Mermaid/syntax 内部近似色 | +| Monaco theme palette | classify as exception | `tools/editor/themes/bitfun-dark.theme.ts` | 该文件是 Monaco theme 完整色板,不是普通 app UI;归入 editor/exception 后不再被误计为 component raw color | +| Flow Chat capture fallback | boundary fallback | `ExportImageButton.tsx`、`captureElementToDownloadsPng.tsx` -> `themeBoundaryFallbacks.ts` | `#121214` 只在 root theme 变量不可用时兜底截图背景,集中 owner 后避免截图工具重复携带 raw fallback | + +Phase 6 首轮约束: + +| 约束 | 当前值 | baseline | 作用 | +| --- | ---: | ---: | --- | +| `nearPairs.indistinguishableTotal` | 0 | 0 | 阻止新增普通组件肉眼不可区分 pair 未被合并或记录 | +| `nearPairs.nearTotal` | 0 | 0 | 阻止新增普通组件 near color 债务;新增必须合并、归类或记录理由 | +| `colorScopes.appUi.uniqueColors` | 0 | 0 | 阻止普通组件 raw color 唯一色回涨 | +| `colorScopes.appUi.occurrences` | 0 | 0 | 阻止普通组件 raw color 出现次数回涨 | +| `tokenAliasLiterals.occurrences` | 0 | 0 | 阻止重新出现可映射到 token 的 app literal | +| `colorDomainScopes.appUi.occurrences` | 0 | 0 | 阻止未归类 app UI 色值回涨 | +| CSS var governance errors | 0 | 0 | 保持 unresolved、fallback-only、non-contract 和 dynamic family 错误为零 | +| `compatibilityAliases.staleRegisteredUnique` | 0 | 0 | 防止兼容 alias registry 保留没有定义或 canonical 目标缺失的 key | +| `compatibilityAliases.staleRegisteredFamilyUnique` | 0 | 0 | 防止 `--radius-*`、`--spacing-*` 这类动态 family 与 canonical family 失配 | +| `compatibilityAliases.missingCanonicalUnique` | 0 | 0 | 防止 family alias 具体 key 缺失对应 canonical key | +| `fallbackContracts.uncontractedUnique` | 0 | 0 | 防止新增未说明边界的 `var(--token, fallback)` | +| `fallbackContracts.staleRegisteredUnique` | 0 | 0 | 防止已删除 fallback 继续留在 registry 中 | +| `colorDomainContracts.activeUncontractedUnique` | 0 | 0 | 防止新增专用颜色域但没有 owner 和 merge policy | + +`nearPairs.*` 只基于非 token、非 exception 的普通组件颜色计算。Theme preset、 +editor、syntax、terminal、language identity、boundary fallback 等专用域通过各自 +`colorDomainScopes.*` 预算约束,不用该 near-pair guard 直接判定是否可合并。 + +视觉证据契约新增在 `scripts/theme-visual-governance-contract.json`,并由 +`pnpm run theme:visual-contract` 校验。它不是截图替代品,而是后续 PR 的覆盖面 +清单:任何影响主题或 UI 色值的变更,都应确认是否触达以下 surface,并按 contract +补充 focused visual review、contrast review、boundary render review 或 mobile build review。 + +| surface | 覆盖形态 | 重点风险 | +| --- | --- | --- | +| app-shell | desktop webview、web、desktop、narrow、dark/light/system | 旧 alias 仍在 shell 邻近组件使用,system theme 不能假设桌面专有行为 | +| flow-chat | desktop webview、web、desktop、narrow、streaming/error/empty | virtualized 和历史 turn 可能隐藏 token 回归 | +| tool-cards-review | tool card、review panel、expanded/collapsed/status | danger alias 保留 destructive 语义,不能和 error 无证据合并 | +| code-editor-diff | Monaco、diff、selection、added/deleted/conflict | editor/diff 色表达相邻状态,不能按数值相似直接合并 | +| terminal | ANSI normal/bright、selection、error | ANSI 语义独立于 app semantic color | +| markdown-mermaid | Markdown、Prism、Mermaid、diagram/error | `--primary-color` 是 embedded override;Mermaid 角色不等于 app status | +| generated-widget | iframe fallback、host payload、loading/error | widget payload 兼容是保留多组旧 alias 的主要原因 | +| theme-settings | theme switcher、system/custom theme preview | custom theme preview 可能比普通组件更早暴露 runtime alias 缺失 | +| mobile-web-shell | mobile-web、mobile/narrow、loading/error/navigation | mobile web 是独立构建目标,不能只依赖 desktop WebView 验证 | ## 现有架构地图 @@ -535,6 +656,9 @@ semantic token 描述产品级语义,应作为共享 UI 的默认使用层。 - 对组件中新 app raw color 的 lint 或 audit 检查。 - 已知 exception file 与 namespace allowlist。 +- compatibility alias、fallback token、color domain 的机器可校验 owner/reason contract。 +- 覆盖 app-shell、Flow Chat、tool card/review、editor/diff、terminal、Mermaid/Markdown、 + generated widget、theme settings 和 mobile web 的视觉证据契约。 - CI 在迁移期只阻止新增问题,不因历史 baseline 直接失败。 验收标准: @@ -542,6 +666,9 @@ semantic token 描述产品级语义,应作为共享 UI 的默认使用层。 - 新增组件级 raw color 必须有明确原因。 - 历史迁移可以按目录增量推进。 - exception 可见、可审查。 +- 兼容 alias 和边界 fallback 可见、可审查,且 stale contract 为 0。 +- CI 至少运行 `theme:color-audit:test`、`theme:color-audit` 和 + `theme:visual-contract`。 ## 风险清单 @@ -636,6 +763,9 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值 实现类 PR: +- `pnpm run theme:color-audit:test` +- `pnpm run theme:color-audit` +- `pnpm run theme:visual-contract` - `pnpm run lint:web` - `pnpm run type-check:web` - `pnpm --dir src/web-ui run test:run` @@ -690,6 +820,8 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值 - 变更是否同时影响 light 和 dark theme。 - 变更是否影响 generated widget、code editor、terminal、Mermaid 或第三方内容。 - 删除 fallback 前,兼容 alias 是否已经存在。 +- 新增或保留的 compatibility alias、fallback token、color domain 是否进入对应 contract。 +- 变更影响的 surface 是否已对照 `theme-visual-governance-contract.json` 确认覆盖形态。 - 高风险 surface 是否有截图或 focused visual check。 - PR 描述是否说明了任何用户可见视觉变化。 @@ -714,11 +846,16 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值 - 范围和影响 surface。 - before/after 指标。 - 用户可见 surface 的截图。 +- 命中的 visual governance surface 和对应证据类型。 - 明确保留的近似色列表。 - 验证命令和结果。 ## 待决问题 +以下问题仍是产品语义决策,不再是未登记游离 key。当前已进入 +`TOKEN_COMPATIBILITY_ALIAS_CONTRACTS` 或 `TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS`, +删除前必须先完成调用点迁移、widget payload 兼容检查和视觉复核。 + - `--color-text-tertiary` 应转正为一等 semantic token,还是迁移到 `--color-text-muted`。 - `--color-primary` 和 `--color-accent-500` 是否是两个角色,还是应统一为 @@ -741,3 +878,4 @@ alpha 差异经常承担 elevation 和交互状态,不应全部压成一个值 - 相邻 surface、交互状态、状态语义和主题个性仍能被用户清楚识别。 - 静态 token、运行时 token、widget payload token 对齐。 - 新增 raw color 必须经过可见 review 决策。 +- 主题治理 CI 覆盖颜色审计、契约测试和视觉证据契约校验。 diff --git a/docs/development/ui-testids-CN.md b/docs/development/ui-testids-CN.md new file mode 100644 index 0000000000..ab6058e286 --- /dev/null +++ b/docs/development/ui-testids-CN.md @@ -0,0 +1,429 @@ +[English](ui-testids.md) | **中文** + +# UI Test IDs + +本文档记录 BitFun UI 自动化使用的稳定 `data-testid` 值。 +测试 ID 按产品区域分组,只应在自动化流程确实需要稳定定位点时添加。 + +规则: + +- `data-testid` 只能作为测试定位器使用。不要让产品逻辑依赖它。 +- 优先标记真实可交互元素:`button`、`input`、可编辑区域或对话框根节点。 +- `data-testid` 必须保持稳定、小写,并使用连字符分隔。 +- 对重复项使用一个共享 `data-testid`,再配合稳定的 `data-*` 属性区分。 +- 不要把可见文案、CSS class、坐标、截图或 XPath 路径作为主定位方式。 +- 优先在配套 `data-*` 属性中使用稳定产品标识,例如 `data-workspace-id`、`data-session-id`、`data-agent-id`、`data-skill-key` 或 `data-settings-tab`。 + +## 覆盖规划 + +### 必须补 + +这些区域是 UI 自动化的高价值入口。在新增或扩展跨平台 pytest 用例前, +应优先提供稳定 ID。 + +| 区域 | 范围 | 原因 | +|---|---|---| +| App shell | App 根节点、主内容区、场景视口 | App 加载和路由就绪锚点。 | +| Navigation | 顶部动作、底部菜单、工作区菜单、工作区行、会话行 | 打开设置、会话、项目、Agents、Skills 和工作区级动作的主路径。 | +| Welcome scene | 场景根节点、打开/新建项目按钮、最近工作区列表 | OH 当前默认启动后会落在这里。 | +| Notifications | 通知按钮、通知中心根节点、关闭按钮、活动区块 | 当前 smoke 覆盖和异步任务可见性。 | +| Settings | 场景根节点、导航 tab、活动内容 | 当前 smoke 覆盖和后续配置测试。 | +| Session and Flow Chat | Session 场景、聊天/辅助面板、消息列表、输入区 | 会话创建稳定后的核心产品路径。 | +| Agents and Skills | 场景根节点、区域/tab、过滤器、卡片、关键动作 | Agent 设置和技能市场流程的高价值入口。 | + +### 可选补 + +这些区域等到有具体测试需要时再补。 + +| 区域 | 范围 | 原因 | +|---|---|---| +| Deep Review / BTW 详情面板 | Review 操作栏、评审成员详情、报告导出动作 | 对深入行为测试有价值,但不是 app smoke 必需。 | +| Tool cards | 特定 approve/retry/open-detail 控件 | 按具体工具流程添加,不要给每个渲染字段都打点。 | +| File、Git、Terminal、Browser 面板 | 面板根节点、主工具栏动作、选中列表行 | 等面板专项 pytest 覆盖出现后再补。 | +| Settings 表单控件 | 具体模型/Provider 字段、保存/重置按钮 | 配置测试需要时添加;避免标记每个展示型 label。 | +| Mini apps | Gallery 根节点、app 卡片、runner 根节点 | 等 Mini App 流程进入自动化计划后再补。 | + +### 不建议补 + +除非有明确自动化流程,否则避免给这些对象添加 ID。 + +| 范围 | 原因 | +|---|---| +| 装饰图标、徽章、计数器、阴影、动画 | 不是有意义的交互或状态锚点。 | +| 每个文本节点、段落和静态 label | 增加维护成本,并且重复绑定 i18n 可见文案。 | +| 生成的 markdown/code 内容和模型输出 span | 输出是动态的,应通过更高层状态断言。 | +| 坐标、canvas 像素、仅截图可见标记或原生窗口控件 | 跨平台 WebView 自动化应保持基于 DOM 和 `data-testid`。 | +| 把本地化文案复制到 `data-testid` 或作为主定位方式 | 文案或语言切换会导致定位失效。 | + +## 命名 + +- 使用区域前缀:`app-*`、`scene-*`、`nav-*`、`welcome-*`、`settings-*`、`notification-*`、`session-*`、`chat-*`、`flowchat-*`、`agents-*`、`skills-*`。 +- 按动作给按钮加后缀:`*-btn`、`*-toggle`、`*-open`、`*-close`、`*-submit`、`*-cancel`、`*-delete`。 +- 按结构给容器加后缀:`*-scene`、`*-panel`、`*-list`、`*-grid`、`*-menu`、`*-content`、`*-zone`。 +- 对重复行/卡片,复用一个 `data-testid` 并搭配稳定属性,例如: + - `nav-workspace-item` + `data-workspace-id` + - `nav-session-item` + `data-session-id` + - `settings-nav-tab` + `data-settings-tab` + - `agent-list-item` + `data-agent-id` / `data-agent-name` + - `skill-list-item` + `data-skill-id` / `data-skill-name` + - `skills-market-card` + `data-skill-install-id` + +## App Shell + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| App 布局根节点 | `app-layout` | App 加载完成锚点。 | +| 主内容区 | `app-main-content` | 主场景内容容器。 | +| 导航面板 | `nav-panel` | 左侧导航容器。 | +| 场景视口根节点 | `scene-viewport` | 场景宿主根节点。 | +| 场景视口裁剪区 | `scene-viewport-clip` | 已挂载场景的裁剪区域。 | +| 空场景视口 | `scene-viewport-empty` | 没有打开 tab 时渲染。 | +| 已挂载场景 wrapper | `scene-viewport-scene` | 重复项。配合 `data-scene-id` 和 `data-scene-active` 使用。 | + +## Welcome + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Welcome 场景根节点 | `welcome-scene` | 默认启动场景锚点。 | +| 打开项目按钮 | `welcome-open-project-btn` | 打开文件/文件夹选择器。 | +| 新建项目按钮 | `welcome-new-project-btn` | 打开新建项目流程。 | +| 最近工作区列表 | `welcome-recent-workspace-list` | 有最近工作区时存在。 | +| 最近工作区行 | `welcome-recent-workspace-row` | 重复项。配合 `data-workspace-id` 使用。 | +| 最近工作区打开按钮 | `welcome-recent-workspace-open` | 重复项。配合 `data-workspace-id` 使用。 | +| 最近工作区移除按钮 | `welcome-recent-workspace-remove` | 重复项。配合 `data-workspace-id` 使用。 | +| 最近工作区空状态 | `welcome-recent-workspace-empty` | 没有最近工作区时存在。 | + +## Navigation + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| 导航搜索触发按钮 | `nav-search-trigger` | 打开导航搜索。 | +| 新建 Code 会话按钮 | `nav-new-code-session-btn` | 为活动项目工作区创建或打开 code 会话。 | +| 新建 Cowork 会话按钮 | `nav-new-cowork-session-btn` | 为活动项目工作区创建或打开 cowork 会话。 | +| Assistant 按钮 | `nav-assistant-btn` | 打开 assistant/persona 场景。 | +| Agent/Skill 入口 | `agent-skill-entry` | 展开 Agents/Skills 导航入口组。 | +| Agent/Skill 面板 | `agent-skill-panel` | Agents/Skills 入口组或当前发现页根节点。 | +| Agent/Skill tabs | `agent-skill-tabs` | Agent 和 Skill 入口 tab 容器。 | +| Agent tab | `agent-tab` | 打开 Agents 发现页。 | +| Skill tab | `skill-tab` | 打开 Skills 发现页。 | +| 导航 sections 容器 | `nav-sections` | 工作区/会话 section 容器。 | +| 导航底部栏 | `nav-bottom-bar` | Mini App/footer 区域容器。 | +| 底部更多按钮 | `nav-footer-more-btn` | 打开底部溢出菜单。 | +| 底部菜单 | `nav-footer-menu` | 由底部更多按钮打开的溢出菜单。 | +| 底部设置菜单项 | `nav-footer-settings-item` | 从底部菜单打开 Settings 场景。 | +| 底部 Shell 按钮 | `shell-panel-entry` | 打开或关闭 shell 场景导航。 | +| 底部 Browser 按钮 | `browser-panel-entry` | 根据当前上下文打开 browser 场景或 browser 面板。 | + +## Navigation Workspaces + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| 工作区添加按钮 | `nav-workspace-add-btn` | 打开工作区添加/最近工作区菜单。 | +| 工作区添加菜单 | `nav-workspace-menu` | 从添加按钮打开的 portal 菜单。 | +| 工作区菜单打开项目 | `nav-workspace-menu-open-project` | 打开项目选择器。 | +| 工作区菜单新建项目 | `nav-workspace-menu-new-project` | 打开新建项目流程。 | +| 工作区菜单远程 SSH | `nav-workspace-menu-remote-ssh` | 打开 SSH 远程连接流程。 | +| 工作区菜单最近工作区 | `nav-workspace-menu-recent-workspace` | 重复项。配合 `data-workspace-id` 使用。 | +| 工作区列表 | `nav-workspace-list` | 按列表类型重复。配合 `data-workspace-list` 使用。 | +| 工作区列表空状态 | `nav-workspace-list-empty` | 配合 `data-workspace-list` 使用。 | +| 工作区拖拽目标 | `nav-workspace-drop-target` | 重复拖拽目标。配合 `data-workspace-id` 使用。 | +| 工作区行 | `nav-workspace-item` | 重复项。配合 `data-workspace-id`、`data-workspace-kind` 和 `data-workspace-active` 使用。 | +| 工作区卡片 | `nav-workspace-card` | 可点击行主体。配合 `data-workspace-id` 使用。 | +| 工作区会话展开按钮 | `nav-workspace-sessions-toggle` | 展开/折叠会话行。配合 `data-workspace-id` 使用。 | +| 工作区名称按钮 | `nav-workspace-name-btn` | 激活工作区或切换会话展开状态。配合 `data-workspace-id` 使用。 | +| 工作区文件按钮 | `nav-workspace-files-btn` | 打开该工作区的文件查看器。配合 `data-workspace-id` 使用。 | +| 工作区搜索索引按钮 | `nav-workspace-search-index-btn` | 存在时打开搜索索引状态弹窗。配合 `data-workspace-id` 使用。 | +| 工作区行菜单按钮 | `nav-workspace-menu-btn` | 打开行操作菜单。配合 `data-workspace-id` 使用。 | +| 工作区行菜单 | `nav-workspace-item-menu` | 单个工作区的 portal 菜单。配合 `data-workspace-id` 使用。 | +| 工作区创建会话 | `nav-workspace-menu-create-session` | Assistant 工作区会话动作。 | +| 工作区创建 Code 会话 | `nav-workspace-menu-create-code-session` | 普通工作区 code 会话动作。 | +| 工作区创建 Cowork 会话 | `nav-workspace-menu-create-cowork-session` | 普通工作区 cowork 会话动作。 | +| 工作区创建 ACP 会话 | `nav-workspace-menu-create-acp-session` | 重复项。配合 `data-acp-client-id` 使用。 | +| 工作区创建 Init 会话 | `nav-workspace-menu-create-init-session` | 启动 AGENTS.md/init 会话。 | +| 工作区相关路径 | `nav-workspace-menu-related-paths` | 打开相关路径对话框。 | +| 工作区新建 worktree | `nav-workspace-menu-new-worktree` | 打开 worktree 创建对话框。 | +| 工作区删除 worktree | `nav-workspace-menu-delete-worktree` | 删除关联 worktree 工作区。 | +| 工作区复制路径 | `nav-workspace-menu-copy-path` | 复制工作区路径。 | +| 工作区 reveal | `nav-workspace-menu-reveal` | 在文件管理器中显示工作区。 | +| 工作区关闭 | `nav-workspace-menu-close` | 关闭工作区。 | +| 工作区重置 assistant | `nav-workspace-menu-reset-assistant` | 重置默认 assistant 工作区。 | +| 工作区删除 assistant | `nav-workspace-menu-delete-assistant` | 删除具名 assistant 工作区。 | +| 工作区会话区域 | `nav-workspace-session-region` | 包含单个工作区的会话。配合 `data-workspace-id` 使用。 | + +## Navigation Sessions + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| 会话列表 | `nav-session-list` | 工作区维度的列表。配合 `data-workspace-id` 使用。 | +| 会话行 | `nav-session-item` | 重复项。配合 `data-session-id`、`data-session-kind`、`data-session-level` 和 `data-session-active` 使用。 | +| 会话菜单按钮 | `nav-session-menu-btn` | 打开行操作菜单。配合 `data-session-id` 使用。 | +| 会话菜单 | `nav-session-menu` | 单个会话的 portal 菜单。配合 `data-session-id` 使用。 | +| 会话重命名项 | `nav-session-menu-rename` | 开始重命名会话。 | +| 会话删除项 | `nav-session-menu-delete` | 删除会话。 | +| 会话列表展开按钮 | `nav-session-list-toggle` | 展开/折叠长会话列表。 | + +## Session And Chat + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Session 场景根节点 | `session-scene` | Session 场景锚点。 | +| Session 聊天面板 | `session-chat-pane` | Session 场景中的左侧聊天面板。 | +| Session 右侧面板 resizer | `session-right-pane-resizer` | 聊天和辅助面板之间的分隔条。 | +| Session 辅助面板 | `session-aux-pane` | 右侧 content canvas 面板。包含 `data-mode`。 | +| Chat pane 根节点 | `chat-pane` | FlowChat 宿主面板。 | +| FlowChat 容器 | `flowchat-container` | FlowChat 根节点。包含 `data-session-id`。 | +| FlowChat 消息区域 | `flowchat-messages` | 消息列表/welcome panel 宿主。 | +| FlowChat 消息列表 | `flowchat-message-list` | 有消息时的虚拟消息列表根节点。 | +| FlowChat 空消息列表 | `flowchat-message-list-empty` | 空虚拟列表状态。 | +| FlowChat 消息项 | `flowchat-message-item` | 重复的虚拟消息项。配合 `data-turn-id`、`data-item-type` 和 `data-item-index` 使用。 | +| Chat 输入容器 | `chat-input-container` | composer 根容器。 | +| Chat 输入可编辑区域 | `chat-input-textarea` | 富文本可编辑区域。 | +| Chat 发送按钮 | `chat-input-send-btn` | 输入有效时的发送动作。 | +| Chat 取消按钮 | `chat-input-cancel-btn` | 存在时用于取消进行中的发送/生成。 | +| Chat 输入工作区条 | `chat-input-workspace-strip` | composer 上方的活动工作区条。 | +| Chat 输入目标切换器 | `chat-input-target-switcher` | 目标/模式切换器。 | +| Chat 输入图片条 | `chat-input-image-strip` | 已附加图片条。 | +| Chat 输入启动 BTW 按钮 | `chat-input-boost-start-btw` | 存在时启动 BTW 流程。 | +| Pending queue 面板 | `pending-queue-panel` | 待处理后台任务队列。 | + +| Chat 模型选择按钮 | `chat-model-selector-btn` | 打开当前会话的模型选择器。 | +| Chat 模型选择菜单 | `chat-model-selector-menu` | 模型选择下拉菜单根节点。 | +| Chat 模型选择项 | `chat-model-selector-option` | 重复项。配合 `data-model-id`、`data-model-name` 和 `data-selected` 使用。 | +| Chat 用户消息 | `chat-user-message` | 重复的用户消息。配合 `data-turn-id`、`data-status` 和 `data-failed` 使用。 | +| Chat 用户消息内容 | `chat-user-message-content` | 用户消息文本内容。配合 `data-turn-id` 使用。 | +| Chat assistant 消息 | `chat-assistant-message` | 重复的模型轮次容器。配合 `data-turn-id`、`data-round-id`、`data-status`、`data-model-id`、`data-model-alias` 和 `data-streaming` 使用。 | +| Chat assistant 消息内容 | `chat-assistant-message-content` | assistant 文本块。配合 `data-turn-id`、`data-flow-item-id`、`data-status` 和 `data-streaming` 使用。 | +| Chat explore group | `chat-explore-group` | ExploreGroup 根节点,用于包裹折叠/合并后的工具轮次。包含 `data-group-kind`、`data-expanded`、`data-read-count`、`data-search-count` 和 `data-command-count`。 | +| Chat explore group toggle | `chat-explore-group-toggle` | ExploreGroup 真实展开/收起点击目标。包含 `data-group-kind` 和 `data-expanded`。 | +| Chat explore group content | `chat-explore-group-content` | ExploreGroup 内层内容容器。包含 `data-group-kind` 和 `data-expanded`。 | +| Chat thinking 面板 | `chat-thinking-panel` | thinking/reasoning 面板根节点。包含 `data-status`、`data-streaming` 和 `data-expanded`。 | +| Chat thinking 展开按钮 | `chat-thinking-toggle` | 可点击的 thinking 展开/收起 header。 | +| Chat thinking 内容 | `chat-thinking-content` | thinking/reasoning 文本内容。包含 `data-status` 和 `data-streaming`。 | +| Chat shell 命令卡片 | `chat-shell-command-card` | Shell 命令工具卡根节点。包含 `data-status`、`data-expanded` 和 `data-terminal-session-id`。 | +| Chat shell 命令展开按钮 | `chat-shell-command-toggle` | Shell 命令卡片的展开/收起点击目标。 | +| Chat shell 命令文本 | `chat-shell-command-text` | Shell 命令文本节点。 | +| Chat shell 命令输出 | `chat-shell-command-output` | Shell 命令 stdout/stderr 或实时输出区域。 | +| Chat shell 命令退出码 | `chat-shell-command-exit-code` | 退出码节点。包含 `data-exit-code` 和 `data-status`。 | +| Chat shell 工具卡片 | `chat-shell-tool-card` | Bash 的外层 FlowToolCard wrapper。包含 `data-tool-name` 和 `data-tool-card-id`。 | +| Chat shell 工具打开面板按钮 | `chat-shell-tool-open-panel` | 存在 terminal session 时,从 Bash ToolCard 打开关联终端面板。 | +| Chat browser 工具卡片 | `chat-browser-tool-card` | WebFetch 的外层 FlowToolCard wrapper。包含 `data-tool-name` 和 `data-tool-card-id`。 | +| Chat 文件变更卡片 | `chat-file-change-card` | 文件操作卡片根节点。包含 `data-status`、`data-action`、`data-path` 和 `data-expanded`。 | +| Chat 文件变更展开按钮 | `chat-file-change-toggle` | 文件操作卡片的展开/收起点击目标。 | +| Chat 文件变更路径 | `chat-file-change-path` | 文件路径/名称节点。包含 `data-path`。 | +| Chat 文件变更动作 | `chat-file-change-action` | 文件操作动作节点。包含 `data-action`。 | +| Chat 文件变更预览 | `chat-file-change-preview` | 文件操作卡片的代码/diff 预览区域。 | +| Chat MiniApp 卡片 | `chat-miniapp-card` | MiniApp 结果卡片根节点。包含 `data-status`、`data-app-id` 和 `data-expanded`。 | +| Chat MiniApp 标题 | `chat-miniapp-title` | MiniApp 标题/名称节点。包含 `data-app-id`。 | +| Chat MiniApp 文件列表 | `chat-miniapp-file-list` | MiniApp 结果文件列表容器。 | +| Chat MiniApp 文件行 | `chat-miniapp-file-row` | MiniApp 结果文件行。包含 `data-path`。 | +| Chat MiniApp 打开按钮 | `chat-miniapp-open-btn` | 打开 MiniApp 场景。包含 `data-app-id`。 | + +## Settings + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Settings 场景根节点 | `settings-scene` | Settings 场景的根内容区。包含 `data-settings-tab`。 | +| Settings 场景内容 | `settings-scene-content` | 当前活动 settings tab 的内容 wrapper。 | +| Settings 导航根节点 | `settings-nav` | 左侧 settings 导航。 | +| Settings 导航 tab | `settings-nav-tab` | 重复项。配合 `data-settings-tab` 使用。 | + +## Settings Models + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| 模型列表 | `settings-model-list` | 已配置模型行的容器。 | +| 创建第一个模型配置按钮 | `settings-model-create-first-config-btn` | 从空状态启动第一个模型提供商配置流程。 | +| 自定义模型配置按钮 | `settings-model-custom-config-btn` | 启动自定义提供商配置。包含 `data-provider-id="custom"`。 | +| 模型提供商选项 | `settings-model-provider-option` | 重复的提供商卡片。配合 `data-provider-id` 使用,例如 `openbitfun`。 | +| 模型提供商名称输入框 | `settings-model-provider-name-input` | 提供商/配置展示名称字段,例如 mock LLM 提供商名称。 | +| 模型 API key 输入框 | `settings-model-api-key-input` | 模型配置表单里的 API key 字段。测试中不要硬编码真实 key,应从 local config 读取。 | +| 模型 Base URL 输入框 | `settings-model-base-url-input` | 自定义/OpenAI-compatible 提供商的 API base URL 字段。 | +| 模型请求格式选择器 | `settings-model-request-format-select` | 请求格式选择器,例如 OpenAI-compatible 或 Anthropic。 | +| 模型选择按钮 | `settings-model-select-btn` | 打开模型选择下拉框。 | +| 模型选择菜单 | `settings-model-select-menu` | 模型选择下拉框根节点。 | +| 模型选择项 | `settings-model-option` | 重复的下拉项。配合 `data-model-id`、`data-model-name` 和 `data-selected` 使用。 | +| 手动模型名称输入框 | `settings-model-manual-name-input` | 手动/自定义模型名称输入字段。 | +| 添加自定义模型按钮 | `settings-model-add-custom-btn` | 将手动模型名称加入已选模型列表。 | +| 已选模型列表 | `settings-model-selected-list` | 已选模型草稿列表。包含 `data-selected-count`。 | +| 已选模型空状态 | `settings-model-selected-list-empty` | 已选模型草稿为空时的状态。包含 `data-selected-count="0"`。 | +| 已选模型行 | `settings-model-selected-row` | 重复的已选模型草稿。配合 `data-model-id`、`data-model-name`、`data-selected` 和 `data-expanded` 使用。 | +| 已选模型移除按钮 | `settings-model-selected-remove-btn` | 移除已选模型草稿。配合 `data-model-id` 和 `data-model-name` 使用。 | +| 模型保存按钮 | `settings-model-save-btn` | 保存模型提供商/模型配置表单。 | +| 模型行 | `settings-model-row` | 重复的已保存模型行。配合 `data-model-id`、`data-model-name` 和 `data-config-id` 使用。 | +| 模型测试状态 | `settings-model-test-status` | 重复的已保存模型测试状态。配合 `data-model-id`、`data-model-name`、`data-config-id` 和 `data-status` 使用,`data-status` 可为 `success` 或 `error`。 | + +## Settings Appearance + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Appearance 页面根节点 | `appearance-config` | Settings 场景中 Appearance 页面内容根节点。 | +| Appearance 主题区域 | `appearance-theme-section` | 语言和主题配置区域根节点。 | +| Appearance 字体区域 | `appearance-font-section` | 字体偏好配置区域根节点。 | +| Appearance 语言选择器 | `appearance-language-select` | Appearance 中 language Select 的真实触发节点。 | +| Appearance 语言选项 | `appearance-language-option` | 重复的语言下拉选项。包含 `data-locale-id`,并带有 Select 组件提供的 `data-selected`。 | +| Appearance 主题选择器 | `appearance-theme-select` | Appearance 中 theme Select 的真实触发节点。 | +| Appearance 主题选项 | `appearance-theme-option` | 重复的主题下拉选项。包含 `data-theme-id`,并带有 Select 组件提供的 `data-selected`。 | +| Appearance UI 字号分组 | `appearance-ui-font-level-group` | UI font size 预置级别按钮组根节点。 | +| Appearance UI 字号按钮 | `appearance-ui-font-level-btn` | 重复的 UI font size 预置级别按钮。包含 `data-font-level` 和 `data-selected`。 | +| Appearance UI 自定义字号控制区 | `appearance-ui-font-custom-controls` | custom UI 字号控制区根节点,仅在 custom 激活时渲染。 | +| Appearance UI 自定义字号输入框 | `appearance-ui-font-custom-input` | custom UI 字号 px 输入框。包含 `data-font-level="custom"`。 | +| Appearance UI 自定义字号减一按钮 | `appearance-ui-font-custom-step-minus` | custom UI 字号减一按钮。 | +| Appearance UI 自定义字号加一按钮 | `appearance-ui-font-custom-step-plus` | custom UI 字号加一按钮。 | +| Appearance UI 字号预览区 | `appearance-ui-font-preview` | UI 字号预览区域。 | +| Appearance Flow Chat 字号开关 | `appearance-flowchat-font-toggle` | Flow Chat 独立字号开关的真实 input 节点。 | +| Appearance Flow Chat 字号选择器 | `appearance-flowchat-font-select` | Flow Chat 字号 Select 的真实触发节点。 | +| Appearance Flow Chat 字号选项 | `appearance-flowchat-font-option` | 重复的 Flow Chat 字号下拉选项。包含 `data-font-px`,并带有 Select 组件提供的 `data-selected`。 | +| Appearance 字体重置按钮 | `appearance-font-reset-btn` | 重置字体偏好到默认值。 | + +## Shell Panel + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Shell 面板入口 | `shell-panel-entry` | 打开 Shell 场景/导航的底部入口。 | +| Shell 面板 | `shell-panel` | Shell 场景、Shell 导航或 Terminal 场景根节点。 | +| Shell 面板标题 | `shell-panel-title` | Shell 导航标题或当前终端 toolbar 标题。 | +| Shell 命令列表 | `shell-command-list` | Shell 导航终端列表或当前终端容器。 | +| Shell 命令项 | `shell-command-item` | Shell 导航行或当前 xterm 根节点。包含 `data-command-id`,可用时包含 `data-command-status`。 | +| Shell 命令文本 | `shell-command-text` | Shell 导航中的终端/session 标签。 | +| Shell 命令输出 | `shell-command-output` | 当前终端的真实 xterm 输出容器。 | +| Shell 命令退出码 | `shell-command-exit-code` | session 退出后终端状态栏中的退出码。包含 `data-exit-code` 和 `data-status`。 | +| Shell 命令状态 | `shell-command-status` | Shell 导航状态点、终端加载/错误状态或终端状态栏。包含 `data-command-status`。 | +| Shell 命令重新运行 | `shell-command-rerun` | 终端错误状态下的重试按钮,或活动终端 toolbar 上的 Ctrl+C 动作。 | +| Shell 面板关闭 | `shell-panel-close` | 当前终端关闭按钮。 | + +说明: + +- 独立 xterm 终端没有结构化的逐命令历史 DOM。测试应使用 `shell-command-output` 断言终端渲染输出,使用 `chat-shell-command-*` 断言结构化 Bash ToolCard。 +- `shell-command-copy` 当前未暴露,因为活动终端复制能力基于选择/右键上下文菜单,并不是稳定可见按钮。 + +## Browser Panel + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Browser 面板入口 | `browser-panel-entry` | 根据当前上下文打开 Browser 场景或 Browser 面板的底部入口。 | +| Browser 面板 | `browser-panel` | Browser 场景或右侧 Browser 面板根节点。 | +| Browser 面板标题 | `browser-panel-title` | Browser toolbar/form 区域。 | +| Browser URL 输入框 | `browser-url-input` | 真实 URL 输入框。按 Enter 打开输入的 URL。 | +| Browser 页面容器 | `browser-page-frame` | iframe/webview host 内容区域。 | +| Browser 加载状态 | `browser-loading-indicator` | URL 加载中时的刷新/加载图标。 | +| Browser 错误信息 | `browser-error-message` | URL 校验、连通性或 webview 加载失败信息。 | +| Browser 当前 URL | `browser-current-url` | webview placeholder 中展示的当前 URL。 | +| Browser 刷新按钮 | `browser-refresh-button` | 刷新当前 Browser 页面。 | +| Browser 后退按钮 | `browser-back-button` | Browser 历史后退。 | +| Browser 前进按钮 | `browser-forward-button` | Browser 历史前进。 | + +说明: + +- `browser-open-button` 当前未暴露,因为 URL 导航通过现有地址栏表单按 Enter 提交;当前没有独立可见的打开按钮。 +- `browser-panel-close` 属于外层 scene/canvas tab chrome,不在 Browser 组件自身内部。 + +## Notifications + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| 通知按钮 | `notification-button` | 打开或切换通知中心。 | +| 通知中心对话框 | `notification-center` | 通知中心弹窗根节点。 | +| 通知中心关闭按钮 | `notification-center-close-btn` | 关闭通知中心。 | +| 通知中心活动区块 | `notification-center-active-section` | 仅在存在活动任务通知时出现。 | + +## Flow Chat Header + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| 后台 subagents 按钮 | `flowchat-header-background-subagents` | 打开后台 subagent 活动状态。 | +| Pull requests 按钮 | `flowchat-header-pull-requests` | 打开 pull request 相关 UI。 | +| Turn 列表 | `flowchat-header-turn-list` | Turn 导航列表。 | +| 上一个 turn 按钮 | `flowchat-header-turn-prev` | 切换到上一个可见 turn。 | +| 下一个 turn 按钮 | `flowchat-header-turn-next` | 切换到下一个可见 turn。 | + +## Agents + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Agent/Skill 面板 | `agent-skill-panel` | Agents 发现页激活时的场景根节点。 | +| Agent 列表 | `agent-list` | 所有 agent 区域和卡片的容器。 | +| Agent 列表项 | `agent-list-item` | 重复卡片。包含 `data-agent-id`、`data-agent-name` 和 `data-agent-kind`。 | +| Agent 列表项标题 | `agent-list-item-title` | Agent 卡片标题。 | +| Agent 列表项描述 | `agent-list-item-description` | Agent 卡片描述。 | +| Agent 列表空状态 | `agent-list-empty` | Agent 列表区块为空时的状态。 | +| Agent 详情面板 | `agent-detail-panel` | Agent 详情弹窗根节点。 | +| Agent 详情标题 | `agent-detail-title` | Agent 详情弹窗标题。 | +| Agent 详情描述 | `agent-detail-description` | Agent 详情描述。 | +| Agent 详情工具区域 | `agent-detail-tools-section` | Agent 能力/工具区域。 | +| Agent 详情工具项 | `agent-detail-tool-item` | 重复的已启用工具项。包含 `data-tool-name`。 | +| Agent 详情关闭按钮 | `agent-detail-close` | 详情弹窗关闭按钮。 | +| Core 锚点按钮 | `agents-anchor-core` | 滚动到 core agents 区域。 | +| Teams 锚点按钮 | `agents-anchor-teams` | 滚动到 teams 区域。 | +| Custom agents 锚点按钮 | `agents-anchor-custom` | 滚动到 custom agents 区域。 | +| Agents 搜索按钮 | `agents-search-btn` | 搜索后缀按钮。 | +| Core agents 区域 | `agents-core-zone` | Core agents section。 | +| Teams 区域 | `agents-teams-zone` | Agent teams section。 | +| Custom agents 区域 | `agents-custom-zone` | Custom/subagent section。 | +| Review team 配置按钮 | `agents-review-team-configure-btn` | 打开 review team 配置。 | +| Agent source 过滤器 | `agents-source-filter` | 重复项。配合 `data-agent-source` 使用。 | +| Agent kind 过滤器 | `agents-kind-filter` | 重复项。配合 `data-agent-kind` 使用。 | +| 创建 agent 按钮 | `agents-create-agent-btn` | 打开 custom agent 创建页。 | +| Agent team 卡片 | `agents-team-card` | 重复项。配合 `data-team-id` 使用。 | +| BTW 停止 review 按钮 | `btw-session-panel-stop-review` | 从 BTW 面板停止 review session。 | +| BTW origin 按钮 | `btw-session-panel-origin-button` | 从 BTW 面板打开 origin session。 | + +## Skills + +| 元素名称 | data-testid | 说明 | +|---|---|---| +| Agent/Skill 面板 | `agent-skill-panel` | Skills 发现页激活时的场景根节点。 | +| Skill 列表 | `skill-list` | 默认安装技能列表网格,也用于 marketplace 搜索结果。 | +| Skill 列表项 | `skill-list-item` | 重复的已安装 skill 卡片。包含 `data-skill-id`、`data-skill-name`、`data-skill-key`、`data-skill-level` 和 `data-skill-builtin`。 | +| Skill 列表项标题 | `skill-list-item-title` | Skill 卡片标题。 | +| Skill 列表项描述 | `skill-list-item-description` | 存在时为 Skill 卡片描述。 | +| Skill 列表空状态 | `skill-list-empty` | 已安装或 marketplace skill 列表为空时的状态。 | +| Skill 详情面板 | `skill-detail-panel` | Skill 详情弹窗根节点。 | +| Skill 详情标题 | `skill-detail-title` | Skill 详情弹窗标题。 | +| Skill 详情描述 | `skill-detail-description` | Skill 详情描述。 | +| Skill 详情能力区域 | `skill-detail-capabilities-section` | 已安装或 marketplace skill 的详情元数据/能力说明区域。 | +| Skill 详情关闭按钮 | `skill-detail-close` | 详情弹窗关闭按钮。 | +| Skills tabs 根节点 | `skills-tabs` | Installed/discover tabs 容器。 | +| Installed tab | `skills-tab-installed` | 包含 `data-skills-tab-active`。 | +| Discover tab | `skills-tab-discover` | 包含 `data-skills-tab-active`。 | +| Installed 面板 | `skills-installed-panel` | 已安装 skills 视图根节点。 | +| Installed 侧边栏 | `skills-installed-sidebar` | 已安装 category 侧边栏。 | +| Installed category | `skills-installed-category` | 重复项。配合 `data-skill-category` 使用。 | +| Installed 内容区 | `skills-installed-content` | 已安装 skills 主内容。 | +| Installed 搜索 | `skills-installed-search` | 已安装 skills 搜索根节点。 | +| 隐藏重复项按钮 | `skills-hide-duplicates-btn` | 包含 `data-active`。 | +| 添加本地 skill 按钮 | `skills-add-local-btn` | 打开添加 skill 表单。 | +| Installed 加载状态 | `skills-installed-loading` | 加载骨架屏容器。 | +| Installed 错误状态 | `skills-installed-error` | 错误状态容器。 | +| Installed 空状态 | `skills-installed-empty` | 空状态容器。 | +| Installed grid | `skills-installed-grid` | 已安装 skill 卡片网格。 | +| Installed skill 卡片 | `skills-installed-card` | 重复项。配合 `data-skill-key`、`data-skill-level` 和 `data-skill-builtin` 使用。 | +| Installed 卡片路径按钮 | `skills-installed-card-path` | 重复项。配合 `data-skill-key` 使用。 | +| Installed 卡片删除按钮 | `skills-installed-card-delete` | 重复项。配合 `data-skill-key` 使用。 | +| Installed 分页 | `skills-installed-pagination` | 已安装列表分页根节点。 | +| Installed 上一页 | `skills-installed-page-prev` | 上一页按钮。 | +| Installed 下一页 | `skills-installed-page-next` | 下一页按钮。 | +| Discover 面板 | `skills-discover-panel` | Marketplace 视图根节点。 | +| Discover 搜索 | `skills-discover-search` | Marketplace 搜索根节点。 | +| Discover 内容区 | `skills-discover-content` | Marketplace 内容区域。 | +| Discover 加载状态 | `skills-discover-loading` | 初始加载骨架屏容器。 | +| Discover 分页加载状态 | `skills-discover-page-loading` | 翻页时的加载状态。 | +| Discover 错误状态 | `skills-discover-error` | 错误状态容器。 | +| Discover 空状态 | `skills-discover-empty` | 空状态容器。 | +| Discover grid | `skills-discover-grid` | Marketplace 卡片网格。 | +| Market skill 卡片 | `skills-market-card` | 重复项。配合 `data-skill-install-id` 和 `data-skill-installed` 使用。 | +| Skill 卡片动作 | `skills-card-action` | 重复卡片动作。配合 `data-skill-action` 使用。 | +| Discover 分页 | `skills-discover-pagination` | Marketplace 分页根节点。 | +| Discover 上一页 | `skills-discover-page-prev` | 上一页按钮。 | +| Discover 下一页 | `skills-discover-page-next` | 下一页按钮。 | +| 详情删除按钮 | `skills-detail-delete-btn` | 删除选中的已安装 skill。 | +| 详情已安装按钮 | `skills-detail-installed-btn` | Marketplace 详情中的 disabled installed 标记。 | +| 详情项目级下载按钮 | `skills-detail-download-project-btn` | 将 market skill 下载到 project scope。 | +| 详情用户级下载按钮 | `skills-detail-download-user-btn` | 将 market skill 下载到 user scope。 | +| 详情路径按钮 | `skills-detail-path-btn` | 显示已安装 skill 路径。 | +| 详情外部链接 | `skills-detail-external-link` | 打开 marketplace 链接。 | +| 添加表单 | `skills-add-form` | 添加本地 skill 弹窗内容。 | +| 添加路径输入框 | `skills-add-path-input` | 本地 skill 路径输入框。 | +| 添加浏览按钮 | `skills-add-browse-btn` | 打开路径选择器。 | +| 添加校验结果 | `skills-add-validation` | 包含 `data-validation-valid`。 | +| 添加取消按钮 | `skills-add-cancel-btn` | 关闭添加表单。 | +| 添加提交按钮 | `skills-add-submit-btn` | 添加已校验的本地 skill。 | diff --git a/docs/development/ui-testids.md b/docs/development/ui-testids.md new file mode 100644 index 0000000000..5adc253b02 --- /dev/null +++ b/docs/development/ui-testids.md @@ -0,0 +1,429 @@ +[中文](ui-testids-CN.md) | **English** + +# UI Test IDs + +This document records stable `data-testid` values used by BitFun UI automation. +Test IDs are grouped by product area and should be added only when an automated +workflow needs a stable locator. + +Rules: + +- Use `data-testid` only as a test locator. Do not branch product logic on it. +- Prefer the real interactive element: `button`, `input`, editable region, or dialog root. +- Keep `data-testid` values stable, lowercase, and hyphen-separated. +- For repeated items, use one shared `data-testid` plus a stable data attribute. +- Do not use visible text, CSS classes, coordinates, screenshots, or XPath paths as primary locators. +- Prefer stable product identifiers in companion `data-*` attributes, such as `data-workspace-id`, `data-session-id`, `data-agent-id`, `data-skill-key`, or `data-settings-tab`. + +## Coverage Planning + +### Must Add + +These areas are high-value UI automation entry points and should have stable IDs +before adding or expanding cross-platform pytest cases. + +| Area | Scope | Rationale | +|---|---|---| +| App shell | App root, main content, scene viewport | App load and routing readiness anchors. | +| Navigation | Top actions, footer menu, workspace menu, workspace rows, session rows | Main path for opening settings, sessions, projects, agents, skills, and workspace-scoped actions. | +| Welcome scene | Scene root, open/new project buttons, recent workspace list | Default startup path currently lands here on OH. | +| Notifications | Notification button, center root, close button, active section | Current smoke coverage and async task visibility. | +| Settings | Scene root, nav tabs, active content | Current smoke coverage and future configuration tests. | +| Session and Flow Chat | Session scene, chat/aux panes, message list, composer | Main product workflow once session creation is stable. | +| Agents and Skills | Scene roots, zones/tabs, filters, cards, key actions | High-value navigation and marketplace/agent setup flows. | + +### Optional Add + +Add these when a concrete test needs them. + +| Area | Scope | Rationale | +|---|---|---| +| Deep Review / BTW detail panels | Review action bars, reviewer/member details, report export actions | Valuable for deeper behavior tests, but not needed for app smoke. | +| Tool cards | Specific approve/retry/open-detail controls | Add per tool workflow instead of tagging every rendered result field. | +| File, Git, Terminal, Browser panels | Panel roots, primary toolbar actions, selected list rows | Useful once panel-specific pytest coverage exists. | +| Settings form controls | Specific model/provider fields and save/reset buttons | Add with configuration tests; avoid tagging every display-only label. | +| Mini apps | Gallery root, app cards, runner root | Add when Mini App flows enter the automation plan. | + +### Not Recommended + +Avoid adding IDs to these surfaces unless there is a clear automated workflow. + +| Scope | Reason | +|---|---| +| Decorative icons, badges, counters, shadows, animations | Not meaningful interaction or state anchors. | +| Every text node, paragraph, and static label | Creates maintenance cost and duplicates i18n-visible content. | +| Generated markdown/code content and model output spans | Output is dynamic and should be asserted through higher-level state. | +| Coordinates, canvas pixels, screenshot-only markers, or native window controls | Cross-platform WebView automation should stay DOM and `data-testid` based. | +| Localized text copied into `data-testid` or required as the primary locator | Breaks when locales or copy change. | + +## Naming + +- Use area prefixes: `app-*`, `scene-*`, `nav-*`, `welcome-*`, `settings-*`, `notification-*`, `session-*`, `chat-*`, `flowchat-*`, `agents-*`, `skills-*`. +- Use action suffixes for buttons: `*-btn`, `*-toggle`, `*-open`, `*-close`, `*-submit`, `*-cancel`, `*-delete`. +- Use structure suffixes for containers: `*-scene`, `*-panel`, `*-list`, `*-grid`, `*-menu`, `*-content`, `*-zone`. +- For repeated rows/cards, reuse one `data-testid` and pair it with a stable attribute, for example: + - `nav-workspace-item` + `data-workspace-id` + - `nav-session-item` + `data-session-id` + - `settings-nav-tab` + `data-settings-tab` + - `agent-list-item` + `data-agent-id` / `data-agent-name` + - `skill-list-item` + `data-skill-id` / `data-skill-name` + - `skills-market-card` + `data-skill-install-id` + +## App Shell + +| Element name | data-testid | Notes | +|---|---|---| +| App layout root | `app-layout` | App load-ready anchor. | +| Main content area | `app-main-content` | Primary scene content container. | +| Navigation panel | `nav-panel` | Left navigation container. | +| Scene viewport root | `scene-viewport` | Scene host root. | +| Scene viewport clip | `scene-viewport-clip` | Mounted scene clip area. | +| Empty scene viewport | `scene-viewport-empty` | Rendered when no tabs are open. | +| Mounted scene wrapper | `scene-viewport-scene` | Repeated item. Pair with `data-scene-id` and `data-scene-active`. | + +## Welcome + +| Element name | data-testid | Notes | +|---|---|---| +| Welcome scene root | `welcome-scene` | Default startup scene anchor. | +| Open project button | `welcome-open-project-btn` | Opens the file/folder picker. | +| New project button | `welcome-new-project-btn` | Opens the new project flow. | +| Recent workspace list | `welcome-recent-workspace-list` | Present when recent workspaces exist. | +| Recent workspace row | `welcome-recent-workspace-row` | Repeated item. Pair with `data-workspace-id`. | +| Recent workspace open button | `welcome-recent-workspace-open` | Repeated item. Pair with `data-workspace-id`. | +| Recent workspace remove button | `welcome-recent-workspace-remove` | Repeated item. Pair with `data-workspace-id`. | +| Recent workspace empty state | `welcome-recent-workspace-empty` | Present when no recent workspace is available. | + +## Navigation + +| Element name | data-testid | Notes | +|---|---|---| +| Nav search trigger | `nav-search-trigger` | Opens navigation search. | +| New code session button | `nav-new-code-session-btn` | Creates or opens a code session for the active project workspace. | +| New cowork session button | `nav-new-cowork-session-btn` | Creates or opens a cowork session for the active project workspace. | +| Assistant button | `nav-assistant-btn` | Opens assistant/persona scene. | +| Agent/Skill entry | `agent-skill-entry` | Expands the Agents/Skills navigation entry group. | +| Agent/Skill panel | `agent-skill-panel` | Agents/Skills entry group or active discovery scene root. | +| Agent/Skill tabs | `agent-skill-tabs` | Navigation tab container for Agent and Skill entries. | +| Agent tab | `agent-tab` | Opens the Agents discovery scene. | +| Skill tab | `skill-tab` | Opens the Skills discovery scene. | +| Navigation sections | `nav-sections` | Container for workspace/session sections. | +| Navigation bottom bar | `nav-bottom-bar` | Container for Mini App/footer region. | +| Footer more button | `nav-footer-more-btn` | Opens the footer overflow menu. | +| Footer menu | `nav-footer-menu` | Overflow menu opened from the footer more button. | +| Footer settings item | `nav-footer-settings-item` | Opens the Settings scene from the footer menu. | +| Footer shell button | `shell-panel-entry` | Opens or closes the shell scene nav. | +| Footer browser button | `browser-panel-entry` | Opens browser scene or browser panel depending on active context. | + +## Navigation Workspaces + +| Element name | data-testid | Notes | +|---|---|---| +| Workspace add button | `nav-workspace-add-btn` | Opens workspace add/recent menu. | +| Workspace add menu | `nav-workspace-menu` | Portal menu opened from add button. | +| Workspace menu open project | `nav-workspace-menu-open-project` | Opens project picker. | +| Workspace menu new project | `nav-workspace-menu-new-project` | Opens new project flow. | +| Workspace menu remote SSH | `nav-workspace-menu-remote-ssh` | Opens SSH remote connect flow. | +| Workspace menu recent workspace | `nav-workspace-menu-recent-workspace` | Repeated item. Pair with `data-workspace-id`. | +| Workspace list | `nav-workspace-list` | Repeated by list type. Pair with `data-workspace-list`. | +| Workspace list empty state | `nav-workspace-list-empty` | Pair with `data-workspace-list`. | +| Workspace drop target | `nav-workspace-drop-target` | Repeated drag target. Pair with `data-workspace-id`. | +| Workspace row | `nav-workspace-item` | Repeated item. Pair with `data-workspace-id`, `data-workspace-kind`, and `data-workspace-active`. | +| Workspace card | `nav-workspace-card` | Clickable row body. Pair with `data-workspace-id`. | +| Workspace sessions toggle | `nav-workspace-sessions-toggle` | Expands/collapses session rows. Pair with `data-workspace-id`. | +| Workspace name button | `nav-workspace-name-btn` | Activates workspace or toggles sessions. Pair with `data-workspace-id`. | +| Workspace files button | `nav-workspace-files-btn` | Opens file viewer for workspace. Pair with `data-workspace-id`. | +| Workspace search index button | `nav-workspace-search-index-btn` | Opens search index status modal when present. Pair with `data-workspace-id`. | +| Workspace row menu button | `nav-workspace-menu-btn` | Opens row action menu. Pair with `data-workspace-id`. | +| Workspace row menu | `nav-workspace-item-menu` | Portal menu for one workspace. Pair with `data-workspace-id`. | +| Workspace create session | `nav-workspace-menu-create-session` | Assistant workspace session action. | +| Workspace create code session | `nav-workspace-menu-create-code-session` | Normal workspace code session action. | +| Workspace create cowork session | `nav-workspace-menu-create-cowork-session` | Normal workspace cowork session action. | +| Workspace create ACP session | `nav-workspace-menu-create-acp-session` | Repeated item. Pair with `data-acp-client-id`. | +| Workspace create init session | `nav-workspace-menu-create-init-session` | Starts AGENTS.md/init session. | +| Workspace related paths | `nav-workspace-menu-related-paths` | Opens related paths dialog. | +| Workspace new worktree | `nav-workspace-menu-new-worktree` | Opens worktree creation dialog. | +| Workspace delete worktree | `nav-workspace-menu-delete-worktree` | Deletes linked worktree workspace. | +| Workspace copy path | `nav-workspace-menu-copy-path` | Copies workspace path. | +| Workspace reveal | `nav-workspace-menu-reveal` | Reveals workspace in file explorer. | +| Workspace close | `nav-workspace-menu-close` | Closes workspace. | +| Workspace reset assistant | `nav-workspace-menu-reset-assistant` | Resets default assistant workspace. | +| Workspace delete assistant | `nav-workspace-menu-delete-assistant` | Deletes named assistant workspace. | +| Workspace session region | `nav-workspace-session-region` | Contains sessions for one workspace. Pair with `data-workspace-id`. | + +## Navigation Sessions + +| Element name | data-testid | Notes | +|---|---|---| +| Session list | `nav-session-list` | Workspace-scoped list. Pair with `data-workspace-id`. | +| Session row | `nav-session-item` | Repeated item. Pair with `data-session-id`, `data-session-kind`, `data-session-level`, and `data-session-active`. | +| Session menu button | `nav-session-menu-btn` | Opens row action menu. Pair with `data-session-id`. | +| Session menu | `nav-session-menu` | Portal menu for one session. Pair with `data-session-id`. | +| Session rename item | `nav-session-menu-rename` | Starts session rename. | +| Session delete item | `nav-session-menu-delete` | Deletes session. | +| Session list toggle | `nav-session-list-toggle` | Expands/collapses long session lists. | + +## Session And Chat + +| Element name | data-testid | Notes | +|---|---|---| +| Session scene root | `session-scene` | Session scene anchor. | +| Session chat pane | `session-chat-pane` | Left chat pane within session scene. | +| Session right pane resizer | `session-right-pane-resizer` | Splitter between chat and aux pane. | +| Session aux pane | `session-aux-pane` | Right content canvas pane. Includes `data-mode`. | +| Chat pane root | `chat-pane` | FlowChat host pane. | +| FlowChat container | `flowchat-container` | FlowChat root. Includes `data-session-id`. | +| FlowChat messages region | `flowchat-messages` | Message list/welcome panel host. | +| FlowChat message list | `flowchat-message-list` | Virtual message list root when messages exist. | +| FlowChat empty message list | `flowchat-message-list-empty` | Empty virtual list state. | +| FlowChat message item | `flowchat-message-item` | Repeated virtual item. Pair with `data-turn-id`, `data-item-type`, and `data-item-index`. | +| Chat input container | `chat-input-container` | Root container for the composer. | +| Chat input editable region | `chat-input-textarea` | Rich text editable region. | +| Chat send button | `chat-input-send-btn` | Send action when input is valid. | +| Chat cancel button | `chat-input-cancel-btn` | Cancels in-progress send/generation when present. | +| Chat input workspace strip | `chat-input-workspace-strip` | Active workspace strip above composer. | +| Chat input target switcher | `chat-input-target-switcher` | Target/mode switcher. | +| Chat input image strip | `chat-input-image-strip` | Attached image strip. | +| Chat input start BTW button | `chat-input-boost-start-btw` | Starts the BTW flow when present. | +| Chat model selector button | `chat-model-selector-btn` | Opens the session model selector. | +| Chat model selector menu | `chat-model-selector-menu` | Model selector dropdown root. | +| Chat model selector option | `chat-model-selector-option` | Repeated item. Pair with `data-model-id`, `data-model-name`, and `data-selected`. | +| Chat user message | `chat-user-message` | Repeated user message. Pair with `data-turn-id`, `data-status`, and `data-failed`. | +| Chat user message content | `chat-user-message-content` | User message text content. Pair with `data-turn-id`. | +| Chat assistant message | `chat-assistant-message` | Repeated model round container. Pair with `data-turn-id`, `data-round-id`, `data-status`, `data-model-id`, `data-model-alias`, and `data-streaming`. | +| Chat assistant message content | `chat-assistant-message-content` | Assistant text block. Pair with `data-turn-id`, `data-flow-item-id`, `data-status`, and `data-streaming`. | +| Chat explore group | `chat-explore-group` | Explore-group root that wraps collapsed/merged tool rounds. Includes `data-group-kind`, `data-expanded`, `data-read-count`, `data-search-count`, and `data-command-count`. | +| Chat explore group toggle | `chat-explore-group-toggle` | Real click target that expands/collapses an explore group. Includes `data-group-kind` and `data-expanded`. | +| Chat explore group content | `chat-explore-group-content` | Inner content container for explore-group items. Includes `data-group-kind` and `data-expanded`. | +| Chat thinking panel | `chat-thinking-panel` | Thinking/reasoning panel root. Includes `data-status`, `data-streaming`, and `data-expanded`. | +| Chat thinking toggle | `chat-thinking-toggle` | Clickable thinking expand/collapse header. | +| Chat thinking content | `chat-thinking-content` | Thinking/reasoning text content. Includes `data-status` and `data-streaming`. | +| Chat shell command card | `chat-shell-command-card` | Shell command tool card root. Includes `data-status`, `data-expanded`, and `data-terminal-session-id`. | +| Chat shell command toggle | `chat-shell-command-toggle` | Click target for expanding/collapsing a shell command card. | +| Chat shell command text | `chat-shell-command-text` | Shell command text node. | +| Chat shell command output | `chat-shell-command-output` | Shell command stdout/stderr or live output area. | +| Chat shell command exit code | `chat-shell-command-exit-code` | Exit code node. Includes `data-exit-code` and `data-status`. | +| Chat shell tool card | `chat-shell-tool-card` | Outer FlowToolCard wrapper for Bash. Includes `data-tool-name` and `data-tool-card-id`. | +| Chat shell tool open panel | `chat-shell-tool-open-panel` | Opens the associated terminal panel when a terminal session is available. | +| Chat browser tool card | `chat-browser-tool-card` | Outer FlowToolCard wrapper for WebFetch. Includes `data-tool-name` and `data-tool-card-id`. | +| Chat file change card | `chat-file-change-card` | File operation card root. Includes `data-status`, `data-action`, `data-path`, and `data-expanded`. | +| Chat file change toggle | `chat-file-change-toggle` | Click target for expanding/collapsing a file operation card. | +| Chat file change path | `chat-file-change-path` | File path/name node. Includes `data-path`. | +| Chat file change action | `chat-file-change-action` | File operation action node. Includes `data-action`. | +| Chat file change preview | `chat-file-change-preview` | Code/diff preview area for file operation cards. | +| Chat MiniApp card | `chat-miniapp-card` | MiniApp result card root. Includes `data-status`, `data-app-id`, and `data-expanded`. | +| Chat MiniApp title | `chat-miniapp-title` | MiniApp title/name node. Includes `data-app-id`. | +| Chat MiniApp file list | `chat-miniapp-file-list` | MiniApp result file list container. | +| Chat MiniApp file row | `chat-miniapp-file-row` | MiniApp result file row. Includes `data-path`. | +| Chat MiniApp open button | `chat-miniapp-open-btn` | Opens the MiniApp scene. Includes `data-app-id`. | +| Pending queue panel | `pending-queue-panel` | Pending background task queue. | + +## Settings + +| Element name | data-testid | Notes | +|---|---|---| +| Settings scene root | `settings-scene` | Root content area for the Settings scene. Includes `data-settings-tab`. | +| Settings scene content | `settings-scene-content` | Active settings tab content wrapper. | +| Settings navigation root | `settings-nav` | Left-side settings navigation. | +| Settings navigation tab | `settings-nav-tab` | Repeated item. Pair with `data-settings-tab`. | + +## Settings Models + +| Element name | data-testid | Notes | +|---|---|---| +| Model list | `settings-model-list` | Container for configured model rows. | +| Create first model config button | `settings-model-create-first-config-btn` | Starts the first model provider setup from the empty state. | +| Custom model config button | `settings-model-custom-config-btn` | Starts custom provider configuration. Includes `data-provider-id="custom"`. | +| Model provider option | `settings-model-provider-option` | Repeated provider card. Pair with `data-provider-id`, for example `openbitfun`. | +| Model provider name input | `settings-model-provider-name-input` | Provider/config display name field, such as a mock LLM provider name. | +| Model API key input | `settings-model-api-key-input` | API key field in the model configuration form. Do not hardcode real keys in tests; load them from local config. | +| Model base URL input | `settings-model-base-url-input` | API base URL field for custom/OpenAI-compatible providers. | +| Model request format select | `settings-model-request-format-select` | Request format selector, for example OpenAI-compatible vs Anthropic. | +| Model select button | `settings-model-select-btn` | Opens the model selection dropdown. | +| Model selection menu | `settings-model-select-menu` | Model selection dropdown root. | +| Model selection option | `settings-model-option` | Repeated dropdown item. Pair with `data-model-id`, `data-model-name`, and `data-selected`. | +| Manual model name input | `settings-model-manual-name-input` | Manual/custom model name entry field. | +| Add custom model button | `settings-model-add-custom-btn` | Adds the manual model name into the selected model list. | +| Selected model list | `settings-model-selected-list` | Selected model draft list. Includes `data-selected-count`. | +| Selected model empty state | `settings-model-selected-list-empty` | Empty selected model draft state. Includes `data-selected-count="0"`. | +| Selected model row | `settings-model-selected-row` | Repeated selected model draft. Pair with `data-model-id`, `data-model-name`, `data-selected`, and `data-expanded`. | +| Selected model remove button | `settings-model-selected-remove-btn` | Removes a selected model draft. Pair with `data-model-id` and `data-model-name`. | +| Model save button | `settings-model-save-btn` | Saves the model provider/configuration form. | +| Model row | `settings-model-row` | Repeated saved model row. Pair with `data-model-id`, `data-model-name`, and `data-config-id`. | +| Model test status | `settings-model-test-status` | Repeated saved model test status. Pair with `data-model-id`, `data-model-name`, `data-config-id`, and `data-status` (`success` or `error`). | + +## Settings Appearance + +| Element name | data-testid | Notes | +|---|---|---| +| Appearance config root | `appearance-config` | Appearance page content root inside the settings scene. | +| Appearance theme section | `appearance-theme-section` | Language and theme settings section root. | +| Appearance font section | `appearance-font-section` | Font preference section root. | +| Appearance language select | `appearance-language-select` | Language select trigger in Appearance settings. | +| Appearance language option | `appearance-language-option` | Repeated language dropdown option. Includes `data-locale-id` and Select-provided `data-selected`. | +| Appearance theme select | `appearance-theme-select` | Theme select trigger in Appearance settings. | +| Appearance theme option | `appearance-theme-option` | Repeated theme dropdown option. Includes `data-theme-id` and Select-provided `data-selected`. | +| Appearance UI font level group | `appearance-ui-font-level-group` | UI font preset button group root. | +| Appearance UI font level button | `appearance-ui-font-level-btn` | Repeated preset button. Includes `data-font-level` and `data-selected`. | +| Appearance UI font custom controls | `appearance-ui-font-custom-controls` | Custom UI font px controls root, rendered when custom is active. | +| Appearance UI font custom input | `appearance-ui-font-custom-input` | Custom UI font px number input. Includes `data-font-level="custom"`. | +| Appearance UI font custom step minus | `appearance-ui-font-custom-step-minus` | Custom UI font px decrement button. | +| Appearance UI font custom step plus | `appearance-ui-font-custom-step-plus` | Custom UI font px increment button. | +| Appearance UI font preview | `appearance-ui-font-preview` | UI font preview area. | +| Appearance Flow Chat font toggle | `appearance-flowchat-font-toggle` | Flow Chat independent font size toggle input. | +| Appearance Flow Chat font select | `appearance-flowchat-font-select` | Flow Chat font size select trigger. | +| Appearance Flow Chat font option | `appearance-flowchat-font-option` | Repeated Flow Chat font size option. Includes `data-font-px` and Select-provided `data-selected`. | +| Appearance font reset button | `appearance-font-reset-btn` | Resets font preferences to defaults. | + +## Shell Panel + +| Element name | data-testid | Notes | +|---|---|---| +| Shell panel entry | `shell-panel-entry` | Footer entry that opens the Shell scene/nav. | +| Shell panel | `shell-panel` | Shell scene, shell nav, or terminal scene root. | +| Shell panel title | `shell-panel-title` | Shell nav title or active terminal toolbar title. | +| Shell command list | `shell-command-list` | Shell nav terminal list or active terminal container. | +| Shell command item | `shell-command-item` | Shell nav row or active xterm root. Includes `data-command-id` and, when available, `data-command-status`. | +| Shell command text | `shell-command-text` | Shell nav terminal/session label. | +| Shell command output | `shell-command-output` | Real xterm output container for the active terminal. | +| Shell command exit code | `shell-command-exit-code` | Terminal status bar exit code when the session has exited. Includes `data-exit-code` and `data-status`. | +| Shell command status | `shell-command-status` | Shell nav status dot, terminal loading/error state, or terminal status bar. Includes `data-command-status`. | +| Shell command rerun | `shell-command-rerun` | Retry button in terminal error state or Ctrl+C toolbar action on active terminal. | +| Shell panel close | `shell-panel-close` | Active terminal close button. | + +Notes: + +- The standalone xterm terminal does not expose a structured per-command history DOM. Tests should use `shell-command-output` for rendered terminal output and `chat-shell-command-*` for structured Bash ToolCard assertions. +- `shell-command-copy` is not currently exposed because the active terminal copy action is context-menu/selection driven rather than a stable visible button. + +## Browser Panel + +| Element name | data-testid | Notes | +|---|---|---| +| Browser panel entry | `browser-panel-entry` | Footer entry that opens the Browser scene or Browser panel, depending on active context. | +| Browser panel | `browser-panel` | Browser scene or right-side Browser panel root. | +| Browser panel title | `browser-panel-title` | Browser toolbar/form region. | +| Browser URL input | `browser-url-input` | Real URL input field. Press Enter to open the typed URL. | +| Browser page frame | `browser-page-frame` | iframe/webview host content area. | +| Browser loading indicator | `browser-loading-indicator` | Refresh/loading icon while a URL is loading. | +| Browser error message | `browser-error-message` | URL validation/connectivity/webview load failure message. | +| Browser current URL | `browser-current-url` | Current URL display in the webview placeholder. | +| Browser refresh button | `browser-refresh-button` | Refreshes the current browser page. | +| Browser back button | `browser-back-button` | Navigates browser history back. | +| Browser forward button | `browser-forward-button` | Navigates browser history forward. | + +Notes: + +- `browser-open-button` is not currently exposed because URL navigation is submitted by the existing address form via Enter; no dedicated visible open button exists. +- `browser-panel-close` depends on the surrounding scene/canvas tab chrome rather than the Browser component itself. + +## Notifications + +| Element name | data-testid | Notes | +|---|---|---| +| Notification button | `notification-button` | Opens or toggles the notification center. | +| Notification center dialog | `notification-center` | Notification center modal root. | +| Notification center close button | `notification-center-close-btn` | Closes the notification center. | +| Notification center active section | `notification-center-active-section` | Present only when active task notifications exist. | + +## Flow Chat Header + +| Element name | data-testid | Notes | +|---|---|---| +| Background subagents button | `flowchat-header-background-subagents` | Opens background subagent activity state. | +| Pull requests button | `flowchat-header-pull-requests` | Opens pull request related UI. | +| Turn list | `flowchat-header-turn-list` | Turn navigation list. | +| Previous turn button | `flowchat-header-turn-prev` | Moves to previous visible turn. | +| Next turn button | `flowchat-header-turn-next` | Moves to next visible turn. | + +## Agents + +| Element name | data-testid | Notes | +|---|---|---| +| Agent/Skill panel | `agent-skill-panel` | Agents scene root when the Agents discovery page is active. | +| Agent list | `agent-list` | Container for all agent zones and cards. | +| Agent list item | `agent-list-item` | Repeated card. Includes `data-agent-id`, `data-agent-name`, and `data-agent-kind`. | +| Agent list item title | `agent-list-item-title` | Agent card title. | +| Agent list item description | `agent-list-item-description` | Agent card description. | +| Agent list empty | `agent-list-empty` | Empty state for an agent list section. | +| Agent detail panel | `agent-detail-panel` | Agent detail modal root. | +| Agent detail title | `agent-detail-title` | Agent detail modal title. | +| Agent detail description | `agent-detail-description` | Agent detail description. | +| Agent detail tools section | `agent-detail-tools-section` | Agent capability/tools section. | +| Agent detail tool item | `agent-detail-tool-item` | Repeated enabled tool item. Includes `data-tool-name`. | +| Agent detail close | `agent-detail-close` | Modal close button. | +| Core anchor button | `agents-anchor-core` | Scrolls to core agents zone. | +| Teams anchor button | `agents-anchor-teams` | Scrolls to teams zone. | +| Custom agents anchor button | `agents-anchor-custom` | Scrolls to custom agents zone. | +| Agents search button | `agents-search-btn` | Search suffix button. | +| Core agents zone | `agents-core-zone` | Core agents section. | +| Teams zone | `agents-teams-zone` | Agent teams section. | +| Custom agents zone | `agents-custom-zone` | Custom/subagent section. | +| Review team configure button | `agents-review-team-configure-btn` | Opens review team configuration. | +| Agent source filter | `agents-source-filter` | Repeated item. Pair with `data-agent-source`. | +| Agent kind filter | `agents-kind-filter` | Repeated item. Pair with `data-agent-kind`. | +| Create agent button | `agents-create-agent-btn` | Opens custom agent creation page. | +| Agent team card | `agents-team-card` | Repeated item. Pair with `data-team-id`. | +| BTW stop review button | `btw-session-panel-stop-review` | Stops review session from BTW panel. | +| BTW origin button | `btw-session-panel-origin-button` | Opens origin session from BTW panel. | + +## Skills + +| Element name | data-testid | Notes | +|---|---|---| +| Agent/Skill panel | `agent-skill-panel` | Skills scene root when the Skills discovery page is active. | +| Skill list | `skill-list` | Installed skill list grid by default. Also used for marketplace results. | +| Skill list item | `skill-list-item` | Repeated installed skill card. Includes `data-skill-id`, `data-skill-name`, `data-skill-key`, `data-skill-level`, and `data-skill-builtin`. | +| Skill list item title | `skill-list-item-title` | Skill card title. | +| Skill list item description | `skill-list-item-description` | Skill card description when present. | +| Skill list empty | `skill-list-empty` | Empty state for installed or marketplace skill list. | +| Skill detail panel | `skill-detail-panel` | Skill detail modal root. | +| Skill detail title | `skill-detail-title` | Skill detail modal title. | +| Skill detail description | `skill-detail-description` | Skill detail description. | +| Skill detail capabilities section | `skill-detail-capabilities-section` | Detail metadata/capability rows for installed or marketplace skill. | +| Skill detail close | `skill-detail-close` | Modal close button. | +| Skills tabs root | `skills-tabs` | Installed/discover tabs container. | +| Installed tab | `skills-tab-installed` | Includes `data-skills-tab-active`. | +| Discover tab | `skills-tab-discover` | Includes `data-skills-tab-active`. | +| Installed panel | `skills-installed-panel` | Installed skills view root. | +| Installed sidebar | `skills-installed-sidebar` | Installed category sidebar. | +| Installed category | `skills-installed-category` | Repeated item. Pair with `data-skill-category`. | +| Installed content | `skills-installed-content` | Main installed skills content. | +| Installed search | `skills-installed-search` | Installed skills search root. | +| Hide duplicates button | `skills-hide-duplicates-btn` | Includes `data-active`. | +| Add local skill button | `skills-add-local-btn` | Opens add skill form. | +| Installed loading state | `skills-installed-loading` | Loading skeleton container. | +| Installed error state | `skills-installed-error` | Error state container. | +| Installed empty state | `skills-installed-empty` | Empty state container. | +| Installed grid | `skills-installed-grid` | Installed skills card grid. | +| Installed skill card | `skills-installed-card` | Repeated item. Pair with `data-skill-key`, `data-skill-level`, and `data-skill-builtin`. | +| Installed card path button | `skills-installed-card-path` | Repeated item. Pair with `data-skill-key`. | +| Installed card delete button | `skills-installed-card-delete` | Repeated item. Pair with `data-skill-key`. | +| Installed pagination | `skills-installed-pagination` | Installed list pagination root. | +| Installed previous page | `skills-installed-page-prev` | Previous page button. | +| Installed next page | `skills-installed-page-next` | Next page button. | +| Discover panel | `skills-discover-panel` | Marketplace view root. | +| Discover search | `skills-discover-search` | Marketplace search root. | +| Discover content | `skills-discover-content` | Marketplace content area. | +| Discover loading state | `skills-discover-loading` | Initial loading skeleton container. | +| Discover page loading state | `skills-discover-page-loading` | Loading state for page changes. | +| Discover error state | `skills-discover-error` | Error state container. | +| Discover empty state | `skills-discover-empty` | Empty state container. | +| Discover grid | `skills-discover-grid` | Marketplace card grid. | +| Market skill card | `skills-market-card` | Repeated item. Pair with `data-skill-install-id` and `data-skill-installed`. | +| Skill card action | `skills-card-action` | Repeated card action. Pair with `data-skill-action`. | +| Discover pagination | `skills-discover-pagination` | Marketplace pagination root. | +| Discover previous page | `skills-discover-page-prev` | Previous page button. | +| Discover next page | `skills-discover-page-next` | Next page button. | +| Detail delete button | `skills-detail-delete-btn` | Deletes selected installed skill. | +| Detail installed button | `skills-detail-installed-btn` | Disabled installed marker for marketplace detail. | +| Detail project download button | `skills-detail-download-project-btn` | Downloads market skill to project scope. | +| Detail user download button | `skills-detail-download-user-btn` | Downloads market skill to user scope. | +| Detail path button | `skills-detail-path-btn` | Reveals installed skill path. | +| Detail external link | `skills-detail-external-link` | Opens marketplace link. | +| Add form | `skills-add-form` | Add local skill modal content. | +| Add path input | `skills-add-path-input` | Local skill path input. | +| Add browse button | `skills-add-browse-btn` | Opens path picker. | +| Add validation result | `skills-add-validation` | Includes `data-validation-valid`. | +| Add cancel button | `skills-add-cancel-btn` | Closes add form. | +| Add submit button | `skills-add-submit-btn` | Adds validated local skill. | diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index a30f26b573..13f377851e 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -15,31 +15,35 @@ ## 2. 已迁移 owner -- `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、diagnostic redaction、session usage/token usage 基础服务。 +- `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、diagnostic redaction、session usage/token usage 基础服务。 - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 -- `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation 和 backend event delivery;core backend event system 只保留兼容 re-export。 +- `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO 和 platform-neutral `EventEmitter` trait。 -- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 -- `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、confirmation facts、truncation recovery presentation。 -- `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、cancellation-state/token-store policy、background exec output capture 和部分 result rendering。 -- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、session management/cancellation port contracts、thread-goal facts、prompt / prompt-cache facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、custom subagent discovery/loading、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping、DeepResearch citation renumber 与 report post-process gate。 +- `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 +- `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy 和 provider-entry materialization;core 只保留 Computer Use 旧 public path re-export / compatibility shim 与产品执行入口。 +- `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、process manager / host adapter 调用、read-state adapter、remote shell/FS 调用和旧工具入口。 +- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、thread-goal facts、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 - `harness` 已建立 descriptor、route plan 和 legacy provider registry。 -- `product-domains` 已承接 MiniApp state/workflow planning、compile / permission adaptation、import lifecycle、AI / Agent permission、rate-limit、model/message/session/workspace/turn-text bridge rules、function-agent prompt/parser/response policy 和部分 Git snapshot/fallback 逻辑。 +- `product-domains` 已承接 MiniApp state/workflow planning、compile / permission adaptation、import lifecycle、AI / Agent permission、rate-limit、model/message/session/workspace/turn-text bridge rules、AI / Agent 请求计划、stream / runtime event payload、worker restart / draft key / workspace input 规则、function-agent prompt/parser/response policy 和部分 Git snapshot/fallback 逻辑。 - `bitfun-core` 的 function-agent AI concrete acquisition 已从旧 `runtime_services` 路径收拢到明确的 core port adapter;Git / AI compatibility re-export 仍保留旧 public path。 -- Product Assembly 已承接 `DeliveryProfile`、`CapabilitySet`、product-full provider plan、service availability report 和 profile-scoped harness registry 入口。 +- Product Assembly 已承接 `DeliveryProfile`、当前交付形态入口矩阵、`CapabilitySet`、feature group matrix、profile-scoped capability plan、product-full provider plan、service availability report、profile-scoped harness registry 入口与 legacy-route 行为保护,以及 `ProductAssembler` 对 explicit profile input、runtime services、harness registry 和 service requirement 的验证;core 只保留兼容 re-export。ProductFull / Desktop / CLI / ACP 保留完整能力;Server / Remote / Web / MobileWeb 不再 materialize product-full capability packs、feature groups、runtime services、tool groups 或 harness routes。 ## 3. 已建立保护 - owner crate 不得依赖回 `bitfun-core`。 - `product-full` 保持完整产品能力集合。 - boundary check 覆盖 owner crate 禁止依赖、旧路径 facade-only、feature gate、six-layer path 解析、Product Assembly 收口和高风险 owner 回流。 -- focused baseline 覆盖 tool manifest、GetToolSpec、execution admission、workspace search、remote workspace fallback、MCP config/catalog、prompt cache、custom subagent、thread-goal tools、AskUserQuestion、DeepReview policy、tool confirmation、session restore、MiniApp storage/builtin/import、function-agent Git、scheduled-job state 等路径。 - -## 4. 明确未完成 - -- `bitfun-core` 仍是完整 product runtime 组装点,尚未退化为纯 compatibility facade。 -- 产品入口仍主要通过 `bitfun-core/product-full` 获取完整能力,交付形态级 feature / dependency trimming 未完成。 -- concrete scheduler lifecycle、prompt-cache persistence orchestration、tool pipeline scheduler glue、concrete prompt assembly、AI client factory / provider acquisition 仍在 core 或产品路径,待 PR-D 通过 SDK / provider port 统一收口。 -- DeepReview concrete Task launch、queue event emission 和 session metadata cache persistence 仍是 core adapter,因为它们依赖 coordinator、session manager、subagent runtime 和产品事件;provider-neutral policy / queue / retry / report shaping 已在 `agent-runtime`。 -- MiniApp larger workflow 的 UI asset / desktop scheduler / AI factory 调用仍属于产品 host adapter;可复用规则已迁入 `product-domains`,不再在 desktop 命令内重复实现。 -- Agent Runtime SDK 具备候选原语,但尚未形成可独立发布的稳定外部 SDK 边界。 +- focused tests 覆盖当前 delivery profile 能力裁剪、ProductAssembler 缺失 service 报告、无直接 core 入口的空 capability plan、SDK fake provider / services / tool / harness / hook / workspace-scoped agent registry 闭环,以及 runtime hook 顺序、timeout、错误策略和重复 id 拦截。 +- focused baseline 覆盖 tool manifest、GetToolSpec、execution admission、workspace search、remote workspace fallback、MCP config/catalog、prompt cache、custom agent / mode / subagent、thread-goal tools、AskUserQuestion、DeepReview policy、tool confirmation、session restore、MiniApp storage/builtin/import、function-agent Git、scheduled-job state 等路径。 +- H4 已完成 Agent Runtime SDK 发布准备的 workspace 内收口:`sdk` facade 暴露 v1 preview 兼容元数据、空默认 feature、稳定注入 registry/service 类型、最小外部 embedder 示例,以及 boundary required rules / self-test 保护。 + +## 4. Adapter 边界与后续专项 + +- `bitfun-core` 仍承载 compatibility facade / `product-full` assembly 和少量迁移期 adapter;不应继续新增 owner 逻辑。 +- 产品入口的能力裁剪已由 Product Assembly profile plan 表达;后续新增入口必须先明确 `ProductCoreDependencyMode`、unsupported / unavailable 语义和兼容性测试。 +- H1 剩余 owner 决策已迁出:dialog start route / outcome lifecycle 继续由 `agent-runtime` 给出可测试决策,tool pipeline 的 Task batch 策略由 `tool-execution` 持有,prompt runtime / workspace / user-context 组合由 `agent-runtime` 持有,AI model selector / cache-key 解析由 `bitfun-ai-adapters` 持有。`bitfun-core` 仍只保留 coordinator 调用、config IO、credential overlay、prompt 事实收集和 prompt-cache persistence IO 等 concrete adapter。 +- DeepReview concrete Task launch 和 session metadata cache persistence 仍是 core adapter,因为它们依赖 coordinator、session manager、subagent runtime 和产品事件;provider-neutral policy / queue / retry / report shaping 与 queue event payload shaping 已在 `agent-runtime`,core 只负责事件发送。 +- H2 已完成:MiniApp AI / Agent 请求计划、stream payload、runtime event payload、worker restart / draft key / workspace input 规则已迁入 `product-domains`,desktop 命令只保留 AI factory、scheduler、worker pool、目录创建和事件发送等 concrete host 调用。 +- MiniApp larger workflow 的 UI asset / desktop scheduler / AI factory 调用仍属于产品 host adapter;可复用规则不得回流到 desktop 命令内重复实现。 +- Agent Runtime SDK 已具备 v1 preview workspace 内公开边界、最小 fake-provider 闭环、runtime services / tool / harness / hook / workspace-scoped agent registry 注入基线、最小 feature 证明和外部 embedder 示例。若后续要独立发布为外部包,需要单独评审发布流程、crate packaging、semver 承诺和长期兼容策略。 +- Skill registry 主体 owner 已收口到 `agent-runtime`:`bitfun-core` 保留本地/远端扫描、config/registry IO、缓存和加载错误映射;内置 skill 分组、root/slot/key 事实、mode default/override、visible resolution、shadow 标记、mode skill info 和加载后 assistant payload 由 runtime 统一给出。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 2db6433a64..6e6a6db480 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -18,26 +18,40 @@ - workspace 已按六层目录展开,旧 `surfaces` / `providers` 目标层级不再使用。 - `bitfun-core --no-default-features` 已裁掉 workspace-search owner、debug ingest HTTP server、AI provider adapter runtime 和 direct `reqwest`。 -- Desktop / CLI / ACP 仍通过 `bitfun-core/product-full` 获取完整能力;Server / Web / Mobile Web 不直接依赖 core,但交付形态级 feature / dependency trimming 仍未闭环。 -- Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立,部分逻辑仍由 core concrete manager 或产品命令路径持有。 -- PR-B 已收口 Agent lifecycle 与 tool side-effect owner:turn skill/agent snapshot DTO / diff / render / store、file-read session state、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state 已迁入 `agent-runtime`;background exec output capture、tool cancellation token store 已迁入 `tool-execution`;core 保留 resolver、产品事件、具体工具执行、IO 编排和旧路径兼容 re-export。 +- Desktop / CLI / ACP 仍通过 `bitfun-core/product-full` 获取完整能力;Server / Remote / Web / Mobile Web 不直接依赖 core。Product Assembly 已按入口矩阵裁剪能力计划:完整兼容入口保留 product-full 能力,无直接 core 入口不再 materialize product-full capability packs、feature groups、runtime services、tool groups 或 harness routes。 +- Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立;Agent Runtime SDK 内部 facade 已能注入 runtime services、tool registry、harness registry、hook registry、workspace-scoped agent registry 和 runtime event queue/router,部分 concrete 生命周期仍由 core concrete manager 或产品命令路径持有。 +- 最新 custom agent / mode / skill 路径已纳入 `agent-runtime` owner:schema、默认值、skill catalog/root specs、mode policy、selection/shadow 规则、markdown parse/render、validation 与 review 工具过滤规则由 runtime 持有;core 和 desktop 只保留产品工具/模型查询、日志、registry/config 写入、文件路径选择、扫描加载 IO 和命令入口。 +- PR-B 已收口 Agent lifecycle 与 tool side-effect owner:turn skill/agent snapshot DTO / diff / render / store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state 已迁入 `agent-runtime`;background exec output capture、tool cancellation token store、prompt-safe tool context facts / custom-data materialization 已迁入 `tool-execution`;core 保留 resolver、产品事件、具体工具执行、IO 编排、runtime handles 和旧路径兼容 re-export。 +- Computer Use 的 provider-neutral DTO、输入解析、截图结果 body/hint 组装已迁入 `tool-contracts`;core 保留 host trait、base64 attachment 生成、产品工具执行和旧 public path re-export / compatibility shim。 +- File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本已迁入 `tool-execution`;file-read state 的 provider-neutral guardrail / freshness 语义已迁入 `agent-runtime`;core 保留 ToolResult 包装、权限、checkpoint、read-state adapter、remote shell/FS 调用和旧工具入口。 - PR-C 已收口 Harness / product workflow 的低风险 owner:MiniApp AI / Agent permission、rate-limit、model/message/session/workspace/turn-text 规则迁入 `product-domains`;DeepResearch 后处理 gate 迁入 `agent-runtime`,report IO 继续由 `services-integrations` 持有;function-agent AI concrete acquisition 收拢为 core port adapter,旧 `runtime_services` 路径删除。 +- H2 concrete adapter 收口已完成:MiniApp AI / Agent 请求计划、stream payload、runtime event payload、worker restart / draft key / workspace input 规则迁入 `product-domains`;DeepReview concrete Task launch、session metadata cache persistence 和 MiniApp concrete AI factory / scheduler / worker pool 调用已复核为 adapter 边界,不在下层 owner crate 中实现。 +- Remote Connect IM bot 的 provider-neutral 支撑已迁入 `services-integrations`:bot config / persistence / form-state、file auto-push helper、locale / menu rendering、chat state / interaction DTO 和 command parsing。`bitfun-core` 仍保留 command router 与 Telegram / Feishu / Weixin adapters,因为它们还依赖 coordinator、session manager、image context 和具体平台 I/O。 ## 3. 已完成但仍需保持的边界 - `services-core` 已承接 session metadata store、session index rebuild、lineage / branch metadata shaping、JSON file store、session layout 和 legacy session-store merge。 -- `runtime-services` 已承接 typed runtime service assembly、capability validation、provider registry、backend event delivery owner。 -- `agent-runtime` 已承接 provider-neutral scheduler decisions、dialog lifecycle port contracts、background delivery decisions、thread-goal facts、prompt-cache facts、turn skill/agent snapshot state、file-read session state、session evidence ledger、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、DeepReview provider-neutral policy / queue / retry / diagnostics shaping。 -- `tool-contracts` / `tool-execution` 已承接 tool manifest / catalog / admission、batching plan、retry policy、state counting、cancellation-state/token-store policy、background exec output capture、shell helper 和部分 local / remote IO helper。 +- `runtime-services` 已承接 typed runtime service assembly、capability validation、provider registry、backend event delivery owner 和无副作用 capability marker ports。 +- `agent-runtime` 已承接 provider-neutral scheduler decisions、dialog lifecycle port contracts、runtime event queue/router、background delivery decisions、thread-goal facts、prompt markup / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot state、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、DeepReview provider-neutral policy / queue / retry / diagnostics shaping 与 queue event payload shaping。 +- `tool-contracts` / `tool-execution` 已承接 tool manifest / catalog / admission、Computer Use contract/payload、Computer Use loop detection / screenshot hash / verification / retry policy、ExecCommand provider-neutral 呈现 / control facts / completion shape、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、prompt-safe tool context facts / custom-data materialization、shell helper、部分 local / remote IO helper,以及 file tool provider-neutral result presentation / mode / status / guardrail facts。 +- `services-core` 已承接 managed runtime command resolution 和 PATH merge 规则;core 只保留产品 managed runtime root 适配。 - `services-integrations` 已承接 remote-connect primitives、workspace search concrete owner、remote SSH/SFTP/PTY owner、MiniApp host dispatch / storage / worker IO、DeepResearch report IO。 -- `product-domains` 已承接 MiniApp workflow planning、compile / permission path adaptation、function-agent prompt / parser / response policy 和部分 Git snapshot/fallback 逻辑。 -- boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件和重点 feature gate。 +- `product-domains` 已承接 MiniApp workflow planning、compile / permission path adaptation、AI / Agent 请求计划、stream/event payload、worker restart / draft key / workspace input 规则、function-agent prompt / parser / response policy 和部分 Git snapshot/fallback 逻辑。 +- Product Assembly 已承接当前 delivery profile 的能力计划裁剪;下层 owner crate 不按产品形态分支。 +- boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件、custom agent owner / custom subagent wrapper 保护和重点 feature gate。 -## 4. 剩余大块 PR +## 4. 后续大块专项 -| PR | 目标 | 主要范围 | 准出标准 | -|---|---|---|---| -| PR-D | Product shape / Agent SDK / core facade closure | 内部 Agent Runtime SDK façade、fake provider 最小 session / turn / event stream、Product Assembly capability matrix、delivery profile feature trimming、`bitfun-core` facade 收口 | cargo metadata / cargo tree 有 no-default/product-full 对比;各产品入口验证通过;SDK 不暴露 `bitfun-core`、product-full、concrete manager 或全局 mutable state | +设计文档中已批准的大块 owner 迁移专项不再按旧 H 标签继续拆分;后续以最新代码审计触发。当前不能宣称 `bitfun-core` 中所有 owner 已彻底迁完:core 仍允许承载 compatibility facade、`product-full` assembly、产品命令适配、concrete manager 接线和少量迁移期 adapter。若最新主干或审计发现 provider-neutral owner 仍留在 core,必须同步迁出主体并删除或显著简化旧 core 路径。 + +后续只在出现以下情况时重新开专项: + +- Agent Runtime SDK 需要从 workspace 内 preview facade 变成独立发布包。 +- 新产品形态需要改变 Product Assembly 的 capability / provider 选择方式。 +- 下层 crate 需要承接新的 concrete runtime owner,并且能同步删除或显著简化旧 core 主体路径。 +- 主干新增 CLI、tool、terminal、session、scheduler、remote、MiniApp、ACP 或 product interface 逻辑导致当前分层边界失效。 + +任何新增专项仍必须满足:先补等价保护,再迁移实现主体,并证明不影响不同操作系统和交付形态的功能范围。 ## 5. 固定执行流程 @@ -57,10 +71,11 @@ | Workspace layout / Cargo path | `cargo metadata --no-deps --format-version 1` | | Runtime Services / backend events | `cargo test -p bitfun-runtime-services`,`cargo check -p bitfun-core --no-default-features` | | Services Core session migration | `cargo test -p bitfun-services-core merge_legacy_session_store`,core workspace-runtime focused tests | +| Remote Connect / IM bot support | `cargo test -p bitfun-services-integrations --features remote-connect --lib remote_connect::bot::`,`cargo test -p bitfun-core --features product-full remote_connect::bot::command_router` | | Agent lifecycle / scheduler | `cargo test -p bitfun-agent-runtime`,core scheduler / session focused tests | | Tool / terminal | `cargo test -p bitfun-agent-tools`,`cargo test -p tool-runtime`,terminal / exec-command focused tests | | Harness / Product Domains | `cargo test -p bitfun-harness`,`cargo test -p bitfun-product-domains`,DeepReview / MiniApp focused tests | -| Product shape / SDK | `cargo test -p bitfun-agent-runtime`,`cargo test -p bitfun-runtime-services`,SDK fake-provider smoke,cargo tree / metadata 对比 | +| Product shape / SDK | `cargo test -p bitfun-product-capabilities`,`cargo test -p bitfun-core product_tool_runtime`,SDK fake-provider smoke,cargo tree / metadata 对比 | | 大范围 owner 迁移 | `cargo check --workspace`,必要时补 `cargo test --workspace` | ## 7. 暂停条件 @@ -69,4 +84,4 @@ - Execution / contracts crate 吸收 Tauri、产品命令、AI provider、MCP client、process execution、Git provider 等 concrete dependency。 - Product Assembly 变成无类型 service locator 或全局 mutable app state。 - PR 只新增抽象,没有迁移、删除或显著简化旧 core 主体路径。 -- SDK façade 必须暴露 `bitfun-core`、`product-full`、concrete service manager 或产品命令 registry 才能完成基本 agent 执行。 +- SDK facade 必须暴露 `bitfun-core`、`product-full`、concrete service manager 或产品命令 registry 才能完成基本 agent 执行。 diff --git a/package.json b/package.json index 9245051f6f..ce109a1939 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "i18n:contract:test:ci": "cross-env BITFUN_I18N_CONTRACT_TEST_PROFILE=ci node --test scripts/i18n-contract.test.mjs", "i18n:audit": "node scripts/i18n-audit.mjs", "theme:color-audit": "node scripts/audit-theme-colors.mjs", + "theme:color-audit:test": "node --test scripts/audit-theme-colors.test.mjs", + "theme:visual-contract": "node scripts/validate-theme-visual-contract.mjs", "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs", "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", @@ -78,7 +80,9 @@ "e2e:test:smoke": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:smoke", "e2e:test:chat": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:chat", "e2e:test:perf:debug": "cross-env BITFUN_E2E_APP_MODE=debug E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf", - "e2e:test:perf:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf" + "e2e:test:perf:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf", + "e2e:test:perf:startup-stability:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast E2E_LOG_LEVEL=warn node tests/e2e/scripts/run-startup-stability.mjs", + "e2e:test:perf:long-session-interactions:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast E2E_LOG_LEVEL=warn node tests/e2e/scripts/run-long-session-interaction-matrix.mjs" }, "devDependencies": { "@tauri-apps/cli": "^2.10.0", diff --git a/png/agent_benchmark_scores.svg b/png/agent_benchmark_scores.svg new file mode 100644 index 0000000000..63c56e8ec8 --- /dev/null +++ b/png/agent_benchmark_scores.svg @@ -0,0 +1,56 @@ + + Code Agent benchmark completion rates + Completion rates for BitFun, Open Code, and Claude Code on SWE-Bench-Pro and SWE-Bench-Verified. BitFun is first in each group. + + + + + + + + + + + 0 + 20 + 40 + 60 + 80 + + SWE-Bench-Pro + BitFun + + + 52% + Open Code + + + 51% + Claude Code + + + 50% + + + + SWE-Bench-Verified + BitFun + + + 74% + Open Code + + + 72% + Claude Code + + + 69% + diff --git a/png/flashgrep_search_speed.svg b/png/flashgrep_search_speed.svg new file mode 100644 index 0000000000..ba15ac53c6 --- /dev/null +++ b/png/flashgrep_search_speed.svg @@ -0,0 +1,33 @@ + + flashgrep search speed comparison + Search speed comparison showing traditional retrieval at 145.9 seconds and BitFun plus flashgrep at 7.82 seconds. + + + + + + + + + + 0s + 50s + 100s + 150s + + Traditional retrieval + + + 145.9s + + BitFun + flashgrep + + + 7.82s + diff --git a/png/kv_cache_hit_rate.svg b/png/kv_cache_hit_rate.svg new file mode 100644 index 0000000000..1a2383fb44 --- /dev/null +++ b/png/kv_cache_hit_rate.svg @@ -0,0 +1,45 @@ + + KV Cache hit rate distribution + KV Cache hit rate distribution across 728 valid cache records. + + + + + + + + + 0 + 100 + 200 + 250 + + + 7 + 80-90% + + + 13 + 90-95% + + + 103 + 95-98% + + + 228 + 98-99% + + + 239 + 99-99.5% + + + 138 + >=99.5% + diff --git a/png/readme_hero_CN.png b/png/readme_hero_CN.png index ab1a286cd5..360bdc02bf 100644 Binary files a/png/readme_hero_CN.png and b/png/readme_hero_CN.png differ diff --git a/scripts/audit-theme-colors.mjs b/scripts/audit-theme-colors.mjs index ad3365d3ab..a68d9833a0 100644 --- a/scripts/audit-theme-colors.mjs +++ b/scripts/audit-theme-colors.mjs @@ -4,31 +4,75 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; -const DEFAULT_ROOT = 'src/web-ui/src'; -const COLOR_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.ts', '.tsx', '.js', '.jsx']); -const TOKEN_PATH_PARTS = [ - 'component-library/styles', - 'infrastructure/theme', - 'theme/presets', -]; -const EXCEPTION_PATH_PARTS = [ - 'monaco', - 'terminal', - 'mermaid', - 'syntax', - 'CodeEditor', -]; +import { + COLOR_DOMAIN_KEYS, + COLOR_DOMAIN_LABELS, + COLOR_DOMAIN_CONTRACTS, + COLOR_DOMAIN_RULES, + COLOR_EXTENSIONS, + CONTRACT_VAR_DEFINITION_PATH_PARTS, + DEFAULT_BASELINE_PATH, + DEFAULT_ROOT, + EXCEPTION_PATH_PARTS, + FALLBACK_VAR_CONTRACTS, + REGISTERED_DYNAMIC_VAR_PREFIXES, + RUNTIME_CONTRACT_VAR_DEFINITION_PATH_PARTS, + STATIC_CONTRACT_VAR_DEFINITION_PATH_PARTS, + TOKEN_COMPATIBILITY_ALIAS_CONTRACTS, + TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS, + TOKEN_ALIAS_SOURCE_PATH_PARTS, + TOKEN_PATH_PARTS, +} from './theme-css-var-contract.mjs'; const COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[-+]?\d*\.?\d+\s*,\s*[-+]?\d*\.?\d+\s*,\s*[-+]?\d*\.?\d+(?:\s*,\s*(?:[-+]?\d*\.?\d+|var\([^)]+\)))?\s*\)|hsla?\(\s*[-+]?\d*\.?\d+(?:deg|rad|turn)?\s*,\s*[-+]?\d*\.?\d+%\s*,\s*[-+]?\d*\.?\d+%(?:\s*,\s*(?:[-+]?\d*\.?\d+|var\([^)]+\)))?\s*\)/g; +const TOKEN_ALIAS_DEFINITION_PATTERN = + /(?:^|[;{\s])(\$[a-zA-Z0-9_-]+|--[a-zA-Z0-9_-]+)\s*:\s*(#[0-9a-fA-F]{3,8}\b|rgba?\(\s*[-+]?\d*\.?\d+\s*,\s*[-+]?\d*\.?\d+\s*,\s*[-+]?\d*\.?\d+(?:\s*,\s*(?:[-+]?\d*\.?\d+|var\([^)]+\)))?\s*\)|hsla?\(\s*[-+]?\d*\.?\d+(?:deg|rad|turn)?\s*,\s*[-+]?\d*\.?\d+%\s*,\s*[-+]?\d*\.?\d+%(?:\s*,\s*(?:[-+]?\d*\.?\d+|var\([^)]+\)))?\s*\))/gm; const CSS_VAR_USAGE_PATTERN = /var\(\s*(--[a-zA-Z0-9_-]+)/g; const CSS_VAR_DEFINITION_PATTERN = /(^|[;{\s])(--[a-zA-Z0-9_-]+)\s*:/g; const VAR_FALLBACK_PATTERN = /var\(\s*(--[a-zA-Z0-9_-]+)\s*,/g; +const CSS_VAR_SET_PROPERTY_PATTERN = /\.setProperty\(\s*['"`](--[a-zA-Z0-9_-]+)/g; +const CSS_VAR_INLINE_STYLE_PATTERN = /['"`](--[a-zA-Z0-9_-]+)['"`]\s*:/g; +const CSS_VAR_DYNAMIC_SET_PATTERN = /\.setProperty\(\s*`(--[a-zA-Z0-9_-]*)\$\{/g; +const REPORT_ROW_LIMIT = 100; +const COLOR_DOMAIN_CONTRACT_BY_KEY = new Map(COLOR_DOMAIN_CONTRACTS.map(contract => [contract.key, contract])); +const FALLBACK_VAR_CONTRACT_BY_KEY = new Map(FALLBACK_VAR_CONTRACTS.map(contract => [contract.key, contract])); +const TOKEN_COMPATIBILITY_ALIAS_BY_KEY = new Map( + TOKEN_COMPATIBILITY_ALIAS_CONTRACTS.map(contract => [contract.key, contract]), +); + +function resolveCompatibilityAliasContract(name) { + const explicit = TOKEN_COMPATIBILITY_ALIAS_BY_KEY.get(name); + if (explicit) { + return explicit; + } + + const family = TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS.find(contract => ( + name.startsWith(contract.prefix) + && name.length > contract.prefix.length + )); + if (!family) { + return null; + } + + return { + key: name, + canonical: `${family.canonicalPrefix}${name.slice(family.prefix.length)}`, + owner: family.owner, + reason: family.reason, + removal: family.removal, + familyPrefix: family.prefix, + canonicalPrefix: family.canonicalPrefix, + }; +} function parseArgs(argv) { const options = { root: DEFAULT_ROOT, json: false, + reportJson: null, + baselinePath: undefined, + noBaseline: false, top: 15, budget: 120, }; @@ -37,6 +81,28 @@ function parseArgs(argv) { const arg = argv[index]; if (arg === '--json') { options.json = true; + } else if (arg === '--report-json') { + options.reportJson = argv[++index]; + if (!options.reportJson) { + throw new Error('--report-json requires an output path'); + } + } else if (arg.startsWith('--report-json=')) { + options.reportJson = arg.slice('--report-json='.length); + if (!options.reportJson) { + throw new Error('--report-json requires an output path'); + } + } else if (arg === '--baseline') { + options.baselinePath = argv[++index]; + if (!options.baselinePath) { + throw new Error('--baseline requires a baseline path'); + } + } else if (arg.startsWith('--baseline=')) { + options.baselinePath = arg.slice('--baseline='.length); + if (!options.baselinePath) { + throw new Error('--baseline requires a baseline path'); + } + } else if (arg === '--no-baseline') { + options.noBaseline = true; } else if (arg === '--root') { options.root = argv[++index] ?? DEFAULT_ROOT; } else if (arg === '--top') { @@ -58,10 +124,13 @@ function printHelp() { console.log(`Usage: node scripts/audit-theme-colors.mjs [options] Options: - --root Directory to scan. Default: ${DEFAULT_ROOT} - --top Number of top rows to print. Default: 15 - --budget Unique app color budget for the summary. Default: 120 - --json Print machine-readable JSON instead of text. + --root Directory to scan. Default: ${DEFAULT_ROOT} + --top Number of top rows to print. Default: 15 + --budget Unique app color budget for the summary. Default: 120 + --baseline Enforce a theme color governance baseline. + --no-baseline Disable baseline enforcement. + --json Print machine-readable JSON instead of text. + --report-json Write the machine-readable report to a file. `); } @@ -94,23 +163,244 @@ function normalizePath(filePath) { return filePath.split(path.sep).join('/'); } +function isAuditTestFile(relativePath) { + return ( + /(^|\/)__tests__\//.test(relativePath) + || /\.(?:test|spec)\.[a-z0-9]+$/i.test(relativePath) + ); +} + function isTokenFile(relativePath) { return TOKEN_PATH_PARTS.some(part => relativePath.includes(part)); } +function isTokenAliasSourceFile(relativePath) { + return TOKEN_ALIAS_SOURCE_PATH_PARTS.some(part => relativePath.endsWith(part)); +} + +function isContractVarDefinitionFile(relativePath) { + return CONTRACT_VAR_DEFINITION_PATH_PARTS.some(part => relativePath.includes(part)); +} + +function isStaticContractVarDefinitionFile(relativePath) { + return STATIC_CONTRACT_VAR_DEFINITION_PATH_PARTS.some(part => relativePath.includes(part)); +} + +function isRuntimeContractVarDefinitionFile(relativePath) { + return RUNTIME_CONTRACT_VAR_DEFINITION_PATH_PARTS.some(part => relativePath.includes(part)); +} + function isExceptionFile(relativePath) { return EXCEPTION_PATH_PARTS.some(part => relativePath.toLowerCase().includes(part.toLowerCase())); } +function pathMatchesPart(relativePath, pathPart) { + const normalizedPath = relativePath.toLowerCase(); + const normalizedPart = pathPart.toLowerCase(); + return ( + normalizedPath === normalizedPart + || normalizedPath.startsWith(`${normalizedPart}/`) + || normalizedPath.startsWith(`${normalizedPart}.`) + || normalizedPath.includes(`/${normalizedPart}/`) + || normalizedPath.includes(`/${normalizedPart}.`) + ); +} + +function getColorDomain(relativePath) { + const rule = COLOR_DOMAIN_RULES.find(entry => ( + entry.pathParts.some(part => pathMatchesPart(relativePath, part)) + )); + return rule?.key ?? 'appUi'; +} + function incrementMap(map, key, amount = 1) { map.set(key, (map.get(key) ?? 0) + amount); } +function addToSetMap(map, key, value) { + const values = map.get(key) ?? new Set(); + values.add(value); + map.set(key, values); +} + function collectMatches(content, pattern) { pattern.lastIndex = 0; return Array.from(content.matchAll(pattern)); } +function previousNonWhitespace(chars) { + for (let index = chars.length - 1; index >= 0; index -= 1) { + const char = chars[index]; + if (!/\s/.test(char)) { + return char; + } + } + return null; +} + +function isRegexLiteralStart(chars) { + const previous = previousNonWhitespace(chars); + return previous == null || '([{=,:;!?&|+-*~^<>'.includes(previous); +} + +function stripCommentsForAudit(content, { stripLineComments = true } = {}) { + const result = []; + let state = 'code'; + let regexCharClass = false; + let returnState = 'code'; + let commentReturnState = 'code'; + let templateExpressionDepth = 0; + const templateReturnStates = []; + + for (let index = 0; index < content.length; index += 1) { + const char = content[index]; + const next = content[index + 1]; + + if (state === 'line-comment') { + if (char === '\n' || char === '\r') { + state = commentReturnState; + result.push(char); + } else { + result.push(' '); + } + continue; + } + + if (state === 'block-comment') { + if (char === '*' && next === '/') { + result.push(' ', ' '); + index += 1; + state = commentReturnState; + } else { + result.push(char === '\n' || char === '\r' ? char : ' '); + } + continue; + } + + if (state === 'regex') { + result.push(char); + if (char === '\\') { + index += 1; + if (index < content.length) { + result.push(content[index]); + } + continue; + } + if (char === '[') { + regexCharClass = true; + } else if (char === ']') { + regexCharClass = false; + } else if (char === '/' && !regexCharClass) { + state = returnState; + } + continue; + } + + if (state === 'single-quote' || state === 'double-quote') { + result.push(char); + if (char === '\\') { + index += 1; + if (index < content.length) { + result.push(content[index]); + } + continue; + } + if ( + (state === 'single-quote' && char === "'") + || (state === 'double-quote' && char === '"') + ) { + state = returnState; + } + continue; + } + + if (state === 'template') { + result.push(char); + if (char === '\\') { + index += 1; + if (index < content.length) { + result.push(content[index]); + } + continue; + } + if (char === '$' && next === '{') { + result.push(next); + index += 1; + templateExpressionDepth = 1; + state = 'template-expression'; + continue; + } + if (char === '`') { + state = templateReturnStates.pop() ?? 'code'; + } + continue; + } + + if (state === 'template-expression') { + if (char === '{') { + templateExpressionDepth += 1; + result.push(char); + continue; + } + if (char === '}') { + templateExpressionDepth -= 1; + result.push(char); + if (templateExpressionDepth <= 0) { + templateExpressionDepth = 0; + state = 'template'; + } + continue; + } + } + + if (char === '/' && next === '*') { + result.push(' ', ' '); + index += 1; + commentReturnState = state; + state = 'block-comment'; + continue; + } + + if (stripLineComments && char === '/' && next === '/') { + result.push(' ', ' '); + index += 1; + commentReturnState = state; + state = 'line-comment'; + continue; + } + + if (char === '/' && isRegexLiteralStart(result)) { + regexCharClass = false; + returnState = state; + state = 'regex'; + result.push(char); + continue; + } + + if (char === "'" || char === '"' || char === '`') { + if (char === '`') { + templateReturnStates.push(state); + state = 'template'; + } else { + returnState = state; + state = char === "'" ? 'single-quote' : 'double-quote'; + } + result.push(char); + continue; + } + + result.push(char); + } + + return result.join(''); +} + +function createAuditContent(content, relativePath) { + return stripCommentsForAudit(content, { + stripLineComments: !relativePath.endsWith('.css'), + }); +} + function parseColor(color) { const trimmed = color.trim().toLowerCase(); const hex = /^#([0-9a-f]{3,8})$/.exec(trimmed); @@ -142,6 +432,14 @@ function parseColor(color) { return null; } +function canonicalColorKey(color) { + const parsed = parseColor(color); + if (!parsed) { + return null; + } + return `${parsed.r},${parsed.g},${parsed.b},${parsed.a}`; +} + function colorDistance(a, b) { return Math.sqrt( (a.r - b.r) ** 2 + @@ -150,7 +448,29 @@ function colorDistance(a, b) { ); } -function buildNearColorPairs(colorCounts) { +function colorPairKey(left, right) { + return [left, right].sort((a, b) => a.localeCompare(b)).join(' <-> '); +} + +function buildNearColorPairRow({ a, b, distance, alphaDiff, colorFiles }) { + const aFiles = Array.from(colorFiles.get(a.color) ?? []).sort(); + const bFiles = Array.from(colorFiles.get(b.color) ?? []).sort(); + return { + key: colorPairKey(a.color, b.color), + a: a.color, + b: b.color, + distance, + alphaDiff, + count: a.count + b.count, + files: Array.from(new Set([...aFiles, ...bFiles])).sort().slice(0, 8), + filesByColor: { + [a.color]: aFiles.slice(0, 5), + [b.color]: bFiles.slice(0, 5), + }, + }; +} + +function buildNearColorPairs(colorCounts, colorFiles) { const parsed = Array.from(colorCounts.entries()) .map(([color, count]) => ({ color, count, parsed: parseColor(color) })) .filter(entry => entry.parsed); @@ -165,17 +485,21 @@ function buildNearColorPairs(colorCounts) { const alphaDiff = Math.abs(a.parsed.a - b.parsed.a); const distance = colorDistance(a.parsed, b.parsed); if (distance <= 2 && alphaDiff <= 0.003) { - indistinguishable.push({ a: a.color, b: b.color, distance, alphaDiff, count: a.count + b.count }); + indistinguishable.push(buildNearColorPairRow({ a, b, distance, alphaDiff, colorFiles })); } else if (distance <= 10 && alphaDiff <= 0.03) { - near.push({ a: a.color, b: b.color, distance, alphaDiff, count: a.count + b.count }); + near.push(buildNearColorPairRow({ a, b, distance, alphaDiff, colorFiles })); } } } const byImpact = (a, b) => b.count - a.count || a.distance - b.distance; + indistinguishable.sort(byImpact); + near.sort(byImpact); return { - indistinguishable: indistinguishable.sort(byImpact).slice(0, 50), - near: near.sort(byImpact).slice(0, 50), + indistinguishableTotal: indistinguishable.length, + nearTotal: near.length, + indistinguishable: indistinguishable.slice(0, 50), + near: near.slice(0, 50), }; } @@ -186,39 +510,262 @@ function topEntries(map, limit) { .map(([key, count]) => ({ key, count })); } +function collectTokenAliasDefinitions(files, cwd) { + const definitionsByColorKey = new Map(); + + for (const file of files) { + const relativePath = normalizePath(path.relative(cwd, file)); + if (!isTokenAliasSourceFile(relativePath)) { + continue; + } + const content = createAuditContent(fs.readFileSync(file, 'utf8'), relativePath); + for (const match of collectMatches(content, TOKEN_ALIAS_DEFINITION_PATTERN)) { + const colorKey = canonicalColorKey(match[2]); + if (!colorKey) { + continue; + } + addToSetMap(definitionsByColorKey, colorKey, match[1]); + } + } + + return definitionsByColorKey; +} + +function buildTokenAliasLiteralRows({ + tokenAliasLiteralCounts, + tokenAliasLiteralFiles, + tokenAliasLiteralExamples, + tokenAliasDefinitionsByColorKey, + limit, +}) { + return Array.from(tokenAliasLiteralCounts.entries()) + .map(([colorKey, count]) => ({ + key: Array.from(tokenAliasLiteralExamples.get(colorKey) ?? []).sort().join(' | '), + count, + aliases: Array.from(tokenAliasDefinitionsByColorKey.get(colorKey) ?? []).sort(), + files: Array.from(tokenAliasLiteralFiles.get(colorKey) ?? []).sort().slice(0, 5), + })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) + .slice(0, limit); +} + +function sumMapValues(map) { + return Array.from(map.values()).reduce((sum, count) => sum + count, 0); +} + +function getValueByPath(value, dottedPath) { + return dottedPath.split('.').reduce((current, segment) => { + if (current == null || typeof current !== 'object') { + return undefined; + } + return current[segment]; + }, value); +} + +function resolveBaselinePath(options) { + if (options.noBaseline) { + return null; + } + if (options.baselinePath !== undefined) { + return path.resolve(options.baselinePath); + } + + const root = path.resolve(options.root); + const defaultRoot = path.resolve(DEFAULT_ROOT); + const defaultBaselinePath = path.resolve(DEFAULT_BASELINE_PATH); + if (root === defaultRoot && fs.existsSync(defaultBaselinePath)) { + return defaultBaselinePath; + } + + return null; +} + +function readBaseline(baselinePath) { + try { + return JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse ${normalizePath(path.relative(process.cwd(), baselinePath))}: ${error.message}`); + } +} + +function validateAllowlistEntry(category, entry, index, baselineLabel) { + const prefix = `${baselineLabel} allowlists.${category}[${index}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return [`${prefix} must be an object`]; + } + const failures = []; + for (const field of ['key', 'owner', 'reason']) { + if (typeof entry[field] !== 'string' || entry[field].trim() === '') { + failures.push(`${prefix}.${field} must be a non-empty string`); + } + } + return failures; +} + +function evaluateAllowlistCategory({ report, baseline, baselineLabel, category, reportField }) { + const findings = report[reportField] ?? []; + if (!Array.isArray(findings)) { + return []; + } + + const failures = []; + const allowlists = baseline.allowlists ?? {}; + const entries = allowlists[category]; + if (!Array.isArray(entries)) { + if (findings.length === 0) { + return []; + } + return [`${baselineLabel} allowlists.${category} must be an array because ${reportField} has findings`]; + } + + const findingKeys = new Set(findings.map(entry => entry.key)); + const allowlistKeys = new Set(); + entries.forEach((entry, index) => { + failures.push(...validateAllowlistEntry(category, entry, index, baselineLabel)); + if (typeof entry?.key === 'string') { + allowlistKeys.add(entry.key); + } + }); + + for (const key of findingKeys) { + if (!allowlistKeys.has(key)) { + failures.push(`${category} is missing allowlist entry for ${key}`); + } + } + for (const key of allowlistKeys) { + if (!findingKeys.has(key)) { + failures.push(`${category} allowlist entry ${key} is stale; remove it from ${baselineLabel}.`); + } + } + + return failures; +} + +function applyBaseline(report, options) { + const baselinePath = resolveBaselinePath(options); + const baselineSummary = { + path: baselinePath ? normalizePath(path.relative(process.cwd(), baselinePath)) : null, + enforced: false, + failures: [], + }; + report.summary.baseline = baselineSummary; + + if (!baselinePath) { + return baselineSummary; + } + + if (!fs.existsSync(baselinePath)) { + baselineSummary.failures.push(`Missing ${baselineSummary.path}`); + baselineSummary.enforced = true; + return baselineSummary; + } + + const baseline = readBaseline(baselinePath); + baselineSummary.enforced = true; + const baselineLabel = baselineSummary.path; + + if (baseline.version !== 1) { + baselineSummary.failures.push(`${baselineLabel} must use version 1`); + } + if (!baseline.budgets || typeof baseline.budgets !== 'object' || Array.isArray(baseline.budgets)) { + baselineSummary.failures.push(`${baselineLabel} must define a budgets object`); + return baselineSummary; + } + + for (const [metricPath, budget] of Object.entries(baseline.budgets)) { + if (!budget || typeof budget !== 'object' || Array.isArray(budget)) { + baselineSummary.failures.push(`${baselineLabel} ${metricPath} budget must be an object`); + continue; + } + if (typeof budget.max !== 'number') { + baselineSummary.failures.push(`${baselineLabel} ${metricPath}.max must be a number`); + continue; + } + const actual = getValueByPath(report, metricPath); + if (typeof actual !== 'number') { + baselineSummary.failures.push(`${baselineLabel} references unknown numeric metric ${metricPath}`); + continue; + } + if (actual > budget.max) { + baselineSummary.failures.push(`${metricPath} has ${actual} candidate(s), baseline is ${budget.max}`); + } else if (actual < budget.max) { + baselineSummary.failures.push(`${metricPath} has ${actual} candidate(s), below baseline ${budget.max}; lower ${baselineLabel}.`); + } + } + + baselineSummary.failures.push(...evaluateAllowlistCategory({ + report, + baseline, + baselineLabel, + category: 'nonContractDynamicInputs', + reportField: 'nonContractDynamicInputVars', + })); + baselineSummary.failures.push(...evaluateAllowlistCategory({ + report, + baseline, + baselineLabel, + category: 'nonContractCssPrivate', + reportField: 'nonContractCssPrivateVars', + })); + return baselineSummary; +} + function audit(options) { const root = path.resolve(options.root); + const checksFullThemeSourceRoot = root === path.resolve(DEFAULT_ROOT); const files = walkFiles(root); const cwd = process.cwd(); + const auditedFiles = files.filter(file => !isAuditTestFile(normalizePath(path.relative(cwd, file)))); + const tokenAliasDefinitionsByColorKey = collectTokenAliasDefinitions(auditedFiles, cwd); const colorCounts = new Map(); const componentColorCounts = new Map(); + const componentColorFiles = new Map(); const fallbackTokenCounts = new Map(); + const fallbackTokenFiles = new Map(); const varUsageCounts = new Map(); const varDefinitionCounts = new Map(); + const varDefinitionKinds = new Map(); + const varDefinitionFiles = new Map(); + const contractVarDefinitions = new Set(); + const staticContractVarDefinitions = new Set(); + const runtimeContractVarDefinitions = new Set(); + const varUsageFiles = new Map(); + const dynamicDefinitionPrefixes = new Set(); + const dynamicDefinitionFiles = new Map(); const fileColorCounts = new Map(); const componentFileColorCounts = new Map(); const exceptionColorCounts = new Map(); const tokenColorCounts = new Map(); + const colorDomainCounts = new Map(); + const colorDomainFiles = new Map(); + const tokenAliasLiteralCounts = new Map(); + const tokenAliasLiteralFiles = new Map(); + const tokenAliasLiteralExamples = new Map(); let colorOccurrences = 0; let componentColorOccurrences = 0; let fallbackOccurrences = 0; - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); + for (const file of auditedFiles) { const relativePath = normalizePath(path.relative(cwd, file)); + const content = createAuditContent(fs.readFileSync(file, 'utf8'), relativePath); const tokenFile = isTokenFile(relativePath); const exceptionFile = isExceptionFile(relativePath); + const colorDomain = getColorDomain(relativePath); const colors = collectMatches(content, COLOR_PATTERN).map(match => match[0]); if (colors.length > 0) { fileColorCounts.set(relativePath, colors.length); + addToSetMap(colorDomainFiles, colorDomain, relativePath); } for (const color of colors) { colorOccurrences += 1; incrementMap(colorCounts, color); + const domainCounts = colorDomainCounts.get(colorDomain) ?? new Map(); + incrementMap(domainCounts, color); + colorDomainCounts.set(colorDomain, domainCounts); if (tokenFile) { incrementMap(tokenColorCounts, color); } else if (exceptionFile) { @@ -226,46 +773,305 @@ function audit(options) { } else { componentColorOccurrences += 1; incrementMap(componentColorCounts, color); + addToSetMap(componentColorFiles, color, relativePath); incrementMap(componentFileColorCounts, relativePath); + + const colorKey = canonicalColorKey(color); + if (colorKey && tokenAliasDefinitionsByColorKey.has(colorKey)) { + incrementMap(tokenAliasLiteralCounts, colorKey); + addToSetMap(tokenAliasLiteralFiles, colorKey, relativePath); + addToSetMap(tokenAliasLiteralExamples, colorKey, color.trim().toLowerCase()); + } } } for (const match of collectMatches(content, CSS_VAR_USAGE_PATTERN)) { incrementMap(varUsageCounts, match[1]); + addToSetMap(varUsageFiles, match[1], relativePath); } for (const match of collectMatches(content, CSS_VAR_DEFINITION_PATTERN)) { incrementMap(varDefinitionCounts, match[2]); + addToSetMap(varDefinitionKinds, match[2], 'css'); + addToSetMap(varDefinitionFiles, match[2], relativePath); + if (isContractVarDefinitionFile(relativePath)) { + contractVarDefinitions.add(match[2]); + } + if (isStaticContractVarDefinitionFile(relativePath)) { + staticContractVarDefinitions.add(match[2]); + } + } + + for (const match of collectMatches(content, CSS_VAR_SET_PROPERTY_PATTERN)) { + incrementMap(varDefinitionCounts, match[1]); + addToSetMap(varDefinitionKinds, match[1], 'runtime'); + addToSetMap(varDefinitionFiles, match[1], relativePath); + if (isContractVarDefinitionFile(relativePath)) { + contractVarDefinitions.add(match[1]); + } + if (isRuntimeContractVarDefinitionFile(relativePath)) { + runtimeContractVarDefinitions.add(match[1]); + } + } + + for (const match of collectMatches(content, CSS_VAR_INLINE_STYLE_PATTERN)) { + incrementMap(varDefinitionCounts, match[1]); + addToSetMap(varDefinitionKinds, match[1], 'inline-style'); + addToSetMap(varDefinitionFiles, match[1], relativePath); + if (isContractVarDefinitionFile(relativePath)) { + contractVarDefinitions.add(match[1]); + } + } + + for (const match of collectMatches(content, CSS_VAR_DYNAMIC_SET_PATTERN)) { + dynamicDefinitionPrefixes.add(match[1]); + addToSetMap(dynamicDefinitionFiles, match[1], relativePath); } for (const match of collectMatches(content, VAR_FALLBACK_PATTERN)) { fallbackOccurrences += 1; incrementMap(fallbackTokenCounts, match[1]); + addToSetMap(fallbackTokenFiles, match[1], relativePath); } } const definedVars = new Set(varDefinitionCounts.keys()); - const undefinedVars = Array.from(varUsageCounts.entries()) - .filter(([name]) => !definedVars.has(name)) - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, 100) + const getDefinitionKinds = name => Array.from(varDefinitionKinds.get(name) ?? ['unknown']).sort(); + const getDefinitionKind = name => { + if (definedVars.has(name)) { + return getDefinitionKinds(name).join('+'); + } + const dynamicPrefix = Array.from(dynamicDefinitionPrefixes).find(prefix => name.startsWith(prefix)); + return dynamicPrefix ? `dynamic-family:${dynamicPrefix}*` : null; + }; + const unresolvedVarEntries = Array.from(varUsageCounts.entries()) + .filter(([name]) => !getDefinitionKind(name)) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + const undefinedVars = unresolvedVarEntries + .slice(0, REPORT_ROW_LIMIT) .map(([key, count]) => ({ key, count })); + const fallbackOnlyEntries = unresolvedVarEntries + .filter(([name]) => fallbackTokenCounts.has(name)); + const fallbackOnlyVars = fallbackOnlyEntries + .slice(0, REPORT_ROW_LIMIT) + .map(([key, count]) => ({ key, count })); + const unresolvedRequiredEntries = unresolvedVarEntries + .filter(([name]) => !fallbackTokenCounts.has(name)); + const unresolvedRequiredVars = unresolvedRequiredEntries + .slice(0, REPORT_ROW_LIMIT) + .map(([key, count]) => ({ + key, + count, + files: Array.from(varUsageFiles.get(key) ?? []).slice(0, 5), + })); + const dynamicDefinedVars = Array.from(varUsageCounts.entries()) + .map(([key, count]) => ({ key, count, kind: getDefinitionKind(key) })) + .filter(entry => entry.kind && entry.kind !== 'css') + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) + .slice(0, REPORT_ROW_LIMIT); + const unregisteredDynamicFamilyEntries = Array.from(dynamicDefinitionPrefixes) + .filter(prefix => !REGISTERED_DYNAMIC_VAR_PREFIXES.has(prefix)) + .sort((a, b) => a.localeCompare(b)) + .map(prefix => ({ + key: prefix, + files: Array.from(dynamicDefinitionFiles.get(prefix) ?? []).sort().slice(0, 5), + })); + const staleRegisteredDynamicFamilyEntries = checksFullThemeSourceRoot + ? Array.from(REGISTERED_DYNAMIC_VAR_PREFIXES) + .filter(prefix => !dynamicDefinitionPrefixes.has(prefix)) + .sort((a, b) => a.localeCompare(b)) + .map(prefix => ({ key: prefix })) + : []; + const nonContractDefinedEntries = Array.from(varUsageCounts.entries()) + .filter(([name]) => definedVars.has(name) && !contractVarDefinitions.has(name)) + .map(([key, count]) => ({ + key, + count, + definitionKinds: getDefinitionKinds(key), + definitionFiles: Array.from(varDefinitionFiles.get(key) ?? []).slice(0, 5), + usageFiles: Array.from(varUsageFiles.get(key) ?? []).slice(0, 5), + usageFileCount: (varUsageFiles.get(key) ?? new Set()).size, + })) + .filter(entry => entry.usageFileCount > 1) + .sort((a, b) => b.usageFileCount - a.usageFileCount || b.count - a.count || a.key.localeCompare(b.key)); + const nonContractDefinedVars = nonContractDefinedEntries; + const nonContractDynamicInputEntries = nonContractDefinedEntries + .filter(entry => entry.definitionKinds.some(kind => kind === 'inline-style' || kind === 'runtime')); + const nonContractDynamicInputVars = nonContractDynamicInputEntries; + const nonContractCssPrivateEntries = nonContractDefinedEntries + .filter(entry => ( + entry.definitionKinds.includes('css') + && !entry.definitionKinds.some(kind => kind === 'inline-style' || kind === 'runtime') + )); + const nonContractCssPrivateVars = nonContractCssPrivateEntries; + const runtimeOnlyRequiredContractEntries = Array.from(varUsageCounts.entries()) + .filter(([name]) => ( + runtimeContractVarDefinitions.has(name) + && !staticContractVarDefinitions.has(name) + && !fallbackTokenCounts.has(name) + )) + .map(([key, count]) => ({ + key, + count, + definitionFiles: Array.from(varDefinitionFiles.get(key) ?? []).slice(0, 5), + usageFiles: Array.from(varUsageFiles.get(key) ?? []).slice(0, 5), + usageFileCount: (varUsageFiles.get(key) ?? new Set()).size, + })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); + const runtimeOnlyRequiredContractVars = runtimeOnlyRequiredContractEntries + .slice(0, REPORT_ROW_LIMIT); - const nearPairs = buildNearColorPairs(componentColorCounts); + const nearPairs = buildNearColorPairs(componentColorCounts, componentColorFiles); const uniqueComponentColors = componentColorCounts.size; + const tokenAliasLiteralRows = buildTokenAliasLiteralRows({ + tokenAliasLiteralCounts, + tokenAliasLiteralFiles, + tokenAliasLiteralExamples, + tokenAliasDefinitionsByColorKey, + limit: options.top, + }); + const colorDomainScopes = Object.fromEntries(COLOR_DOMAIN_KEYS.map(key => { + const counts = colorDomainCounts.get(key) ?? new Map(); + const filesWithColors = colorDomainFiles.get(key) ?? new Set(); + return [key, { + occurrences: sumMapValues(counts), + filesWithColors: filesWithColors.size, + uniqueColors: counts.size, + topColors: topEntries(counts, options.top), + }]; + })); + const fallbackVars = Array.from(fallbackTokenCounts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([key, count]) => ({ + key, + count, + files: Array.from(fallbackTokenFiles.get(key) ?? []).sort().slice(0, 5), + })); + const compatibilityAliasEntries = Array.from(varUsageCounts.entries()) + .map(([key, count]) => { + const contract = resolveCompatibilityAliasContract(key); + if (!contract) { + return null; + } + return { + key, + count, + canonical: contract.canonical, + familyPrefix: contract.familyPrefix ?? null, + files: Array.from(varUsageFiles.get(key) ?? []).sort().slice(0, 5), + }; + }) + .filter(Boolean) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); + const compatibilityAliasFamilyEntries = TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS + .map(contract => { + const usedEntries = Array.from(varUsageCounts.entries()) + .filter(([key]) => key.startsWith(contract.prefix) && key.length > contract.prefix.length); + const usageCount = usedEntries.reduce((total, [, count]) => total + count, 0); + const usedUnique = usedEntries.length; + const isDefined = ( + dynamicDefinitionPrefixes.has(contract.prefix) + || Array.from(definedVars).some(key => key.startsWith(contract.prefix)) + ); + const canonicalIsDefined = ( + dynamicDefinitionPrefixes.has(contract.canonicalPrefix) + || Array.from(definedVars).some(key => key.startsWith(contract.canonicalPrefix)) + ); + return { + key: contract.prefix, + canonical: contract.canonicalPrefix, + count: usageCount, + usedUnique, + defined: isDefined, + canonicalDefined: canonicalIsDefined, + }; + }) + .sort((a, b) => a.key.localeCompare(b.key)); + const missingCompatibilityAliasCanonicalEntries = compatibilityAliasEntries + .filter(entry => entry.familyPrefix && !getDefinitionKind(entry.canonical)) + .map(entry => ({ + key: entry.key, + canonical: entry.canonical, + count: entry.count, + files: entry.files, + })) + .sort((a, b) => a.key.localeCompare(b.key)); + const staleCompatibilityAliasEntries = checksFullThemeSourceRoot + ? TOKEN_COMPATIBILITY_ALIAS_CONTRACTS + .map(contract => ({ + key: contract.key, + canonical: contract.canonical, + definitionKind: getDefinitionKind(contract.key), + canonicalDefinitionKind: getDefinitionKind(contract.canonical), + })) + .filter(entry => !entry.definitionKind || !entry.canonicalDefinitionKind) + .sort((a, b) => a.key.localeCompare(b.key)) + : []; + const staleCompatibilityAliasFamilyEntries = checksFullThemeSourceRoot + ? compatibilityAliasFamilyEntries + .filter(entry => !entry.defined || !entry.canonicalDefined) + .map(entry => ({ key: entry.key, canonical: entry.canonical })) + : []; + const uncontractedFallbackVars = fallbackVars + .filter(entry => !FALLBACK_VAR_CONTRACT_BY_KEY.has(entry.key)) + .map(entry => ({ + key: entry.key, + count: entry.count, + files: entry.files, + })); + const staleFallbackContractEntries = checksFullThemeSourceRoot + ? FALLBACK_VAR_CONTRACTS + .filter(contract => !fallbackTokenCounts.has(contract.key)) + .map(contract => ({ key: contract.key })) + .sort((a, b) => a.key.localeCompare(b.key)) + : []; + const missingColorDomainContractEntries = COLOR_DOMAIN_RULES + .filter(rule => !COLOR_DOMAIN_CONTRACT_BY_KEY.has(rule.key)) + .map(rule => ({ key: rule.key })) + .sort((a, b) => a.key.localeCompare(b.key)); + const staleColorDomainContractEntries = COLOR_DOMAIN_CONTRACTS + .filter(contract => !COLOR_DOMAIN_KEYS.includes(contract.key)) + .map(contract => ({ key: contract.key })) + .sort((a, b) => a.key.localeCompare(b.key)); + const activeUncontractedColorDomainEntries = Object.entries(colorDomainScopes) + .filter(([key, scope]) => ( + key !== 'appUi' + && scope.occurrences > 0 + && !COLOR_DOMAIN_CONTRACT_BY_KEY.has(key) + )) + .map(([key, scope]) => ({ key, count: scope.occurrences })) + .sort((a, b) => a.key.localeCompare(b.key)); return { root: normalizePath(path.relative(cwd, root)) || '.', - filesScanned: files.length, + filesScanned: auditedFiles.length, + ignoredTestFiles: files.length - auditedFiles.length, filesWithColors: fileColorCounts.size, colorOccurrences, uniqueColors: colorCounts.size, + colorScopes: { + appUi: { + occurrences: componentColorOccurrences, + filesWithColors: componentFileColorCounts.size, + uniqueColors: componentColorCounts.size, + }, + token: { + occurrences: sumMapValues(tokenColorCounts), + uniqueColors: tokenColorCounts.size, + }, + exception: { + occurrences: sumMapValues(exceptionColorCounts), + uniqueColors: exceptionColorCounts.size, + }, + }, + colorDomainScopes, componentColorOccurrences, componentFilesWithColors: componentFileColorCounts.size, uniqueComponentColors, tokenUniqueColors: tokenColorCounts.size, exceptionUniqueColors: exceptionColorCounts.size, fallbackOccurrences, + fallbackUniqueTokens: fallbackVars.length, budget: { uniqueAppColorBudget: options.budget, uniqueComponentColors, @@ -274,9 +1080,79 @@ function audit(options) { topColors: topEntries(colorCounts, options.top), topComponentColors: topEntries(componentColorCounts, options.top), topFiles: topEntries(fileColorCounts, options.top), - topFallbackTokens: topEntries(fallbackTokenCounts, options.top), + topFallbackTokens: fallbackVars.slice(0, options.top).map(({ key, count }) => ({ key, count })), + fallbackVars, + fallbackContracts: { + registeredUnique: FALLBACK_VAR_CONTRACTS.length, + uncontractedUnique: uncontractedFallbackVars.length, + staleRegisteredUnique: staleFallbackContractEntries.length, + }, + uncontractedFallbackVars, + staleFallbackContracts: staleFallbackContractEntries, + compatibilityAliases: { + registeredUnique: TOKEN_COMPATIBILITY_ALIAS_CONTRACTS.length, + usedUnique: compatibilityAliasEntries.length, + occurrences: compatibilityAliasEntries.reduce((total, entry) => total + entry.count, 0), + staleRegisteredUnique: staleCompatibilityAliasEntries.length, + familyRegisteredUnique: TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS.length, + familyUsedUnique: compatibilityAliasFamilyEntries.filter(entry => entry.usedUnique > 0).length, + familyOccurrences: compatibilityAliasFamilyEntries.reduce((total, entry) => total + entry.count, 0), + staleRegisteredFamilyUnique: staleCompatibilityAliasFamilyEntries.length, + missingCanonicalUnique: missingCompatibilityAliasCanonicalEntries.length, + top: compatibilityAliasEntries.slice(0, options.top), + families: compatibilityAliasFamilyEntries, + }, + staleCompatibilityAliases: staleCompatibilityAliasEntries, + staleCompatibilityAliasFamilies: staleCompatibilityAliasFamilyEntries, + missingCompatibilityAliasCanonicals: missingCompatibilityAliasCanonicalEntries, + colorDomainContracts: { + registeredUnique: COLOR_DOMAIN_CONTRACTS.length, + missingRegisteredUnique: missingColorDomainContractEntries.length, + staleRegisteredUnique: staleColorDomainContractEntries.length, + activeUncontractedUnique: activeUncontractedColorDomainEntries.length, + }, + missingColorDomainContracts: missingColorDomainContractEntries, + staleColorDomainContracts: staleColorDomainContractEntries, + activeUncontractedColorDomains: activeUncontractedColorDomainEntries, + tokenAliasLiterals: { + occurrences: sumMapValues(tokenAliasLiteralCounts), + uniqueColors: tokenAliasLiteralCounts.size, + top: tokenAliasLiteralRows, + }, undefinedVars, + cssVarDefinitions: { + definedUnique: definedVars.size, + contractDefinedUnique: contractVarDefinitions.size, + staticContractDefinedUnique: staticContractVarDefinitions.size, + runtimeContractDefinedUnique: runtimeContractVarDefinitions.size, + dynamicFamilyPrefixes: Array.from(dynamicDefinitionPrefixes).sort(), + unregisteredDynamicFamilyUnique: unregisteredDynamicFamilyEntries.length, + staleRegisteredDynamicFamilyUnique: staleRegisteredDynamicFamilyEntries.length, + unresolvedUnique: unresolvedVarEntries.length, + fallbackOnlyUnique: fallbackOnlyEntries.length, + unresolvedRequiredUnique: unresolvedRequiredEntries.length, + runtimeOnlyRequiredContractUnique: runtimeOnlyRequiredContractEntries.length, + nonContractCrossFileUnique: nonContractDefinedEntries.length, + nonContractDynamicInputUnique: nonContractDynamicInputEntries.length, + nonContractCssPrivateUnique: nonContractCssPrivateEntries.length, + }, + dynamicDefinedVars, + unregisteredDynamicFamilies: unregisteredDynamicFamilyEntries, + staleRegisteredDynamicFamilies: staleRegisteredDynamicFamilyEntries, + nonContractDefinedVars, + nonContractDynamicInputVars, + nonContractCssPrivateVars, + runtimeOnlyRequiredContractVars, + fallbackOnlyVars, + unresolvedRequiredVars, nearPairs, + summary: { + baseline: { + path: null, + enforced: false, + failures: [], + }, + }, }; } @@ -285,6 +1161,7 @@ function printText(report) { console.log(`Theme color audit: ${report.root}`); console.log(`Files scanned: ${report.filesScanned}`); + console.log(`Ignored test files: ${report.ignoredTestFiles}`); console.log(`Files with colors: ${report.filesWithColors}`); console.log(`Color occurrences: ${report.colorOccurrences}`); console.log(`Unique colors: ${report.uniqueColors}`); @@ -294,6 +1171,29 @@ function printText(report) { console.log(`Unique component color budget: ${report.budget.uniqueAppColorBudget}`); console.log(`Over budget by: ${report.budget.overBudgetBy}`); console.log(`Fallback var occurrences: ${report.fallbackOccurrences}`); + console.log(`Fallback var unique tokens: ${report.fallbackUniqueTokens}`); + console.log(`Token-equivalent app literal occurrences: ${report.tokenAliasLiterals.occurrences}`); + console.log(`Token-equivalent app literal unique colors: ${report.tokenAliasLiterals.uniqueColors}`); + console.log( + `Compatibility aliases: registered=${report.compatibilityAliases.registeredUnique}, ` + + `used=${report.compatibilityAliases.usedUnique}, ` + + `occurrences=${report.compatibilityAliases.occurrences}, ` + + `stale=${report.compatibilityAliases.staleRegisteredUnique}, ` + + `families=${report.compatibilityAliases.familyRegisteredUnique}, ` + + `staleFamilies=${report.compatibilityAliases.staleRegisteredFamilyUnique}, ` + + `missingCanonicals=${report.compatibilityAliases.missingCanonicalUnique}` + ); + console.log( + `Fallback contracts: registered=${report.fallbackContracts.registeredUnique}, ` + + `uncontracted=${report.fallbackContracts.uncontractedUnique}, ` + + `stale=${report.fallbackContracts.staleRegisteredUnique}` + ); + console.log( + `Color domain contracts: registered=${report.colorDomainContracts.registeredUnique}, ` + + `missing=${report.colorDomainContracts.missingRegisteredUnique}, ` + + `stale=${report.colorDomainContracts.staleRegisteredUnique}, ` + + `activeUncontracted=${report.colorDomainContracts.activeUncontractedUnique}` + ); console.log('\nTop colors:'); console.log(printRows(report.topColors)); @@ -301,17 +1201,207 @@ function printText(report) { console.log('\nTop component/non-token colors:'); console.log(printRows(report.topComponentColors)); + console.log('\nColor domain scopes:'); + for (const key of COLOR_DOMAIN_KEYS) { + const scope = report.colorDomainScopes[key]; + if (!scope || scope.occurrences === 0) { + continue; + } + console.log( + ` ${COLOR_DOMAIN_LABELS[key].padEnd(18)} ` + + `occurrences=${scope.occurrences.toString().padStart(4)} ` + + `unique=${scope.uniqueColors.toString().padStart(4)} ` + + `files=${scope.filesWithColors.toString().padStart(3)}` + ); + } + console.log('\nTop files:'); console.log(printRows(report.topFiles)); console.log('\nTop fallback tokens:'); console.log(printRows(report.topFallbackTokens)); - console.log('\nUndefined or dynamically-defined CSS vars (top):'); + console.log('\nUncontracted fallback tokens:'); + console.log(printRows(report.uncontractedFallbackVars.slice(0, 10))); + + console.log('\nStale fallback token contracts:'); + console.log(printRows(report.staleFallbackContracts.slice(0, 10).map(row => ({ ...row, count: 1 })))); + + console.log('\nTop compatibility alias usage:'); + if (report.compatibilityAliases.top.length === 0) { + console.log(' none'); + } else { + for (const row of report.compatibilityAliases.top.slice(0, 10)) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} -> ${row.canonical} files=${row.files.join(', ')}` + ); + } + } + + console.log('\nCompatibility alias families:'); + if (report.compatibilityAliases.families.length === 0) { + console.log(' none'); + } else { + for (const row of report.compatibilityAliases.families) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key}* -> ${row.canonical}* ` + + `usedUnique=${row.usedUnique} defined=${row.defined} canonicalDefined=${row.canonicalDefined}` + ); + } + } + + console.log('\nStale compatibility aliases:'); + if (report.staleCompatibilityAliases.length === 0) { + console.log(' none'); + } else { + for (const row of report.staleCompatibilityAliases.slice(0, 10)) { + console.log(` ${row.key} -> ${row.canonical}`); + } + } + + console.log('\nStale compatibility alias families:'); + if (report.staleCompatibilityAliasFamilies.length === 0) { + console.log(' none'); + } else { + for (const row of report.staleCompatibilityAliasFamilies.slice(0, 10)) { + console.log(` ${row.key}* -> ${row.canonical}*`); + } + } + + console.log('\nMissing compatibility alias family canonicals:'); + if (report.missingCompatibilityAliasCanonicals.length === 0) { + console.log(' none'); + } else { + for (const row of report.missingCompatibilityAliasCanonicals.slice(0, 10)) { + console.log( + ` ${row.key} -> ${row.canonical} ` + + `count=${row.count} files=${row.files.join(', ')}` + ); + } + } + + console.log('\nColor domain contract gaps:'); + const colorDomainGapRows = [ + ...report.missingColorDomainContracts.map(row => ({ ...row, count: 1 })), + ...report.staleColorDomainContracts.map(row => ({ ...row, count: 1 })), + ...report.activeUncontractedColorDomains, + ]; + console.log(printRows(colorDomainGapRows.slice(0, 10))); + + console.log('\nTop token-equivalent app literals:'); + if (report.tokenAliasLiterals.top.length === 0) { + console.log(' none'); + } else { + for (const row of report.tokenAliasLiterals.top) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} ` + + `aliases=${row.aliases.join(', ')} files=${row.files.join(', ')}` + ); + } + } + + console.log('\nUnresolved CSS vars before fallback classification (top):'); console.log(printRows(report.undefinedVars)); + console.log( + `\nCSS var definition coverage: defined=${report.cssVarDefinitions.definedUnique}, ` + + `contractDefined=${report.cssVarDefinitions.contractDefinedUnique}, ` + + `staticContract=${report.cssVarDefinitions.staticContractDefinedUnique}, ` + + `runtimeContract=${report.cssVarDefinitions.runtimeContractDefinedUnique}, ` + + `dynamicFamilies=${report.cssVarDefinitions.dynamicFamilyPrefixes.length}, ` + + `unregisteredDynamicFamilies=${report.cssVarDefinitions.unregisteredDynamicFamilyUnique}, ` + + `staleRegisteredDynamicFamilies=${report.cssVarDefinitions.staleRegisteredDynamicFamilyUnique}, ` + + `unresolved=${report.cssVarDefinitions.unresolvedUnique}, ` + + `fallbackOnly=${report.cssVarDefinitions.fallbackOnlyUnique}, ` + + `requiredMissing=${report.cssVarDefinitions.unresolvedRequiredUnique}, ` + + `runtimeOnlyRequired=${report.cssVarDefinitions.runtimeOnlyRequiredContractUnique}, ` + + `nonContractCrossFile=${report.cssVarDefinitions.nonContractCrossFileUnique}, ` + + `nonContractDynamicInputs=${report.cssVarDefinitions.nonContractDynamicInputUnique}, ` + + `nonContractCssPrivate=${report.cssVarDefinitions.nonContractCssPrivateUnique}` + ); + + console.log('\nDynamic/runtime-defined CSS vars (top):'); + console.log( + report.dynamicDefinedVars + .slice(0, 10) + .map(row => ` ${row.count.toString().padStart(5)} ${row.key} ${row.kind}`) + .join('\n') || ' none' + ); + + console.log('\nUnregistered dynamic CSS var families:'); + if (report.unregisteredDynamicFamilies.length === 0) { + console.log(' none'); + } else { + for (const row of report.unregisteredDynamicFamilies.slice(0, 10)) { + console.log(` ${row.key} definitions=${row.files.join(', ')}`); + } + } + + console.log('\nStale registered dynamic CSS var families:'); + console.log(printRows(report.staleRegisteredDynamicFamilies.slice(0, 10).map(row => ({ ...row, count: 1 })))); + + console.log('\nFallback-only unresolved CSS vars (top):'); + console.log(printRows(report.fallbackOnlyVars.slice(0, 10))); + + console.log('\nNon-contract CSS vars used across files (top):'); + if (report.nonContractDefinedVars.length === 0) { + console.log(' none'); + } else { + for (const row of report.nonContractDefinedVars.slice(0, 10)) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} ` + + `usageFiles=${row.usageFileCount} kinds=${row.definitionKinds.join('+')} ` + + `definitions=${row.definitionFiles.join(', ')}` + ); + } + } + + console.log('\nNon-contract dynamic input CSS vars (top):'); + if (report.nonContractDynamicInputVars.length === 0) { + console.log(' none'); + } else { + for (const row of report.nonContractDynamicInputVars.slice(0, 10)) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} ` + + `usageFiles=${row.usageFileCount} definitions=${row.definitionFiles.join(', ')}` + ); + } + } + + console.log('\nNon-contract component-private CSS vars (top):'); + if (report.nonContractCssPrivateVars.length === 0) { + console.log(' none'); + } else { + for (const row of report.nonContractCssPrivateVars.slice(0, 10)) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} ` + + `usageFiles=${row.usageFileCount} definitions=${row.definitionFiles.join(', ')}` + ); + } + } + + console.log('\nRequired unresolved CSS vars (top):'); + if (report.unresolvedRequiredVars.length === 0) { + console.log(' none'); + } else { + for (const row of report.unresolvedRequiredVars.slice(0, 10)) { + console.log(` ${row.count.toString().padStart(5)} ${row.key} files=${row.files.join(', ')}`); + } + } - console.log('\nIndistinguishable component color pairs (sample):'); - if (report.nearPairs.indistinguishable.length === 0) { + console.log('\nRuntime-only contract CSS vars without fallback (top):'); + if (report.runtimeOnlyRequiredContractVars.length === 0) { + console.log(' none'); + } else { + for (const row of report.runtimeOnlyRequiredContractVars.slice(0, 10)) { + console.log( + ` ${row.count.toString().padStart(5)} ${row.key} ` + + `usageFiles=${row.usageFileCount} definitions=${row.definitionFiles.join(', ')}` + ); + } + } + + console.log(`\nIndistinguishable component color pairs (total=${report.nearPairs.indistinguishableTotal}, sample):`); + if (report.nearPairs.indistinguishableTotal === 0) { console.log(' none'); } else { for (const pair of report.nearPairs.indistinguishable.slice(0, 10)) { @@ -319,8 +1409,8 @@ function printText(report) { } } - console.log('\nNear component color pairs needing evidence (sample):'); - if (report.nearPairs.near.length === 0) { + console.log(`\nNear component color pairs needing evidence (total=${report.nearPairs.nearTotal}, sample):`); + if (report.nearPairs.nearTotal === 0) { console.log(' none'); } else { for (const pair of report.nearPairs.near.slice(0, 10)) { @@ -332,11 +1422,24 @@ function printText(report) { try { const options = parseArgs(process.argv.slice(2)); const report = audit(options); + const baselineSummary = applyBaseline(report, options); + if (options.reportJson) { + const reportJsonPath = path.resolve(options.reportJson); + fs.mkdirSync(path.dirname(reportJsonPath), { recursive: true }); + fs.writeFileSync(reportJsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + } if (options.json) { console.log(JSON.stringify(report, null, 2)); } else { printText(report); } + if (baselineSummary.failures.length > 0) { + console.error('\nTheme color audit baseline failures:'); + for (const failure of baselineSummary.failures) { + console.error(` - ${failure}`); + } + process.exit(1); + } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/scripts/audit-theme-colors.test.mjs b/scripts/audit-theme-colors.test.mjs new file mode 100644 index 0000000000..60aa6f98c2 --- /dev/null +++ b/scripts/audit-theme-colors.test.mjs @@ -0,0 +1,687 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + COLOR_DOMAIN_CONTRACTS, + COLOR_DOMAIN_KEYS, + COLOR_DOMAIN_RULES, + DYNAMIC_VAR_FAMILY_CONTRACTS, + FALLBACK_VAR_CONTRACTS, + TOKEN_COMPATIBILITY_ALIAS_CONTRACTS, + TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS, +} from './theme-css-var-contract.mjs'; + +const root = process.cwd(); + +function writeText(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function createFixture(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-theme-audit-')); + const sourceRoot = path.join(dir, 'src', 'web-ui', 'src'); + for (const [relativePath, content] of Object.entries(files)) { + writeText(path.join(sourceRoot, relativePath), content); + } + return { dir, sourceRoot }; +} + +function runAudit(args) { + return spawnSync(process.execPath, ['scripts/audit-theme-colors.mjs', ...args], { + cwd: root, + encoding: 'utf8', + }); +} + +test('theme CSS var contract registry is explicit and non-overlapping', () => { + const domainKeys = new Set(COLOR_DOMAIN_RULES.map(rule => rule.key)); + assert.equal(domainKeys.size, COLOR_DOMAIN_RULES.length, 'color domain keys must be unique'); + assert.ok(COLOR_DOMAIN_KEYS.includes('appUi'), 'app UI must remain the fallback color domain'); + for (const rule of COLOR_DOMAIN_RULES) { + assert.equal(typeof rule.label, 'string'); + assert.ok(rule.label.trim(), `${rule.key} must have a label`); + assert.ok(Array.isArray(rule.pathParts) && rule.pathParts.length > 0, `${rule.key} must have path parts`); + } + + const dynamicPrefixes = new Set(DYNAMIC_VAR_FAMILY_CONTRACTS.map(contract => contract.prefix)); + assert.equal( + dynamicPrefixes.size, + DYNAMIC_VAR_FAMILY_CONTRACTS.length, + 'dynamic CSS var family prefixes must be unique', + ); + for (const contract of DYNAMIC_VAR_FAMILY_CONTRACTS) { + assert.match(contract.prefix, /^--[a-z0-9-]+-$/); + assert.ok(contract.owner.includes('src/web-ui/src/'), `${contract.prefix} must name a source owner`); + assert.ok(contract.reason.trim().length >= 20, `${contract.prefix} must explain why it is dynamic`); + if (contract.canonicalPrefix !== undefined) { + assert.match(contract.canonicalPrefix, /^--[a-z0-9-]+-$/); + } + } + + const domainContractKeys = new Set(COLOR_DOMAIN_CONTRACTS.map(contract => contract.key)); + assert.equal( + domainContractKeys.size, + COLOR_DOMAIN_CONTRACTS.length, + 'color domain contracts must be unique', + ); + assert.deepEqual( + [...domainContractKeys].sort(), + COLOR_DOMAIN_RULES.map(rule => rule.key).sort(), + 'every specialized color domain must have an owner contract', + ); + for (const contract of COLOR_DOMAIN_CONTRACTS) { + assert.ok(contract.owner.includes('src/web-ui/src/'), `${contract.key} must name a source owner`); + assert.ok(contract.reason.trim().length >= 30, `${contract.key} must explain why the domain exists`); + assert.ok(contract.mergePolicy.trim().length >= 30, `${contract.key} must define a merge policy`); + } + + const compatibilityAliasKeys = new Set(TOKEN_COMPATIBILITY_ALIAS_CONTRACTS.map(contract => contract.key)); + assert.equal( + compatibilityAliasKeys.size, + TOKEN_COMPATIBILITY_ALIAS_CONTRACTS.length, + 'compatibility alias keys must be unique', + ); + for (const contract of TOKEN_COMPATIBILITY_ALIAS_CONTRACTS) { + assert.match(contract.key, /^--[a-z0-9-]+$/); + assert.match(contract.canonical, /^--[a-z0-9-]+$/); + assert.notEqual(contract.key, contract.canonical, `${contract.key} must point to a different canonical token`); + assert.ok(contract.owner.includes('src/web-ui/src/'), `${contract.key} must name a source owner`); + assert.ok(contract.reason.trim().length >= 30, `${contract.key} must explain compatibility need`); + assert.ok(contract.removal.trim().length >= 30, `${contract.key} must define retirement criteria`); + } + + const compatibilityAliasPrefixes = new Set(TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS.map(contract => contract.prefix)); + assert.equal( + compatibilityAliasPrefixes.size, + TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS.length, + 'compatibility alias family prefixes must be unique', + ); + for (const contract of TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS) { + assert.match(contract.prefix, /^--[a-z0-9-]+-$/); + assert.match(contract.canonicalPrefix, /^--[a-z0-9-]+-$/); + assert.notEqual(contract.prefix, contract.canonicalPrefix, `${contract.prefix} must point to a different family`); + assert.ok(contract.owner.includes('src/web-ui/src/'), `${contract.prefix} must name a source owner`); + assert.ok(contract.reason.trim().length >= 30, `${contract.prefix} must explain compatibility need`); + assert.ok(contract.removal.trim().length >= 30, `${contract.prefix} must define retirement criteria`); + } + + const fallbackContractKeys = new Set(FALLBACK_VAR_CONTRACTS.map(contract => contract.key)); + assert.equal(fallbackContractKeys.size, FALLBACK_VAR_CONTRACTS.length, 'fallback contracts must be unique'); + for (const contract of FALLBACK_VAR_CONTRACTS) { + assert.match(contract.key, /^--[a-z0-9-]+$/); + assert.ok(contract.owner.includes('src/web-ui/src/'), `${contract.key} must name a source owner`); + assert.ok(contract.reason.trim().length >= 30, `${contract.key} must explain why fallback is intentional`); + assert.ok(contract.boundary.trim().length >= 10, `${contract.key} must classify the fallback boundary`); + } +}); + +test('repository dynamic CSS var families match the registered contract', () => { + const result = runAudit(['--json', '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = JSON.parse(result.stdout); + const registeredPrefixes = DYNAMIC_VAR_FAMILY_CONTRACTS + .map(contract => contract.prefix) + .sort(); + assert.deepEqual(report.cssVarDefinitions.dynamicFamilyPrefixes, registeredPrefixes); + assert.equal(report.cssVarDefinitions.unregisteredDynamicFamilyUnique, 0); + assert.equal(report.cssVarDefinitions.staleRegisteredDynamicFamilyUnique, 0); + assert.equal(report.compatibilityAliases.staleRegisteredUnique, 0); + assert.equal(report.compatibilityAliases.staleRegisteredFamilyUnique, 0); + assert.equal(report.compatibilityAliases.missingCanonicalUnique, 0); + assert.equal(report.fallbackContracts.uncontractedUnique, 0); + assert.equal(report.fallbackContracts.staleRegisteredUnique, 0); + assert.equal(report.colorDomainContracts.missingRegisteredUnique, 0); + assert.equal(report.colorDomainContracts.staleRegisteredUnique, 0); + assert.equal(report.colorDomainContracts.activeUncontractedUnique, 0); +}); + +test('theme color audit reports alias family usages whose exact canonical key is missing', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': [ + ':root {', + ' --size-radius-sm: 6px;', + ' --radius-sm: var(--size-radius-sm);', + ' --radius-ghost: 10px;', + '}', + '', + ].join('\n'), + 'app/App.scss': [ + '.app {', + ' border-radius: var(--radius-ghost);', + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = runAudit(['--root', sourceRoot, '--json', '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = JSON.parse(result.stdout); + assert.equal(report.compatibilityAliases.missingCanonicalUnique, 1); + assert.deepEqual( + report.missingCompatibilityAliasCanonicals.map(row => [row.key, row.canonical]), + [['--radius-ghost', '--size-radius-ghost']], + ); +}); + +test('theme color audit emits scoped machine-readable reports', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': [ + ':root {', + ' --color-text-primary: #111111;', + ' --static-only: #222222;', + '}', + '', + ].join('\n'), + 'infrastructure/theme/core/ThemeService.ts': [ + "document.documentElement.style.setProperty('--runtime-only', '#333333');", + '', + ].join('\n'), + 'app/App.scss': [ + '.app {', + ' color: #444444;', + ' background: var(--fallback-only, #ffffff);', + ' border-color: var(--runtime-only);', + '}', + '', + ].join('\n'), + 'tools/mermaid-editor/theme/mermaidTheme.ts': "export const line = '#555555';\n", + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const result = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = readJson(reportPath); + assert.equal(report.colorScopes.appUi.uniqueColors, 2); + assert.equal(report.colorScopes.token.uniqueColors, 3); + assert.equal(report.colorScopes.exception.uniqueColors, 1); + assert.equal(report.tokenAliasLiterals.occurrences, 0); + assert.equal(report.tokenAliasLiterals.uniqueColors, 0); + assert.equal(report.cssVarDefinitions.runtimeOnlyRequiredContractUnique, 1); + assert.equal(report.cssVarDefinitions.unregisteredDynamicFamilyUnique, 0); + assert.equal(report.cssVarDefinitions.staleRegisteredDynamicFamilyUnique, 0); + assert.equal(report.summary.baseline.enforced, false); +}); + +test('theme color audit reports compatibility alias usage without treating it as raw color debt', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': [ + ':root {', + ' --color-accent-500: #60a5fa;', + ' --color-primary: var(--color-accent-500);', + ' --size-radius-sm: 6px;', + ' --radius-sm: var(--size-radius-sm);', + '}', + '', + ].join('\n'), + 'app/App.scss': [ + '.app {', + ' color: var(--color-primary);', + ' border-radius: var(--radius-sm);', + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = runAudit(['--root', sourceRoot, '--json', '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = JSON.parse(result.stdout); + assert.equal(report.compatibilityAliases.usedUnique, 2); + assert.equal(report.compatibilityAliases.occurrences, 2); + assert.equal(report.compatibilityAliases.familyUsedUnique, 1); + assert.equal(report.compatibilityAliases.familyOccurrences, 1); + assert.equal(report.compatibilityAliases.missingCanonicalUnique, 0); + assert.deepEqual( + report.compatibilityAliases.top.map(row => [row.key, row.canonical]), + [ + ['--color-primary', '--color-accent-500'], + ['--radius-sm', '--size-radius-sm'], + ], + ); + assert.equal(report.colorScopes.appUi.occurrences, 0); +}); + +test('theme color audit reports fallback tokens that lack a boundary contract', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/App.scss': [ + '.app {', + ' color: var(--runtime-accent, var(--color-accent-500));', + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = runAudit(['--root', sourceRoot, '--json', '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = JSON.parse(result.stdout); + assert.equal(report.fallbackContracts.uncontractedUnique, 1); + assert.deepEqual(report.uncontractedFallbackVars.map(row => row.key), ['--runtime-accent']); +}); + +test('theme color audit reports specialized color domains separately from app UI', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': ':root { --color-text-primary: #111111; }\n', + 'infrastructure/theme/presets/dark-theme.ts': "export const bg = '#222222';\n", + 'tools/mermaid-editor/theme/mermaidTheme.ts': "export const node = '#333333';\n", + 'tools/editor/themes/bitfun-dark.theme.ts': "export const editorBg = '#444444';\n", + 'shared/prism/prismTheme.ts': "export const prism = { keyword: '#555555' };\n", + 'tools/terminal/utils/xtermTheme.ts': "export const cursor = '#c0c0c0';\n", + 'tools/generative-widget/themePayload.ts': "export const fallback = { '--color-text-primary': '#666666' };\n", + 'shared/theme/themeBoundaryFallbacks.ts': "export const fallback = { text: '#999000' };\n", + 'shared/inspector/inspectorOverlayTheme.ts': "export const overlay = { activeBorder: '#777777' };\n", + 'shared/theme/uiExceptionAccents.ts': "export const accents = { tool: '#dddddd' };\n", + 'shared/theme/languageIdentityAccents.ts': "export const accents = { rust: '#aa5500' };\n", + 'infrastructure/language-detection/core/LanguageRegistry.ts': "export const rust = '#888888';\n", + 'component-library/components/TextStrokeEffect/TextStrokeEffect.tsx': "export const stroke = '#999999';\n", + 'component-library/components/StreamText/StreamText.scss': ".stream { color: #bbbbbb; }\n", + 'app/tools/mermaid-editorish/FakePanel.ts': "export const fake = '#cccccc';\n", + 'app/App.scss': '.app { color: #aaaaaa; }\n', + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const result = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = readJson(reportPath); + assert.equal(report.colorDomainScopes.tokenContract.uniqueColors, 1); + assert.equal(report.colorDomainScopes.themePreset.uniqueColors, 1); + assert.equal(report.colorDomainScopes.mermaid.uniqueColors, 1); + assert.equal(report.colorDomainScopes.editor.uniqueColors, 1); + assert.equal(report.colorDomainScopes.syntax.uniqueColors, 1); + assert.equal(report.colorDomainScopes.terminal.uniqueColors, 1); + assert.equal(report.colorDomainScopes.generatedWidget.uniqueColors, 1); + assert.equal(report.colorDomainScopes.boundaryFallback.uniqueColors, 1); + assert.equal(report.colorDomainScopes.debugOverlay.uniqueColors, 1); + assert.equal(report.colorDomainScopes.uiException.uniqueColors, 1); + assert.equal(report.colorDomainScopes.languageIdentity.uniqueColors, 2); + assert.equal(report.colorDomainScopes.visualEffect.uniqueColors, 2); + assert.equal(report.colorDomainScopes.appUi.uniqueColors, 2); +}); + +test('theme color audit ignores comment-only color-like text', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/App.tsx': [ + 'export const real = "#123456";', + '// issue #1176 should not be counted as a color', + '// comment mentions `template` before issue #2026', + 'const escaped = real.replace(/["\\\\]/g, "\\\\$&"); // issue #3456 after a regex', + 'const interpolated = `${real /* issue #7890 inside a template expression */}`;', + 'const url = "https://example.com/#keep-strings";', + '/*', + ' * retired value: #abcdef', + ' */', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const result = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = readJson(reportPath); + assert.equal(report.colorOccurrences, 1); + assert.equal(report.uniqueColors, 1); + assert.equal(report.topColors[0].key, '#123456'); + assert.equal(report.colorDomainScopes.appUi.uniqueColors, 1); +}); + +test('theme color audit keeps template literal and expression color values', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/App.tsx': [ + 'export const literal = `#abcdef`;', + 'export const expression = `${enabled ? "#654321" : "#111111"}`;', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const result = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = readJson(reportPath); + assert.equal(report.colorOccurrences, 3); + assert.equal(report.uniqueColors, 3); + assert.deepEqual(new Set(report.topColors.map(entry => entry.key)), new Set(['#abcdef', '#654321', '#111111'])); + assert.equal(report.colorDomainScopes.appUi.uniqueColors, 3); +}); + +test('theme color audit counts full CSS var governance debt before row truncation', (t) => { + const missingRules = Array.from( + { length: 101 }, + (_, index) => `.missing-${index} { color: var(--missing-${index}); }`, + ); + const fallbackRules = Array.from( + { length: 101 }, + (_, index) => `.fallback-${index} { color: var(--fallback-${index}, #ffffff); }`, + ); + const runtimeDefinitions = Array.from( + { length: 101 }, + (_, index) => `document.documentElement.style.setProperty('--runtime-${index}', '#ffffff');`, + ); + const runtimeRules = Array.from( + { length: 101 }, + (_, index) => `.runtime-${index} { color: var(--runtime-${index}); }`, + ); + const looseStyleEntries = Array.from( + { length: 101 }, + (_, index) => ` '--loose-${index}': 'red',`, + ); + const looseRules = Array.from( + { length: 101 }, + (_, index) => `.loose-${index} { color: var(--loose-${index}); }`, + ); + const { dir, sourceRoot } = createFixture({ + 'infrastructure/theme/core/ThemeService.ts': `${runtimeDefinitions.join('\n')}\n`, + 'app/App.scss': `${missingRules.join('\n')}\n${fallbackRules.join('\n')}\n${runtimeRules.join('\n')}\n`, + 'app/LooseVar.tsx': [ + 'export function LooseVar() {', + ' return
;', + '}', + '', + ].join('\n'), + 'app/LooseVarA.scss': `${looseRules.join('\n')}\n`, + 'app/LooseVarB.scss': `${looseRules.join('\n')}\n`, + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const result = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = readJson(reportPath); + assert.equal(report.cssVarDefinitions.unresolvedUnique, 202); + assert.equal(report.cssVarDefinitions.fallbackOnlyUnique, 101); + assert.equal(report.cssVarDefinitions.unresolvedRequiredUnique, 101); + assert.equal(report.cssVarDefinitions.runtimeOnlyRequiredContractUnique, 101); + assert.equal(report.cssVarDefinitions.nonContractCrossFileUnique, 101); + assert.equal(report.cssVarDefinitions.nonContractDynamicInputUnique, 101); + assert.equal(report.undefinedVars.length, 100); + assert.equal(report.fallbackOnlyVars.length, 100); + assert.equal(report.unresolvedRequiredVars.length, 100); + assert.equal(report.runtimeOnlyRequiredContractVars.length, 100); + assert.equal(report.nonContractDynamicInputVars.length, 101); +}); + +test('theme color audit reports app literals that duplicate token values', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': [ + '$color-accent-600: #3b82f6;', + '$color-warning: #f59e0b;', + '', + ].join('\n'), + 'app/App.scss': [ + '.app {', + ' color: #3b82f6;', + ' border-color: rgb(245, 158, 11);', + '}', + '', + ].join('\n'), + 'tools/mermaid-editor/theme/mermaidTheme.ts': "export const accent = '#3b82f6';\n", + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const result = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = readJson(reportPath); + assert.equal(report.tokenAliasLiterals.occurrences, 2); + assert.equal(report.tokenAliasLiterals.uniqueColors, 2); + assert.deepEqual( + report.tokenAliasLiterals.top.map(row => row.aliases), + [['$color-accent-600'], ['$color-warning']], + ); + assert.equal(report.colorScopes.exception.occurrences, 1); +}); + +test('theme color audit reports near color pair sources and enforces pair budgets', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/One.scss': '.one { color: #111111; }\n', + 'app/Two.scss': '.two { color: #111112; }\n', + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const reportPath = path.join(dir, 'theme-report.json'); + + const reportResult = runAudit(['--root', sourceRoot, '--report-json', reportPath, '--no-baseline']); + assert.equal(reportResult.status, 0, reportResult.stderr || reportResult.stdout); + assert.match(reportResult.stdout, /Indistinguishable component color pairs \(total=1, sample\):/); + + const report = readJson(reportPath); + assert.equal(report.nearPairs.indistinguishableTotal, 1); + assert.equal(report.nearPairs.indistinguishable.length, 1); + assert.equal(report.nearPairs.indistinguishable[0].key, '#111111 <-> #111112'); + assert.deepEqual( + report.nearPairs.indistinguishable[0].files.map(file => file.replace(/\\/g, '/').split('/').slice(-2).join('/')), + ['app/One.scss', 'app/Two.scss'], + ); + + const baselinePath = path.join(dir, 'theme-baseline.json'); + writeText(baselinePath, `${JSON.stringify({ + version: 1, + budgets: { + 'nearPairs.indistinguishableTotal': { max: 0 }, + }, + }, null, 2)}\n`); + + const blocked = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.notEqual(blocked.status, 0, 'new indistinguishable color pairs must fail the audit'); + assert.match( + `${blocked.stdout}\n${blocked.stderr}`, + /nearPairs\.indistinguishableTotal has 1 candidate\(s\), baseline is 0/, + ); +}); + +test('theme color audit excludes test files from production color budgets', (t) => { + const { dir, sourceRoot } = createFixture({ + 'component-library/styles/tokens.scss': ':root { --color-error: #ef4444; }\n', + 'app/App.scss': '.app { color: #ef4444; }\n', + 'app/App.test.tsx': "expect(button).toHaveStyle({ color: '#ef4444' });\n", + 'app/__tests__/Fixture.tsx': "export const visualLock = '#ef4444';\n", + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const result = runAudit(['--root', sourceRoot, '--json', '--no-baseline']); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const report = JSON.parse(result.stdout); + assert.equal(report.filesScanned, 2); + assert.equal(report.ignoredTestFiles, 2); + assert.equal(report.colorScopes.appUi.occurrences, 1); + assert.equal(report.tokenAliasLiterals.occurrences, 1); +}); + +test('theme color audit fails when metrics exceed the checked baseline', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/App.scss': [ + '.app {', + ' color: var(--missing, #ffffff);', + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const baselinePath = path.join(dir, 'theme-baseline.json'); + writeText(baselinePath, `${JSON.stringify({ + version: 1, + budgets: { + fallbackOccurrences: { max: 0 }, + }, + }, null, 2)}\n`); + + const result = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.notEqual(result.status, 0, 'fallback growth over baseline must fail the audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /fallbackOccurrences has 1 candidate\(s\), baseline is 0/, + ); +}); + +test('theme color audit fails when fallback tokens lack a boundary contract', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/App.scss': [ + '.app {', + ' color: var(--runtime-accent, var(--color-accent-500));', + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const baselinePath = path.join(dir, 'theme-baseline.json'); + writeText(baselinePath, `${JSON.stringify({ + version: 1, + budgets: { + fallbackUniqueTokens: { max: 1 }, + 'fallbackContracts.uncontractedUnique': { max: 0 }, + }, + }, null, 2)}\n`); + + const result = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.notEqual(result.status, 0, 'uncontracted fallback tokens must fail the audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /fallbackContracts\.uncontractedUnique has 1 candidate\(s\), baseline is 0/, + ); +}); + +test('theme color audit requires dynamic CSS var families to be registered', (t) => { + const { dir, sourceRoot } = createFixture({ + 'infrastructure/theme/core/ThemeService.ts': [ + "for (const [key, value] of Object.entries(theme.extra)) {", + " document.documentElement.style.setProperty(`--unregistered-${key}`, value);", + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const baselinePath = path.join(dir, 'theme-baseline.json'); + writeText(baselinePath, `${JSON.stringify({ + version: 1, + budgets: { + 'cssVarDefinitions.unregisteredDynamicFamilyUnique': { max: 0 }, + }, + }, null, 2)}\n`); + + const result = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.notEqual(result.status, 0, 'unregistered dynamic CSS var families must fail the audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /cssVarDefinitions\.unregisteredDynamicFamilyUnique has 1 candidate\(s\), baseline is 0/, + ); + assert.match(`${result.stdout}\n${result.stderr}`, /--unregistered-/); +}); + +test('theme color audit accepts registered dynamic CSS var families', (t) => { + const { dir, sourceRoot } = createFixture({ + 'infrastructure/theme/core/ThemeService.ts': [ + "for (const [key, value] of Object.entries(theme.effects.spacing)) {", + " document.documentElement.style.setProperty(`--spacing-${key}`, value);", + '}', + '', + ].join('\n'), + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const baselinePath = path.join(dir, 'theme-baseline.json'); + writeText(baselinePath, `${JSON.stringify({ + version: 1, + budgets: { + 'cssVarDefinitions.unregisteredDynamicFamilyUnique': { max: 0 }, + }, + }, null, 2)}\n`); + + const result = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + +test('theme color audit requires non-contract cross-file vars to be explicitly allowlisted', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/LooseVar.tsx': [ + "export function LooseVar() {", + " return
;", + '}', + '', + ].join('\n'), + 'app/LooseVar.scss': '.one { color: var(--loose-var); }\n', + 'app/LooseVarOther.scss': '.two { border-color: var(--loose-var); }\n', + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const baselinePath = path.join(dir, 'theme-baseline.json'); + const baseline = { + version: 1, + budgets: { + 'cssVarDefinitions.nonContractDynamicInputUnique': { max: 1 }, + }, + allowlists: { + nonContractDynamicInputs: [], + }, + }; + writeText(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`); + + const blocked = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.notEqual(blocked.status, 0, 'unallowlisted dynamic input vars must fail the audit'); + assert.match( + `${blocked.stdout}\n${blocked.stderr}`, + /nonContractDynamicInputs is missing allowlist entry for --loose-var/, + ); + + baseline.allowlists.nonContractDynamicInputs.push({ + key: '--loose-var', + owner: 'scripts/audit-theme-colors.test.mjs', + reason: 'fixture dynamic input token', + }); + writeText(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`); + + const allowed = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.equal(allowed.status, 0, allowed.stderr || allowed.stdout); +}); + +test('theme color audit fails stale non-contract var allowlist entries', (t) => { + const { dir, sourceRoot } = createFixture({ + 'app/App.scss': '.app { color: #ffffff; }\n', + }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const baselinePath = path.join(dir, 'theme-baseline.json'); + writeText(baselinePath, `${JSON.stringify({ + version: 1, + budgets: { + 'cssVarDefinitions.nonContractDynamicInputUnique': { max: 0 }, + }, + allowlists: { + nonContractDynamicInputs: [ + { + key: '--removed-var', + owner: 'scripts/audit-theme-colors.test.mjs', + reason: 'fixture stale allowlist token', + }, + ], + }, + }, null, 2)}\n`); + + const result = runAudit(['--root', sourceRoot, '--baseline', baselinePath]); + assert.notEqual(result.status, 0, 'stale dynamic input allowlist entries must fail the audit'); + assert.match( + `${result.stdout}\n${result.stderr}`, + /nonContractDynamicInputs allowlist entry --removed-var is stale/, + ); +}); diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 548b9ad387..8c0cc7168a 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,6 +1,21 @@ // Boundary rules for source ownership, facades, and required owner content. export const forbiddenContentRules = [ + { + path: 'src/crates/execution/agent-runtime/tests/sdk_smoke.rs', + patterns: [ + { + regex: /\bbitfun_runtime_services::test_support\b/, + message: + 'agent-runtime SDK smoke tests must prove the public sdk facade is enough; do not rely on runtime-services test_support', + }, + { + regex: /\bFakeRuntimeServicesProvider\b/, + message: + 'agent-runtime SDK smoke tests must build fake services through sdk-reexported ports and RuntimeServicesBuilder', + }, + ], + }, { path: 'src/crates/contracts/core-types/src/ai.rs', patterns: [ @@ -1175,6 +1190,46 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/assembly/core/src/agentic/tools/computer_use_optimizer.rs', + patterns: [ + { + regex: /\bpub struct ComputerUseOptimizer\b/, + message: + 'core Computer Use optimizer facade must not own optimizer state; use tool-runtime computer_use', + }, + { + regex: /\bVecDeque\b/, + message: + 'core Computer Use optimizer facade must not own action history storage; use tool-runtime computer_use', + }, + { + regex: /\bpub fn hash_screenshot_bytes\b/, + message: + 'core Computer Use optimizer facade must not own screenshot hashing; use tool-runtime computer_use', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/agentic/tools/computer_use_verification.rs', + patterns: [ + { + regex: /\bpub struct VerificationResult\b/, + message: + 'core Computer Use verification facade must not own verification contracts; use tool-runtime computer_use', + }, + { + regex: /\bpub struct RetryStrategy\b/, + message: + 'core Computer Use verification facade must not own retry strategy state; use tool-runtime computer_use', + }, + { + regex: /\bpub fn detect_visual_change\b/, + message: + 'core Computer Use verification facade must not own visual-change logic; use tool-runtime computer_use', + }, + ], + }, { path: 'src/crates/assembly/core/src/agentic/session/turn_skill_agent_snapshot_store.rs', patterns: [ diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index e8e49d20f8..434d377b42 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -18,6 +18,10 @@ export const requiredContentRules = [ regex: /\bpub struct CapabilityAvailability\b/, message: 'missing capability availability contract', }, + { + regex: /\bpub struct RuntimeServiceMarkerPort\b/, + message: 'missing runtime service marker port owner', + }, { regex: /\bpub trait RuntimeServicesProvider\b/, message: 'missing runtime services provider contract', @@ -99,6 +103,10 @@ export const requiredContentRules = [ regex: /\bregistered_remote_ports_expose_owner_contract_methods\b/, message: 'missing remote port owner contract regression', }, + { + regex: /\bmarker_ports_register_optional_service_availability_without_core_dependency\b/, + message: 'missing marker-port capability availability regression', + }, ], }, { @@ -194,6 +202,22 @@ export const requiredContentRules = [ regex: /\bpub fn with_event_stream\b/, message: 'missing agent runtime event stream builder hook', }, + { + regex: /\bpub trait RuntimeToolRegistry\b/, + message: 'missing SDK tool registry abstraction', + }, + { + regex: /\bpub fn with_tool_registry\b/, + message: 'missing SDK tool registry builder hook', + }, + { + regex: /\bpub fn with_harness_registry\b/, + message: 'missing SDK harness registry builder hook', + }, + { + regex: /\bpub fn with_hook_registry\b/, + message: 'missing SDK hook registry builder hook', + }, { regex: /\bpub enum SessionSelector\b/, message: 'missing session selector contract', @@ -228,6 +252,126 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/agent-runtime/tests/sdk_smoke.rs', + reason: + 'agent-runtime SDK smoke tests must prove the facade works with injected fake provider, services, tools, harnesses, and hooks without core', + patterns: [ + { + regex: /\bsdk_facade_exposes_versioned_preview_compatibility_contract\b/, + message: 'missing SDK API version and compatibility smoke', + }, + { + regex: /\bsdk_facade_runs_with_fake_provider_and_local_event_stream\b/, + message: 'missing SDK fake-provider event-stream smoke', + }, + { + regex: /\bsdk_facade_accepts_fake_services_tools_harnesses_and_hooks_without_core\b/, + message: 'missing SDK services/tools/harnesses/hooks injection smoke', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/sdk.rs', + reason: + 'agent-runtime SDK public facade must expose versioned compatibility metadata and only stable injection contracts', + patterns: [ + { + regex: /\bpub const AGENT_RUNTIME_SDK_API_VERSION\b/, + message: 'missing SDK API version constant', + }, + { + regex: /\bpub enum AgentRuntimeSdkStability\b/, + message: 'missing SDK stability contract', + }, + { + regex: /#\[non_exhaustive\]\s+pub enum AgentRuntimeSdkStability/s, + message: 'SDK stability contract must remain externally extensible', + }, + { + regex: /\bpub struct AgentRuntimeSdkCompatibility\b/, + message: 'missing SDK compatibility contract', + }, + { + regex: /#\[non_exhaustive\]\s+pub struct AgentRuntimeSdkCompatibility/s, + message: 'SDK compatibility contract must remain externally extensible', + }, + { + regex: /\bimpl AgentRuntimeSdkCompatibility\b/, + message: 'missing current SDK compatibility entrypoint', + }, + { + regex: /\bpub use bitfun_agent_tools::\{/, + message: 'missing SDK tool registry re-exports', + }, + { + regex: /\bpub use bitfun_harness::\{/, + message: 'missing SDK harness registry re-exports', + }, + { + regex: /\bpub use bitfun_runtime_services::\{/, + message: 'missing SDK runtime-services re-exports', + }, + { + regex: /\bPortResult\b/, + message: 'missing SDK port result re-export', + }, + { + regex: /\bRuntimeServicePort\b/, + message: 'missing SDK runtime service port re-export', + }, + { + regex: /\bFileSystemPort\b/, + message: 'missing SDK filesystem service port re-export', + }, + { + regex: /\bRemoteWorkspacePort\b/, + message: 'missing SDK remote workspace service port re-export', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/Cargo.toml', + reason: + 'agent-runtime SDK package must keep an explicit empty default feature set for minimal embedders', + patterns: [ + { + regex: /\[features\]/, + message: 'missing explicit SDK feature section', + }, + { + regex: /default = \[\]/, + message: 'agent-runtime default feature set must stay empty', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/examples/sdk_minimal.rs', + reason: + 'agent-runtime SDK must keep a minimal external embedder example that uses the sdk facade without core', + patterns: [ + { + regex: /\buse bitfun_agent_runtime::sdk::\{/, + message: 'SDK example must import through the public sdk facade', + }, + { + regex: /\bAgentRuntimeSdkCompatibility::current\b/, + message: 'SDK example must expose the compatibility contract', + }, + { + regex: /\bimpl AgentSubmissionPort for ExampleAgentProvider\b/, + message: 'SDK example must show caller-provided submission port injection', + }, + { + regex: /\bAgentRuntimeBuilder::new\(\)/, + message: 'SDK example must build through AgentRuntimeBuilder', + }, + { + regex: /\bAgentRunRequest::new\b/, + message: 'SDK example must run through AgentRunRequest', + }, + ], + }, { path: 'src/crates/execution/agent-runtime/src/prompt.rs', reason: @@ -676,6 +820,69 @@ export const requiredContentRules = [ regex: /\bProductCapabilityAssembly\b/, message: 'missing product capability assembly owner', }, + { + regex: /\bProductFeatureGroup\b/, + message: 'missing product feature group fact owner', + }, + { + regex: /\bProductRuntimeAssembly\b/, + message: 'missing product runtime assembly owner', + }, + { + regex: /\bProductDeliveryProfileEntry\b/, + message: 'missing product delivery profile entry matrix', + }, + { + regex: /\bMobileWeb\b/, + message: 'missing mobile web delivery profile coverage', + }, + { + regex: /\bProductAssembler\b/, + message: 'missing typed product assembler', + }, + { + regex: /\bProductAssemblyInput\b/, + message: 'missing product assembly input contract', + }, + { + regex: /\bProductRuntimeParts\b/, + message: 'missing product runtime parts output', + }, + { + regex: /\bfeature_groups_from_tool_provider_group_plan\b/, + message: 'missing tool-provider feature group projection owner', + }, + ], + }, + { + path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + reason: + 'product-capabilities tests must protect product shape facts, runtime service gap reporting, and legacy harness routing', + patterns: [ + { + regex: /\bproduct_assembly_plan_exposes_build_feature_groups_explicitly\b/, + message: 'missing product feature group shape regression', + }, + { + regex: /\bproduct_runtime_assembly_reports_runtime_service_capability_gaps\b/, + message: 'missing product runtime service gap regression', + }, + { + regex: /\bproduct_delivery_profile_matrix_documents_current_core_dependency_shape\b/, + message: 'missing delivery profile entry matrix regression', + }, + { + regex: /\ball_current_product_profiles\b/, + message: 'missing delivery profile matrix coverage guard', + }, + { + regex: /\bproduct_assembler_builds_runtime_parts_from_explicit_profile_input\b/, + message: 'missing typed product assembler regression', + }, + { + regex: /\bproduct_harness_provider_plans_legacy_facade_without_execution\b/, + message: 'missing legacy harness route non-execution regression', + }, ], }, { @@ -786,81 +993,116 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/src/custom_subagent.rs', + path: 'src/crates/execution/agent-runtime/src/custom_agent.rs', reason: - 'agent-runtime must own custom subagent portable schema defaults, discovery, and markdown front-matter IO', + 'agent-runtime must own custom agent portable schema defaults, discovery, validation, and markdown front-matter IO', patterns: [ { - regex: /\bpub enum CustomSubagentKind\b/, - message: 'missing custom subagent source-kind contract', + regex: /\bpub enum CustomAgentKind\b/, + message: 'missing custom agent kind contract', }, { - regex: /\bpub struct CustomSubagentDiscoveryRoots\b/, - message: 'missing custom subagent discovery root contract', + regex: /\bpub struct CustomAgentDiscoveryRoots\b/, + message: 'missing custom agent discovery root contract', }, { - regex: /\bpub struct CustomSubagentLoadReport\b/, - message: 'missing custom subagent load report contract', + regex: /\bpub struct CustomAgentLoadReport\b/, + message: 'missing custom agent load report contract', }, { - regex: /\bpub struct CustomSubagentDefinition\b/, - message: 'missing custom subagent definition schema', + regex: /\bpub struct CustomAgentDefinition\b/, + message: 'missing custom agent definition schema', }, { - regex: /\bpub enum CustomSubagentDefinitionError\b/, - message: 'missing custom subagent definition validation errors', + regex: /\bpub enum CustomAgentDefinitionError\b/, + message: 'missing custom agent definition validation errors', + }, + { + regex: /\bDEFAULT_CUSTOM_MODE_TOOLS\b/, + message: 'missing custom mode default tools contract', }, { regex: /\bDEFAULT_CUSTOM_SUBAGENT_TOOLS\b/, message: 'missing custom subagent default tools contract', }, { - regex: /\bpub fn custom_subagent_tools_from_front_matter\b/, - message: 'missing custom subagent tools front-matter parser', + regex: /\bpub fn custom_agent_read_markdown_file\b/, + message: 'missing custom agent markdown file reader', + }, + { + regex: /\bpub fn custom_agent_save_markdown_file\b/, + message: 'missing custom agent markdown file writer', + }, + { + regex: /\bpub fn custom_agent_possible_dirs\b/, + message: 'missing custom agent directory discovery owner', + }, + { + regex: /\bpub fn load_custom_agent_definitions\b/, + message: 'missing custom agent definition loading owner', }, { - regex: /\bpub fn custom_subagent_tools_to_front_matter\b/, - message: 'missing custom subagent tools front-matter serializer', + regex: /\bpub struct CustomAgentValidationContext\b/, + message: 'missing custom agent validation context', }, { - regex: /\bpub const fn custom_subagent_readonly_should_save\b/, - message: 'missing custom subagent readonly save decision', + regex: /\bpub struct CustomAgentValidationReport\b/, + message: 'missing custom agent validation report', }, { - regex: /\bpub const fn custom_subagent_review_should_save\b/, - message: 'missing custom subagent review save decision', + regex: /\bpub struct CustomAgentModelFallback\b/, + message: 'missing custom agent model fallback contract', }, { - regex: /\bpub fn custom_subagent_model_should_save\b/, - message: 'missing custom subagent model save decision', + regex: /\bpub fn validate_custom_agent_definition\b/, + message: 'missing custom agent validation owner', }, { - regex: /\bpub fn custom_subagent_read_markdown_file\b/, - message: 'missing custom subagent markdown file reader', + regex: /\bpub fn custom_agent_review_writable_tools\b/, + message: 'missing custom agent review-tool validation owner', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/custom_subagent.rs', + reason: + 'agent-runtime custom_subagent module must stay a legacy compatibility wrapper over custom_agent owner decisions', + patterns: [ + { + regex: /\bpub type CustomSubagentKind = CustomAgentLevel\b/, + message: 'missing custom subagent kind compatibility alias', }, { - regex: /\bpub fn custom_subagent_save_markdown_parts\b/, - message: 'missing custom subagent markdown file writer', + regex: /\bpub type CustomSubagentDefinition = CustomAgentDefinition\b/, + message: 'missing custom subagent definition compatibility alias', }, { - regex: /\bpub fn custom_subagent_possible_dirs\b/, - message: 'missing custom subagent directory discovery owner', + regex: /\bpub type CustomSubagentDiscoveryRoots = CustomAgentDiscoveryRoots\b/, + message: 'missing custom subagent discovery root compatibility alias', }, { regex: /\bpub fn load_custom_subagent_definitions\b/, - message: 'missing custom subagent definition loading owner', + message: 'missing custom subagent filtered load wrapper', + }, + { + regex: /\bcustom_agent_read_markdown_file\b/, + message: 'missing custom subagent markdown read delegation', + }, + { + regex: /\bcustom_agent_save_markdown_file\b/, + message: 'missing custom subagent markdown save delegation', }, ], }, { path: 'src/crates/execution/agent-runtime/tests/custom_subagent_discovery_contracts.rs', reason: - 'agent-runtime custom subagent discovery owner must keep behavior-equivalence contracts for directory priority, deduplication, and load errors', + 'agent-runtime custom subagent discovery owner must keep behavior-equivalence contracts for BitFun directory priority, foreign directory exclusion, and load errors', patterns: [ { regex: - /\bcustom_subagent_discovery_preserves_directory_priority_and_deduplication\b/, - message: 'missing custom subagent discovery priority/dedup regression', + /\bcustom_subagent_discovery_preserves_bitfun_priority_and_ignores_foreign_agent_dirs\b/, + message: 'missing custom subagent discovery priority/foreign-dir regression', }, { regex: @@ -895,7 +1137,7 @@ export const requiredContentRules = [ message: 'missing custom subagent missing-field regression', }, { - regex: /\bcustom_subagent_markdown_io_preserves_legacy_front_matter_shape\b/, + regex: /\bcustom_subagent_markdown_io_writes_canonical_front_matter\b/, message: 'missing custom subagent markdown IO regression', }, { @@ -907,11 +1149,39 @@ export const requiredContentRules = [ { path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', reason: - 'agent-runtime must own portable post-call hook routing decisions while concrete hook execution stays in the owning runtime', + 'agent-runtime must own portable hook registry and post-call routing decisions while concrete hook execution stays in the owning runtime', patterns: [ { - regex: /\bpub enum PostCallHookKind\b/, - message: 'missing post-call hook kind contract', + regex: /\bpub enum RuntimeHookKind\b/, + message: 'missing runtime hook kind contract', + }, + { + regex: /\bpub enum RuntimeHookErrorPolicy\b/, + message: 'missing runtime hook error policy contract', + }, + { + regex: /\bpub struct RuntimeHookPlan\b/, + message: 'missing runtime hook plan contract', + }, + { + regex: /\bpub struct RuntimeHookRegistry\b/, + message: 'missing runtime hook registry contract', + }, + { + regex: /\btimeout_millis\b/, + message: 'missing runtime hook timeout contract', + }, + { + regex: /\bDuplicateHookId\b/, + message: 'missing runtime hook duplicate-id guard', + }, + { + regex: /\bEmptyHookId\b/, + message: 'missing runtime hook empty-id guard', + }, + { + regex: /\bInvalidTimeoutMillis\b/, + message: 'missing runtime hook non-zero-timeout guard', }, { regex: /\bpub const fn successful_tool_post_call_hooks\b/, @@ -936,6 +1206,18 @@ export const requiredContentRules = [ regex: /\bsuccessful_tool_call_routes_to_shared_context_measurement_hook\b/, message: 'missing successful tool post-call hook routing regression', }, + { + regex: /\bruntime_hook_registry_preserves_order_timeout_and_error_policy\b/, + message: 'missing runtime hook order/timeout/error-policy regression', + }, + { + regex: /\bruntime_hook_registry_rejects_duplicate_ids\b/, + message: 'missing runtime hook duplicate-id regression', + }, + { + regex: /\bruntime_hook_registry_rejects_unstable_ids_and_zero_timeouts\b/, + message: 'missing runtime hook invalid-id/timeout regression', + }, ], }, { @@ -1362,6 +1644,86 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/agent-runtime/src/event_queue.rs', + reason: + 'agent-runtime must own provider-neutral runtime event queue delivery without core queue implementation', + patterns: [ + { + regex: /\bpub struct EventQueue\b/, + message: 'missing runtime event queue owner', + }, + { + regex: /\bimpl StreamEventSink for EventQueue\b/, + message: 'missing stream event sink implementation', + }, + { + regex: /\bpub async fn clear_session\b/, + message: 'missing session-scoped event queue cleanup', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/event_router.rs', + reason: + 'agent-runtime must own provider-neutral event subscriber routing without core router implementation', + patterns: [ + { + regex: /\bpub trait EventSubscriber\b/, + message: 'missing event subscriber contract', + }, + { + regex: /\bpub struct EventRouter\b/, + message: 'missing event router owner', + }, + { + regex: /\bpub async fn route_batch\b/, + message: 'missing batched event routing entrypoint', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/prompt_markup.rs', + reason: + 'agent-runtime must own provider-neutral prompt markup contracts used by core compatibility paths', + patterns: [ + { + regex: /\bpub struct PromptEnvelope\b/, + message: 'missing prompt envelope owner', + }, + { + regex: /\bpub fn render_user_query\b/, + message: 'missing user-query prompt markup helper', + }, + { + regex: /\bpub fn strip_prompt_markup\b/, + message: 'missing prompt markup stripping helper', + }, + { + regex: /\bstrips_current_and_legacy_system_reminder_suffix\b/, + message: 'missing legacy system-reminder markup regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/remote_file_delivery.rs', + reason: + 'agent-runtime must own provider-neutral remote file delivery prompt facts without core implementation', + patterns: [ + { + regex: /\bTOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY\b/, + message: 'missing remote file delivery context key', + }, + { + regex: /\bpub fn remote_file_delivery_reminder\b/, + message: 'missing remote file delivery reminder owner', + }, + { + regex: /\bpub fn user_workspace_relative_file_link\b/, + message: 'missing remote file link presentation helper', + }, + ], + }, { path: 'src/crates/execution/agent-runtime/src/scheduled_job.rs', reason: @@ -1750,42 +2112,46 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs', reason: - 'core custom subagent path must stay a compatibility facade over agent-runtime schema/default and markdown IO decisions', + 'core custom subagent path must stay a compatibility facade over agent-runtime custom-agent schema/default and markdown IO decisions', patterns: [ { regex: /pub use bitfun_agent_runtime::custom_subagent::CustomSubagentKind/, message: 'missing custom subagent kind compatibility re-export', }, { - regex: /\bCustomSubagentDefinition::new\b/, - message: 'missing custom subagent definition construction delegation', + regex: /\bCustomAgentDefinition::new\b/, + message: 'missing custom agent definition construction delegation', }, { - regex: /\bcustom_subagent_read_markdown_file\b/, - message: 'missing custom subagent markdown read delegation', + regex: /\bcustom_agent_read_markdown_file\b/, + message: 'missing custom agent markdown read delegation', }, { - regex: /\bcustom_subagent_save_markdown_parts\b/, - message: 'missing custom subagent markdown save delegation', + regex: /\bCustomAgentData::from_definition\b/, + message: 'missing custom agent data adapter delegation', }, ], }, { path: 'src/crates/assembly/core/src/agentic/agents/registry/custom.rs', reason: - 'core custom subagent registry must delegate portable discovery/loading to agent-runtime while retaining validation and registry writes', + 'core custom agent registry must delegate portable discovery/loading and validation to agent-runtime while retaining product tool/model lookup, logging, and registry writes', patterns: [ { - regex: /\bload_custom_subagent_definitions\b/, - message: 'missing custom subagent runtime load delegation', + regex: /\bload_custom_agent_definitions\b/, + message: 'missing custom agent runtime load delegation', }, { - regex: /\bCustomSubagentDiscoveryRoots\b/, - message: 'missing custom subagent runtime discovery root adapter', + regex: /\bCustomAgentDiscoveryRoots\b/, + message: 'missing custom agent runtime discovery root adapter', }, { - regex: /\bCustomSubagent::from_definition\b/, - message: 'missing custom subagent runtime definition adapter', + regex: /\bvalidate_custom_agent_definition\b/, + message: 'missing custom agent runtime validation delegation', + }, + { + regex: /\bcustom_agent_from_definition\b/, + message: 'missing custom agent runtime definition adapter', }, ], }, @@ -2265,6 +2631,48 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/services/services-core/src/managed_runtime.rs', + reason: + 'services-core must own managed runtime command resolution and PATH merge rules while core supplies only the product runtime root', + patterns: [ + { + regex: /\bpub struct ManagedRuntimeResolver\b/, + message: 'missing managed runtime resolver owner', + }, + { + regex: /\bpub enum RuntimeSource\b/, + message: 'missing managed runtime source contract', + }, + { + regex: /\bpub fn resolve_command\b/, + message: 'missing managed runtime command resolution entrypoint', + }, + { + regex: /\bpub fn merged_path_env\b/, + message: 'missing managed runtime PATH merge owner', + }, + { + regex: /\bnormalizes_windows_alias_for_managed_lookup\b/, + message: 'missing Windows command alias regression', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/runtime/mod.rs', + reason: + 'core runtime service must remain a thin compatibility adapter over services-core managed runtime owner', + patterns: [ + { + regex: /\bManagedRuntimeResolver::new\b/, + message: 'missing services-core managed runtime delegation', + }, + { + regex: /\bget_path_manager_arc\b/, + message: 'missing product-managed runtime root adapter', + }, + ], + }, { path: 'src/crates/assembly/core/src/service/filesystem/service.rs', reason: @@ -2400,15 +2808,23 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/tools/restrictions.rs', reason: - 'core tool restrictions facade must preserve per-tool denial messages while runtime restrictions live in agent-tools', + 'core tool restrictions facade must delegate runtime restriction policy to agent-tools while preserving core error and local-path adapters', patterns: [ { - regex: /\bdenied_tool_messages\b/, - message: 'missing per-tool denial message field propagation', + regex: /\btool_restrictions_for_delegation_policy\b/, + message: 'missing agent-tools runtime restriction policy re-export', + }, + { + regex: /\bminiapp_headless_agent_tool_restrictions\b/, + message: 'missing agent-tools MiniApp headless restriction re-export', + }, + { + regex: /\bimpl From for BitFunError\b/, + message: 'missing core error mapping adapter', }, { - regex: /\bcustom_deny_message_overrides_generic_runtime_error\b/, - message: 'missing custom deny message regression', + regex: /\bis_local_path_within_root\b/, + message: 'missing local filesystem path containment adapter', }, ], }, @@ -2534,6 +2950,61 @@ export const requiredContentRules = [ regex: /\bpub fn count_tool_states\b/, message: 'missing tool state counting policy', }, + { + regex: /\bpub struct ToolStateEventFacts\b/, + message: 'missing provider-neutral tool event facts owner', + }, + { + regex: /\bpub enum ToolStateEventKind\b/, + message: 'missing provider-neutral tool event state owner', + }, + { + regex: /\bpub fn tool_state_event_data\b/, + message: 'missing tool state event payload owner', + }, + { + regex: /\bpub fn sanitize_tool_result_for_event\b/, + message: 'missing tool result event redaction owner', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/context.rs', + reason: + 'tool-runtime must own provider-neutral tool custom-data materialization and context facts projection while core keeps runtime handles and concrete ToolUseContext', + patterns: [ + { + regex: /\bpub struct ToolRuntimeCustomDataInput\b/, + message: 'missing tool runtime custom-data input DTO', + }, + { + regex: /\bpub fn build_tool_runtime_custom_data\b/, + message: 'missing tool runtime custom-data owner', + }, + { + regex: /\bpub struct ToolRuntimeContextFactsInput\b/, + message: 'missing tool runtime context facts input DTO', + }, + { + regex: /\bpub fn project_tool_context_facts\b/, + message: 'missing tool runtime context facts projection owner', + }, + { + regex: /\bpub fn delegation_policy_from_custom_data\b/, + message: 'missing delegation policy parsing owner', + }, + { + regex: /\bpub fn primary_model_supports_image_understanding\b/, + message: 'missing model image-support policy owner', + }, + { + regex: /\bmaterializes_provider_neutral_tool_custom_data\b/, + message: 'missing tool runtime custom-data regression', + }, + { + regex: /\bprojects_prompt_safe_tool_context_facts_only\b/, + message: 'missing prompt-safe context facts regression', + }, ], }, { @@ -2867,6 +3338,76 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/tool-execution/src/exec_command.rs', + reason: + 'tool-runtime must own provider-neutral ExecCommand presentation, control facts, completion shape, and session-not-found result builders while core keeps concrete process managers', + patterns: [ + { + regex: /\bpub enum ExecCommandControlAction\b/, + message: 'missing provider-neutral exec control action contract', + }, + { + regex: /\bpub struct ExecCommandControlRequest\b/, + message: 'missing provider-neutral exec control request contract', + }, + { + regex: /\bpub fn render_exec_command_response_for_assistant\b/, + message: 'missing ExecCommand assistant response owner', + }, + { + regex: /\bpub fn render_write_stdin_response_for_assistant\b/, + message: 'missing WriteStdin assistant response owner', + }, + { + regex: /\bpub fn exec_control_session_not_found_result\b/, + message: 'missing ExecControl session-not-found result owner', + }, + { + regex: /\bpub fn exec_command_background_output_status\b/, + message: 'missing ExecCommand background-output status owner', + }, + { + regex: /\bcompletion_value_uses_stable_snake_case_shape\b/, + message: 'missing ExecCommand completion shape regression', + }, + { + regex: /\bbackground_output_status_maps_terminal_completion_without_core_types\b/, + message: 'missing ExecCommand background status regression', + }, + ], + }, + { + path: 'src/crates/execution/tool-execution/src/computer_use.rs', + reason: + 'tool-runtime must own provider-neutral Computer Use loop detection, screenshot hashing, verification, and retry policy while core keeps host adapters', + patterns: [ + { + regex: /\bpub struct ComputerUseOptimizer\b/, + message: 'missing Computer Use optimizer owner', + }, + { + regex: /\bpub fn hash_screenshot_bytes\b/, + message: 'missing Computer Use screenshot hash owner', + }, + { + regex: /\bpub struct VerificationResult\b/, + message: 'missing Computer Use verification result contract', + }, + { + regex: /\bpub fn should_retry_action_message\b/, + message: 'missing provider-neutral Computer Use retry decision owner', + }, + { + regex: /\bdetects_repeated_action_loop\b/, + message: 'missing Computer Use loop detection regression', + }, + { + regex: /\bretry_decision_uses_error_text_without_core_error_type\b/, + message: 'missing Computer Use retry decision regression', + }, + ], + }, { path: 'src/crates/assembly/core/src/service/remote_ssh/mod.rs', reason: @@ -5087,7 +5628,7 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs', reason: - 'product runtime materialization must keep only concrete tool construction and product plan adapter while delegating generic registry assembly to agent-tools', + 'product runtime materialization must keep only concrete tool construction while delegating generic provider-entry registry assembly to agent-tools', patterns: [ { regex: /\bProductConcreteToolFactory\b/, @@ -5098,16 +5639,8 @@ export const requiredContentRules = [ message: 'missing concrete tool factory implementation', }, { - regex: /\bProductToolProviderPlanAdapter\b/, - message: 'missing product provider plan adapter', - }, - { - regex: /\bimpl StaticToolProviderPlan for ProductToolProviderPlanAdapter\b/, - message: 'missing product provider plan adapter contract', - }, - { - regex: /\bcreate_registry_from_static_provider_plans\b/, - message: 'missing generic agent-tools plan-to-registry delegation', + regex: /\bcreate_registry_from_static_provider_entries\b/, + message: 'missing generic agent-tools provider-entry registry delegation', }, { regex: /\bcreate_product_tool_registry_from_plan\b/, @@ -5300,10 +5833,26 @@ export const requiredContentRules = [ regex: /\bcreate_registry_from_static_provider_plans\b/, message: 'missing generic static-provider plan-to-registry helper', }, + { + regex: /\bcreate_registry_from_static_provider_entries\b/, + message: 'missing generic static-provider entry-to-registry helper', + }, { regex: /\bpub fn install_static_provider\b/, message: 'missing static provider registry installer', }, + { + regex: /\bpub fn miniapp_headless_agent_tool_restrictions\b/, + message: 'missing MiniApp headless runtime restriction policy owner', + }, + { + regex: /\bpub fn tool_restrictions_for_delegation_policy\b/, + message: 'missing delegation-policy runtime restriction owner', + }, + { + regex: /\bdenied_tool_messages\b/, + message: 'missing per-tool denial message propagation owner', + }, { regex: /\bpub fn build_get_tool_spec_duplicate_load_result\b/, message: 'missing provider-neutral GetToolSpec duplicate-load result helper', @@ -5455,6 +6004,18 @@ export const requiredContentRules = [ regex: /\bto_tool_context_facts\b/, message: 'missing portable ToolUseContext facts projection', }, + { + regex: /\bproject_tool_context_facts\b/, + message: 'missing tool-runtime context facts owner delegation', + }, + { + regex: /\bbuild_tool_runtime_custom_data\b/, + message: 'missing tool-runtime custom-data owner delegation', + }, + { + regex: /\bdelegation_policy_from_custom_data\b/, + message: 'missing tool-runtime delegation policy owner delegation', + }, { regex: /\bimpl PortableToolContextProvider for ToolUseContext\b/, message: 'missing portable ToolUseContext facts provider impl', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 31f00c1644..b616208541 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -986,6 +986,7 @@ export function runManifestParserSelfTest({ 'RuntimeServices', 'RuntimeServicesBuilder', 'CapabilityAvailability', + 'RuntimeServiceMarkerPort', 'RuntimeServicesProvider', 'RuntimeServicesRegistry', 'CapabilityMismatch', @@ -1012,6 +1013,7 @@ export function runManifestParserSelfTest({ 'capability_availability_reports_optional_service_status_without_side_effects', 'builder_rejects_port_registered_under_the_wrong_capability', 'registered_remote_ports_expose_owner_contract_methods', + 'marker_ports_register_optional_service_availability_without_core_dependency', ], }, { @@ -1049,6 +1051,45 @@ export function runManifestParserSelfTest({ 'port_errors_remain_typed', ], }, + { + path: 'src/crates/execution/agent-runtime/src/sdk.rs', + contracts: [ + 'AGENT_RUNTIME_SDK_API_VERSION', + '#[non_exhaustive]', + 'AgentRuntimeSdkStability', + 'AgentRuntimeSdkCompatibility', + 'impl AgentRuntimeSdkCompatibility', + 'bitfun_agent_tools', + 'bitfun_harness', + 'bitfun_runtime_services', + 'PortResult', + 'RuntimeServicePort', + 'FileSystemPort', + 'RemoteWorkspacePort', + ], + }, + { + path: 'src/crates/execution/agent-runtime/Cargo.toml', + contracts: ['[features]', 'default = []'], + }, + { + path: 'src/crates/execution/agent-runtime/tests/sdk_smoke.rs', + contracts: [ + 'sdk_facade_exposes_versioned_preview_compatibility_contract', + 'sdk_facade_runs_with_fake_provider_and_local_event_stream', + 'sdk_facade_accepts_fake_services_tools_harnesses_and_hooks_without_core', + ], + }, + { + path: 'src/crates/execution/agent-runtime/examples/sdk_minimal.rs', + contracts: [ + 'bitfun_agent_runtime::sdk', + 'AgentRuntimeSdkCompatibility::current', + 'impl AgentSubmissionPort for ExampleAgentProvider', + 'AgentRuntimeBuilder::new', + 'AgentRunRequest::new', + ], + }, { path: 'src/crates/execution/agent-runtime/src/agents.rs', contracts: [ @@ -1081,29 +1122,41 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/execution/agent-runtime/src/custom_subagent.rs', + path: 'src/crates/execution/agent-runtime/src/custom_agent.rs', contracts: [ - 'CustomSubagentKind', - 'CustomSubagentDiscoveryRoots', - 'CustomSubagentLoadReport', - 'CustomSubagentDefinition', - 'CustomSubagentDefinitionError', + 'CustomAgentKind', + 'CustomAgentDiscoveryRoots', + 'CustomAgentLoadReport', + 'CustomAgentDefinition', + 'CustomAgentDefinitionError', + 'DEFAULT_CUSTOM_MODE_TOOLS', 'DEFAULT_CUSTOM_SUBAGENT_TOOLS', - 'custom_subagent_tools_from_front_matter', - 'custom_subagent_tools_to_front_matter', - 'custom_subagent_readonly_should_save', - 'custom_subagent_review_should_save', - 'custom_subagent_model_should_save', - 'custom_subagent_read_markdown_file', - 'custom_subagent_save_markdown_parts', - 'custom_subagent_possible_dirs', + 'custom_agent_read_markdown_file', + 'custom_agent_save_markdown_file', + 'custom_agent_possible_dirs', + 'load_custom_agent_definitions', + 'CustomAgentValidationContext', + 'CustomAgentValidationReport', + 'CustomAgentModelFallback', + 'validate_custom_agent_definition', + 'custom_agent_review_writable_tools', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/custom_subagent.rs', + contracts: [ + 'pub type CustomSubagentKind = CustomAgentLevel', + 'pub type CustomSubagentDefinition = CustomAgentDefinition', + 'pub type CustomSubagentDiscoveryRoots = CustomAgentDiscoveryRoots', 'load_custom_subagent_definitions', + 'custom_agent_read_markdown_file', + 'custom_agent_save_markdown_file', ], }, { path: 'src/crates/execution/agent-runtime/tests/custom_subagent_discovery_contracts.rs', contracts: [ - 'custom_subagent_discovery_preserves_directory_priority_and_deduplication', + 'custom_subagent_discovery_preserves_bitfun_priority_and_ignores_foreign_agent_dirs', 'custom_subagent_discovery_reports_parse_errors_without_dropping_valid_files', ], }, @@ -1115,14 +1168,19 @@ export function runManifestParserSelfTest({ 'custom_subagent_default_fields_are_omitted_when_saved', 'custom_subagent_definition_from_front_matter_preserves_schema_and_defaults', 'custom_subagent_definition_reports_legacy_missing_field_errors', - 'custom_subagent_markdown_io_preserves_legacy_front_matter_shape', + 'custom_subagent_markdown_io_writes_canonical_front_matter', 'custom_subagent_markdown_parse_errors_match_legacy_prefixes', ], }, { path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', contracts: [ - 'PostCallHookKind', + 'RuntimeHookKind', + 'RuntimeHookErrorPolicy', + 'RuntimeHookPlan', + 'RuntimeHookRegistry', + 'EmptyHookId', + 'InvalidTimeoutMillis', 'successful_tool_post_call_hooks', 'SuccessfulToolPostCallHookExecutor', 'run_successful_tool_post_call_hooks', @@ -1130,7 +1188,12 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/execution/agent-runtime/tests/post_call_hook_contracts.rs', - contracts: ['successful_tool_call_routes_to_shared_context_measurement_hook'], + contracts: [ + 'successful_tool_call_routes_to_shared_context_measurement_hook', + 'runtime_hook_registry_preserves_order_timeout_and_error_policy', + 'runtime_hook_registry_rejects_duplicate_ids', + 'runtime_hook_registry_rejects_unstable_ids_and_zero_timeouts', + ], }, { path: 'src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs', @@ -1368,6 +1431,31 @@ export function runManifestParserSelfTest({ 'turn_outcome_kind_matches_existing_reply_policy_contract', ], }, + { + path: 'src/crates/execution/agent-runtime/src/event_queue.rs', + contracts: ['EventQueue', 'impl StreamEventSink for EventQueue', 'clear_session'], + }, + { + path: 'src/crates/execution/agent-runtime/src/event_router.rs', + contracts: ['EventSubscriber', 'EventRouter', 'route_batch'], + }, + { + path: 'src/crates/execution/agent-runtime/src/prompt_markup.rs', + contracts: [ + 'PromptEnvelope', + 'render_user_query', + 'strip_prompt_markup', + 'strips_current_and_legacy_system_reminder_suffix', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/remote_file_delivery.rs', + contracts: [ + 'TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY', + 'remote_file_delivery_reminder', + 'user_workspace_relative_file_link', + ], + }, { path: 'src/crates/execution/agent-runtime/src/scheduled_job.rs', contracts: [ @@ -1504,6 +1592,20 @@ export function runManifestParserSelfTest({ 'metadata_store_delete_session_updates_visible_index', ], }, + { + path: 'src/crates/services/services-core/src/managed_runtime.rs', + contracts: [ + 'ManagedRuntimeResolver', + 'RuntimeSource', + 'resolve_command', + 'merged_path_env', + 'normalizes_windows_alias_for_managed_lookup', + ], + }, + { + path: 'src/crates/assembly/core/src/service/runtime/mod.rs', + contracts: ['ManagedRuntimeResolver::new', 'get_path_manager_arc'], + }, { path: 'src/crates/assembly/core/src/agentic/persistence/manager.rs', contracts: [ @@ -1518,6 +1620,25 @@ export function runManifestParserSelfTest({ 'ensure_runtime_for_write', ], }, + { + path: 'src/crates/assembly/product-capabilities/src/lib.rs', + contracts: [ + 'HarnessProviderDescriptor', + 'build_descriptor_harness_registry', + 'ProductCapabilityAssembly', + 'ProductFeatureGroup', + 'ProductRuntimeAssembly', + 'feature_groups_from_tool_provider_group_plan', + ], + }, + { + path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + contracts: [ + 'product_assembly_plan_exposes_build_feature_groups_explicitly', + 'product_runtime_assembly_reports_runtime_service_capability_gaps', + 'product_harness_provider_plans_legacy_facade_without_execution', + ], + }, { path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', contracts: [ @@ -1535,7 +1656,12 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/assembly/core/src/agentic/tools/restrictions.rs', - contracts: ['denied_tool_messages', 'custom_deny_message_overrides_generic_runtime_error'], + contracts: [ + 'tool_restrictions_for_delegation_policy', + 'miniapp_headless_agent_tool_restrictions', + 'impl From for BitFunError', + 'is_local_path_within_root', + ], }, { path: 'src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs', @@ -1555,6 +1681,10 @@ export function runManifestParserSelfTest({ 'summarize_dialog_turn_cancellation', 'ToolCancellationTokenStore', 'count_tool_states', + 'ToolStateEventFacts', + 'ToolStateEventKind', + 'tool_state_event_data', + 'sanitize_tool_result_for_event', ], }, { @@ -2058,9 +2188,7 @@ export function runManifestParserSelfTest({ contracts: [ 'ProductConcreteToolFactory', 'StaticToolProviderFactory', - 'ProductToolProviderPlanAdapter', - 'StaticToolProviderPlan', - 'create_registry_from_static_provider_plans', + 'create_registry_from_static_provider_entries', 'create_product_tool_registry_from_plan', 'materialize_tool', 'GetToolSpecTool', @@ -2080,6 +2208,7 @@ export function runManifestParserSelfTest({ 'materialize_static_tool_provider_groups', 'ToolRuntimeAssembly', 'create_registry_from_static_provider_plans', + 'create_registry_from_static_provider_entries', 'ToolCatalogRuntime', 'ToolDecoratorRef', 'SnapshotToolWrapper', @@ -2089,6 +2218,9 @@ export function runManifestParserSelfTest({ 'resolve_readonly_enabled_tools', 'build_get_tool_spec_duplicate_load_result', 'build_get_tool_spec_detail_result', + 'miniapp_headless_agent_tool_restrictions', + 'tool_restrictions_for_delegation_policy', + 'denied_tool_messages', 'resolve_get_tool_spec_execution_plan', 'resolve_get_tool_spec_execution_result_from_provider', 'GetToolSpecRuntime', @@ -2154,6 +2286,9 @@ export function runManifestParserSelfTest({ contracts: [ 'pub struct ToolUseContext', 'to_tool_context_facts', + 'project_tool_context_facts', + 'build_tool_runtime_custom_data', + 'delegation_policy_from_custom_data', 'impl PortableToolContextProvider for ToolUseContext', 'tool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated', 'customData', @@ -3040,6 +3175,20 @@ export function runManifestParserSelfTest({ throw new Error('SessionControl old create-path boundary rule must cover legacy create path'); } + const sdkSmokeRuleText = forbiddenRuleTextForPath( + 'src/crates/execution/agent-runtime/tests/sdk_smoke.rs', + ); + for (const forbiddenSdkSmokeImport of [ + 'bitfun_runtime_services::test_support', + 'FakeRuntimeServicesProvider', + ]) { + if (!sdkSmokeRuleText.includes(forbiddenSdkSmokeImport)) { + throw new Error( + `SDK smoke boundary rule must forbid ${forbiddenSdkSmokeImport}`, + ); + } + } + const remoteWorkspaceRule = forbiddenContentRules.find( (rule) => rule.path === 'src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs', ); diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json new file mode 100644 index 0000000000..cf2ce1b8c0 --- /dev/null +++ b/scripts/theme-color-governance-baseline.json @@ -0,0 +1,168 @@ +{ + "version": 1, + "description": "Baseline for Web UI theme color governance. Lower values when debt is removed; do not raise without a documented review reason.", + "budgets": { + "fallbackOccurrences": { + "max": 25 + }, + "fallbackUniqueTokens": { + "max": 7 + }, + "fallbackContracts.uncontractedUnique": { + "max": 0 + }, + "fallbackContracts.staleRegisteredUnique": { + "max": 0 + }, + "compatibilityAliases.registeredUnique": { + "max": 63 + }, + "compatibilityAliases.usedUnique": { + "max": 68 + }, + "compatibilityAliases.occurrences": { + "max": 608 + }, + "compatibilityAliases.staleRegisteredUnique": { + "max": 0 + }, + "compatibilityAliases.familyRegisteredUnique": { + "max": 2 + }, + "compatibilityAliases.familyUsedUnique": { + "max": 2 + }, + "compatibilityAliases.familyOccurrences": { + "max": 51 + }, + "compatibilityAliases.staleRegisteredFamilyUnique": { + "max": 0 + }, + "compatibilityAliases.missingCanonicalUnique": { + "max": 0 + }, + "colorDomainContracts.registeredUnique": { + "max": 13 + }, + "colorDomainContracts.missingRegisteredUnique": { + "max": 0 + }, + "colorDomainContracts.staleRegisteredUnique": { + "max": 0 + }, + "colorDomainContracts.activeUncontractedUnique": { + "max": 0 + }, + "colorScopes.appUi.uniqueColors": { + "max": 0 + }, + "colorScopes.appUi.occurrences": { + "max": 0 + }, + "colorScopes.token.uniqueColors": { + "max": 697 + }, + "colorScopes.exception.uniqueColors": { + "max": 274 + }, + "cssVarDefinitions.unresolvedRequiredUnique": { + "max": 0 + }, + "cssVarDefinitions.runtimeOnlyRequiredContractUnique": { + "max": 0 + }, + "cssVarDefinitions.fallbackOnlyUnique": { + "max": 0 + }, + "cssVarDefinitions.nonContractCrossFileUnique": { + "max": 0 + }, + "cssVarDefinitions.nonContractDynamicInputUnique": { + "max": 0 + }, + "cssVarDefinitions.nonContractCssPrivateUnique": { + "max": 0 + }, + "cssVarDefinitions.unregisteredDynamicFamilyUnique": { + "max": 0 + }, + "cssVarDefinitions.staleRegisteredDynamicFamilyUnique": { + "max": 0 + }, + "tokenAliasLiterals.occurrences": { + "max": 0 + }, + "tokenAliasLiterals.uniqueColors": { + "max": 0 + }, + "nearPairs.indistinguishableTotal": { + "max": 0 + }, + "nearPairs.nearTotal": { + "max": 0 + }, + "colorDomainScopes.themePreset.occurrences": { + "max": 1033 + }, + "colorDomainScopes.themePreset.uniqueColors": { + "max": 611 + }, + "colorDomainScopes.themeRuntime.occurrences": { + "max": 54 + }, + "colorDomainScopes.tokenContract.occurrences": { + "max": 268 + }, + "colorDomainScopes.generatedWidget.occurrences": { + "max": 0 + }, + "colorDomainScopes.generatedWidget.uniqueColors": { + "max": 0 + }, + "colorDomainScopes.boundaryFallback.occurrences": { + "max": 22 + }, + "colorDomainScopes.boundaryFallback.uniqueColors": { + "max": 22 + }, + "colorDomainScopes.editor.occurrences": { + "max": 56 + }, + "colorDomainScopes.syntax.occurrences": { + "max": 18 + }, + "colorDomainScopes.terminal.occurrences": { + "max": 38 + }, + "colorDomainScopes.debugOverlay.occurrences": { + "max": 0 + }, + "colorDomainScopes.uiException.occurrences": { + "max": 38 + }, + "colorDomainScopes.uiException.uniqueColors": { + "max": 34 + }, + "colorDomainScopes.languageIdentity.occurrences": { + "max": 52 + }, + "colorDomainScopes.languageIdentity.uniqueColors": { + "max": 50 + }, + "colorDomainScopes.visualEffect.occurrences": { + "max": 0 + }, + "colorDomainScopes.appUi.occurrences": { + "max": 0 + }, + "colorDomainScopes.appUi.uniqueColors": { + "max": 0 + }, + "colorDomainScopes.mermaid.occurrences": { + "max": 139 + }, + "colorDomainScopes.mermaid.uniqueColors": { + "max": 95 + } + } +} diff --git a/scripts/theme-css-var-contract.mjs b/scripts/theme-css-var-contract.mjs new file mode 100644 index 0000000000..9c3a5743af --- /dev/null +++ b/scripts/theme-css-var-contract.mjs @@ -0,0 +1,797 @@ +export const DEFAULT_ROOT = 'src/web-ui/src'; +export const DEFAULT_BASELINE_PATH = 'scripts/theme-color-governance-baseline.json'; + +export const COLOR_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.ts', '.tsx', '.js', '.jsx']); + +export const TOKEN_PATH_PARTS = [ + 'component-library/styles', + 'infrastructure/theme', + 'theme/presets', +]; + +export const TOKEN_ALIAS_SOURCE_PATH_PARTS = [ + 'component-library/styles/tokens.scss', +]; + +export const CONTRACT_VAR_DEFINITION_PATH_PARTS = [ + 'component-library/styles', + 'infrastructure/theme', + 'tools/generative-widget/themePayload.ts', +]; + +export const STATIC_CONTRACT_VAR_DEFINITION_PATH_PARTS = [ + 'component-library/styles', +]; + +export const RUNTIME_CONTRACT_VAR_DEFINITION_PATH_PARTS = [ + 'infrastructure/theme', +]; + +export const EXCEPTION_PATH_PARTS = [ + 'shared/theme/uiExceptionAccents', + 'shared/theme/languageIdentityAccents', + 'shared/theme/syntaxHighlightAccents', + 'shared/theme/themeBoundaryFallbacks', + 'monaco', + 'terminal', + 'mermaid', + 'syntax', + 'CodeEditor', + 'tools/editor/themes', +]; + +export const COLOR_DOMAIN_RULES = [ + { + key: 'themePreset', + label: 'Theme presets', + pathParts: ['infrastructure/theme/presets', 'theme/presets'], + }, + { + key: 'themeRuntime', + label: 'Theme runtime', + pathParts: ['infrastructure/theme/core'], + }, + { + key: 'tokenContract', + label: 'Token contracts', + pathParts: ['component-library/styles'], + }, + { + key: 'generatedWidget', + label: 'Generated widget', + pathParts: ['tools/generative-widget'], + }, + { + key: 'boundaryFallback', + label: 'Boundary fallback', + pathParts: ['shared/theme/themeBoundaryFallbacks'], + }, + { + key: 'mermaid', + label: 'Mermaid', + pathParts: ['tools/mermaid-editor'], + }, + { + key: 'editor', + label: 'Editor', + pathParts: ['tools/editor', 'component-library/components/CodeEditor', 'infrastructure/theme/integrations/MonacoThemeSync'], + }, + { + key: 'syntax', + label: 'Syntax', + pathParts: ['shared/prism', 'shared/theme/syntaxHighlightAccents'], + }, + { + key: 'terminal', + label: 'Terminal', + pathParts: [ + 'tools/terminal', + 'flow_chat/tool-cards/TerminalToolCard', + 'app/components/panels/TerminalEditModal', + ], + }, + { + key: 'debugOverlay', + label: 'Debug overlay', + pathParts: ['shared/inspector'], + }, + { + key: 'uiException', + label: 'UI exception registry', + pathParts: ['shared/theme/uiExceptionAccents'], + }, + { + key: 'languageIdentity', + label: 'Language identity', + pathParts: ['infrastructure/language-detection', 'shared/theme/languageIdentityAccents'], + }, + { + key: 'visualEffect', + label: 'Visual effects', + pathParts: [ + 'component-library/components/TextStrokeEffect', + 'component-library/components/StreamText', + ], + }, +]; + +export const COLOR_DOMAIN_KEYS = [ + ...COLOR_DOMAIN_RULES.map(rule => rule.key), + 'appUi', +]; + +export const COLOR_DOMAIN_LABELS = Object.fromEntries([ + ...COLOR_DOMAIN_RULES.map(rule => [rule.key, rule.label]), + ['appUi', 'App UI'], +]); + +export const COLOR_DOMAIN_CONTRACTS = [ + { + key: 'themePreset', + owner: 'src/web-ui/src/infrastructure/theme/presets', + reason: 'Builtin themes own primitive palette mapping and must keep per-theme personality instead of being folded into shared app tokens.', + mergePolicy: 'Only merge exact duplicate primitive values after confirming the theme still exposes distinct semantic roles.', + }, + { + key: 'themeRuntime', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Runtime theme injection is the cross-platform bridge for static CSS, desktop WebView, web preview, and generated widget payloads.', + mergePolicy: 'Do not remove runtime aliases until static contract, runtime contract, and widget payload all stop requiring them.', + }, + { + key: 'tokenContract', + owner: 'src/web-ui/src/component-library/styles', + reason: 'Static token files are the canonical contract for component styling and first paint before runtime theme injection completes.', + mergePolicy: 'Prefer aliasing to canonical tokens; only keep raw values for primitives or documented component roots.', + }, + { + key: 'generatedWidget', + owner: 'src/web-ui/src/tools/generative-widget', + reason: 'Generated widgets run in an isolated iframe boundary and need an explicit payload instead of scraping host CSS variables.', + mergePolicy: 'Keep payload variables stable for compatibility; shrink only after widget consumers no longer read the alias.', + }, + { + key: 'boundaryFallback', + owner: 'src/web-ui/src/shared/theme/themeBoundaryFallbacks.ts', + reason: 'Boundary fallback colors cover iframe, mini app, and capture surfaces before the host theme contract is available.', + mergePolicy: 'Centralize fallback values here; do not duplicate fallback palettes in component selectors.', + }, + { + key: 'mermaid', + owner: 'src/web-ui/src/tools/mermaid-editor', + reason: 'Mermaid rendering owns graph palette semantics that do not map one-to-one to app surface states.', + mergePolicy: 'Treat as a specialized palette unless a graph role is proven to be equivalent across all Mermaid themes.', + }, + { + key: 'editor', + owner: 'src/web-ui/src/tools/editor; src/web-ui/src/component-library/components/CodeEditor', + reason: 'Code editor and Monaco palettes encode syntax, diff, selection, and editor chrome states beyond generic app UI.', + mergePolicy: 'Do not merge editor states into app tokens without code-editor focused visual evidence.', + }, + { + key: 'syntax', + owner: 'src/web-ui/src/shared/prism; src/web-ui/src/shared/theme/syntaxHighlightAccents.ts', + reason: 'Syntax highlight colors preserve token class contrast and language readability, not generic app emphasis.', + mergePolicy: 'Only merge within the syntax palette after checking token adjacency and light/dark contrast.', + }, + { + key: 'terminal', + owner: 'src/web-ui/src/tools/terminal; src/web-ui/src/flow_chat/tool-cards/TerminalToolCard', + reason: 'Terminal colors include ANSI and terminal surface roles that must stay compatible with shell output semantics.', + mergePolicy: 'Keep ANSI roles independent even when values resemble app semantic colors.', + }, + { + key: 'debugOverlay', + owner: 'src/web-ui/src/shared/inspector', + reason: 'Inspector overlays need high-visibility diagnostic marks and should not influence product token budgets.', + mergePolicy: 'Keep diagnostic overlays isolated; merge only if the overlay no longer carries a debugging role.', + }, + { + key: 'uiException', + owner: 'src/web-ui/src/shared/theme/uiExceptionAccents.ts', + reason: 'UI exception accents centralize fixed role and identity colors that are intentionally not global semantic tokens.', + mergePolicy: 'Require a role owner before adding; promote to component or semantic token only when multiple surfaces share the role.', + }, + { + key: 'languageIdentity', + owner: 'src/web-ui/src/infrastructure/language-detection; src/web-ui/src/shared/theme/languageIdentityAccents.ts', + reason: 'Language identity colors help recognition of files and snippets and are not interchangeable with status colors.', + mergePolicy: 'Do not merge adjacent language identities solely by numeric color distance.', + }, + { + key: 'visualEffect', + owner: 'src/web-ui/src/component-library/components/TextStrokeEffect; src/web-ui/src/component-library/components/StreamText', + reason: 'Visual effects use decorative gradients and animation colors that are separate from UI state semantics.', + mergePolicy: 'Merge only extremely similar decorative colors when they are not adjacent and do not encode separate modes.', + }, +]; + +export const TOKEN_COMPATIBILITY_ALIAS_CONTRACTS = [ + { + key: '--color-bg-flowchat', + canonical: '--color-bg-scene', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Flow chat background remains a named surface alias while the scene background is the canonical root value.', + removal: 'Retire only after FlowChat, generated widget payload, and any persisted custom CSS no longer read the alias.', + }, + { + key: '--color-bg-surface', + canonical: '--color-bg-secondary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Surface background is an older shared role that currently resolves to the secondary background in every builtin theme.', + removal: 'Retire after component-level surface tokens replace generic surface reads.', + }, + { + key: '--color-bg-subtle', + canonical: '--element-bg-subtle', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Subtle background is an element-layer alias, not an independent app background palette.', + removal: 'Retire after callers migrate to element surface tokens.', + }, + { + key: '--color-bg-hover', + canonical: '--element-bg-hover', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Hover background historically lived under color-bg but now belongs to the element interaction layer.', + removal: 'Retire after hover callers move to element or component interaction tokens.', + }, + { + key: '--color-bg-elevated-hover', + canonical: '--element-bg-hover', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Elevated hover resolves to the same element hover role and does not represent a separate theme primitive today.', + removal: 'Retire after elevated surfaces expose component-specific hover tokens.', + }, + { + key: '--color-bg-base', + canonical: '--color-bg-primary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Base background is a historical alias for the primary application background.', + removal: 'Retire after legacy layout selectors stop reading base background.', + }, + { + key: '--color-surface-elevated', + canonical: '--element-bg-elevated', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Elevated surface is implemented by the element elevated layer, not a separate color family.', + removal: 'Retire after elevated component tokens cover all consumers.', + }, + { + key: '--color-surface-hover', + canonical: '--element-bg-hover', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Surface hover is a compatibility alias for the element hover layer.', + removal: 'Retire after component selectors migrate to element or component hover tokens.', + }, + { + key: '--color-hover', + canonical: '--element-bg-hover', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Generic hover is retained for old selectors that predate the element layer naming.', + removal: 'Retire after all callers use role-specific hover tokens.', + }, + { + key: '--bg-primary', + canonical: '--color-bg-primary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short background aliases are kept for historical component and external widget compatibility.', + removal: 'Retire after source and generated widget payload stop exposing short background aliases.', + }, + { + key: '--bg-secondary', + canonical: '--color-bg-secondary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short background aliases are kept for historical component and external widget compatibility.', + removal: 'Retire after source and generated widget payload stop exposing short background aliases.', + }, + { + key: '--bg-tertiary', + canonical: '--color-bg-tertiary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short background aliases are kept for historical component and external widget compatibility.', + removal: 'Retire after source and generated widget payload stop exposing short background aliases.', + }, + { + key: '--bg-elevated', + canonical: '--color-bg-elevated', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short elevated background alias preserves older selector and generated widget payload compatibility.', + removal: 'Retire after elevated component tokens replace the short alias.', + }, + { + key: '--bg-hover', + canonical: '--element-bg-hover', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short hover background alias maps to the canonical element hover layer.', + removal: 'Retire after all hover consumers use element or component tokens.', + }, + { + key: '--secondary-bg', + canonical: '--color-bg-secondary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Legacy secondary background alias remains for older CSS modules and generated widget compatibility.', + removal: 'Retire after workspace and legacy CSS callers migrate to color-bg-secondary.', + }, + { + key: '--background-primary', + canonical: '--color-bg-primary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Background primary alias preserves older naming used by app and embedded surfaces.', + removal: 'Retire after all background-* callers migrate to color-bg-* names.', + }, + { + key: '--background-secondary', + canonical: '--color-bg-secondary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Background secondary alias preserves older naming used by app and embedded surfaces.', + removal: 'Retire after all background-* callers migrate to color-bg-* names.', + }, + { + key: '--background-tertiary', + canonical: '--color-bg-tertiary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Background tertiary alias preserves older naming used by app and embedded surfaces.', + removal: 'Retire after all background-* callers migrate to color-bg-* names.', + }, + { + key: '--color-background-secondary', + canonical: '--color-bg-secondary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Color-background secondary exists only as a historical spelling variant.', + removal: 'Retire after callers use color-bg-secondary.', + }, + { + key: '--color-background-tertiary', + canonical: '--color-bg-tertiary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Color-background tertiary exists only as a historical spelling variant.', + removal: 'Retire after callers use color-bg-tertiary.', + }, + { + key: '--color-text-tertiary', + canonical: '--color-text-muted', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Tertiary text resolves to muted text in the current theme model and should not imply a fourth text ramp.', + removal: 'Retire after consumers choose either muted text or a component-specific subdued text role.', + }, + { + key: '--text-primary', + canonical: '--color-text-primary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short text aliases are retained for legacy CSS and generated widget payload compatibility.', + removal: 'Retire after all text-* consumers move to color-text-* names.', + }, + { + key: '--text-secondary', + canonical: '--color-text-secondary', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short text aliases are retained for legacy CSS and generated widget payload compatibility.', + removal: 'Retire after all text-* consumers move to color-text-* names.', + }, + { + key: '--text-tertiary', + canonical: '--color-text-muted', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short tertiary text alias maps to muted text and should not become an independent text scale.', + removal: 'Retire after consumers migrate to muted text or component-specific subdued text tokens.', + }, + { + key: '--text-muted', + canonical: '--color-text-muted', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short muted text alias is kept for legacy CSS and generated widget payload compatibility.', + removal: 'Retire after all text-* consumers move to color-text-* names.', + }, + { + key: '--text-disabled', + canonical: '--color-text-disabled', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short disabled text alias is kept for legacy CSS and generated widget payload compatibility.', + removal: 'Retire after all text-* consumers move to color-text-* names.', + }, + { + key: '--color-primary', + canonical: '--color-accent-500', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary currently means the active accent midpoint; it is kept as compatibility for older primary-button and focus selectors.', + removal: 'Retire only after primary action tokens are componentized and widget payload no longer exports this key.', + }, + { + key: '--color-primary-hover', + canonical: '--color-accent-600', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary hover is the active accent hover stop in all builtin themes.', + removal: 'Retire after callers use accent hover or component action tokens.', + }, + { + key: '--color-accent', + canonical: '--color-accent-500', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Generic accent alias remains for legacy selectors that predate numeric accent scale usage.', + removal: 'Retire after callers use explicit accent scale stops or component tokens.', + }, + { + key: '--color-accent-primary', + canonical: '--color-accent-500', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Accent-primary is a historical spelling of the active accent midpoint.', + removal: 'Retire after generated widget payload and source callers stop reading it.', + }, + { + key: '--accent-primary', + canonical: '--color-accent-500', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Accent-primary short alias is kept for older CSS modules and external payload compatibility.', + removal: 'Retire after callers use color-accent-500 or component action tokens.', + }, + { + key: '--accent-primary-hover', + canonical: '--color-accent-600', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Accent-primary hover short alias resolves to the canonical accent hover stop.', + removal: 'Retire after callers use color-accent-600 or component action tokens.', + }, + { + key: '--color-primary-400', + canonical: '--color-accent-400', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary scale aliases mirror accent scale stops for historical primary naming.', + removal: 'Retire after primary-* scale reads migrate to color-accent-*.', + }, + { + key: '--color-primary-500', + canonical: '--color-accent-500', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary scale aliases mirror accent scale stops for historical primary naming.', + removal: 'Retire after primary-* scale reads migrate to color-accent-*.', + }, + { + key: '--color-primary-alpha', + canonical: '--color-accent-100', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary alpha is a compatibility alias for the faint accent surface.', + removal: 'Retire after callers use color-accent-100 or component-specific accent backgrounds.', + }, + { + key: '--color-primary-bg', + canonical: '--color-accent-100', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary background is a compatibility alias for the faint accent surface.', + removal: 'Retire after callers use color-accent-100 or component-specific accent backgrounds.', + }, + { + key: '--color-primary-bg-subtle', + canonical: '--color-accent-50', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary subtle background is a compatibility alias for the faintest accent surface.', + removal: 'Retire after callers use color-accent-50 or component-specific accent backgrounds.', + }, + { + key: '--color-accent-alpha', + canonical: '--color-accent-100', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Accent alpha is a compatibility name for the faint accent surface stop.', + removal: 'Retire after callers use explicit accent scale stops.', + }, + { + key: '--color-success-100', + canonical: '--color-success-bg', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Semantic numeric aliases mirror background and foreground roles for older components.', + removal: 'Retire after callers use semantic role names instead of numeric status stops.', + }, + { + key: '--color-success-500', + canonical: '--color-success', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Semantic numeric aliases mirror background and foreground roles for older components.', + removal: 'Retire after callers use semantic role names instead of numeric status stops.', + }, + { + key: '--color-warning-100', + canonical: '--color-warning-bg', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Semantic numeric aliases mirror background and foreground roles for older components.', + removal: 'Retire after callers use semantic role names instead of numeric status stops.', + }, + { + key: '--color-warning-500', + canonical: '--color-warning', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Semantic numeric aliases mirror background and foreground roles for older components.', + removal: 'Retire after callers use semantic role names instead of numeric status stops.', + }, + { + key: '--color-warning-700', + canonical: '--color-warning', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Warning 700 currently resolves to warning foreground and is not a separate warning ramp stop.', + removal: 'Retire after warning state roles use semantic names or a real multi-stop warning palette exists.', + }, + { + key: '--color-semantic-error', + canonical: '--color-error', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Semantic error is a historical spelling of the canonical error foreground.', + removal: 'Retire after callers use color-error.', + }, + { + key: '--color-danger', + canonical: '--color-error', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Danger action color currently shares the error palette but stays named to avoid silently changing destructive-action semantics.', + removal: 'Retire only after destructive actions have a separate component action token or explicitly choose color-error.', + }, + { + key: '--color-danger-500', + canonical: '--color-error', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Danger numeric foreground currently maps to the canonical error foreground.', + removal: 'Retire after destructive action callers use role names rather than numeric danger stops.', + }, + { + key: '--color-danger-text', + canonical: '--color-error', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Danger text currently maps to error foreground while preserving destructive-action intent at call sites.', + removal: 'Retire only after destructive text call sites explicitly migrate to error or action tokens.', + }, + { + key: '--color-danger-bg', + canonical: '--color-error-bg', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Danger background currently maps to error background while preserving destructive-action intent at call sites.', + removal: 'Retire only after destructive surfaces explicitly migrate to error or action tokens.', + }, + { + key: '--color-danger-border', + canonical: '--color-error-border', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Danger border currently maps to error border while preserving destructive-action intent at call sites.', + removal: 'Retire only after destructive surfaces explicitly migrate to error or action tokens.', + }, + { + key: '--color-danger-hover', + canonical: '--color-error', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Danger hover currently maps to error foreground while preserving destructive-action intent at call sites.', + removal: 'Retire only after destructive hover states move to component action tokens.', + }, + { + key: '--border-color', + canonical: '--border-subtle', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Generic border color is retained for older selectors and maps to the subtle border role.', + removal: 'Retire after callers use explicit border-subtle or component border tokens.', + }, + { + key: '--border-hover', + canonical: '--border-medium', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Border hover maps to the medium border role in the current interaction scale.', + removal: 'Retire after hover states use component interaction border tokens.', + }, + { + key: '--border-muted', + canonical: '--border-subtle', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Muted border is a historical spelling for subtle border.', + removal: 'Retire after callers use border-subtle.', + }, + { + key: '--border-primary', + canonical: '--border-base', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Primary border means base border in the current contract and is kept for legacy selector compatibility.', + removal: 'Retire after callers use border-base or component border tokens.', + }, + { + key: '--color-border', + canonical: '--border-base', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Color-border is a legacy spelling of the canonical base border token.', + removal: 'Retire after callers use border-base.', + }, + { + key: '--color-border-primary', + canonical: '--border-base', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Color-border-primary is a legacy spelling of the canonical base border token.', + removal: 'Retire after callers use border-base.', + }, + { + key: '--color-border-subtle', + canonical: '--border-subtle', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Color-border-subtle is a legacy spelling of the canonical subtle border token.', + removal: 'Retire after callers use border-subtle.', + }, + { + key: '--element-bg', + canonical: '--element-bg-base', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Generic element background remains as compatibility for older element surface selectors.', + removal: 'Retire after callers use explicit element-bg-base or component surface tokens.', + }, + { + key: '--motion-normal', + canonical: '--motion-base', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Motion-normal is a historical alias for the base motion duration.', + removal: 'Retire after callers use motion-base.', + }, + { + key: '--font-sans', + canonical: '--font-family-sans', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short font aliases are kept for historical CSS and runtime theme payload compatibility.', + removal: 'Retire after callers use font-family-* names and widget payload no longer exports short aliases.', + }, + { + key: '--font-mono', + canonical: '--font-family-mono', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Short font aliases are kept for historical CSS and runtime theme payload compatibility.', + removal: 'Retire after callers use font-family-* names and widget payload no longer exports short aliases.', + }, + { + key: '--markdown-font-mono', + canonical: '--font-family-mono', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Markdown monospace remains a named alias so markdown surfaces can diverge later without breaking callers.', + removal: 'Retire only if markdown and app monospace are confirmed to remain the same contract.', + }, + { + key: '--tool-compact-summary-font', + canonical: '--font-family-sans', + owner: 'src/web-ui/src/component-library/styles/tokens.scss', + reason: 'Tool compact summaries currently use the global sans font but keep a surface alias for future tool-card typography changes.', + removal: 'Retire only if tool card typography will not diverge from global sans.', + }, +]; + +export const TOKEN_COMPATIBILITY_ALIAS_FAMILY_CONTRACTS = [ + { + prefix: '--radius-', + canonicalPrefix: '--size-radius-', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Radius aliases keep older selectors and widget payloads working while size-radius is the canonical shape scale.', + removal: 'Retire after all source and generated widget consumers migrate to --size-radius-*.', + }, + { + prefix: '--spacing-', + canonicalPrefix: '--size-gap-', + owner: 'src/web-ui/src/component-library/styles/tokens.scss; src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Spacing aliases keep older selectors and widget payloads working while size-gap is the canonical spacing scale.', + removal: 'Retire after all source and generated widget consumers migrate to --size-gap-*.', + }, +]; + +export const FALLBACK_VAR_CONTRACTS = [ + { + key: '--surface-stagger-index', + owner: 'src/web-ui/src/app/components/GalleryLayout', + reason: 'Runtime inline animation index with zero fallback for first paint and non-animated states.', + boundary: 'layout-runtime-input', + }, + { + key: '--mission-control-group-color', + owner: 'src/web-ui/src/app/components/panels/content-canvas/mission-control', + reason: 'Runtime group identity color with accent fallback when no group color is assigned.', + boundary: 'data-driven-identity-color', + }, + { + key: '--char-index', + owner: 'src/web-ui/src/component-library/components/StreamText', + reason: 'Runtime per-character animation offset with zero fallback outside animated rendering.', + boundary: 'animation-runtime-input', + }, + { + key: '--gallery-grid-min', + owner: 'src/web-ui/src/app/components/GalleryLayout', + reason: 'Runtime layout sizing input with a stable responsive grid fallback.', + boundary: 'layout-runtime-input', + }, + { + key: '--gallery-skeleton-height', + owner: 'src/web-ui/src/app/components/GalleryLayout', + reason: 'Runtime skeleton sizing input with a stable placeholder height fallback.', + boundary: 'layout-runtime-input', + }, + { + key: '--primary-color', + owner: 'src/web-ui/src/component-library/components/Markdown', + reason: 'Embedded markdown primary accent override with global accent fallback.', + boundary: 'embedded-content-theme-override', + }, + { + key: '--scene-viewport-border-width', + owner: 'src/web-ui/src/app/scenes/SceneViewport.scss', + reason: 'Runtime viewport layout override with a stable one-pixel default.', + boundary: 'layout-runtime-input', + }, +]; + +export const DYNAMIC_VAR_FAMILY_CONTRACTS = [ + { + prefix: '--blur-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports configurable blur scale entries from the active theme effects.', + }, + { + prefix: '--color-accent-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports the active accent palette scale by numeric stop.', + }, + { + prefix: '--color-purple-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports the secondary purple palette scale by numeric stop.', + }, + { + prefix: '--easing-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports motion easing aliases from theme motion tokens.', + }, + { + prefix: '--flowchat-font-size-', + owner: 'src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts', + reason: 'Font preference runtime exports FlowChat font-size aliases from the adjusted typography scale.', + }, + { + prefix: '--font-size-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts; src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts', + reason: 'Theme runtime exports baseline typography size entries; font preference runtime can override the same family for user scaling.', + }, + { + prefix: '--font-weight-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports configurable typography weight entries.', + }, + { + prefix: '--line-height-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports configurable typography line-height entries.', + }, + { + prefix: '--motion-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports motion duration entries from active theme motion tokens.', + }, + { + prefix: '--nav-font-size-', + owner: 'src/web-ui/src/infrastructure/font-preference/core/FontPreferenceService.ts', + reason: 'Font preference runtime exports navigation font-size aliases from the adjusted typography scale.', + }, + { + prefix: '--radius-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + canonicalPrefix: '--size-radius-', + reason: 'Theme runtime exports configurable radius entries.', + }, + { + prefix: '--shadow-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Theme runtime exports configurable shadow entries.', + }, + { + prefix: '--size-gap-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Size gap aliases are derived from theme spacing entries.', + }, + { + prefix: '--size-radius-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + reason: 'Size radius aliases are derived from theme radius entries.', + }, + { + prefix: '--spacing-', + owner: 'src/web-ui/src/infrastructure/theme/core/ThemeService.ts', + canonicalPrefix: '--size-gap-', + reason: 'Theme runtime exports configurable spacing entries.', + }, +]; + +export const REGISTERED_DYNAMIC_VAR_PREFIXES = new Set( + DYNAMIC_VAR_FAMILY_CONTRACTS.map(contract => contract.prefix), +); diff --git a/scripts/theme-visual-governance-contract.json b/scripts/theme-visual-governance-contract.json new file mode 100644 index 0000000000..46f46f7bb0 --- /dev/null +++ b/scripts/theme-visual-governance-contract.json @@ -0,0 +1,218 @@ +{ + "version": 1, + "description": "Visual review contract for theme-token governance. This is a coverage contract, not proof that screenshots or contrast checks already passed.", + "surfaces": [ + { + "key": "app-shell", + "owner": "src/web-ui/src/app/layout; src/web-ui/src/app/components/NavPanel", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "hover", "focus", "selected", "disabled"], + "tokenFamilies": ["--color-bg-*", "--color-text-*", "--element-bg-*", "--border-*", "--bitfun-nav-*"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "No appUi raw colors, unresolved vars, unregistered dynamic families, or near ordinary component color pairs." + }, + { + "type": "focused-visual-review", + "requirement": "Review desktop and narrow layouts after any shell, nav, background, border, or text token change." + } + ], + "risks": [ + "Short legacy aliases such as --color-primary and --text-secondary are still used by shell-adjacent components.", + "System theme resolution must not assume desktop-only media query behavior." + ] + }, + { + "key": "flow-chat", + "owner": "src/web-ui/src/flow_chat; src/web-ui/src/app/scenes/session", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "streaming", "hover", "focus", "selected", "error", "empty"], + "tokenFamilies": ["--flowchat-*", "--tool-card-*", "--color-bg-flowchat", "--color-text-*", "--accent-primary"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Flow Chat changes must not add appUi raw colors or unregistered dynamic CSS var families." + }, + { + "type": "focused-visual-review", + "requirement": "Review streaming, completed, background-command, markdown, and empty states in dark and light themes." + } + ], + "risks": [ + "Streaming and virtualized items can hide token regressions until historical turns are rendered.", + "Several high-use compatibility aliases are still visible in Flow Chat surfaces." + ] + }, + { + "key": "tool-cards-review", + "owner": "src/web-ui/src/flow_chat/tool-cards; src/web-ui/src/app/components/panels/review-platform", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "expanded", "collapsed", "hover", "focus", "success", "warning", "error"], + "tokenFamilies": ["--tool-card-*", "--color-success*", "--color-warning*", "--color-error*", "--color-danger*"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Tool-card status colors must remain tokenized and must not introduce token-equivalent app literals." + }, + { + "type": "focused-visual-review", + "requirement": "Review expanded and compact cards, review results, and destructive/error affordances separately." + } + ], + "risks": [ + "Danger aliases intentionally preserve destructive-action semantics even when they map to error colors today.", + "Expanded cards can combine border, text, and status tokens in the same viewport." + ] + }, + { + "key": "code-editor-diff", + "owner": "src/web-ui/src/tools/editor; src/web-ui/src/component-library/components/CodeEditor", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "focus", "selection", "search", "added", "deleted", "changed", "conflict"], + "tokenFamilies": ["Monaco palette", "--diff-editor-*", "--git-color-*", "--color-text-*"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Editor colors must stay in editor, syntax, or token contract domains instead of appUi." + }, + { + "type": "contrast-review", + "requirement": "Review syntax, selection, inline diff, and gutter contrast before merging editor palette changes." + } + ], + "risks": [ + "Editor and diff colors encode adjacent semantic states and cannot be merged by numeric similarity alone.", + "Monaco theme payloads may not use normal CSS variable fallback behavior." + ] + }, + { + "key": "terminal", + "owner": "src/web-ui/src/tools/terminal; src/web-ui/src/flow_chat/tool-cards/TerminalToolCard", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "focus", "selection", "ansi-normal", "ansi-bright", "error"], + "tokenFamilies": ["terminal ANSI palette", "--color-text-*", "--border-*"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Terminal colors must remain in the terminal domain and must not be counted as generic app UI colors." + }, + { + "type": "contrast-review", + "requirement": "Review normal and bright ANSI colors against terminal background before merging terminal palette changes." + } + ], + "risks": [ + "ANSI colors may look close to app semantic colors but carry external terminal meaning.", + "Terminal cards are embedded inside Flow Chat and need both terminal and card contrast reviewed." + ] + }, + { + "key": "markdown-mermaid", + "owner": "src/web-ui/src/component-library/components/Markdown; src/web-ui/src/tools/mermaid-editor", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "code", "table", "link", "diagram", "error"], + "tokenFamilies": ["--markdown-*", "--primary-color", "Mermaid palette", "Prism palette"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Markdown, Prism, and Mermaid colors must remain in syntax, mermaid, boundary, or token domains." + }, + { + "type": "focused-visual-review", + "requirement": "Review prose, links, tables, code blocks, and diagrams in dark and light themes after token changes." + } + ], + "risks": [ + "--primary-color is an embedded-content override and must not be removed as a normal fallback.", + "Mermaid graph roles do not map directly to app status colors." + ] + }, + { + "key": "generated-widget", + "owner": "src/web-ui/src/tools/generative-widget", + "platforms": ["generated-widget", "web"], + "formFactors": ["iframe", "desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["fallback-before-host-payload", "host-payload", "loading", "error"], + "tokenFamilies": ["--color-*", "--border-*", "--element-bg-*", "--radius-*", "--size-radius-*", "--spacing-*", "--size-gap-*"], + "evidence": [ + { + "type": "boundary-render-review", + "requirement": "Review iframe fallback rendering and host payload rendering whenever widget payload tokens change." + }, + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Generated widget fallback colors must remain centralized in themeBoundaryFallbacks." + } + ], + "risks": [ + "Widget payload compatibility is why several legacy aliases cannot be deleted immediately.", + "Iframe first paint depends on boundary fallback values before host variables arrive." + ] + }, + { + "key": "theme-settings", + "owner": "src/web-ui/src/app/scenes/settings; src/web-ui/src/infrastructure/theme", + "platforms": ["desktop-webview", "web"], + "formFactors": ["desktop", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "selected", "hover", "focus", "custom-theme", "system-theme"], + "tokenFamilies": ["--color-accent-*", "--color-primary*", "--color-bg-*", "--color-text-*"], + "evidence": [ + { + "type": "theme-color-audit", + "command": "pnpm run theme:color-audit", + "requirement": "Theme runtime and preset changes must keep dynamic family and domain contracts clean." + }, + { + "type": "focused-visual-review", + "requirement": "Review theme switcher, system theme resolution, and custom theme previews after theme runtime changes." + } + ], + "risks": [ + "Theme selection can resolve differently under system mode.", + "Custom theme previews can expose missing runtime aliases before ordinary components do." + ] + }, + { + "key": "mobile-web-shell", + "owner": "src/mobile-web; src/web-ui/src/component-library/styles", + "platforms": ["mobile-web"], + "formFactors": ["mobile", "narrow"], + "themes": ["dark", "light", "system"], + "states": ["default", "loading", "error", "navigation"], + "tokenFamilies": ["shared web tokens", "--color-bg-*", "--color-text-*", "--border-*"], + "evidence": [ + { + "type": "mobile-build-review", + "command": "pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web", + "requirement": "Theme contract changes must not assume desktop-only WebView behavior or break the mobile web build." + } + ], + "risks": [ + "Mobile web is a separate build target and may not exercise desktop-only theme runtime paths.", + "Narrow form factor can surface contrast and spacing regressions that desktop screenshots miss." + ] + } + ] +} diff --git a/scripts/validate-theme-visual-contract.mjs b/scripts/validate-theme-visual-contract.mjs new file mode 100644 index 0000000000..aeffa0c9df --- /dev/null +++ b/scripts/validate-theme-visual-contract.mjs @@ -0,0 +1,180 @@ +import fs from 'node:fs'; + +const CONTRACT_URL = new URL('./theme-visual-governance-contract.json', import.meta.url); +const REQUIRED_SURFACE_KEYS = [ + 'app-shell', + 'flow-chat', + 'tool-cards-review', + 'code-editor-diff', + 'terminal', + 'markdown-mermaid', + 'generated-widget', + 'theme-settings', + 'mobile-web-shell', +]; +const ALLOWED_PLATFORMS = new Set(['desktop-webview', 'web', 'mobile-web', 'generated-widget']); +const ALLOWED_FORM_FACTORS = new Set(['desktop', 'narrow', 'mobile', 'iframe']); +const ALLOWED_THEMES = new Set(['dark', 'light', 'system']); +const ALLOWED_EVIDENCE_TYPES = new Set([ + 'boundary-render-review', + 'contrast-review', + 'focused-visual-review', + 'mobile-build-review', + 'theme-color-audit', +]); + +function readContract() { + try { + return JSON.parse(fs.readFileSync(CONTRACT_URL, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse scripts/theme-visual-governance-contract.json: ${error.message}`); + } +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim() !== ''; +} + +function requireString(value, path, failures) { + if (!isNonEmptyString(value)) { + failures.push(`${path} must be a non-empty string`); + } +} + +function requireStringArray(value, path, failures, { allowedValues, minLength = 1 } = {}) { + if (!Array.isArray(value)) { + failures.push(`${path} must be an array`); + return; + } + if (value.length < minLength) { + failures.push(`${path} must contain at least ${minLength} item(s)`); + return; + } + const seen = new Set(); + value.forEach((entry, index) => { + if (!isNonEmptyString(entry)) { + failures.push(`${path}[${index}] must be a non-empty string`); + return; + } + if (seen.has(entry)) { + failures.push(`${path}[${index}] duplicates ${entry}`); + } + seen.add(entry); + if (allowedValues && !allowedValues.has(entry)) { + failures.push(`${path}[${index}] has unsupported value ${entry}`); + } + }); +} + +function validateEvidence(surface, failures) { + const path = `surfaces.${surface.key}.evidence`; + if (!Array.isArray(surface.evidence) || surface.evidence.length === 0) { + failures.push(`${path} must contain at least one evidence requirement`); + return; + } + + let hasActionableEvidence = false; + surface.evidence.forEach((entry, index) => { + const entryPath = `${path}[${index}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + failures.push(`${entryPath} must be an object`); + return; + } + requireString(entry.type, `${entryPath}.type`, failures); + if (isNonEmptyString(entry.type) && !ALLOWED_EVIDENCE_TYPES.has(entry.type)) { + failures.push(`${entryPath}.type has unsupported value ${entry.type}`); + } + requireString(entry.requirement, `${entryPath}.requirement`, failures); + if (isNonEmptyString(entry.command)) { + hasActionableEvidence = true; + } + if (entry.type !== 'focused-visual-review' && entry.type !== 'contrast-review') { + hasActionableEvidence = true; + } + }); + + if (!hasActionableEvidence) { + failures.push(`${path} must include at least one command-backed or boundary-specific evidence requirement`); + } +} + +function validateSurface(surface, index, failures) { + const path = `surfaces[${index}]`; + if (!surface || typeof surface !== 'object' || Array.isArray(surface)) { + failures.push(`${path} must be an object`); + return; + } + + requireString(surface.key, `${path}.key`, failures); + if (isNonEmptyString(surface.key) && !/^[a-z0-9-]+$/.test(surface.key)) { + failures.push(`${path}.key must be kebab-case`); + } + requireString(surface.owner, `${path}.owner`, failures); + if (isNonEmptyString(surface.owner) && !surface.owner.includes('src/')) { + failures.push(`${path}.owner must point to a source path`); + } + requireStringArray(surface.platforms, `${path}.platforms`, failures, { allowedValues: ALLOWED_PLATFORMS }); + requireStringArray(surface.formFactors, `${path}.formFactors`, failures, { allowedValues: ALLOWED_FORM_FACTORS }); + requireStringArray(surface.themes, `${path}.themes`, failures, { allowedValues: ALLOWED_THEMES, minLength: 2 }); + if (Array.isArray(surface.themes)) { + for (const requiredTheme of ['dark', 'light']) { + if (!surface.themes.includes(requiredTheme)) { + failures.push(`${path}.themes must include ${requiredTheme}`); + } + } + } + requireStringArray(surface.states, `${path}.states`, failures, { minLength: 3 }); + requireStringArray(surface.tokenFamilies, `${path}.tokenFamilies`, failures, { minLength: 2 }); + requireStringArray(surface.risks, `${path}.risks`, failures, { minLength: 2 }); + validateEvidence(surface, failures); +} + +function validateContract(contract) { + const failures = []; + if (!contract || typeof contract !== 'object' || Array.isArray(contract)) { + return ['theme visual governance contract must be an object']; + } + if (contract.version !== 1) { + failures.push('version must be 1'); + } + requireString(contract.description, 'description', failures); + if (!Array.isArray(contract.surfaces)) { + failures.push('surfaces must be an array'); + return failures; + } + + const surfaceKeys = new Set(); + contract.surfaces.forEach((surface, index) => { + validateSurface(surface, index, failures); + if (isNonEmptyString(surface?.key)) { + if (surfaceKeys.has(surface.key)) { + failures.push(`surfaces[${index}].key duplicates ${surface.key}`); + } + surfaceKeys.add(surface.key); + } + }); + + for (const requiredKey of REQUIRED_SURFACE_KEYS) { + if (!surfaceKeys.has(requiredKey)) { + failures.push(`surfaces is missing required surface ${requiredKey}`); + } + } + + return failures; +} + +const contract = readContract(); +const failures = validateContract(contract); + +if (failures.length > 0) { + console.error('Theme visual governance contract failed:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exitCode = 1; +} else { + console.log( + `Theme visual governance contract: ${contract.surfaces.length} surfaces, ` + + `${REQUIRED_SURFACE_KEYS.length} required surfaces covered.` + ); +} diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 6954e8024d..42c3b4ea94 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -67,5 +67,8 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +[dev-dependencies] +tempfile = "3" + [features] default = [] diff --git a/src/apps/cli/src/logging.rs b/src/apps/cli/src/logging.rs new file mode 100644 index 0000000000..bd31f78b18 --- /dev/null +++ b/src/apps/cli/src/logging.rs @@ -0,0 +1,465 @@ +//! Shared file logging initialization for CLI modes. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use chrono::Local; +use tracing_subscriber::filter::filter_fn; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::Layer; + +use crate::config::CliConfig; + +const FLASHGREP_LOG_TARGET_PREFIX: &str = "flashgrep"; +const CLI_LOGS_DIR_NAME: &str = "cli-logs"; +const SESSION_DIR_FORMAT: &str = "%Y%m%dT%H%M%S"; +const ROTATED_LOG_TIME_FORMAT: &str = "%Y-%m-%d_%H-%M-%S"; +const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024; +const ROTATED_LOG_KEEP_COUNT: usize = 2; + +pub const DEFAULT_LOG_LEVEL: tracing::Level = tracing::Level::DEBUG; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliLogPaths { + pub session_log_dir: PathBuf, + pub app_log_path: PathBuf, + pub ai_log_path: PathBuf, + pub flashgrep_log_path: PathBuf, +} + +struct RotatingFile { + dir: PathBuf, + file_name: String, + path: PathBuf, + max_size: u64, + current_size: u64, + inner: Option, + buffer: Vec, +} + +impl RotatingFile { + fn new( + dir: impl AsRef, + file_name: impl Into, + max_size: u64, + ) -> std::io::Result { + let dir = dir.as_ref().to_path_buf(); + let file_name = file_name.into(); + let path = dir.join(&file_name).with_extension("log"); + + let mut rotator = Self { + dir, + file_name, + path, + max_size, + current_size: 0, + inner: None, + buffer: Vec::new(), + }; + + rotator.open_file()?; + if rotator.current_size >= rotator.max_size { + rotator.rotate()?; + } + rotator.remove_old_files(ROTATED_LOG_KEEP_COUNT)?; + + Ok(rotator) + } + + fn open_file(&mut self) -> std::io::Result<()> { + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + self.current_size = file.metadata()?.len(); + self.inner = Some(file); + Ok(()) + } + + fn rotate(&mut self) -> std::io::Result<()> { + if let Some(mut file) = self.inner.take() { + let _ = file.flush(); + } + + if self.path.exists() { + self.remove_old_files(ROTATED_LOG_KEEP_COUNT.saturating_sub(1))?; + self.rename_file_to_dated()?; + } + + self.open_file() + } + + fn remove_old_files(&self, keep_count: usize) -> std::io::Result<()> { + let mut files = fs::read_dir(&self.dir)? + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + let old_file_name = path.file_name()?.to_string_lossy().into_owned(); + + if old_file_name.starts_with(&self.file_name) + && old_file_name != format!("{}.log", self.file_name) + { + let date = old_file_name + .strip_prefix(&self.file_name)? + .strip_prefix('_')? + .strip_suffix(".log")?; + Some((path, date.to_string())) + } else { + None + } + }) + .collect::>(); + + files.sort_by(|a, b| a.1.cmp(&b.1)); + + if files.len() > keep_count { + for (old_log_path, _) in files.iter().take(files.len() - keep_count) { + fs::remove_file(old_log_path)?; + } + } + + Ok(()) + } + + fn rename_file_to_dated(&self) -> std::io::Result<()> { + let to = self.dir.join(format!( + "{}_{}.log", + self.file_name, + Local::now().format(ROTATED_LOG_TIME_FORMAT) + )); + + if to.is_file() { + let mut to_bak = to.clone(); + to_bak.set_file_name(format!( + "{}.bak", + to_bak.file_name().unwrap().to_string_lossy() + )); + fs::rename(&to, to_bak)?; + } + + fs::rename(&self.path, &to) + } +} + +impl Write for RotatingFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if self.buffer.is_empty() { + return Ok(()); + } + + if self.inner.is_none() { + self.open_file()?; + } + + if self.current_size != 0 && self.current_size + self.buffer.len() as u64 > self.max_size { + self.rotate()?; + } + + if let Some(file) = self.inner.as_mut() { + file.write_all(&self.buffer)?; + self.current_size += self.buffer.len() as u64; + file.flush()?; + } + + self.buffer.clear(); + Ok(()) + } +} + +#[derive(Clone)] +struct SharedRotatingWriter { + inner: Arc>, +} + +impl SharedRotatingWriter { + fn new(inner: RotatingFile) -> Self { + Self { + inner: Arc::new(Mutex::new(inner)), + } + } +} + +impl Write for SharedRotatingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut guard = self + .inner + .lock() + .map_err(|_| std::io::Error::other("log writer lock poisoned"))?; + let written = guard.write(buf)?; + guard.flush()?; + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner + .lock() + .map_err(|_| std::io::Error::other("log writer lock poisoned"))? + .flush() + } +} + +pub fn default_log_level(verbose: bool) -> tracing::Level { + if verbose { + tracing::Level::TRACE + } else { + DEFAULT_LOG_LEVEL + } +} + +pub fn resolve_logs_root() -> PathBuf { + CliConfig::config_dir() + .ok() + .map(|d| d.join(CLI_LOGS_DIR_NAME)) + .unwrap_or_else(|| { + std::env::temp_dir() + .join("bitfun-cli") + .join(CLI_LOGS_DIR_NAME) + }) +} + +pub fn create_session_log_dir(logs_root: &Path) -> PathBuf { + let timestamp = Local::now().format(SESSION_DIR_FORMAT).to_string(); + let session_dir = logs_root.join(timestamp); + fs::create_dir_all(&session_dir).ok(); + session_dir +} + +pub fn build_log_paths(session_log_dir: &Path) -> CliLogPaths { + CliLogPaths { + session_log_dir: session_log_dir.to_path_buf(), + app_log_path: session_log_dir.join("app.log"), + ai_log_path: session_log_dir.join("ai.log"), + flashgrep_log_path: session_log_dir.join("flashgrep.log"), + } +} + +fn create_rotating_writer( + session_log_dir: &Path, + file_name: &str, +) -> std::io::Result { + RotatingFile::new(session_log_dir, file_name, MAX_LOG_FILE_SIZE).map(SharedRotatingWriter::new) +} + +fn is_ai_target(target: &str) -> bool { + target.starts_with("ai") +} + +fn is_flashgrep_target(target: &str) -> bool { + target.starts_with(FLASHGREP_LOG_TARGET_PREFIX) +} + +fn is_app_target(target: &str) -> bool { + !is_ai_target(target) && !is_flashgrep_target(target) +} + +fn matches_target_rule(target: &str, rule: &str) -> bool { + target == rule || target.starts_with(&format!("{rule}::")) +} + +fn level_rank(level: tracing::Level) -> u8 { + match level { + tracing::Level::ERROR => 1, + tracing::Level::WARN => 2, + tracing::Level::INFO => 3, + tracing::Level::DEBUG => 4, + tracing::Level::TRACE => 5, + } +} + +fn target_override_rank(target: &str) -> Option { + if matches_target_rule(target, "ignore") + || matches_target_rule(target, "ignore::walk") + || matches_target_rule(target, "globset") + || matches_target_rule(target, "tracing") + || matches_target_rule(target, "opentelemetry_sdk") + || matches_target_rule(target, "opentelemetry-otlp") + || matches_target_rule(target, "notify") + { + return Some(0); + } + + if matches_target_rule(target, "bitfun_core::agentic::events::queue") + || matches_target_rule(target, "bitfun_core::agentic::events::router") + || matches_target_rule(target, "bitfun_agent_runtime::event_queue") + || matches_target_rule(target, "bitfun_agent_runtime::event_router") + { + return Some(level_rank(tracing::Level::DEBUG)); + } + + if matches_target_rule(target, "hyper_util") + || matches_target_rule(target, "h2") + || matches_target_rule(target, "portable_pty") + || matches_target_rule(target, "russh") + { + return Some(level_rank(tracing::Level::INFO)); + } + + if matches_target_rule(target, "grep_searcher") { + return Some(level_rank(tracing::Level::WARN)); + } + + None +} + +fn allowed_level_rank_for_target(target: &str, default_level: tracing::Level) -> u8 { + let default_rank = level_rank(default_level); + target_override_rank(target) + .map(|override_rank| default_rank.min(override_rank)) + .unwrap_or(default_rank) +} + +fn is_enabled_for_target(metadata: &tracing::Metadata<'_>, default_level: tracing::Level) -> bool { + let allowed_rank = allowed_level_rank_for_target(metadata.target(), default_level); + + allowed_rank != 0 && level_rank(*metadata.level()) <= allowed_rank +} + +fn build_file_layer( + writer: SharedRotatingWriter, + target_filter: F, + default_level: tracing::Level, +) -> impl tracing_subscriber::Layer + Send + Sync +where + S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>, + F: Fn(&str) -> bool + Send + Sync + 'static, +{ + tracing_subscriber::fmt::layer() + .with_writer(move || writer.clone()) + .with_ansi(false) + .with_target(true) + .with_thread_ids(true) + .with_filter(filter_fn(move |metadata| { + target_filter(metadata.target()) && is_enabled_for_target(metadata, default_level) + })) +} + +pub fn init_file_logging_at(session_log_dir: &Path, log_level: tracing::Level) -> CliLogPaths { + fs::create_dir_all(session_log_dir).ok(); + let paths = build_log_paths(session_log_dir); + + let app_writer = create_rotating_writer(session_log_dir, "app"); + let ai_writer = create_rotating_writer(session_log_dir, "ai"); + let flashgrep_writer = create_rotating_writer(session_log_dir, "flashgrep"); + + if let (Ok(app_writer), Ok(ai_writer), Ok(flashgrep_writer)) = + (app_writer, ai_writer, flashgrep_writer) + { + tracing_subscriber::registry() + .with(build_file_layer(app_writer, is_app_target, log_level)) + .with(build_file_layer(ai_writer, is_ai_target, log_level)) + .with(build_file_layer( + flashgrep_writer, + is_flashgrep_target, + log_level, + )) + .init(); + } else { + tracing_subscriber::fmt() + .with_max_level(log_level) + .with_target(true) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + } + + paths +} + +pub fn init_file_logging(log_level: tracing::Level) -> CliLogPaths { + let logs_root = resolve_logs_root(); + let session_log_dir = create_session_log_dir(&logs_root); + init_file_logging_at(&session_log_dir, log_level) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_session_log_dir_creates_timestamped_subdirectory() { + let temp = tempfile::tempdir().expect("tempdir"); + let session_dir = create_session_log_dir(temp.path()); + + assert!(session_dir.exists()); + assert_eq!(session_dir.parent(), Some(temp.path())); + assert_eq!(session_dir.file_name().unwrap().to_string_lossy().len(), 15); + } + + #[test] + fn build_log_paths_uses_split_log_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let paths = build_log_paths(temp.path()); + + assert_eq!(paths.app_log_path, temp.path().join("app.log")); + assert_eq!(paths.ai_log_path, temp.path().join("ai.log")); + assert_eq!(paths.flashgrep_log_path, temp.path().join("flashgrep.log")); + } + + #[test] + fn create_rotating_writer_creates_expected_files() { + let temp = tempfile::tempdir().expect("tempdir"); + create_rotating_writer(temp.path(), "app").expect("app writer"); + create_rotating_writer(temp.path(), "ai").expect("ai writer"); + create_rotating_writer(temp.path(), "flashgrep").expect("flashgrep writer"); + + assert!(temp.path().join("app.log").exists()); + assert!(temp.path().join("ai.log").exists()); + assert!(temp.path().join("flashgrep.log").exists()); + } + + #[test] + fn target_filter_rules_match_desktop_defaults() { + assert_eq!( + allowed_level_rank_for_target( + "bitfun_core::agentic::events::queue", + tracing::Level::TRACE, + ), + level_rank(tracing::Level::DEBUG) + ); + assert_eq!( + allowed_level_rank_for_target( + "bitfun_agent_runtime::event_queue", + tracing::Level::TRACE, + ), + level_rank(tracing::Level::DEBUG) + ); + assert_eq!( + allowed_level_rank_for_target("grep_searcher", tracing::Level::TRACE), + level_rank(tracing::Level::WARN) + ); + assert_eq!( + allowed_level_rank_for_target("notify", tracing::Level::TRACE), + 0 + ); + assert_eq!( + allowed_level_rank_for_target( + "bitfun_core::agentic::events::queue", + tracing::Level::ERROR, + ), + level_rank(tracing::Level::ERROR) + ); + } + + #[test] + fn rotating_file_keep_some_removes_old_archives() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write(temp.path().join("app.log"), "current").expect("write active"); + fs::write(temp.path().join("app_2026-06-01_10-00-00.log"), "1").expect("write old 1"); + fs::write(temp.path().join("app_2026-06-01_10-00-01.log"), "2").expect("write old 2"); + fs::write(temp.path().join("app_2026-06-01_10-00-02.log"), "3").expect("write old 3"); + + let _rotator = RotatingFile::new(temp.path(), "app", MAX_LOG_FILE_SIZE).expect("rotator"); + + assert!(!temp.path().join("app_2026-06-01_10-00-00.log").exists()); + assert!(temp.path().join("app_2026-06-01_10-00-01.log").exists()); + assert!(temp.path().join("app_2026-06-01_10-00-02.log").exists()); + } +} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index f4404139bc..2c351d2112 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -10,6 +10,7 @@ mod agent; mod chat_state; mod commands; mod config; +mod logging; mod management; mod modes; mod prompts; @@ -529,41 +530,19 @@ async fn run_cli() -> Result<()> { let cli = Cli::parse(); let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - let log_level = if cli.verbose { - tracing::Level::DEBUG - } else if is_tui_mode { - tracing::Level::INFO + let is_exec_mode = matches!(cli.command, Some(Commands::Exec { .. })); + let file_log_level = logging::default_log_level(cli.verbose); + let stderr_log_level = if cli.verbose { + tracing::Level::TRACE } else { tracing::Level::ERROR }; - if is_tui_mode { - use std::fs::OpenOptions; - - 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) { - tracing_subscriber::fmt() - .with_max_level(log_level) - .with_writer(move || file.try_clone().unwrap()) - .with_ansi(false) - .with_target(false) - .init(); - } else { - tracing_subscriber::fmt() - .with_max_level(log_level) - .with_target(false) - .init(); - } + if is_tui_mode || is_exec_mode { + logging::init_file_logging(file_log_level); } else { tracing_subscriber::fmt() - .with_max_level(log_level) + .with_max_level(stderr_log_level) .with_writer(std::io::stderr) .with_ansi(false) .with_target(false) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 4cd48ce1c9..b0bacba51f 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -10,6 +10,7 @@ use tauri::{AppHandle, State}; use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; use crate::startup_trace::DesktopStartupTrace; +use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ AssistantBootstrapBlockReason, AssistantBootstrapEnsureOutcome, AssistantBootstrapSkipReason, ConversationCoordinator, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, @@ -2009,6 +2010,9 @@ pub async fn get_available_modes( config_profile_id, config_profile_label: info.config_profile_label, config_profile_member_mode_ids: info.config_profile_member_mode_ids, + source: info.source, + path: info.path, + model: info.model, } }) .collect(); @@ -2037,6 +2041,11 @@ pub struct ModeInfoDTO { pub config_profile_label: Option, #[serde(default)] pub config_profile_member_mode_ids: Vec, + pub source: AgentSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, } fn assistant_bootstrap_outcome_to_response( diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 15bd6ce7d2..7402187bab 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -35,6 +35,23 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, State}; +struct WorkspaceStateSnapshot { + current_workspace: Option, + recent_workspaces: Vec, + opened_workspaces: Vec, + legacy_remote_workspace: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceStartupStateSnapshotDto { + pub cleanup_removed_count: usize, + pub current_workspace: Option, + pub recent_workspaces: Vec, + pub opened_workspaces: Vec, + pub legacy_remote_workspace: Option, +} + fn remote_workspace_from_info(info: &WorkspaceInfo) -> Option { if info.workspace_kind != WorkspaceKind::Remote { return None; @@ -1007,14 +1024,12 @@ async fn apply_active_workspace_context( } } -#[tauri::command] -pub async fn initialize_global_state( - state: State<'_, AppState>, - app: tauri::AppHandle, - startup_trace: State<'_, DesktopStartupTrace>, -) -> Result { - let command_started = Instant::now(); - let trace = startup_trace.inner(); +async fn initialize_global_state_impl( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + trace: &DesktopStartupTrace, +) { + let total_started = Instant::now(); let step_started = Instant::now(); let current_workspace = state.workspace_service.get_current_workspace().await; trace.record_elapsed_step( @@ -1051,11 +1066,8 @@ pub async fn initialize_global_state( trace.record_elapsed_step( "tauri_command", "initialize_global_state.total", - command_started, + total_started, ); - trace.record_tauri_command_elapsed("initialize_global_state", None, command_started); - - Ok("Global state initialized successfully".to_string()) } #[tauri::command] @@ -1751,7 +1763,7 @@ pub async fn delete_workspace( } info!( - "Workspace deleted: workspace_id={}, kind={}, path={}", + "Workspace deleted: workspace_id={:?}, kind={:?}, path={:?}", request.workspace_id, workspace_info.workspace_kind, workspace_info.root_path.display() @@ -2077,6 +2089,34 @@ pub async fn get_recent_workspaces( result } +async fn collect_workspace_state_snapshot(state: &State<'_, AppState>) -> WorkspaceStateSnapshot { + let workspace_service = &state.workspace_service; + let current_workspace = workspace_service + .get_current_workspace() + .await + .map(|info| WorkspaceInfoDto::from_workspace_info(&info)); + let recent_workspaces = workspace_service + .get_recent_workspaces() + .await + .into_iter() + .map(|info| WorkspaceInfoDto::from_workspace_info(&info)) + .collect(); + let opened_workspaces = workspace_service + .get_opened_workspaces() + .await + .into_iter() + .map(|info| WorkspaceInfoDto::from_workspace_info(&info)) + .collect(); + let legacy_remote_workspace = state.get_remote_workspace_async().await; + + WorkspaceStateSnapshot { + current_workspace, + recent_workspaces, + opened_workspaces, + legacy_remote_workspace, + } +} + #[tauri::command] pub async fn remove_recent_workspace( state: State<'_, AppState>, @@ -2096,19 +2136,124 @@ pub async fn cleanup_invalid_workspaces( startup_trace: State<'_, DesktopStartupTrace>, ) -> Result { let trace_started = Instant::now(); + cleanup_invalid_workspaces_impl( + &state, + &app, + &startup_trace, + "cleanup_invalid_workspaces", + Some("cleanup_invalid_workspaces"), + trace_started, + ) + .await +} + +#[tauri::command] +pub async fn initialize_workspace_startup_state( + state: State<'_, AppState>, + app: tauri::AppHandle, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result { + let command_started = Instant::now(); + let result = + initialize_workspace_startup_state_impl(&state, &app, &startup_trace, command_started) + .await; + startup_trace.record_tauri_command_elapsed( + "initialize_workspace_startup_state", + None, + command_started, + ); + result +} + +pub async fn prepare_workspace_startup_bootstrap_snapshot( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + startup_trace: &State<'_, DesktopStartupTrace>, +) -> Option { + let started = Instant::now(); + let snapshot = + initialize_workspace_startup_state_impl(state, app, startup_trace, started).await; + startup_trace.record_elapsed_step( + "native_setup", + "prepare_workspace_startup_bootstrap_snapshot", + started, + ); + match snapshot { + Ok(snapshot) => Some(snapshot), + Err(error) => { + warn!( + "Failed to prepare workspace startup bootstrap snapshot, frontend will fall back to startup command: {}", + error + ); + None + } + } +} + +async fn initialize_workspace_startup_state_impl( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + startup_trace: &State<'_, DesktopStartupTrace>, + command_started: Instant, +) -> Result { + let trace = startup_trace.inner(); + + initialize_global_state_impl(&state, &app, trace).await; + + let cleanup_removed_count = match cleanup_invalid_workspaces_impl( + &state, + &app, + &startup_trace, + "initialize_workspace_startup_state.cleanup_invalid_workspaces", + None, + command_started, + ) + .await + { + Ok(removed_count) => removed_count, + Err(error) => { + return Err(error); + } + }; + + let snapshot_started = Instant::now(); + let snapshot = collect_workspace_state_snapshot(&state).await; + startup_trace.record_elapsed_step( + "tauri_command", + "initialize_workspace_startup_state.collect_workspace_state_snapshot", + snapshot_started, + ); + + Ok(WorkspaceStartupStateSnapshotDto { + cleanup_removed_count, + current_workspace: snapshot.current_workspace, + recent_workspaces: snapshot.recent_workspaces, + opened_workspaces: snapshot.opened_workspaces, + legacy_remote_workspace: snapshot.legacy_remote_workspace, + }) +} + +async fn cleanup_invalid_workspaces_impl( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + startup_trace: &State<'_, DesktopStartupTrace>, + trace_step_prefix: &str, + command_name: Option<&str>, + command_started: Instant, +) -> Result { let cleanup_started = Instant::now(); match state.workspace_service.cleanup_invalid_workspaces().await { Ok(local_removed_count) => { startup_trace.record_elapsed_step( "tauri_command", - "cleanup_invalid_workspaces.local_workspace_cleanup", + format!("{trace_step_prefix}.local_workspace_cleanup"), cleanup_started, ); let prune_remote_started = Instant::now(); - let remote_removed_count = prune_unrecoverable_remote_workspaces(&state).await; + let remote_removed_count = prune_unrecoverable_remote_workspaces(state).await; startup_trace.record_elapsed_step( "tauri_command", - "cleanup_invalid_workspaces.remote_workspace_prune", + format!("{trace_step_prefix}.remote_workspace_prune"), prune_remote_started, ); let removed_count = local_removed_count + remote_removed_count; @@ -2121,7 +2266,7 @@ pub async fn cleanup_invalid_workspaces( } startup_trace.record_elapsed_step( "tauri_command", - "cleanup_invalid_workspaces.apply_active_workspace_context", + format!("{trace_step_prefix}.apply_active_workspace_context"), apply_context_started, ); @@ -2138,7 +2283,7 @@ pub async fn cleanup_invalid_workspaces( } startup_trace.record_elapsed_step( "tauri_command", - "cleanup_invalid_workspaces.sync_identity_watchers", + format!("{trace_step_prefix}.sync_identity_watchers"), sync_watchers_started, ); @@ -2146,20 +2291,16 @@ pub async fn cleanup_invalid_workspaces( "Invalid workspaces cleaned up: removed_count={}", removed_count ); - startup_trace.record_tauri_command_elapsed( - "cleanup_invalid_workspaces", - None, - trace_started, - ); + if let Some(command_name) = command_name { + startup_trace.record_tauri_command_elapsed(command_name, None, command_started); + } Ok(removed_count) } Err(e) => { error!("Failed to cleanup invalid workspaces: {}", e); - startup_trace.record_tauri_command_elapsed( - "cleanup_invalid_workspaces", - None, - trace_started, - ); + if let Some(command_name) = command_name { + startup_trace.record_tauri_command_elapsed(command_name, None, command_started); + } Err(format!("Failed to cleanup invalid workspaces: {}", e)) } } diff --git a/src/apps/desktop/src/api/custom_agent_api.rs b/src/apps/desktop/src/api/custom_agent_api.rs new file mode 100644 index 0000000000..b73131b799 --- /dev/null +++ b/src/apps/desktop/src/api/custom_agent_api.rs @@ -0,0 +1,446 @@ +use crate::api::app_state::AppState; +use bitfun_core::agentic::agents::{ + custom_agent_model_or_default, custom_agent_review_writable_tools, default_custom_agent_tools, + default_custom_agent_user_context_policy, CustomAgentDetail, CustomAgentKind, CustomAgentLevel, + CustomMode, CustomSubagent, UserContextPolicy, UserContextSection, +}; +use log::{debug, warn}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use tauri::State; + +const AGENT_ID_REGEX: &str = "^[a-zA-Z][a-zA-Z0-9_-]*$"; + +fn workspace_root_from_request(workspace_path: Option<&str>) -> Option { + workspace_path + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +fn validate_agent_id(id: &str) -> Result<(), String> { + let id = id.trim(); + if id.is_empty() { + return Err("Id cannot be empty".to_string()); + } + let regex = regex::Regex::new(AGENT_ID_REGEX).map_err(|error| error.to_string())?; + if !regex.is_match(id) { + return Err( + "Id must start with a letter and contain only letters, numbers, -, _".to_string(), + ); + } + Ok(()) +} + +fn validate_agent_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err("Name cannot be empty".to_string()); + } + Ok(()) +} + +fn policy_from_sections( + sections: Option>, + kind: CustomAgentKind, +) -> UserContextPolicy { + sections + .map(|sections| { + let mut policy = UserContextPolicy::empty(); + for section in sections { + policy = policy.with_section(section); + } + policy + }) + .unwrap_or_else(|| default_custom_agent_user_context_policy(kind)) +} + +fn readonly_tool_names(state: &AppState) -> Vec { + state + .tool_registry + .iter() + .filter(|tool| tool.is_readonly()) + .map(|tool| tool.name().to_string()) + .collect() +} + +fn ensure_review_tools_are_readonly( + state: &AppState, + agent_id: &str, + tools: &[String], +) -> Result<(), String> { + let readonly_tools = readonly_tool_names(state); + let writable_tools = custom_agent_review_writable_tools(tools, &readonly_tools); + + if writable_tools.is_empty() { + return Ok(()); + } + + Err(format!( + "Review Sub-Agent '{}' can only use read-only tools; remove writable tools: {}", + agent_id, + writable_tools.join(", ") + )) +} + +async fn existing_agent_ids(state: &AppState, workspace: Option<&PathBuf>) -> HashSet { + let modes = state.agent_registry.get_modes_info().await; + let subagents = state + .agent_registry + .get_subagents_info(workspace.map(PathBuf::as_path)) + .await; + modes + .iter() + .map(|mode| mode.id.to_lowercase()) + .chain(subagents.iter().map(|agent| agent.id.to_lowercase())) + .collect() +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetCustomAgentDetailRequest { + pub agent_id: String, + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn get_custom_agent_detail( + state: State<'_, AppState>, + request: GetCustomAgentDetailRequest, +) -> Result { + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + state + .agent_registry + .get_custom_agent_detail(&request.agent_id, workspace.as_deref()) + .await + .map_err(|error| error.to_string()) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateCustomAgentRequest { + pub kind: CustomAgentKind, + pub level: Option, + pub id: String, + pub name: String, + pub description: String, + pub prompt: String, + pub tools: Option>, + pub readonly: Option, + pub review: Option, + pub model: Option, + pub user_context_policy: Option>, + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn create_custom_agent( + state: State<'_, AppState>, + request: CreateCustomAgentRequest, +) -> Result<(), String> { + let id = request.id.trim().to_string(); + validate_agent_id(&id)?; + validate_agent_name(&request.name)?; + if request.description.trim().is_empty() { + return Err("Description cannot be empty".to_string()); + } + if request.prompt.trim().is_empty() { + return Err("Prompt cannot be empty".to_string()); + } + + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let level = request.level.unwrap_or(CustomAgentLevel::User); + + if request.kind == CustomAgentKind::Mode && level == CustomAgentLevel::Project { + return Err("Custom modes do not support project level".to_string()); + } + if level == CustomAgentLevel::Project && workspace.is_none() { + return Err("Project-level Agent requires opening a workspace first".to_string()); + } + + state + .agent_registry + .load_custom_agents(workspace.as_deref()) + .await; + + let existing_ids = existing_agent_ids(&state, workspace.as_ref()).await; + if existing_ids.contains(&id.to_lowercase()) { + return Err(format!("Id '{}' conflicts with an existing agent", id)); + } + + let path_manager = state.workspace_service.path_manager(); + let agents_dir = match level { + CustomAgentLevel::User => path_manager.user_agents_dir(), + CustomAgentLevel::Project => { + let root = workspace.as_deref().ok_or("Workspace path not available")?; + path_manager.project_agents_dir(root) + } + }; + std::fs::create_dir_all(&agents_dir) + .map_err(|error| format!("Failed to create directory: {}", error))?; + + let file_path = agents_dir.join(format!("{}.md", id.to_lowercase())); + let path_str = file_path.to_string_lossy().to_string(); + if file_path.exists() { + return Err(format!("File '{}' already exists", path_str)); + } + + let mut tools = request + .tools + .filter(|items| !items.is_empty()) + .unwrap_or_else(|| { + if request.kind == CustomAgentKind::Mode { + warn!( + "Custom mode {} created without explicit tools; defaulting to minimal tool set", + id + ); + } + default_custom_agent_tools(request.kind) + }); + if tools.is_empty() { + tools = default_custom_agent_tools(request.kind); + } + + let review = request.review.unwrap_or(false); + if request.kind == CustomAgentKind::Mode && review { + return Err("Custom modes cannot enable review".to_string()); + } + if review { + ensure_review_tools_are_readonly(&state, &id, &tools)?; + } + + let readonly = if review { + true + } else { + request + .readonly + .unwrap_or(request.kind == CustomAgentKind::Subagent) + }; + let model = request + .model + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| custom_agent_model_or_default(request.kind, None).to_string()); + let user_context_policy = + policy_from_sections(request.user_context_policy.clone(), request.kind); + + match request.kind { + CustomAgentKind::Mode => { + let mode = CustomMode::new( + id.clone(), + request.name.trim().to_string(), + request.description.trim().to_string(), + tools, + request.prompt.trim().to_string(), + readonly, + path_str.clone(), + model.clone(), + user_context_policy, + ); + mode.save_to_file(None).map_err(|error| error.to_string())?; + } + CustomAgentKind::Subagent => { + let mut subagent = CustomSubagent::new_with_id( + id.clone(), + request.name.trim().to_string(), + request.description.trim().to_string(), + tools, + request.prompt.trim().to_string(), + readonly, + path_str.clone(), + level, + model.clone(), + user_context_policy, + ); + subagent.set_review(review); + subagent + .save_to_file(None) + .map_err(|error| error.to_string())?; + } + } + state + .agent_registry + .load_custom_agents(workspace.as_deref()) + .await; + debug!("Created custom agent {} ({:?})", id, request.kind); + + Ok(()) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCustomAgentRequest { + pub agent_id: String, + pub name: String, + pub description: String, + pub prompt: String, + pub tools: Option>, + pub readonly: Option, + pub review: Option, + pub model: Option, + pub user_context_policy: Option>, + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn update_custom_agent( + state: State<'_, AppState>, + request: UpdateCustomAgentRequest, +) -> Result<(), String> { + validate_agent_name(&request.name)?; + if request.description.trim().is_empty() { + return Err("Description cannot be empty".to_string()); + } + if request.prompt.trim().is_empty() { + return Err("Prompt cannot be empty".to_string()); + } + + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let current = state + .agent_registry + .get_custom_agent_detail(&request.agent_id, workspace.as_deref()) + .await + .map_err(|error| error.to_string())?; + + let kind = match current.kind.as_str() { + "mode" => CustomAgentKind::Mode, + _ => CustomAgentKind::Subagent, + }; + let user_context_policy = request + .user_context_policy + .clone() + .map(|sections| policy_from_sections(Some(sections), kind)); + + if kind == CustomAgentKind::Mode && request.review.unwrap_or(false) { + return Err("Custom modes cannot enable review".to_string()); + } + if kind == CustomAgentKind::Subagent && request.review.unwrap_or(current.review) { + let tools = request + .tools + .clone() + .filter(|items| !items.is_empty()) + .unwrap_or_else(|| current.tools.clone()); + ensure_review_tools_are_readonly(&state, &request.agent_id, &tools)?; + } + + state + .agent_registry + .update_custom_agent_definition( + &request.agent_id, + workspace.as_deref(), + request.name.trim().to_string(), + request.description.trim().to_string(), + request.prompt.trim().to_string(), + request.tools.clone(), + request.readonly, + request.review, + user_context_policy, + request.model.clone(), + ) + .await + .map_err(|error| error.to_string()) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteCustomAgentRequest { + pub agent_id: String, +} + +#[tauri::command] +pub async fn delete_custom_agent( + state: State<'_, AppState>, + request: DeleteCustomAgentRequest, +) -> Result<(), String> { + let agent_id = request.agent_id; + + if let Some(path) = state + .agent_registry + .remove_custom_agent(&agent_id) + .map_err(|error| error.to_string())? + { + if let Err(error) = std::fs::remove_file(&path) { + warn!( + "Failed to delete custom agent file: path={}, error={}", + path, error + ); + } + } + + let config_service = &state.config_service; + + let mut agent_models: HashMap = config_service + .get_config(Some("ai.agent_models")) + .await + .unwrap_or_default(); + if agent_models.remove(&agent_id).is_some() { + if let Err(error) = config_service + .set_config("ai.agent_models", &agent_models) + .await + { + warn!( + "Failed to clean up ai.agent_models after custom agent deletion: agent_id={}, error={}", + agent_id, error + ); + } + } + + let mut agent_profiles: serde_json::Map = config_service + .get_config(Some("ai.agent_profiles")) + .await + .unwrap_or_default(); + if agent_profiles.remove(&agent_id).is_some() { + if let Err(error) = config_service + .set_config("ai.agent_profiles", &agent_profiles) + .await + { + warn!( + "Failed to clean up ai.agent_profiles after custom agent deletion: agent_id={}, error={}", + agent_id, error + ); + } + } + + let default_mode_id: Option = config_service + .get_config(Some("app.flow_chat.default_mode_id")) + .await + .unwrap_or_default(); + if default_mode_id.as_deref() == Some(agent_id.as_str()) { + if let Err(error) = config_service + .set_config("app.flow_chat.default_mode_id", Option::::None) + .await + { + warn!( + "Failed to clear default chat input mode after custom agent deletion: agent_id={}, error={}", + agent_id, error + ); + } + } + + if let Err(error) = bitfun_core::service::config::reload_global_config().await { + warn!( + "Failed to reload global config after custom agent deletion: agent_id={}, error={}", + agent_id, error + ); + } + + Ok(()) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReloadCustomAgentsRequest { + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn reload_custom_agents( + state: State<'_, AppState>, + request: ReloadCustomAgentsRequest, +) -> Result<(), String> { + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + state + .agent_registry + .load_custom_agents(workspace.as_deref()) + .await; + Ok(()) +} diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs index 9c969cb70e..662fd641b4 100644 --- a/src/apps/desktop/src/api/miniapp_agent_api.rs +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -24,11 +24,11 @@ use bitfun_core::agentic::coordination::{ }; use bitfun_core::agentic::core::{MessageContent, MessageRole, SessionConfig}; use bitfun_core::miniapp::agent_bridge::{ - agent_owner, agent_run_metadata, default_agent_run_id, extract_agent_turn_text, - requested_session_id, require_enabled_agent_permissions, resolve_agent_workspace_path, - session_name_or_default, validate_reused_session, MiniAppAgentRateLimiter, - MiniAppAgentRunRecord, MiniAppAgentRunRegistry, MiniAppAgentTurnMessage, - MiniAppAgentTurnMessageRole, MINIAPP_AGENT_KIND, UNKNOWN_AGENT_RUN_MESSAGE, + agent_run_id_from_request, build_agent_submission_plan, extract_agent_turn_text, + plan_agent_workspace, require_agent_prompt, require_enabled_agent_permissions, + validate_reused_session, MiniAppAgentRateLimiter, MiniAppAgentRunRecord, + MiniAppAgentRunRegistry, MiniAppAgentTurnMessage, MiniAppAgentTurnMessageRole, + MINIAPP_AGENT_KIND, UNKNOWN_AGENT_RUN_MESSAGE, }; // ============== Run registry ============== @@ -57,22 +57,6 @@ fn now_ms() -> u64 { .as_millis() as u64 } -/// Resolve a MiniApp-requested agent workspace inside the app's own appdata -/// directory. The subdir must be a clean relative path (no `..`, no absolute -/// or rooted components) so a MiniApp can never point the agent outside its -/// own storage. The directory is created if missing. -fn resolve_app_data_workspace( - state: &AppState, - app_id: &str, - subdir: &str, -) -> Result { - let app_data_dir = state.miniapp_manager.path_manager().miniapp_dir(app_id); - let workspace = resolve_agent_workspace_path(None, Some(subdir), &app_data_dir)?; - std::fs::create_dir_all(&workspace) - .map_err(|e| format!("Failed to create MiniApp agent workspace: {}", e))?; - Ok(workspace.to_string_lossy().to_string()) -} - fn check_agent_rate_limit(app_id: &str, rate_limit_per_minute: u32) -> Result<(), String> { agent_rate_limiter().check(app_id, rate_limit_per_minute, now_ms()) } @@ -178,50 +162,51 @@ pub async fn miniapp_agent_run( scheduler: State<'_, Arc>, request: MiniAppAgentRunRequest, ) -> Result { - if request.prompt.trim().is_empty() { - return Err("prompt is required".to_string()); - } + require_agent_prompt(&request.prompt)?; let agent_perms = require_agent_permission(&state, &request.app_id).await?; check_agent_rate_limit( &request.app_id, agent_perms.rate_limit_per_minute.unwrap_or(0), )?; - let workspace_path = if let Some(subdir) = request - .app_data_workspace + let app_data_dir = state + .miniapp_manager + .path_manager() + .miniapp_dir(&request.app_id); + let workspace_plan = plan_agent_workspace( + request.workspace_path.as_deref(), + request.app_data_workspace.as_deref(), + &app_data_dir, + )?; + if workspace_plan.create_if_missing { + std::fs::create_dir_all(&workspace_plan.path) + .map_err(|e| format!("Failed to create MiniApp agent workspace: {}", e))?; + } + let workspace_path = workspace_plan.workspace_path.clone(); + let run_sequence = if request + .run_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) + .is_some() { - resolve_app_data_workspace(&state, &request.app_id, subdir)? + 0 } else { - request - .workspace_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or("workspacePath is required for MiniApp agent runs")? - .to_string() + AGENT_RUN_COUNTER.fetch_add(1, Ordering::Relaxed) }; - - let run_id = request - .run_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - default_agent_run_id( - &request.app_id, - AGENT_RUN_COUNTER.fetch_add(1, Ordering::Relaxed), - ) - }); - let owner = agent_owner(&request.app_id, &run_id); - let session_name = session_name_or_default(request.session_name.as_deref()); - - let requested_session_id = requested_session_id(request.session_id.as_deref()); - - let session_id = if let Some(existing_session_id) = requested_session_id { + let run_id = + agent_run_id_from_request(&request.app_id, request.run_id.as_deref(), run_sequence); + let submission_plan = build_agent_submission_plan( + &request.app_id, + &run_id, + request.session_name.as_deref(), + request.session_id.as_deref(), + &workspace_path, + request.enable_tools, + ); + + let session_id = if let Some(existing_session_id) = submission_plan.requested_session_id.clone() + { // Reuse a hidden session created by an earlier run of this MiniApp so // the new turn shares its context (skills, research, prior outputs). let session = coordinator @@ -232,15 +217,14 @@ pub async fn miniapp_agent_run( session.created_by.as_deref(), session.config.workspace_path.as_deref(), &request.app_id, - &workspace_path, + &submission_plan.workspace_path, )?; existing_session_id } else { // One hidden session per task keeps MiniApp work isolated and out of // the visible session list. Follow-up turns may reuse it via sessionId. - let enable_tools = request.enable_tools.unwrap_or(true); let config = SessionConfig { - enable_tools, + enable_tools: submission_plan.enable_tools, safe_mode: true, auto_compact: true, enable_context_compression: true, @@ -252,11 +236,11 @@ pub async fn miniapp_agent_run( let session = coordinator .create_hidden_subagent_session_with_workspace( None, - session_name, + submission_plan.session_name.clone(), MINIAPP_AGENT_KIND.to_string(), config, - workspace_path.clone(), - Some(owner), + submission_plan.workspace_path.clone(), + Some(submission_plan.owner.clone()), ) .await .map_err(|e| format!("Failed to create MiniApp agent session: {}", e))?; @@ -265,19 +249,18 @@ pub async fn miniapp_agent_run( let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) .with_skip_tool_confirmation(true); - let metadata = agent_run_metadata(&request.app_id, &run_id); let outcome = scheduler .submit( session_id.clone(), request.prompt.clone(), Some("MiniApp agent run".to_string()), - Some(run_id.clone()), + Some(submission_plan.run_id.clone()), MINIAPP_AGENT_KIND.to_string(), - Some(workspace_path), + Some(submission_plan.workspace_path.clone()), policy, None, - Some(metadata), + Some(submission_plan.metadata.clone()), None, ) .await @@ -291,13 +274,13 @@ pub async fn miniapp_agent_run( agent_run_registry().register(MiniAppAgentRunRecord { app_id: request.app_id.clone(), session_id: session_id.clone(), - turn_id: run_id.clone(), + turn_id: submission_plan.run_id.clone(), }); Ok(MiniAppAgentRunResponse { session_id, - turn_id: run_id.clone(), - action_run_id: run_id, + turn_id: submission_plan.run_id.clone(), + action_run_id: submission_plan.run_id, status: status.to_string(), }) } diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index cbfb9eeb21..4dde54d087 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -4,8 +4,16 @@ use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use bitfun_core::miniapp::ai_bridge::{ - available_models_for_permissions, build_ai_message_plan, require_enabled_ai_permissions, - validate_model, MiniAppAiMessageRole, MiniAppAiModelDescriptor, MiniAppAiModelInfo, + ai_stream_chunk_payload, ai_stream_done_payload, ai_stream_error_payload, + available_models_for_permissions, plan_ai_chat_request, plan_ai_complete_request, + require_enabled_ai_permissions, require_non_empty_ai_messages, require_non_empty_stream_id, + MiniAppAiMessagePlan, MiniAppAiMessageRole, MiniAppAiModelDescriptor, MiniAppAiModelInfo, + MiniAppAiUsage, +}; +use bitfun_core::miniapp::lifecycle::{ + draft_worker_key, miniapp_runtime_event_payload, miniapp_worker_stopped_payload, + should_emit_worker_restarted, should_stop_worker_for_runtime_update, worker_restart_reason, + workspace_root_from_input, }; use bitfun_core::miniapp::rate_limit::{MiniAppRateLimitState, MiniAppRateLimitSubject}; use bitfun_core::miniapp::{ @@ -264,23 +272,6 @@ pub struct RecompileResult { pub warnings: Option>, } -fn miniapp_payload(app: &MiniApp, reason: &str) -> Value { - json!({ - "id": app.id, - "name": app.name, - "version": app.version, - "updatedAt": app.updated_at, - "reason": reason, - "runtime": { - "sourceRevision": app.runtime.source_revision, - "depsRevision": app.runtime.deps_revision, - "depsDirty": app.runtime.deps_dirty, - "workerRestartRequired": app.runtime.worker_restart_required, - "uiRecompileRequired": app.runtime.ui_recompile_required, - } - }) -} - async fn emit_miniapp_event(event_name: &str, payload: Value) { let _ = emit_global_event(BackendEvent::Custom { event_name: event_name.to_string(), @@ -289,25 +280,14 @@ async fn emit_miniapp_event(event_name: &str, payload: Value) { .await; } -fn workspace_root_from_input(workspace_path: Option<&str>) -> Option { - workspace_path - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(PathBuf::from) -} - -fn draft_worker_key(app_id: &str, draft_id: &str) -> String { - format!("{app_id}:draft:{draft_id}") -} - async fn maybe_stop_worker(state: &State<'_, AppState>, app: &MiniApp) { - if app.runtime.worker_restart_required { + if should_stop_worker_for_runtime_update(app) { if let Some(ref pool) = state.js_worker_pool { pool.stop(&app.id).await; } emit_miniapp_event( "miniapp-worker-stopped", - json!({ "id": app.id, "reason": "pending-restart" }), + miniapp_worker_stopped_payload(&app.id, "pending-restart"), ) .await; } @@ -351,7 +331,11 @@ async fn ensure_worker_dependencies( .mark_deps_installed(app_id) .await .map_err(|e| e.to_string())?; - emit_miniapp_event("miniapp-updated", miniapp_payload(app, "deps-installed")).await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(app, "deps-installed"), + ) + .await; Ok(true) } @@ -420,7 +404,11 @@ pub async fn create_miniapp( ) .await .map_err(|e| e.to_string())?; - emit_miniapp_event("miniapp-created", miniapp_payload(&app, "create")).await; + emit_miniapp_event( + "miniapp-created", + miniapp_runtime_event_payload(&app, "create"), + ) + .await; Ok(app) } @@ -448,7 +436,11 @@ pub async fn update_miniapp( .await .map_err(|e| e.to_string())?; maybe_stop_worker(&state, &app).await; - emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "update")).await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "update"), + ) + .await; Ok(app) } @@ -494,8 +486,16 @@ pub async fn rollback_miniapp( .await .map_err(|e| e.to_string())?; maybe_stop_worker(&state, &app).await; - emit_miniapp_event("miniapp-rolled-back", miniapp_payload(&app, "rollback")).await; - emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "rollback")).await; + emit_miniapp_event( + "miniapp-rolled-back", + miniapp_runtime_event_payload(&app, "rollback"), + ) + .await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "rollback"), + ) + .await; Ok(app) } @@ -597,7 +597,11 @@ pub async fn miniapp_worker_call( let worker_revision = state .miniapp_manager .build_worker_revision(&app, &policy_json); - let should_emit_restart = !was_running || deps_installed || app.runtime.worker_restart_required; + let should_emit_restart = should_emit_worker_restarted( + was_running, + deps_installed, + app.runtime.worker_restart_required, + ); let result = pool .call( &request.app_id, @@ -617,14 +621,7 @@ pub async fn miniapp_worker_call( .map_err(|e| e.to_string())?; emit_miniapp_event( "miniapp-worker-restarted", - miniapp_payload( - &app, - if deps_installed { - "deps-installed" - } else { - "runtime-restart" - }, - ), + miniapp_runtime_event_payload(&app, worker_restart_reason(deps_installed)), ) .await; } @@ -683,7 +680,7 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> } emit_miniapp_event( "miniapp-worker-stopped", - json!({ "id": app_id, "reason": "manual-stop" }), + miniapp_worker_stopped_payload(&app_id, "manual-stop"), ) .await; Ok(()) @@ -729,7 +726,11 @@ pub async fn miniapp_install_deps( .mark_deps_installed(&app_id) .await .map_err(|e| e.to_string())?; - emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "deps-installed")).await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "deps-installed"), + ) + .await; } Ok(install) } @@ -746,8 +747,16 @@ pub async fn miniapp_recompile( .recompile(&request.app_id, theme_type, workspace_root.as_deref()) .await .map_err(|e| e.to_string())?; - emit_miniapp_event("miniapp-recompiled", miniapp_payload(&app, "recompile")).await; - emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "recompile")).await; + emit_miniapp_event( + "miniapp-recompiled", + miniapp_runtime_event_payload(&app, "recompile"), + ) + .await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "recompile"), + ) + .await; Ok(RecompileResult { success: true, warnings: None, @@ -778,7 +787,11 @@ pub async fn miniapp_import_from_path( .await .map_err(|e| e.to_string())?; maybe_stop_worker(&state, &app).await; - emit_miniapp_event("miniapp-created", miniapp_payload(&app, "import")).await; + emit_miniapp_event( + "miniapp-created", + miniapp_runtime_event_payload(&app, "import"), + ) + .await; Ok(app) } @@ -795,7 +808,11 @@ pub async fn miniapp_sync_from_fs( .await .map_err(|e| e.to_string())?; maybe_stop_worker(&state, &app).await; - emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "sync-from-fs")).await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "sync-from-fs"), + ) + .await; Ok(app) } @@ -911,10 +928,14 @@ pub async fn miniapp_apply_draft( } emit_miniapp_event( "miniapp-draft-applied", - miniapp_payload(&app, "draft-apply"), + miniapp_runtime_event_payload(&app, "draft-apply"), + ) + .await; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "draft-apply"), ) .await; - emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "draft-apply")).await; Ok(app) } @@ -1178,14 +1199,6 @@ pub struct MiniAppAiCompleteResponse { pub usage: Option, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MiniAppAiUsage { - pub prompt_tokens: u32, - pub completion_tokens: u32, - pub total_tokens: u32, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MiniAppAiChatRequest { @@ -1221,37 +1234,15 @@ pub struct MiniAppAiListModelsRequest { pub app_id: String, } -// ---- Payload structs for Tauri events ---- - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -struct AiStreamChunkPayload { - pub app_id: String, - pub stream_id: String, - #[serde(rename = "type")] - pub payload_type: String, - pub data: serde_json::Value, -} - -// ---- Helper: build Message list from request ---- - -fn build_messages_for_ai( - system_prompt: Option<&str>, - chat_messages: &[MiniAppAiChatMessage], -) -> Vec { - build_ai_message_plan( - system_prompt, - chat_messages - .iter() - .map(|message| (message.role.as_str(), message.content.as_str())), - ) - .into_iter() - .map(|message| match message.role { - MiniAppAiMessageRole::System => Message::system(message.content), - MiniAppAiMessageRole::Assistant => Message::assistant(message.content), - MiniAppAiMessageRole::User => Message::user(message.content), - }) - .collect() +fn build_messages_for_ai_plan(messages: Vec) -> Vec { + messages + .into_iter() + .map(|message| match message.role { + MiniAppAiMessageRole::System => Message::system(message.content), + MiniAppAiMessageRole::Assistant => Message::assistant(message.content), + MiniAppAiMessageRole::User => Message::user(message.content), + }) + .collect() } // ---- Commands ---- @@ -1281,21 +1272,20 @@ pub async fn miniapp_ai_complete( MiniAppRateLimitSubject::Ai, )?; - let model_ref = validate_model(request.model.as_deref(), ai_perms)?; + let ai_plan = plan_ai_complete_request( + ai_perms, + request.model.as_deref(), + request.system_prompt.as_deref(), + &request.prompt, + )?; let ai_client = state .ai_client_factory - .get_client_resolved(&model_ref) + .get_client_resolved(&ai_plan.model_ref) .await .map_err(|e| format!("Failed to get AI client: {}", e))?; - let messages = build_messages_for_ai( - request.system_prompt.as_deref(), - &[MiniAppAiChatMessage { - role: "user".to_string(), - content: request.prompt.clone(), - }], - ); + let messages = build_messages_for_ai_plan(ai_plan.messages); let stream_response = ai_client .send_message_stream(messages, None, None) @@ -1339,12 +1329,8 @@ pub async fn miniapp_ai_chat( state: State<'_, AppState>, request: MiniAppAiChatRequest, ) -> Result { - if request.stream_id.trim().is_empty() { - return Err("streamId is required".to_string()); - } - if request.messages.is_empty() { - return Err("messages must not be empty".to_string()); - } + let stream_id = require_non_empty_stream_id(&request.stream_id)?; + require_non_empty_ai_messages(request.messages.len())?; let miniapp = state .miniapp_manager @@ -1365,15 +1351,23 @@ pub async fn miniapp_ai_chat( MiniAppRateLimitSubject::Ai, )?; - let model_ref = validate_model(request.model.as_deref(), ai_perms)?; + let ai_plan = plan_ai_chat_request( + ai_perms, + request.model.as_deref(), + request.system_prompt.as_deref(), + request + .messages + .iter() + .map(|message| (message.role.as_str(), message.content.as_str())), + )?; let ai_client = state .ai_client_factory - .get_client_resolved(&model_ref) + .get_client_resolved(&ai_plan.model_ref) .await .map_err(|e| format!("Failed to get AI client: {}", e))?; - let messages = build_messages_for_ai(request.system_prompt.as_deref(), &request.messages); + let messages = build_messages_for_ai_plan(ai_plan.messages); let stream_response = ai_client .send_message_stream(messages, None, None) @@ -1386,10 +1380,10 @@ pub async fn miniapp_ai_chat( let mut registry = ai_stream_registry() .lock() .unwrap_or_else(|p| p.into_inner()); - registry.insert(request.stream_id.clone(), cancel_flag.clone()); + registry.insert(stream_id.clone(), cancel_flag.clone()); } - let stream_id = request.stream_id.clone(); + let response_stream_id = stream_id.clone(); let app_id = request.app_id.clone(); let app_handle = app.clone(); @@ -1417,15 +1411,12 @@ pub async fn miniapp_ai_chat( if let Some(ref t) = chunk.text { full_text.push_str(t); } - let payload = AiStreamChunkPayload { - app_id: app_id.clone(), - stream_id: stream_id.clone(), - payload_type: "chunk".to_string(), - data: json!({ - "text": chunk.text, - "reasoningContent": chunk.reasoning_content, - }), - }; + let payload = ai_stream_chunk_payload( + &app_id, + &stream_id, + chunk.text, + chunk.reasoning_content, + ); if let Err(e) = app_handle.emit("miniapp://ai-stream", &payload) { log::warn!("Failed to emit AI stream chunk: {}", e); } @@ -1446,12 +1437,7 @@ pub async fn miniapp_ai_chat( } } Err(e) => { - let payload = AiStreamChunkPayload { - app_id: app_id.clone(), - stream_id: stream_id.clone(), - payload_type: "error".to_string(), - data: json!({ "message": e.to_string() }), - }; + let payload = ai_stream_error_payload(&app_id, &stream_id, e.to_string()); let _ = app_handle.emit("miniapp://ai-stream", &payload); // Clean up registry let mut registry = ai_stream_registry() @@ -1464,22 +1450,7 @@ pub async fn miniapp_ai_chat( } // Emit done - let usage_val = last_usage.map(|u| { - json!({ - "promptTokens": u.prompt_tokens, - "completionTokens": u.completion_tokens, - "totalTokens": u.total_tokens, - }) - }); - let done_payload = AiStreamChunkPayload { - app_id: app_id.clone(), - stream_id: stream_id.clone(), - payload_type: "done".to_string(), - data: json!({ - "fullText": full_text, - "usage": usage_val, - }), - }; + let done_payload = ai_stream_done_payload(&app_id, &stream_id, full_text, last_usage); let _ = app_handle.emit("miniapp://ai-stream", &done_payload); // Clean up registry @@ -1490,7 +1461,7 @@ pub async fn miniapp_ai_chat( }); Ok(MiniAppAiChatStartedResponse { - stream_id: request.stream_id, + stream_id: response_stream_id, }) } diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index cc8a19cd9b..3672b79a57 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -13,6 +13,7 @@ pub mod computer_use_api; pub mod config_api; pub mod context_upload_api; pub mod cron_api; +pub mod custom_agent_api; pub mod debug_api; pub mod diff_api; pub mod dto; diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 8691fb1690..9da0715743 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -2,15 +2,13 @@ use crate::api::app_state::AppState; use bitfun_core::agentic::agents::{ - subagent_source_from_custom_kind, AgentCategory, AgentInfo, CustomSubagent, - CustomSubagentConfig, CustomSubagentDetail, CustomSubagentKind, SubAgentSource, + AgentInfo, CustomSubagent, CustomSubagentDetail, CustomSubagentKind, SubAgentSource, SubagentListScope, SubagentQueryContext, }; use log::warn; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::Arc; use tauri::State; #[derive(Debug, Clone, Deserialize)] @@ -357,19 +355,12 @@ pub async fn create_subagent( path_str.clone(), kind, ); - subagent.review = review; + subagent.set_review(review); subagent.save_to_file(None).map_err(|e| e.to_string())?; - - let custom_config = CustomSubagentConfig { - model: subagent.model.clone(), - }; - - state.agent_registry.register_agent( - Arc::new(subagent), - AgentCategory::SubAgent, - Some(subagent_source_from_custom_kind(kind)), - Some(custom_config), - ); + state + .agent_registry + .load_custom_agents(workspace.as_deref()) + .await; Ok(()) } diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index eff4ba216a..ff06e66821 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -1,12 +1,16 @@ //! System API +use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::service::system; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, Position, Size, State}; #[cfg(not(target_env = "ohos"))] +use tauri_plugin_opener::OpenerExt; +#[cfg(not(target_env = "ohos"))] use tauri_plugin_updater::UpdaterExt; /// Emitted during `install_update` download; matches `installUpdateWithProgress` / frontend listener. @@ -168,6 +172,53 @@ pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Re } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenHtmlFileInBrowserRequest { + pub path: String, +} + +fn is_html_file_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm") + }) + .unwrap_or(false) +} + +#[tauri::command] +pub async fn open_html_file_in_browser( + app: AppHandle, + request: OpenHtmlFileInBrowserRequest, +) -> Result<(), String> { + let path = Path::new(&request.path); + + if !is_html_file_path(path) { + return Err("Only HTML files can be opened in the browser".to_string()); + } + + let metadata = std::fs::metadata(path) + .map_err(|error| format!("Failed to read HTML file metadata: {}", error))?; + if !metadata.is_file() { + return Err("HTML path is not a file".to_string()); + } + + #[cfg(not(target_env = "ohos"))] + { + app.opener() + .open_path(&request.path, None::<&str>) + .map_err(|error| format!("Failed to open HTML file in browser: {}", error)) + } + + #[cfg(target_env = "ohos")] + { + use crate::api::ohos::browser::open_browser; + open_browser(request.path).await + } + +} + #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct RestartAppRequest {} @@ -443,9 +494,15 @@ pub fn ohos_mark_clean_shutdown() { /// Hide the main window so it lives only in the system tray (used by the "ask" /// dialog when the user chooses to minimize instead of quitting). #[tauri::command] -pub async fn minimize_to_tray(app: tauri::AppHandle) -> Result<(), String> { +pub async fn minimize_to_tray( + app: tauri::AppHandle, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result<(), String> { #[cfg(not(target_env = "ohos"))] { + if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { + log::warn!("Failed to initialize tray before minimizing: {}", error); + } if let Some(window) = app.get_webview_window("main") { window.hide().map_err(|e| e.to_string())?; log::info!("Main window minimized to tray via command"); @@ -456,12 +513,31 @@ pub async fn minimize_to_tray(app: tauri::AppHandle) -> Result<(), String> { { Err("Do not support the minimize to tray via command".to_string()) } + +} + +/// Initialize the desktop tray after the startup shell has become interactive. +#[tauri::command] +pub async fn initialize_tray_after_startup( + app: tauri::AppHandle, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + crate::tray::setup_tray(&app, &startup_trace).map_err(|e| e.to_string()) + } + #[cfg(target_env = "ohos")] + { + Err("Do not support the initialized tray before startup".to_string()) + } + } /// Minimal startup-window controls used by the static pre-React splash. #[tauri::command] pub async fn startup_window_control( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, app: tauri::AppHandle, request: StartupWindowControlRequest, ) -> Result<(), String> { @@ -496,20 +572,22 @@ pub async fn startup_window_control( .await .unwrap_or_else(|_| "minimize_to_tray".to_string()); - if behavior == "quit" { - log::info!("Quit requested from startup window control"); - crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); - crate::perform_process_exit_cleanup(); - app.exit(0); - } else { - window.hide().map_err(|error| { - format!("Failed to hide main window during startup close: {}", error) - })?; - log::info!("Main window hidden from startup window control"); + if behavior == "quit" { + log::info!("Quit requested from startup window control"); + crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); + crate::perform_process_exit_cleanup(); + app.exit(0); + } else { + if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { + log::warn!("Failed to initialize tray before startup close: {}", error); } + window.hide().map_err(|error| { + format!("Failed to hide main window during startup close: {}", error) + })?; + log::info!("Main window hidden from startup window control"); } } - + } Ok(()) } diff --git a/src/apps/desktop/src/api/workspace_activation.rs b/src/apps/desktop/src/api/workspace_activation.rs index 73b77c0c94..58d312af85 100644 --- a/src/apps/desktop/src/api/workspace_activation.rs +++ b/src/apps/desktop/src/api/workspace_activation.rs @@ -61,9 +61,9 @@ async fn warm_workspace_background_services( if is_workspace_active(&workspace_path, &target_path).await { let subagents_started_at = Instant::now(); - agent_registry.load_custom_subagents(&target_path).await; + agent_registry.load_custom_agents(Some(&target_path)).await; debug!( - "Workspace custom subagent warmup completed: path={}, elapsed_ms={}", + "Workspace custom agent warmup completed: path={}, elapsed_ms={}", target_path.display(), subagents_started_at.elapsed().as_millis() ); diff --git a/src/apps/desktop/src/computer_use/macos_ax_ui.rs b/src/apps/desktop/src/computer_use/macos_ax_ui.rs index 47a43028cd..400b4f477d 100644 --- a/src/apps/desktop/src/computer_use/macos_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/macos_ax_ui.rs @@ -318,14 +318,6 @@ impl CandidateMatch { score += 20; } - // WeChat (and similar): global search field is often the first AXTextField match but is the wrong target - // when the user wants the **chat composer**. Deprioritize known search chrome. - if let Some(ref id) = self.identifier { - if id.contains("_SC_SEARCH_FIELD") { - score -= 1500; - } - } - // Among text inputs, the composer is usually **lower** on screen than the top search bar. let rl = self.role.to_lowercase(); if rl.contains("textfield") || rl.contains("textarea") { @@ -719,7 +711,7 @@ pub fn locate_ui_element_center( if candidates.is_empty() { return Err(BitFunError::tool( - "No accessibility element matched in the frontmost app. Tips: `role_substring` **`TextArea`** also matches **`AXTextField`** (WeChat compose is often TextField). Use `filter_combine: \"any\"` for OR matching; match UI language; ensure the target app is focused. For chat apps, if the conversation is already open, **`type_text`** may work without clicking. Or use `move_to_text` / `screenshot`." + "No accessibility element matched in the frontmost app. Tips: `role_substring` **`TextArea`** also matches **`AXTextField`**; use `text_contains` for any visible label; use `filter_combine: \"any\"` for OR matching; match the UI language; ensure the target app is focused. If the AX tree is sparse, fall back to `move_to_text` (OCR) or `describe_screen` / `screenshot` to observe, or `key_chord` keyboard navigation." .to_string(), )); } @@ -1224,3 +1216,82 @@ unsafe fn first_ax_window_from_ax_windows(app: AXUIElementRef) -> Option String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/computer_use/macos_ax_ui.rs"); + let mut src = String::new(); + std::fs::File::open(&path) + .unwrap() + .read_to_string(&mut src) + .unwrap(); + src + } + + /// Extract the body of `fn rank_score` (the AX candidate scoring logic), + /// by brace-matching the function body. + fn rank_score_body(src: &str) -> &str { + let sig = src.find("fn rank_score").expect("rank_score present"); + let open = src[sig..].find('{').expect("rank_score body open brace") + sig; + let mut depth: i32 = 0; + let mut end = open; + for (i, b) in src[open..].char_indices() { + match b { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = open + i; + break; + } + } + _ => {} + } + } + &src[open + 1..end] + } + + /// Guard against regressing special-case, app-specific adaptations in the AX + /// ranking logic. A prior WeChat-private hack inspected the `identifier` + /// field for a vendor prefix and applied a hardcoded score penalty. It must + /// not come back: AX ranking stays generic (role, visibility, size, depth, + /// screen position) and app-specific disambiguation is left to the model via + /// `describe_screen` / `get_app_state`. + #[test] + fn ax_ranking_does_not_branch_on_identifier() { + let src = module_source(); + let body = rank_score_body(&src); + assert!( + !body.contains("identifier"), + "rank_score must not branch on the AX `identifier` field — that invites app-specific hacks. Got: {}", + body + ); + } + + /// The "no AX element matched" error must stay model-neutral and app-agnostic: + /// no WeChat / chat-app specific guidance baked into a generic locate failure. + #[test] + fn no_match_error_is_app_agnostic() { + let src = module_source(); + // The error string lives outside the test module, so scoping to the + // pre-`#[cfg(test)]` slice keeps the assertion self-contained. + let scope_end = src.find("#[cfg(test)]").unwrap_or(src.len()); + let scope = &src[..scope_end]; + let err_start = scope + .find("No accessibility element matched in the frontmost app") + .expect("no-match error string present"); + let err_end = scope[err_start..] + .find('\n') + .unwrap_or(scope.len() - err_start); + let err = &scope[err_start..err_start + err_end]; + assert!( + !err.contains("WeChat") && !err.contains("chat app"), + "no-match error must not name a specific app/category: {}", + err + ); + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 66ba2a24fa..68227add48 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -43,6 +43,10 @@ use api::commands::*; use api::computer_use_api::*; use api::config_api::*; use api::cron_api::*; +use api::custom_agent_api::{ + create_custom_agent, delete_custom_agent, get_custom_agent_detail, reload_custom_agents, + update_custom_agent, +}; use api::diff_api::*; use api::git_agent_api::*; use api::git_api::*; @@ -568,41 +572,42 @@ pub async fn _run() { } let app_handle = app.handle().clone(); - let window_started = Instant::now(); - startup_trace.record_phase("main_window_create_start", "native_window"); - theme::create_main_window(&app_handle, &startup_trace_id, &startup_trace); - let window_duration_ms = elapsed_ms(window_started); - startup_trace.record_step( - "native_step_end", - "native_window", - "create_main_window", - window_duration_ms, - ); - log::debug!( + + #[cfg(not(target_env = "ohos"))] + { + let window_duration_ms = elapsed_ms(window_started); + startup_trace.record_step( + "native_step_end", + "native_window", + "create_main_window", + window_duration_ms, + ); + log::debug!( "Desktop startup step completed: step=create_main_window, duration_ms={}", window_duration_ms - ); - let webdriver_started = Instant::now(); - bitfun_webdriver::maybe_start(app_handle.clone()); - startup_trace.record_elapsed_step( - "native_setup", - "maybe_start_webdriver", - webdriver_started, - ); - let window_phase_duration_ms = elapsed_ms(setup_started); - let since_process_start_ms = elapsed_ms(startup_started); - startup_trace.record_step( - "native_step_end", - "native_setup", - "tauri_setup_until_main_window_created", - window_phase_duration_ms, - ); - startup_trace.record_phase("tauri_setup_window_phase_end", "native_setup"); - log::debug!( - "Desktop startup timing: phase=tauri_setup_until_main_window_created, duration_ms={}, since_process_start_ms={}", - window_phase_duration_ms, - since_process_start_ms - ); + ); + let webdriver_started = Instant::now(); + bitfun_webdriver::maybe_start(app_handle.clone()); + startup_trace.record_elapsed_step( + "native_setup", + "maybe_start_webdriver", + webdriver_started, + ); + let window_phase_duration_ms = elapsed_ms(setup_started); + let since_process_start_ms = elapsed_ms(startup_started); + startup_trace.record_step( + "native_step_end", + "native_setup", + "tauri_setup_until_main_window_created", + window_phase_duration_ms, + ); + startup_trace.record_phase("tauri_setup_window_phase_end", "native_setup"); + log::debug!( + "Desktop startup timing: phase=tauri_setup_until_main_window_created, duration_ms={}, since_process_start_ms={}", + window_phase_duration_ms, + since_process_start_ms + ); + } #[cfg(target_os = "macos")] { @@ -661,10 +666,12 @@ pub async fn _run() { app.state(); let terminal_state_inner = api::terminal_api::TerminalState::new(); let app_handle_clone = app_handle.clone(); - api::terminal_api::start_terminal_event_loop( - terminal_state_inner, - app_handle_clone, - ); + tauri::async_runtime::spawn(async move { + api::terminal_api::start_terminal_event_loop( + terminal_state_inner, + app_handle_clone, + ); + }); startup_trace.record_elapsed_step( "native_setup", "spawn_terminal_event_loop", @@ -687,16 +694,8 @@ pub async fn _run() { logging::spawn_log_cleanup_task(); startup_trace.record_elapsed_step("native_setup", "spawn_log_cleanup_task", step_started); - // Set up system tray icon. - #[cfg(not(target_env = "ohos"))] - { - let step_started = Instant::now(); - if let Err(error) = crate::tray::setup_tray(app, &startup_trace) { - log::warn!("Failed to set up system tray: {}", error); - } - startup_trace.record_elapsed_step("native_setup", "setup_tray", step_started); - } - + let step_started = Instant::now(); + startup_trace.record_elapsed_step("native_setup", "setup_tray_deferred", step_started); let setup_duration_ms = elapsed_ms(setup_started); let since_process_start_ms = elapsed_ms(startup_started); @@ -819,7 +818,7 @@ pub async fn _run() { validate_tool_input, execute_tool, submit_user_answers, - initialize_global_state, + initialize_workspace_startup_state, get_available_tools, report_ide_control_result, get_health_status, @@ -899,6 +898,11 @@ pub async fn _run() { list_subagents, list_visible_subagents, list_manageable_subagents, + get_custom_agent_detail, + create_custom_agent, + update_custom_agent, + delete_custom_agent, + reload_custom_agents, get_subagent_detail, delete_subagent, create_subagent, @@ -1131,11 +1135,13 @@ pub async fn _run() { get_app_version, check_for_updates, install_update, + api::system_api::open_html_file_in_browser, restart_app, open_external_ohos, send_system_notification, api::system_api::quit_app, api::system_api::minimize_to_tray, + api::system_api::initialize_tray_after_startup, api::system_api::startup_window_control, api::system_api::toggle_main_window_fullscreen, check_command_exists, diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index dec09e4cce..a8a49514ad 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -310,6 +310,11 @@ pub fn build_log_plugin(log_targets: Vec) -> TauriPlugin "bitfun_core::agentic::events::router", log::LevelFilter::Debug, ) + .level_for("bitfun_agent_runtime::event_queue", log::LevelFilter::Debug) + .level_for( + "bitfun_agent_runtime::event_router", + log::LevelFilter::Debug, + ) .level_for("hyper_util", log::LevelFilter::Info) .level_for("h2", log::LevelFilter::Info) .level_for("portable_pty", log::LevelFilter::Info) diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 434af27914..7e3cea25b3 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -208,6 +208,16 @@ pub struct ThemeConfig { pub accent_color: String, } +#[derive(Debug, Clone)] +struct StartupBootstrapConfig { + theme: ThemeConfig, + locale: String, + keybindings: Option, +} + +const MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES: usize = 64 * 1024; +const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024; + impl Default for ThemeConfig { fn default() -> Self { let mut theme = Self::get_builtin_theme("bitfun-light").unwrap_or_else(|| Self { @@ -321,9 +331,13 @@ impl ThemeConfig { } } - pub fn load_from_config() -> Self { - let default = Self::default(); - + fn load_startup_bootstrap_config() -> StartupBootstrapConfig { + let default_theme = Self::default(); + let default = StartupBootstrapConfig { + theme: default_theme.clone(), + locale: "zh-CN".to_string(), + keybindings: None, + }; let path_manager = match try_get_path_manager_arc() { Ok(pm) => pm, Err(e) => { @@ -345,14 +359,33 @@ impl ThemeConfig { } }; - let global_config: GlobalConfig = match serde_json::from_str(&config_content) { - Ok(config) => config, + let config_value: serde_json::Value = match serde_json::from_str(&config_content) { + Ok(value) => value, Err(e) => { debug!("Failed to parse config file, using default theme: {}", e); return default; } }; + let locale = config_value + .pointer("/app/language") + .and_then(|value| value.as_str()) + .or_else(|| { + config_value + .pointer("/i18n/currentLanguage") + .and_then(|value| value.as_str()) + }) + .unwrap_or("zh-CN") + .to_string(); + + let global_config: GlobalConfig = match serde_json::from_value(config_value) { + Ok(config) => config, + Err(e) => { + debug!("Failed to parse config file, using default theme: {}", e); + return StartupBootstrapConfig { locale, ..default }; + } + }; + let theme_id = global_config .themes .as_ref() @@ -361,15 +394,21 @@ impl ThemeConfig { let resolved_id = Self::resolve_builtin_theme_id(theme_id); - match Self::get_builtin_theme(resolved_id) { + let theme = match Self::get_builtin_theme(resolved_id) { Some(mut config) => { config.selection_id = Some(theme_id.to_string()); config } None => { warn!("Unknown theme ID: {}, using default theme", theme_id); - default + default_theme } + }; + + StartupBootstrapConfig { + theme, + locale, + keybindings: global_config.app.keybindings, } } @@ -379,30 +418,6 @@ impl ThemeConfig { "system" } - fn load_startup_locale_from_config() -> String { - let path_manager = match try_get_path_manager_arc() { - Ok(pm) => pm, - Err(_) => return "zh-CN".to_string(), - }; - let config_file = path_manager.app_config_file(); - let Ok(config_content) = std::fs::read_to_string(config_file) else { - return "zh-CN".to_string(); - }; - let Ok(config_value) = serde_json::from_str::(&config_content) else { - return "zh-CN".to_string(); - }; - config_value - .pointer("/app/language") - .and_then(|value| value.as_str()) - .or_else(|| { - config_value - .pointer("/i18n/currentLanguage") - .and_then(|value| value.as_str()) - }) - .unwrap_or("zh-CN") - .to_string() - } - fn startup_messages_json(locale: &str) -> String { let messages = match locale { "en-US" | "en" => serde_json::json!({ @@ -430,9 +445,14 @@ impl ThemeConfig { messages.to_string() } - pub fn generate_init_script(&self, startup_trace_id: &str) -> String { + fn generate_init_script( + &self, + startup_trace_id: &str, + bootstrap_config: &StartupBootstrapConfig, + workspace_startup_state: Option<&serde_json::Value>, + ) -> String { let theme_type = if self.is_light { "light" } else { "dark" }; - let startup_locale = Self::load_startup_locale_from_config(); + let startup_locale = &bootstrap_config.locale; let startup_locale_json = serde_json::to_string(&startup_locale).unwrap_or_else(|_| "\"zh-CN\"".to_string()); let startup_messages_json = Self::startup_messages_json(&startup_locale); @@ -453,6 +473,16 @@ impl ThemeConfig { .as_ref() .and_then(|selection| serde_json::to_string(selection).ok()) .unwrap_or_else(|| "null".to_string()); + let bootstrap_keybindings_assignment = serde_json::to_string(&bootstrap_config.keybindings) + .ok() + .filter(|json| json.len() <= MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES) + .map(|json| format!("window.__BITFUN_BOOTSTRAP_KEYBINDINGS__ = {json};")) + .unwrap_or_default(); + let bootstrap_workspace_startup_state_assignment = workspace_startup_state + .and_then(|state| serde_json::to_string(state).ok()) + .filter(|json| json.len() <= MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES) + .map(|json| format!("window.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = {json};")) + .unwrap_or_default(); format!( r#" @@ -465,6 +495,8 @@ impl ThemeConfig { window.__BITFUN_SHOW_STARTUP_WINDOW_CONTROLS__ = {show_startup_window_controls}; window.__BITFUN_BOOTSTRAP_THEME_ID__ = {bootstrap_theme_id_json}; window.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = {bootstrap_theme_selection_json}; + {bootstrap_keybindings_assignment} + {bootstrap_workspace_startup_state_assignment} function applyTheme() {{ var root = document.documentElement; if (!root) return false; @@ -513,6 +545,9 @@ impl ThemeConfig { startup_locale_json = startup_locale_json, startup_messages_json = startup_messages_json, show_startup_window_controls = show_startup_window_controls, + bootstrap_keybindings_assignment = bootstrap_keybindings_assignment, + bootstrap_workspace_startup_state_assignment = + bootstrap_workspace_startup_state_assignment, ) } @@ -529,11 +564,17 @@ pub fn create_main_window( app_handle: &tauri::AppHandle, startup_trace_id: &str, startup_trace: &DesktopStartupTrace, + workspace_startup_state: Option, ) { let total_started_at = Instant::now(); - let theme = ThemeConfig::load_from_config(); + let bootstrap_config = ThemeConfig::load_startup_bootstrap_config(); + let theme = bootstrap_config.theme.clone(); let bg_color = theme.to_tauri_color(); - let init_script = theme.generate_init_script(startup_trace_id); + let init_script = theme.generate_init_script( + startup_trace_id, + &bootstrap_config, + workspace_startup_state.as_ref(), + ); startup_trace.record_step( "native_step_end", "native_window", diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index fa9d021b45..691a4da400 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -13,7 +13,7 @@ //! The context menu is rebuilt every time the user left-clicks (for freshness), //! periodically, and after locale changes. -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock}; use std::time::Instant; use tauri::menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}; @@ -28,6 +28,8 @@ use crate::api::app_state::AppState; use crate::startup_trace::DesktopStartupTrace; static TRAY_ICON: OnceLock = OnceLock::new(); +static TRAY_SETUP_LOCK: Mutex<()> = Mutex::new(()); +const TRAY_TRACE_CATEGORY: &str = "native_background"; struct TrayStrings { show_app: &'static str, @@ -167,16 +169,27 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> { /// Build and attach the system tray icon to the Tauri application. pub fn setup_tray( - app: &tauri::App, + app: &tauri::AppHandle, startup_trace: &DesktopStartupTrace, ) -> Result<(), Box> { + if TRAY_ICON.get().is_some() { + return Ok(()); + } + + let _guard = TRAY_SETUP_LOCK + .lock() + .map_err(|_| "Tray setup lock poisoned")?; + if TRAY_ICON.get().is_some() { + return Ok(()); + } + let step_started = Instant::now(); let pet_item = CheckMenuItemBuilder::with_id("toggle_desktop_pet", STRINGS_EN_US.desktop_pet) .checked(false) .build(app)?; let show_item = MenuItemBuilder::with_id("show_window", STRINGS_EN_US.show_app).build(app)?; let quit_item = MenuItemBuilder::with_id("quit", STRINGS_EN_US.quit_app).build(app)?; - startup_trace.record_elapsed_step("native_setup", "setup_tray.menu_items", step_started); + startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.menu_items", step_started); let step_started = Instant::now(); let initial_menu = MenuBuilder::new(app) @@ -186,14 +199,14 @@ pub fn setup_tray( .separator() .item(&quit_item) .build()?; - startup_trace.record_elapsed_step("native_setup", "setup_tray.menu", step_started); + startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.menu", step_started); let step_started = Instant::now(); let icon = app .default_window_icon() .ok_or("No default window icon")? .clone(); - startup_trace.record_elapsed_step("native_setup", "setup_tray.icon", step_started); + startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.icon", step_started); let step_started = Instant::now(); let tray = TrayIconBuilder::new() @@ -234,14 +247,14 @@ pub fn setup_tray( _ => {} }) .build(app)?; - startup_trace.record_elapsed_step("native_setup", "setup_tray.build", step_started); + startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.build", step_started); let step_started = Instant::now(); let _ = TRAY_ICON.set(tray); - startup_trace.record_elapsed_step("native_setup", "setup_tray.store", step_started); + startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.store", step_started); let step_started = Instant::now(); - let app_handle = app.handle().clone(); + let app_handle = app.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; rebuild_tray_menu(&app_handle).await; @@ -252,7 +265,7 @@ pub fn setup_tray( rebuild_tray_menu(&app_handle).await; } }); - startup_trace.record_elapsed_step("native_setup", "setup_tray.spawn_refresh", step_started); + startup_trace.record_elapsed_step(TRAY_TRACE_CATEGORY, "setup_tray.spawn_refresh", step_started); Ok(()) } diff --git a/src/apps/ohos/entry/src/main/module.json5 b/src/apps/ohos/entry/src/main/module.json5 index 43271a909b..fbeec3341f 100644 --- a/src/apps/ohos/entry/src/main/module.json5 +++ b/src/apps/ohos/entry/src/main/module.json5 @@ -93,6 +93,14 @@ ], "when": "always" } + }, + { + "name": "ohos.permission.kernel.ALLOW_WRITABLE_CODE_MEMORY", + "reason": "$string:permission_writable_code_memory_reason" + }, + { + "name": "ohos.permission.ALLOW_EXTERNAL_NATIVE_CODE", + "reason": "$string:permission_external_native_code_reason" } ], "extensionAbilities": [ diff --git a/src/apps/ohos/entry/src/main/resources/base/element/string.json b/src/apps/ohos/entry/src/main/resources/base/element/string.json index 7de142a028..5ecc2f4005 100644 --- a/src/apps/ohos/entry/src/main/resources/base/element/string.json +++ b/src/apps/ohos/entry/src/main/resources/base/element/string.json @@ -38,6 +38,14 @@ { "name": "permission_download_directory_reason", "value": "Used to access download directory for file management and project operations" + }, + { + "name": "permission_writable_code_memory_reason", + "value": "Used to allow writable code memory for runtime code generation and JIT compilation" + }, + { + "name": "permission_external_native_code_reason", + "value": "Used to allow loading and executing external native code libraries" } ] } \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json index ed1bd84af6..0727ba935d 100644 --- a/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json @@ -39,6 +39,14 @@ { "name": "permission_download_directory_reason", "value": "用于访问下载目录以进行文件管理和项目操作" + }, + { + "name": "permission_writable_code_memory_reason", + "value": "用于允许可写代码内存以支持运行时代码生成和JIT编译" + }, + { + "name": "permission_external_native_code_reason", + "value": "用于允许加载和执行外部原生代码库" } ] } \ No newline at end of file diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index ee247c87a5..dd7374a18d 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -14,29 +14,32 @@ name = "bitfun-relay-server" path = "src/main.rs" [dependencies] +# NOTE: Dependencies are intentionally inlined rather than inherited from the +# workspace so that this crate can be built standalone inside its Docker +# context (src/apps/relay-server/) without copying the whole workspace. # Web framework -axum = { workspace = true } -tower-http = { workspace = true } +axum = { version = "0.8", features = ["json", "ws"] } +tower-http = { version = "0.6.11", features = ["cors", "fs"] } # Async runtime -tokio = { workspace = true } -futures-util = { workspace = true } +tokio = { version = "1.52", features = ["full"] } +futures-util = "0.3.31" # Serialization -serde = { workspace = true } -serde_json = { workspace = true } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" # Error handling -anyhow = { workspace = true } +anyhow = "1.0" # Logging -tracing = { workspace = true } -tracing-subscriber = { workspace = true } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Utilities -uuid = { workspace = true } -chrono = { workspace = true } -dashmap = { workspace = true } -rand = { workspace = true } -base64 = { workspace = true } -sha2 = { workspace = true } +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock"] } +dashmap = "6" +rand = "0.8" +base64 = "0.22" +sha2 = "0.10" diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 8080bcb2fe..e5159225c5 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -53,6 +53,17 @@ In **Remote Connect → Self-Hosted → Server URL**, use one of: `/relay` is only needed when your reverse proxy is configured with that path prefix. +### Network Binding + +By default, the relay process listens on `0.0.0.0:9700` (all interfaces) and Docker Compose publishes the container port on the host's `0.0.0.0:9700`. + +If you need to restrict the service to localhost only, set the environment variable before running `start.sh`/`restart.sh`/`deploy.sh`: + +```bash +export RELAY_HOST_BIND_IP=127.0.0.1 +bash deploy.sh +``` + ### Manual Run ```bash @@ -79,8 +90,8 @@ RELAY_PORT=9700 ./target/release/bitfun-relay-server | Variable | Default | Description | |----------|---------|-------------| | `RELAY_PORT` | `9700` | Server listen port | -| `RELAY_STATIC_DIR` | `./static` | Path to mobile web static files (fallback SPA) | -| `RELAY_ROOM_WEB_DIR` | `/tmp/bitfun-room-web` | Directory for per-room uploaded mobile-web files | +| `RELAY_STATIC_DIR` | _(none)_ | Path to mobile web static files fallback SPA. When unset, no fallback static files are served. Docker Compose sets this to `/app/static`. | +| `RELAY_ROOM_WEB_DIR` | `/tmp/bitfun-room-web` | Directory for per-room uploaded mobile-web files. Docker Compose uses a named volume mounted at `/app/room-web`. | | `RELAY_ROOM_TTL` | `3600` | Room TTL in seconds (0 = no expiry) | ## API Endpoints diff --git a/src/apps/relay-server/restart.sh b/src/apps/relay-server/restart.sh index 3ed2b2dc5d..35e2ef3193 100755 --- a/src/apps/relay-server/restart.sh +++ b/src/apps/relay-server/restart.sh @@ -6,7 +6,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTAINER_NAME="bitfun-relay" -RELAY_HOST_BIND_IP="127.0.0.1" usage() { cat <<'EOF' @@ -66,14 +65,14 @@ cd "$SCRIPT_DIR" if container_running; then echo "Relay service is running. Restarting it..." - RELAY_HOST_BIND_IP="$RELAY_HOST_BIND_IP" docker compose up -d --force-recreate + docker compose up -d --force-recreate else echo "Relay service is not running. Starting it instead..." - RELAY_HOST_BIND_IP="$RELAY_HOST_BIND_IP" docker compose up -d + docker compose up -d fi echo "" echo "Relay service is ready." -echo "Relay endpoint: http://127.0.0.1:9700" +echo "Relay endpoint: http://:9700" echo "Check status: docker compose ps" echo "View logs: docker compose logs -f relay-server" diff --git a/src/apps/relay-server/start.sh b/src/apps/relay-server/start.sh index 8cca1fd1c3..7585838a0d 100755 --- a/src/apps/relay-server/start.sh +++ b/src/apps/relay-server/start.sh @@ -6,7 +6,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTAINER_NAME="bitfun-relay" -RELAY_HOST_BIND_IP="127.0.0.1" usage() { cat <<'EOF' @@ -78,10 +77,10 @@ else echo "Relay service is not created yet. Creating and starting it..." fi -RELAY_HOST_BIND_IP="$RELAY_HOST_BIND_IP" docker compose up -d +docker compose up -d echo "" echo "Relay service started." -echo "Relay endpoint: http://127.0.0.1:9700" +echo "Relay endpoint: http://:9700" echo "Check status: docker compose ps" echo "View logs: docker compose logs -f relay-server" diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index 41f86d4f8e..2b2b824a09 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -5,14 +5,14 @@ //! `params` and returns a JSON `result`. use crate::bootstrap::ServerAppState; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use bitfun_core::agentic::agents::SubAgentSource; use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; use bitfun_core::agentic::core::SessionConfig; use bitfun_core::agentic::deep_review_policy::{ - DeepReviewQueueControlAction, apply_deep_review_queue_control, + apply_deep_review_queue_control, DeepReviewQueueControlAction, }; -use bitfun_core::service::i18n::{LocaleId, LocaleMetadata, sync_global_i18n_service_locale}; +use bitfun_core::service::i18n::{sync_global_i18n_service_locale, LocaleId, LocaleMetadata}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -210,9 +210,10 @@ pub async fn dispatch( let workspace = workspace_root_from_request(request.get("workspacePath").and_then(|v| v.as_str())); - if let Some(workspace) = workspace.as_deref() { - state.agent_registry.load_custom_subagents(workspace).await; - } + state + .agent_registry + .load_custom_agents(workspace.as_deref()) + .await; if state .agent_registry diff --git a/src/crates/adapters/ai-adapters/AGENTS.md b/src/crates/adapters/ai-adapters/AGENTS.md index 803f6722e3..f23e4ef64f 100644 --- a/src/crates/adapters/ai-adapters/AGENTS.md +++ b/src/crates/adapters/ai-adapters/AGENTS.md @@ -2,8 +2,9 @@ Scope: this guide applies to `src/crates/adapters/ai-adapters`. -`bitfun-ai-adapters` owns provider-specific request/response mapping and stream -protocol parsing. Keep provider quirks here, then convert stream chunks into the +`bitfun-ai-adapters` owns provider-specific request/response mapping, stream +protocol parsing, and provider/model selection helpers that are independent of +core config IO. Keep provider quirks here, then convert stream chunks into the provider-neutral contracts owned by `bitfun-agent-stream`. ## Guardrails diff --git a/src/crates/adapters/ai-adapters/src/lib.rs b/src/crates/adapters/ai-adapters/src/lib.rs index f86290ba75..b9ffa1d5a9 100644 --- a/src/crates/adapters/ai-adapters/src/lib.rs +++ b/src/crates/adapters/ai-adapters/src/lib.rs @@ -2,6 +2,7 @@ pub mod client; pub mod diagnostics; +pub mod model_selector; pub mod providers; pub mod stream; pub mod tool_call_accumulator; @@ -12,6 +13,10 @@ pub use client::{ AIClient, StreamOptions, StreamResponse, DEFAULT_STREAM_IDLE_TIMEOUT_SECS, DEFAULT_STREAM_TTFT_TIMEOUT_SECS, REASONING_STREAM_TTFT_TIMEOUT_SECS, }; +pub use model_selector::{ + classify_model_selector, resolve_cache_model_selector, resolve_required_model_selector, + ModelSelectorError, ModelSelectorKind, +}; pub use stream::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; pub use trace::{ ModelExchangeRequestAttempt, ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, diff --git a/src/crates/adapters/ai-adapters/src/model_selector.rs b/src/crates/adapters/ai-adapters/src/model_selector.rs new file mode 100644 index 0000000000..8858812116 --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/model_selector.rs @@ -0,0 +1,71 @@ +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelSelectorKind { + Primary, + Fast, + Explicit(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelSelectorError { + PrimaryUnavailable, + FastUnavailable, +} + +impl fmt::Display for ModelSelectorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PrimaryUnavailable => write!(f, "Primary model not configured or invalid"), + Self::FastUnavailable => write!( + f, + "Fast model not configured or invalid, and primary model not configured or invalid" + ), + } + } +} + +impl std::error::Error for ModelSelectorError {} + +pub fn classify_model_selector(model_id: &str) -> ModelSelectorKind { + let trimmed = model_id.trim(); + match trimmed { + "" | "auto" | "default" | "primary" => ModelSelectorKind::Primary, + "fast" => ModelSelectorKind::Fast, + _ => ModelSelectorKind::Explicit(trimmed.to_string()), + } +} + +pub fn resolve_required_model_selector( + model_id: &str, + mut resolve_selection: impl FnMut(&str) -> Option, + mut resolve_reference: impl FnMut(&str) -> Option, +) -> Result { + match classify_model_selector(model_id) { + ModelSelectorKind::Primary => { + resolve_selection("primary").ok_or(ModelSelectorError::PrimaryUnavailable) + } + ModelSelectorKind::Fast => { + resolve_selection("fast").ok_or(ModelSelectorError::FastUnavailable) + } + ModelSelectorKind::Explicit(model_ref) => { + Ok(resolve_reference(&model_ref).unwrap_or(model_ref)) + } + } +} + +pub fn resolve_cache_model_selector( + model_id: &str, + mut resolve_selection: impl FnMut(&str) -> Option, + mut resolve_reference: impl FnMut(&str) -> Option, +) -> String { + match classify_model_selector(model_id) { + ModelSelectorKind::Primary => { + resolve_selection("primary").unwrap_or_else(|| "primary".to_string()) + } + ModelSelectorKind::Fast => resolve_selection("fast").unwrap_or_else(|| "fast".to_string()), + ModelSelectorKind::Explicit(model_ref) => { + resolve_reference(&model_ref).unwrap_or(model_ref) + } + } +} diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs index e75df20454..3ec33276ea 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs @@ -172,66 +172,100 @@ impl OpenAIMessageConverter { })]); } - let parsed = match serde_json::from_str::(content) { - Ok(parsed) if parsed.is_array() => parsed, - _ => { - return Some(vec![json!({ + Some( + Self::parse_responses_content_parts(role, content).unwrap_or_else(|| { + vec![json!({ "type": text_item_type, "text": content, - })]); + })] + }), + ) + } + + fn responses_text_item_type(role: &str) -> &'static str { + if role == "assistant" { + "output_text" + } else { + "input_text" + } + } + + fn parse_responses_content_parts(role: &str, content: &str) -> Option> { + let items = serde_json::from_str::(content) + .ok()? + .as_array()? + .clone(); + let text_item_type = Self::responses_text_item_type(role); + let mut content_items = Vec::with_capacity(items.len()); + + for item in items { + let item_type = item.get("type").and_then(Value::as_str)?; + match item_type { + "text" | "input_text" | "output_text" => { + let text = item.get("text").and_then(Value::as_str)?; + content_items.push(json!({ + "type": text_item_type, + "text": text, + })); + } + "image_url" if role != "assistant" => { + let image_url = Self::extract_image_url_value(item.get("image_url")?)?; + content_items.push(json!({ + "type": "input_image", + "image_url": image_url, + })); + } + _ => return None, } - }; + } - let mut content_items = Vec::new(); - - if let Some(items) = parsed.as_array() { - for item in items { - let item_type = item.get("type").and_then(Value::as_str); - match item_type { - Some("text") | Some("input_text") | Some("output_text") => { - if let Some(text) = item.get("text").and_then(Value::as_str) { - content_items.push(json!({ - "type": text_item_type, - "text": text, - })); - } - } - Some("image_url") if role != "assistant" => { - let image_url = item.get("image_url").and_then(|value| { - value - .get("url") - .and_then(Value::as_str) - .or_else(|| value.as_str()) - }); - - if let Some(image_url) = image_url { - content_items.push(json!({ - "type": "input_image", - "image_url": image_url, - })); + Some(content_items) + } + + fn parse_chat_completions_content_parts(role: &str, content: &str) -> Option> { + let items = serde_json::from_str::(content) + .ok()? + .as_array()? + .clone(); + let mut content_items = Vec::with_capacity(items.len()); + + for item in items { + let item_type = item.get("type").and_then(Value::as_str)?; + match item_type { + "text" | "input_text" | "output_text" => { + let text = item.get("text").and_then(Value::as_str)?; + content_items.push(json!({ + "type": "text", + "text": text, + })); + } + "image_url" if role == "user" => { + let image_url_value = item.get("image_url")?; + let image_url = Self::extract_image_url_value(image_url_value)?; + let mut content_item = json!({ + "type": "image_url", + "image_url": { + "url": image_url, } + }); + if let Some(detail) = image_url_value.get("detail").and_then(Value::as_str) { + content_item["image_url"]["detail"] = Value::String(detail.to_string()); } - _ => {} + content_items.push(content_item); } + _ => return None, } } - if content_items.is_empty() { - Some(vec![json!({ - "type": text_item_type, - "text": content, - })]) - } else { - Some(content_items) - } + Some(content_items) } - fn responses_text_item_type(role: &str) -> &'static str { - if role == "assistant" { - "output_text" - } else { - "input_text" - } + fn extract_image_url_value(value: &Value) -> Option { + value + .get("url") + .and_then(Value::as_str) + .or_else(|| value.as_str()) + .map(ToString::to_string) } fn convert_single_message(mut msg: Message) -> Value { @@ -304,12 +338,10 @@ impl OpenAIMessageConverter { warn!("[OpenAI] Message content is empty: role={}", msg.role); } } else { - if let Ok(parsed) = serde_json::from_str::(&content) { - if parsed.is_array() { - openai_msg["content"] = parsed; - } else { - openai_msg["content"] = Value::String(content); - } + if let Some(content_parts) = + Self::parse_chat_completions_content_parts(&msg.role, &content) + { + openai_msg["content"] = Value::Array(content_parts); } else { openai_msg["content"] = Value::String(content); } @@ -558,6 +590,149 @@ mod tests { assert_eq!(content[1]["text"], json!("ok")); } + #[test] + fn keeps_tool_json_array_result_as_plain_text_for_chat_completions() { + let raw_json = json!([ + { + "name": ".github", + "type": "dir" + } + ]) + .to_string(); + + let msg = Message { + role: "tool".to_string(), + content: Some(raw_json.clone()), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: Some("call_1".to_string()), + name: Some("WebFetch".to_string()), + is_error: None, + tool_image_attachments: None, + }; + + let openai = OpenAIMessageConverter::convert_messages(vec![msg]); + + assert_eq!(openai[0]["content"], json!(raw_json)); + } + + #[test] + fn keeps_non_content_json_array_as_plain_text_for_chat_completions() { + let raw_json = json!([ + { + "name": ".github", + "type": "dir" + } + ]) + .to_string(); + + let msg = Message { + role: "user".to_string(), + content: Some(raw_json.clone()), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }; + + let openai = OpenAIMessageConverter::convert_messages(vec![msg]); + + assert_eq!(openai[0]["content"], json!(raw_json)); + } + + #[test] + fn keeps_mixed_valid_and_invalid_json_array_as_plain_text_for_chat_completions() { + let raw_json = json!([ + { + "type": "text", + "text": "hello" + }, + { + "type": "dir", + "name": ".github" + } + ]) + .to_string(); + + let msg = Message { + role: "user".to_string(), + content: Some(raw_json.clone()), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }; + + let openai = OpenAIMessageConverter::convert_messages(vec![msg]); + + assert_eq!(openai[0]["content"], json!(raw_json)); + } + + #[test] + fn keeps_non_content_json_array_as_plain_text_for_responses_input() { + let raw_json = json!([ + { + "name": ".github", + "type": "dir" + } + ]) + .to_string(); + + let (_, input) = + OpenAIMessageConverter::convert_messages_to_responses_input(vec![Message { + role: "user".to_string(), + content: Some(raw_json.clone()), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }]); + + assert_eq!(input[0]["content"][0]["type"], json!("input_text")); + assert_eq!(input[0]["content"][0]["text"], json!(raw_json)); + } + + #[test] + fn keeps_mixed_valid_and_invalid_json_array_as_plain_text_for_responses_input() { + let raw_json = json!([ + { + "type": "text", + "text": "hello" + }, + { + "type": "dir", + "name": ".github" + } + ]) + .to_string(); + + let (_, input) = + OpenAIMessageConverter::convert_messages_to_responses_input(vec![Message { + role: "user".to_string(), + content: Some(raw_json.clone()), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }]); + + assert_eq!(input[0]["content"][0]["type"], json!("input_text")); + assert_eq!(input[0]["content"][0]["text"], json!(raw_json)); + } + #[test] fn preserves_empty_reasoning_content_for_chat_completions() { let msg = Message { diff --git a/src/crates/adapters/ai-adapters/src/stream/stream_handler/openai.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/openai.rs index f0e093ff65..5ec482f0ff 100644 --- a/src/crates/adapters/ai-adapters/src/stream/stream_handler/openai.rs +++ b/src/crates/adapters/ai-adapters/src/stream/stream_handler/openai.rs @@ -1,7 +1,7 @@ use super::inline_think::InlineThinkParser; use super::stream_stats::StreamStats; use super::{next_stream_item, TimedStreamItem}; -use crate::stream::types::openai::{OpenAISSEData, OpenAIToolCallArgumentsNormalizer}; +use crate::stream::types::openai::OpenAISSEData; use crate::stream::types::unified::UnifiedResponse; use anyhow::{anyhow, Result}; use eventsource_stream::Eventsource; @@ -20,22 +20,16 @@ const AI_STREAM_RESPONSE_TARGET: &str = "ai::openai_stream_response"; #[derive(Debug)] struct OpenAIResponseNormalizer { - tool_arguments_normalizer: OpenAIToolCallArgumentsNormalizer, inline_think_parser: InlineThinkParser, } impl OpenAIResponseNormalizer { fn new(inline_think_in_text: bool) -> Self { Self { - tool_arguments_normalizer: OpenAIToolCallArgumentsNormalizer::default(), inline_think_parser: InlineThinkParser::new(inline_think_in_text), } } - fn normalize_sse_data(&mut self, sse_data: &mut OpenAISSEData) { - sse_data.normalize_tool_call_arguments(&mut self.tool_arguments_normalizer); - } - fn normalize_response(&mut self, response: UnifiedResponse) -> Vec { self.inline_think_parser.normalize_response(response) } @@ -175,7 +169,7 @@ pub async fn handle_openai_stream( } stats.increment("chunk:chat_completion"); - let mut sse_data: OpenAISSEData = match serde_json::from_value(event_json) { + let sse_data: OpenAISSEData = match serde_json::from_value(event_json) { Ok(event) => event, Err(e) => { let error_msg = format!("SSE data schema error: {}, data: {}", e, &raw); @@ -196,8 +190,6 @@ pub async fn handle_openai_stream( ); } - normalizer.normalize_sse_data(&mut sse_data); - let has_empty_choices = sse_data.is_choices_empty(); let unified_responses = sse_data.into_unified_responses(); trace!( diff --git a/src/crates/adapters/ai-adapters/src/stream/types/openai.rs b/src/crates/adapters/ai-adapters/src/stream/types/openai.rs index 8146969f99..5b26fa8d20 100644 --- a/src/crates/adapters/ai-adapters/src/stream/types/openai.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/openai.rs @@ -1,5 +1,5 @@ use super::unified::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; #[derive(Debug, Deserialize)] struct PromptTokensDetails { @@ -60,8 +60,6 @@ struct Choice { #[serde(default)] delta: Delta, finish_reason: Option, - #[serde(default, deserialize_with = "deserialize_optional_stringish")] - stop_reason: Option, } /// MiniMax `reasoning_details` array element. @@ -133,70 +131,7 @@ pub struct OpenAISSEData { usage: Option, } -#[derive(Debug, Default)] -pub struct OpenAIToolCallArgumentsNormalizer; - -fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - Ok(match value { - None | Some(serde_json::Value::Null) => None, - Some(serde_json::Value::String(value)) => Some(value), - Some(serde_json::Value::Number(value)) => Some(value.to_string()), - Some(serde_json::Value::Bool(value)) => Some(value.to_string()), - Some(other) => Some(other.to_string()), - }) -} - -impl OpenAIToolCallArgumentsNormalizer { - fn normalize_choice(&mut self, choice: &mut Choice) { - let has_stop_reason = choice.stop_reason.is_some(); - let Some(tool_calls) = choice.delta.tool_calls.as_mut() else { - return; - }; - - for tool_call in tool_calls.iter_mut() { - self.normalize_tool_call(tool_call, has_stop_reason); - } - } - - fn normalize_tool_call(&mut self, tool_call: &mut OpenAIToolCall, has_stop_reason: bool) { - let has_id = tool_call.id.as_ref().is_some_and(|value| !value.is_empty()); - let has_name = tool_call - .function - .as_ref() - .and_then(|function| function.name.as_ref()) - .is_some_and(|value| !value.is_empty()); - - let Some(function) = tool_call.function.as_mut() else { - return; - }; - let Some(arguments) = function.arguments.as_ref() else { - return; - }; - - if arguments.is_empty() { - return; - } - - if has_stop_reason && !has_id && !has_name { - tool_call.arguments_is_snapshot = true; - } - } -} - impl OpenAISSEData { - pub fn normalize_tool_call_arguments( - &mut self, - normalizer: &mut OpenAIToolCallArgumentsNormalizer, - ) { - if let Some(first_choice) = self.choices.first_mut() { - normalizer.normalize_choice(first_choice); - } - } - pub fn is_choices_empty(&self) -> bool { self.choices.is_empty() } @@ -334,7 +269,7 @@ impl From for UnifiedResponse { #[cfg(test)] mod tests { - use super::{OpenAISSEData, OpenAIToolCallArgumentsNormalizer}; + use super::OpenAISSEData; #[test] fn splits_multiple_tool_calls_in_first_choice() { @@ -619,50 +554,8 @@ mod tests { } #[test] - fn marks_stop_reason_tool_chunk_as_snapshot() { - let mut normalizer = OpenAIToolCallArgumentsNormalizer::default(); - - let mut first_chunk: OpenAISSEData = serde_json::from_str( - r#"{ - "id": "chatcmpl_test", - "created": 123, - "model": "gpt-test", - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": 0, - "id": "call_1", - "type": "function", - "function": { - "name": "tool_a", - "arguments": "{\"city\":\"Bei" - } - }] - }, - "finish_reason": null - }] - }"#, - ) - .expect("valid first chunk"); - first_chunk.normalize_tool_call_arguments(&mut normalizer); - let first_responses = first_chunk.into_unified_responses(); - assert_eq!( - first_responses[0] - .tool_call - .as_ref() - .and_then(|tool| tool.arguments.as_deref()), - Some("{\"city\":\"Bei") - ); - assert!( - !first_responses[0] - .tool_call - .as_ref() - .expect("tool call") - .arguments_is_snapshot - ); - - let mut snapshot_chunk: OpenAISSEData = serde_json::from_str( + fn stop_reason_tool_chunk_keeps_default_non_snapshot_behavior() { + let data: OpenAISSEData = serde_json::from_str( r#"{ "id": "chatcmpl_test", "created": 123, @@ -682,31 +575,28 @@ mod tests { }] }"#, ) - .expect("valid snapshot chunk"); - snapshot_chunk.normalize_tool_call_arguments(&mut normalizer); - let snapshot_responses = snapshot_chunk.into_unified_responses(); + .expect("valid stop_reason chunk"); + let responses = data.into_unified_responses(); assert_eq!( - snapshot_responses[0] + responses[0] .tool_call .as_ref() .and_then(|tool| tool.arguments.as_deref()), Some("{\"city\":\"Beijing\"}") ); assert!( - snapshot_responses[0] + !responses[0] .tool_call .as_ref() .expect("tool call") .arguments_is_snapshot ); - assert!(snapshot_responses[0].finish_reason.is_none()); + assert!(responses[0].finish_reason.is_none()); } #[test] fn leaves_normal_tool_delta_chunks_as_non_snapshot() { - let mut normalizer = OpenAIToolCallArgumentsNormalizer::default(); - - let mut chunk: OpenAISSEData = serde_json::from_str( + let chunk: OpenAISSEData = serde_json::from_str( r#"{ "id": "chatcmpl_test", "created": 123, @@ -727,7 +617,6 @@ mod tests { }"#, ) .expect("valid chunk"); - chunk.normalize_tool_call_arguments(&mut normalizer); let responses = chunk.into_unified_responses(); assert_eq!(responses.len(), 1); assert!( @@ -740,7 +629,7 @@ mod tests { } #[test] - fn parses_numeric_stop_reason_as_string() { + fn accepts_numeric_stop_reason_payload() { let data: OpenAISSEData = serde_json::from_str( r#"{ "id": "chatcmpl_test", @@ -762,10 +651,6 @@ mod tests { }"#, ) .expect("valid numeric stop_reason payload"); - - let mut normalizer = OpenAIToolCallArgumentsNormalizer::default(); - let mut data = data; - data.normalize_tool_call_arguments(&mut normalizer); let responses = data.into_unified_responses(); assert_eq!(responses.len(), 1); @@ -773,7 +658,7 @@ mod tests { } #[test] - fn parses_string_stop_reason_unchanged() { + fn accepts_string_stop_reason_payload() { let data: OpenAISSEData = serde_json::from_str( r#"{ "id": "chatcmpl_test", @@ -795,10 +680,6 @@ mod tests { }"#, ) .expect("valid string stop_reason payload"); - - let mut normalizer = OpenAIToolCallArgumentsNormalizer::default(); - let mut data = data; - data.normalize_tool_call_arguments(&mut normalizer); let responses = data.into_unified_responses(); assert_eq!(responses.len(), 1); diff --git a/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse index f4e73b30fb..7478e535ad 100644 --- a/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse +++ b/src/crates/adapters/ai-adapters/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse @@ -1,6 +1,6 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":500,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"tool_a","arguments":"{\"city\":\"Bei"}}]},"finish_reason":null}],"usage":null} -data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":501,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":null,"type":"function","function":{"arguments":"{\"city\":\"Beijing\"}"}}]},"stop_reason":"stop"}],"usage":null} +data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":501,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":null,"type":"function","function":{"arguments":"jing\"}"}}]},"stop_reason":"stop"}],"usage":null} data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":502,"model":"gpt-test","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":6,"total_tokens":9}} diff --git a/src/crates/adapters/ai-adapters/tests/model_selector.rs b/src/crates/adapters/ai-adapters/tests/model_selector.rs new file mode 100644 index 0000000000..fedfd20216 --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/model_selector.rs @@ -0,0 +1,93 @@ +use bitfun_ai_adapters::{ + classify_model_selector, resolve_cache_model_selector, resolve_required_model_selector, + ModelSelectorError, ModelSelectorKind, +}; + +fn resolve_selection(selector: &str) -> Option { + match selector { + "primary" => Some("model-primary".to_string()), + "fast" => Some("model-fast".to_string()), + _ => None, + } +} + +fn resolve_reference(model_ref: &str) -> Option { + match model_ref { + "Primary Chat" | "claude-sonnet-4.5" => Some("model-primary".to_string()), + _ => None, + } +} + +#[test] +fn classifies_auto_default_and_empty_as_primary() { + assert_eq!(classify_model_selector("auto"), ModelSelectorKind::Primary); + assert_eq!( + classify_model_selector(" default "), + ModelSelectorKind::Primary + ); + assert_eq!(classify_model_selector(""), ModelSelectorKind::Primary); + assert_eq!( + classify_model_selector("model-primary"), + ModelSelectorKind::Explicit("model-primary".to_string()) + ); +} + +#[test] +fn required_selector_resolves_defaults_and_references() { + assert_eq!( + resolve_required_model_selector("primary", resolve_selection, resolve_reference) + .expect("primary should resolve"), + "model-primary" + ); + assert_eq!( + resolve_required_model_selector("fast", resolve_selection, resolve_reference) + .expect("fast should resolve"), + "model-fast" + ); + assert_eq!( + resolve_required_model_selector("Primary Chat", resolve_selection, resolve_reference) + .expect("named model should resolve"), + "model-primary" + ); + assert_eq!( + resolve_required_model_selector("literal-model-id", resolve_selection, resolve_reference) + .expect("literal selector should pass through"), + "literal-model-id" + ); +} + +#[test] +fn required_selector_preserves_current_missing_default_errors() { + let missing = |_selector: &str| None; + + assert_eq!( + resolve_required_model_selector("primary", missing, resolve_reference), + Err(ModelSelectorError::PrimaryUnavailable) + ); + assert_eq!( + resolve_required_model_selector("fast", missing, resolve_reference), + Err(ModelSelectorError::FastUnavailable) + ); + assert_eq!( + ModelSelectorError::FastUnavailable.to_string(), + "Fast model not configured or invalid, and primary model not configured or invalid" + ); +} + +#[test] +fn cache_selector_keeps_legacy_default_passthrough_when_unresolved() { + let missing = |_selector: &str| None; + + assert_eq!( + resolve_cache_model_selector("primary", missing, resolve_reference), + "primary" + ); + assert_eq!( + resolve_cache_model_selector("fast", missing, resolve_reference), + "fast" + ); + assert_eq!( + resolve_cache_model_selector("claude-sonnet-4.5", resolve_selection, resolve_reference), + "model-primary" + ); +} diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs b/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs index 2c0d84ec21..e3ce1c4536 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs +++ b/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs @@ -244,7 +244,7 @@ async fn openai_fixture_reattaches_id_only_prelude_to_following_payload_chunk() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn openai_fixture_replaces_snapshot_tool_args_after_stop_reason_chunk() { +async fn openai_fixture_keeps_appending_tool_args_after_stop_reason_chunk() { let output = run_stream_fixture( StreamFixtureProvider::OpenAi, "stream/openai/tool_args_snapshot_stop_reason.sse", @@ -275,10 +275,7 @@ async fn openai_fixture_replaces_snapshot_tool_args_after_stop_reason_chunk() { _ => None, }) .collect(); - assert_eq!( - partial_params, - vec!["{\"city\":\"Bei", "{\"city\":\"Beijing\"}"] - ); + assert_eq!(partial_params, vec!["{\"city\":\"Bei", "jing\"}"]); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/crates/adapters/webdriver/src/server/mod.rs b/src/crates/adapters/webdriver/src/server/mod.rs index f90931ab9b..74a0650ab3 100644 --- a/src/crates/adapters/webdriver/src/server/mod.rs +++ b/src/crates/adapters/webdriver/src/server/mod.rs @@ -57,7 +57,7 @@ impl AppState { } pub fn start(state: Arc) { - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { if let Err(error) = serve(state).await { log::error!("Embedded WebDriver failed to start: {}", error); } diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md new file mode 100644 index 0000000000..1cc6dc30b5 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md @@ -0,0 +1,251 @@ +--- +name: miniapp-dev +description: 'Generate and refine BitFun MiniApps. Use when the user wants a new MiniApp, wants an existing MiniApp redesigned or extended, or asks for a BitFun in-app tool. Typical triggers: "做一个小应用", "生成 MiniApp", "写个 BitFun 小工具", "创建 mini app".' +--- + +# BitFun MiniApp 生成指南 + +本技能用于**为用户生成、改造、完善一个 MiniApp**: + +- 做一个新的 BitFun 小应用 +- 修改某个 MiniApp 的交互、界面、能力、数据流 +- 把一个想法变成可运行的 MiniApp + +开始生成新的 MiniApp 前,先读 [`design-playbook.md`](design-playbook.md);运行时能力和宿主 API 细节再查 [`api-reference.md`](api-reference.md)。 + +## 目标 + +**交付一个能在 BitFun 里运行、风格合适、权限最小、结构清晰的 MiniApp**。 + +成功标准: + +- 用户的问题被这个 MiniApp 直接解决 +- 生成结果能在 MiniApp 场景里运行 +- 只申请必要权限 +- 不假设不存在的宿主 API +- 在 light/dark、zh/en 下都可用 + +## 先做什么 + +在写代码前,先完成这 4 件事: + +1. 明确用户目标 + 这个 MiniApp 是工具型、展示型,还是混合型?核心动作是什么? + +2. 找最近的参考 + 看 `references/examples/` 中最贴近任务形态的内置/示例 MiniApp 目录 + +3. 选运行模式 + 先判断是否真的需要 `worker.js` 和 `node.enabled = true`。 + +4. 定最小交付面 + 第一版只做最核心路径,不为了“看起来完整”堆功能。 + +## 生成流程 + +### 1. 先澄清,再实现 + +如果下面任一项不清楚,先问清楚,不要替用户脑补: + +- 解决什么问题 +- 谁使用 +- 要读写哪些路径 +- 要不要读工作区文件 +- 要执行哪些命令 +- 要不要执行命令 +- 要访问哪些域名 +- 要不要联网 +- 要不要持久化状态 +- 要不要多语言 +- 要不要 Tweaks 这类运行时可调变体 +- 有没有现成视觉参考 + +### 2. 优先复用现有 MiniApp 语言 + +不要从零发明一套 BitFun 风格。先从已有 MiniApp 中借鉴: + +- 布局密度 +- 圆角和间距 +- 卡片和面板结构 +- 主题变量使用方式 +- i18n 组织方式 + +默认优先做**工具型**设计:冷静、克制、信息密度高、操作路径短。 + +### 3. 优先选“无 Node 模式” + +如果需求只靠这些能力就能完成: + +- `app.fs.*` +- `app.shell.exec` +- `app.net.fetch` +- `app.os.info` +- `app.storage.*` + +那么优先使用: + +```json +{ + "permissions": { + "node": { "enabled": false } + } +} +``` + +只有在这些场景下才启用 `node.enabled = true`: + +- 需要自定义 `worker.js` 方法 +- 需要 npm 依赖 +- 需要较长链路或较复杂的后台逻辑 + +### 4. 用 `InitMiniApp` 创建骨架 + +创建后,围绕这些文件工作: + +- `index.html` +- `style.css` +- `ui.js` +- `worker.js`(只有需要时) +- `meta.json` + +默认做法: + +- `index.html` 只放清晰结构 +- `style.css` 先声明设计系统 +- `ui.js` 负责状态、渲染、事件、i18n +- `worker.js` 只承载真正需要后台执行的逻辑 + +### 5. 只使用真实存在的宿主能力 + +MiniApp 里可用的是 `window.app`。 + +默认可依赖的能力: + +- `app.fs.*` +- `app.shell.exec` +- `app.net.fetch` +- `app.os.info` +- `app.storage.get/set` +- `app.dialog.*` +- `app.clipboard.*` +- `app.ai.*` +- `app.theme` +- `app.locale` +- `app.onThemeChange` +- `app.onLocaleChange` +- `app.t(...)` +- `app.call(...)` 仅在 `node.enabled = true` 时 + +详细接口查: + +- [`api-reference.md`](api-reference.md) + +### 6. 不要假设这些 API 存在 + +默认**不要**写这些不存在的接口: + +- `app.bitfun.*` +- `app.workspace.*` +- `app.git.*` +- `app.session.*` +- `app.terminal.*` +- `app.browser.*` + +如果你需要 Git 能力,优先: + +```javascript +await app.shell.exec('git ...', { cwd: app.workspaceDir }) +``` + +如果你需要工作区数据,优先: + +```javascript +await app.fs.readFile(...) +``` + +### 7. 从第一版就带上 i18n 和 theme + +不要把多语言和主题适配留到最后。 + +至少做到: + +- `meta.json` 带 `i18n.locales` +- 静态文案可重渲染 +- 动态文案走 `app.t(...)` 或自有 `I18N` 表 +- 样式优先使用 `--bitfun-*` +- 测试 light/dark + zh/en + +### 8. 先做核心体验,不补假内容 + +如果缺素材、图标、真实数据: + +- 用明确占位 +- 用 fixture 数据 +- 用“待补”标记 + +不要: + +- 硬画劣质插画 +- 编造业务数据 +- 用装饰性内容填空白 + +## 硬约束 + +### 交互 + +- 首屏就要能理解用途 +- 主路径操作数尽量少 +- 点击区域至少 32px +- 正文不要小于 13px + +### 视觉 + +- 禁止默认蓝紫渐变 AI 风背景 +- 禁止 emoji 充当主图标 +- 禁止“每块一个风格” +- 禁止堆无意义 stats、sparkline、装饰 icon + +### 代码 + +- 不需要 `worker.js` 时不要启用 Node +- 不需要的权限不要申请 +- 不要把大量逻辑塞进 HTML +- `ui.js` 过长时主动拆成模块化结构 + +### 内容 + +- 不为填空白加内容 +- 每个 section 都要有明确用途 +- 不擅自扩 scope + +## 你应该参考什么 + +生成前优先阅读最贴近的一两个参考,而不是全看: + +- `references/examples/demo-git-graph/` +- `references/examples/demo-icon-design-system/` +- `references/examples/builtin-regex-playground/` +- `references/examples/builtin-coding-selfie/` +- `references/examples/builtin-gomoku/` +- `references/examples/builtin-daily-divination/` + +生成新的 MiniApp 时,默认先读: + +- [`design-playbook.md`](design-playbook.md) + +如果任务偏运行时调用,再看: + +- [`api-reference.md`](api-reference.md) + +## 交付前检查 + +交付前至少确认: + +- MiniApp 能运行 +- 主路径可操作 +- 权限是最小集 +- `node.enabled` 选择合理 +- 没有调用不存在的 `app.*` API +- i18n 至少覆盖 `zh-CN` / `en-US` +- light/dark 没有明显样式问题 +- 没有遗留 “TODO / 占位 / Lorem ipsum” diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md new file mode 100644 index 0000000000..a2321a627b --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/api-reference.md @@ -0,0 +1,433 @@ +# MiniApp API 参考 + +此文档定义生成或修改 MiniApp 时可用的 API,供实现时直接参考。 + +> **实际全局对象为 `window.app`**(非 `window.__BITFUN__`),以下各节均基于 `window.app`。 + +## 能力边界 + +MiniApp **能且只能**用以下 API,没有任何"通用 BitFun 后端通道"。生成代码前请先确认你需要的能力在表内: + +- `app.fs.*` —— 文件系统(受 `permissions.fs.read/write` 限制) +- `app.shell.exec` —— 子进程命令行(受 `permissions.shell.allow` 命令名白名单限制) +- `app.net.fetch` —— HTTP 请求(受 `permissions.net.allow` 域名白名单限制) +- `app.os.info` —— 只读系统信息 +- `app.storage.get/set` —— 每应用独立 KV 存储 +- `app.ai.complete / chat / cancel / getModels` —— 复用宿主 AI(无需 API Key) +- `app.dialog.open/save/message` —— 文件对话框 +- `app.clipboard.readText/writeText` —— 剪贴板 +- `app.call('xxx', ...)` + `worker.js` —— 自定义 Node 后端(仅 `node.enabled = true` 时) +- `app.theme / locale / on*` —— 主题与 i18n + +MiniApp 不提供通用 BitFun 后端通道。不要假设这些接口存在: + +- `app.bitfun.*` +- `app.workspace.*` +- `app.git.*` +- `app.session.*` +- `app.terminal.*` +- `app.browser.*` + +需要相关能力时,优先这样做: + +1. 需要 Git 或其他命令行能力:用 `app.shell.exec`(如 git → 在 `permissions.shell.allow` 加 `"git"`,参考 `references/examples/builtin-coding-selfie/ui.js`); +2. 需要工作区文件:用 `app.fs.*`(把 `{workspace}` 加到 `permissions.fs.read`); +3. 需要未列出的宿主内部能力:当前不支持,不要自行模拟一套 `app.*` 接口。 + +## 标准 Node.js API(通过 require() shim) + +### fs/promises + +```javascript +const fs = require('fs/promises'); +``` + +| 方法 | 签名 | 说明 | +|------|------|------| +| `readFile` | `(path, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `writeFile` | `(path, data, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `appendFile` | `(path, data) → Promise` | | +| `readdir` | `(path, opts?) → Promise` | opts: `{ withFileTypes: boolean }` | +| `mkdir` | `(path, opts?) → Promise` | opts: `{ recursive: boolean }` | +| `rmdir` | `(path, opts?) → Promise` | opts: `{ recursive: boolean }` | +| `rm` | `(path, opts?) → Promise` | opts: `{ recursive: boolean, force: boolean }` | +| `stat` | `(path) → Promise` | Returns: `{ size, isFile, isDirectory, mtime, ctime }` | +| `lstat` | `(path) → Promise` | | +| `access` | `(path) → Promise` | throws if not accessible | +| `copyFile` | `(src, dst) → Promise` | | +| `rename` | `(oldPath, newPath) → Promise` | | +| `unlink` | `(path) → Promise` | | + +### path(纯 JS,零延迟) + +```javascript +const path = require('path'); +``` + +`join`, `resolve`, `dirname`, `basename`, `extname`, `parse`, `sep` + +### child_process + +```javascript +const { exec } = require('child_process'); +``` + +| 方法 | 签名 | 说明 | +|------|------|------| +| `exec` | `(cmd, opts?, callback?) → Promise \| void` | opts: `{ cwd, timeout }` | + +支持两种调用风格: +- **Promise 风格**:`const result = await exec(cmd, opts)` → 返回 `{ stdout, stderr, exit_code }` +- **Callback 风格**:`exec(cmd, opts, (err, stdout, stderr) => { ... })` → 无返回值 + +受 `permissions.shell.allow` 命令白名单限制。 + +### os(纯 JS) + +```javascript +const os = require('os'); +``` + +`platform()`, `homedir()`, `tmpdir()`, `cpus()`, `hostname()` + +### crypto + +```javascript +const crypto = require('crypto'); +``` + +映射 `window.crypto.subtle`,支持 `randomUUID()`。 + +## 标准浏览器 API + +MiniApp 运行在 iframe 中,完整支持: +- DOM、CSS(含 CSS 变量 `--bitfun-bg`, `--bitfun-text`, `--bitfun-accent` 等) +- Canvas 2D / WebGL +- Web Audio +- LocalStorage / SessionStorage(iframe 级隔离) +- `navigator.clipboard`(通过 `app.clipboard.*` 代理,绕过 sandbox 限制) + +## `window.app` — 全局 Runtime Adapter + +MiniApp 中所有与宿主通信的 API 均通过 `window.app` 暴露。 + +### 基本属性 + +```javascript +app.appId // string — 当前 MiniApp 的 ID +app.appDataDir // string — 应用数据目录绝对路径 +app.workspaceDir // string — 当前工作区路径 +app.theme // 'dark' | 'light' — 当前主题 +app.locale // string — 当前语言 ID(如 'zh-CN' / 'en-US'),随宿主切换更新 +app.platform // 'win32' | 'darwin' | 'linux' +app.mode // 'hosted' +``` + +### `app.fs.*` — 文件系统 + +需在 `permissions.fs` 中声明读写范围。 + +| 方法 | 签名 | 说明 | +|------|------|------| +| `readFile` | `(path, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `writeFile` | `(path, data, opts?) → Promise` | opts: `{ encoding: 'utf-8' \| 'base64' }` | +| `appendFile` | `(path, data) → Promise` | | +| `readdir` | `(path, opts?) → Promise` | opts: `{ withFileTypes: boolean }` | +| `mkdir` | `(path, opts?) → Promise` | opts: `{ recursive: boolean }` | +| `rm` | `(path, opts?) → Promise` | opts: `{ recursive: boolean, force: boolean }` | +| `stat` | `(path) → Promise` | `{ size, isFile, isDirectory, mtime, ctime }` | +| `copyFile` | `(src, dst) → Promise` | | +| `rename` | `(oldPath, newPath) → Promise` | | + +### `app.storage.*` — KV 持久化存储 + +无权限要求,数据存储在 `{appdata}/storage.json`。 + +```javascript +await app.storage.set('myKey', { foo: 'bar' }); +const value = await app.storage.get('myKey'); // { foo: 'bar' } +``` + +### `app.dialog.*` — 系统对话框 + +```javascript +const path = await app.dialog.open({ + title: '选择文件', + multiple: false, + filters: [{ name: 'SVG', extensions: ['svg'] }] +}); +const savePath = await app.dialog.save({ title: '保存', defaultPath: 'output.svg' }); +await app.dialog.message({ title: '提示', message: '操作成功' }); +``` + +### `app.shell.*` — Shell 命令执行 + +需在 `permissions.shell.allow` 中声明命令白名单。 + +```javascript +const result = await app.shell.exec('git log --oneline -10', { cwd: app.workspaceDir }); +``` + +### `app.net.*` — 网络请求(Worker 侧) + +需在 `permissions.net.allow` 中声明域名白名单。 + +```javascript +const data = await app.net.fetch('https://api.example.com/data', { method: 'GET' }); +``` + +### `app.os.*` — 系统信息 + +```javascript +const info = await app.os.info(); // { platform, homedir, tmpdir, ... } +``` + +### `app.call(method, params)` — 调用 Worker 方法 + +调用 `source/worker.js` 中导出的函数。 + +```javascript +const result = await app.call('myWorkerMethod', { key: 'value' }); +``` + +> **要求 `permissions.node.enabled = true`**。`node.enabled = false` 时只能调用框架原语(`app.fs.* / shell.* / net.* / os.* / storage.*`),调用任何自定义方法会得到明确的错误提示。 + +--- + +## `app.ai.*` — AI 接口(v2) + +直接复用宿主应用的 AI Client,无需配置 API Key。需在 `permissions.ai` 中声明。 + +### `app.ai.complete(prompt, opts?)` — 单次补全 + +返回完整文本,适合一次性生成场景。 + +```javascript +const result = await app.ai.complete('生成一个设置图标的 SVG,viewBox 24x24,线性风格', { + systemPrompt: '你是一个图标设计专家,只输出 SVG 代码,不含任何说明文字。', + model: 'fast', // 'primary' | 'fast' | 具体 model_id,默认 'primary' + maxTokens: 4096, + temperature: 0.7, +}); +console.log(result.text); // SVG 字符串 +console.log(result.usage); // { promptTokens, completionTokens, totalTokens } +``` + +### `app.ai.chat(messages, opts?)` — 流式对话 + +支持多轮对话和流式输出,适合交互式生成场景。 + +```javascript +const handle = await app.ai.chat( + [ + { role: 'user', content: '设计一个首页图标,圆角风格,24px 网格' } + ], + { + systemPrompt: '你是图标设计专家,生成符合设计规范的 SVG 代码。', + model: 'primary', + onChunk: ({ text, reasoningContent }) => { + // 实时更新预览 + if (text) appendToPreview(text); + }, + onDone: ({ fullText, usage }) => { + // 完成后处理完整结果 + const svg = extractSvg(fullText); + renderIcon(svg); + }, + onError: ({ message }) => { + console.error('AI error:', message); + }, + } +); + +// 取消流式请求 +cancelButton.onclick = () => handle.cancel(); + +// handle.streamId — 当前流的唯一 ID +``` + +### `app.ai.getModels()` — 查询可用模型 + +返回当前 MiniApp 权限范围内可用的模型列表(不含 API Key 等敏感信息)。 + +```javascript +const models = await app.ai.getModels(); +// [{ id: 'gpt4o', name: 'GPT-4o', provider: 'openai', isDefault: true }, ...] +``` + +### `app.ai.cancel(streamId)` — 取消流式请求 + +```javascript +await app.ai.cancel(handle.streamId); +``` + +### AI 权限声明 + +```json +{ + "permissions": { + "ai": { + "enabled": true, + "allowed_models": ["primary", "fast"], + "max_tokens_per_request": 8192, + "rate_limit_per_minute": 30 + } + } +} +``` + +- `allowed_models`:可用模型引用列表,支持 `"primary"`、`"fast"` 及具体 model_id;为空则允许所有模型 +- `max_tokens_per_request`:单次请求最大输出 token 数 +- `rate_limit_per_minute`:每分钟最大请求次数(按 app 计数) + +--- + +## `app.clipboard.*` — 剪贴板 + +通过宿主代理,绕过 iframe sandbox 的 clipboard 限制。 + +```javascript +await app.clipboard.writeText('Hello World'); +const text = await app.clipboard.readText(); +``` + +--- + +## 生命周期钩子 + +```javascript +app.onActivate(() => { /* Tab 变为活跃状态 */ }); +app.onDeactivate(() => { /* Tab 切走 */ }); +app.onThemeChange((payload) => { + // payload: { type: 'dark'|'light', vars: { '--bitfun-bg': '...', ... } } +}); +app.onLocaleChange((locale) => { + // locale: 新的语言 ID 字符串(如 'zh-CN' / 'en-US') +}); +``` + +## 国际化 i18n + +### `app.t(table, fallback)` — 多语言字符串挑选 + +```javascript +const label = app.t({ 'zh-CN': '保存', 'en-US': 'Save' }, 'Save'); +``` + +挑选顺序:`app.locale` → `'en-US'` → `'zh-CN'` → 表的第一个值 → `fallback`。适合在 JS 里就地写少量翻译。 + +更完整的做法(推荐): + +1. 在 `meta.json` 顶层加 `i18n.locales` 块翻译 `name` / `description` / `tags`,宿主 Gallery 自动按当前语言显示。 +2. 在 HTML 静态文案上加 `data-i18n="key"`(可选 `data-i18n-attr="aria-label"` 翻译属性)。 +3. 在 `ui.js` 中维护 `I18N` 字典,封装 `t(key)` 与 `applyStaticI18n()`,并 `app.onLocaleChange(...)` 时重新渲染动态内容。 +4. `app.storage` 持久化的字段保存语言无关的索引/键,避免存了翻译后字符串导致切换语言失效。 + +参考实现:`references/examples/builtin-gomoku/ui.js`、`references/examples/builtin-regex-playground/ui.js`。 + +## 自定义事件 + +```javascript +app.on('myEvent', (payload) => { /* 处理事件 */ }); +app.off('myEvent', handler); +``` + +--- + +## `app.dialog.*` — 系统对话框(详细) + +### `app.dialog.open` + +```javascript +const filePath = await app.dialog.open({ + title: '选择文件', + directory: false, // true 选目录 + multiple: false, // true 多选 + filters: [ + { name: 'Images', extensions: ['png', 'jpg', 'webp'] } + ] +}); +``` + +### `app.dialog.save` + +```javascript +const savePath = await app.dialog.save({ + title: '保存文件', + defaultPath: 'output.png', + filters: [ + { name: 'PNG', extensions: ['png'] } + ] +}); +``` + +## 权限声明格式 + +```json +{ + "permissions": { + "fs": { + "read": ["{workspace}", "{appdata}", "{user-selected}"], + "write": ["{appdata}", "{user-selected}"] + }, + "shell": { + "allow": ["git", "ffmpeg"] + }, + "net": { + "allow": ["api.example.com", "cdn.jsdelivr.net"] + }, + "ai": { + "enabled": true, + "allowed_models": ["primary", "fast"], + "max_tokens_per_request": 8192, + "rate_limit_per_minute": 30 + }, + "node": { + "enabled": true, + "timeout_ms": 30000 + } + } +} +``` + +### 无 Node 模式:`node.enabled = false` + +如果你的小应用只用 `app.fs.* / app.shell.* / app.net.fetch / app.os.info / app.storage.*`(即不需要在 `worker.js` 里自定义任何方法、也不需要安装 npm 依赖),把 `node.enabled` 设为 `false`: + +```json +{ + "permissions": { + "fs": { "read": ["{workspace}", "{appdata}"], "write": ["{appdata}"] }, + "shell": { "allow": ["git"] }, + "node": { "enabled": false } + } +} +``` + +宿主会把这些框架原语直接路由到 Rust `host_dispatch` 实现,完全不需要 Bun/Node 运行时;权限策略与 Worker 路径共用同一份 `resolve_policy`,行为完全等价。在这种模式下: + +- `app.shell.exec` / `app.fs.*` / `app.net.fetch` / `app.os.info` / `app.storage.get|set` —— 全部可用; +- `app.call('myCustomMethod', …)` —— **不可用**(宿主会显式报错),需要走完整的 Worker 路径请把 `node.enabled` 设回 `true` 并提供 `worker.js`。 + +推荐:所有"只是包一下 git/curl/系统命令"的开发者工具型小应用都使用此模式,避免 bundle 后宿主缺少 Bun/Node 时的运行时报错。 + +路径变量: +- `{appdata}` — `{user_data_dir}/miniapps/{app_id}/data/`,始终可读写 +- `{workspace}` — 当前打开的工作区路径 +- `{user-selected}` — 用户通过 app.dialog.open/save 选择的路径 +- `{home}` — 用户主目录(高风险) + +## CDN 依赖 + +通过 `source.dependencies` 声明,编译器自动注入 `