diff --git a/.github/workflows/benchmark-cli.yml b/.github/workflows/benchmark-cli.yml new file mode 100644 index 00000000..798e5fdc --- /dev/null +++ b/.github/workflows/benchmark-cli.yml @@ -0,0 +1,40 @@ +name: Benchmark CLI build + +on: + pull_request: + paths: + - crates/cli/** + - crates/sandbox/** + - .github/workflows/benchmark-cli.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux-cli: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + - uses: mlugg/setup-zig@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild@0.23.0 + - uses: Swatinem/rust-cache@v2 + with: + key: benchmark-linux-cli + - name: Verify detached Linux kernel fence + run: cargo test -p devo-sandbox --test headless_tty -- --nocapture + - name: Build native Linux CLI for container smoke runs + run: cargo zigbuild --target x86_64-unknown-linux-musl -p devo-cli + - name: Strip debug information from the benchmark artifact + run: strip target/x86_64-unknown-linux-musl/debug/devo + - uses: actions/upload-artifact@v4 + with: + name: devo-benchmark-linux-${{ github.event.pull_request.head.sha || github.sha }} + path: target/x86_64-unknown-linux-musl/debug/devo + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e2ab3c2..f83d72c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,13 +5,296 @@ on: branches: [main, master] pull_request: branches: [main, master] + workflow_dispatch: env: CARGO_TERM_COLOR: always RUSTFLAGS: -Dwarnings jobs: + changes: + name: Select affected CI components + runs-on: ubuntu-latest + outputs: + rust: ${{ steps.scope.outputs.rust }} + desktop: ${{ steps.scope.outputs.desktop }} + tui: ${{ steps.scope.outputs.tui }} + kernel_python: ${{ steps.scope.outputs.kernel_python }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: python -m unittest discover -s scripts -p test_ci_scope.py -v + - id: scope + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + python scripts/ci_scope.py --base "$BASE_SHA" --head "$HEAD_SHA" --output "$GITHUB_OUTPUT" + else + python scripts/ci_scope.py --all --output "$GITHUB_OUTPUT" + fi + + unix-kernel: + needs: changes + if: needs.changes.outputs.rust == 'true' + name: Unix kernel (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + - uses: mlugg/setup-zig@v2 + if: runner.os == 'Linux' + - uses: taiki-e/install-action@v2 + if: runner.os == 'Linux' + with: + tool: cargo-zigbuild@0.23.0 + - run: cargo zigbuild --locked -p devo-kernel --lib --target ${{ matrix.target }} + if: runner.os == 'Linux' + - run: cargo check --locked -p devo-kernel --all-targets --target ${{ matrix.target }} + if: runner.os == 'macOS' + - run: cargo test --locked -p devo-kernel credential_unix::tests + if: runner.os == 'macOS' + + windows-kernel: + needs: changes + if: needs.changes.outputs.kernel_python == 'true' + name: Windows kernel (Python ${{ matrix.python }}) + runs-on: windows-latest + strategy: + matrix: + python: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install dill==0.4.1 + - run: python -m unittest discover -s crates/kernel/rlm-runtime/tests -v + env: + PYTHONPATH: crates/kernel/rlm-runtime/src + + windows-check: + needs: changes + if: needs.changes.outputs.rust == 'true' + name: Windows Rust check and Clippy + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --workspace --all-targets + - run: cargo clippy --workspace --all-targets -- -D warnings + + - run: cargo test -p devo-windows-sandbox --bin devo-command-runner + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - run: bun test apps/desktop/scripts/dev-runtime.test.ts + + desktop: + needs: changes + if: needs.changes.outputs.desktop == 'true' + name: Desktop tests and types + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/desktop + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - run: bun install --frozen-lockfile + - run: bun run test + - run: bunx tsc --noEmit + + tui: + needs: changes + if: needs.changes.outputs.tui == 'true' + name: TUI tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + defaults: + run: + working-directory: apps/tui + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: apps/tui/package-lock.json + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm test + - run: node --import tsx --test lib/coding-agent/src/modes/interactive/interactive-mode-model-candidates.test.ts lib/coding-agent/src/modes/interactive/interactive-mode-session-ancillary.test.ts + + tmux-secrets: + name: Detect live TUI provider secrets + runs-on: ubuntu-latest + outputs: + live: ${{ steps.detect.outputs.live }} + steps: + # Job-level `if` cannot read the secrets context; fork PRs see empty + # secrets, so this doubles as the fork gate for the live job. + - id: detect + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + run: | + if [ -n "$DEEPSEEK_API_KEY" ]; then + echo "live=true" >> "$GITHUB_OUTPUT" + else + echo "live=false" >> "$GITHUB_OUTPUT" + fi + + tmux: + needs: changes + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tui == 'true' + name: TUI tmux smoke (mock provider) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: apps/tui/package-lock.json + - name: Ensure tmux + run: command -v tmux || (sudo apt-get update && sudo apt-get install -y tmux) + - name: Install TUI dependencies + working-directory: apps/tui + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Build CLI + run: cargo build -p devo-cli --bin devo --locked + - name: Boot and outcome scenarios (no provider credentials) + working-directory: apps/tui + run: | + for scenario in T1 goal heartbeat; do + echo "::group::tmux smoke: $scenario" + node scripts/run-tmux-smoke.js "$scenario" + echo "::endgroup::" + done + - name: Completed-turn scenario against the local mock provider + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "120000" + run: | + home="$(mktemp -d)" + node scripts/smoke-mock-provider.mjs 8801 & + mock_pid=$! + trap 'kill "$mock_pid" 2>/dev/null || true' EXIT + printf '%s\n' \ + '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "http://127.0.0.1:8801"}}}' \ + > "$home/providers.json" + printf '%s\n' '{"deepseek": {"type": "api_key", "key": "ci-mock-key"}}' > "$home/auth.json" + chmod 600 "$home/auth.json" + DEVO_HOME="$home" node scripts/run-tmux-smoke.js T2 + + tmux-live: + needs: [changes, tmux-secrets] + if: >- + needs.tmux-secrets.outputs.live == 'true' && + (needs.changes.outputs.rust == 'true' || needs.changes.outputs.tui == 'true') + name: TUI tmux live LLM (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # macOS covers the seatbelt fence and python3 fallback paths that + # Linux cannot reach with the same code. + os: [ubuntu-latest, macos-latest] + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: apps/tui/package-lock.json + - name: Ensure tmux + run: command -v tmux || (if [ "$(uname)" = Darwin ]; then brew install tmux; else sudo apt-get update && sudo apt-get install -y tmux; fi) + - name: Install TUI dependencies + working-directory: apps/tui + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Build CLI + run: cargo build -p devo-cli --bin devo --locked + - name: Seed live Devo homes (one per wire API) + run: | + # chat-completions: builtin DeepSeek endpoint + chat="$(mktemp -d)/home"; mkdir -p "$chat" + printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "https://api.deepseek.com", "wire_api": "openai_chat_completions"}}}' > "$chat/providers.json" + # anthropic-messages: DeepSeek's Anthropic-compatible endpoint, same key + anth="$(mktemp -d)/home"; mkdir -p "$anth" + printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "https://api.deepseek.com/anthropic", "wire_api": "anthropic_messages"}}}' > "$anth/providers.json" + # responses: builtin OpenAI (openai_responses wire) + resp="$(mktemp -d)/home"; mkdir -p "$resp" + printf '%s\n' '{"model": "openai/gpt-5-mini", "provider": {"openai": {"credential": "openai", "enabled": true, "base_url": "https://api.openai.com/v1", "wire_api": "openai_responses"}}}' > "$resp/providers.json" + for home in "$chat" "$anth"; do + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ deepseek: { type: "api_key", key: process.env.DEEPSEEK_API_KEY } }, null, 2));' "$home/auth.json" + chmod 600 "$home/auth.json" + done + node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ openai: { type: "api_key", key: process.env.OPENAI_API_KEY } }, null, 2));' "$resp/auth.json" + chmod 600 "$resp/auth.json" + echo "DEVO_LIVE_CHAT_HOME=$chat" >> "$GITHUB_ENV" + echo "DEVO_LIVE_ANTHROPIC_HOME=$anth" >> "$GITHUB_ENV" + echo "DEVO_LIVE_RESPONSES_HOME=$resp" >> "$GITHUB_ENV" + - name: Chat Completions wire (real DeepSeek) + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_CHAT_HOME" node scripts/run-tmux-smoke.js T2 + - name: Anthropic Messages wire (real DeepSeek) + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_ANTHROPIC_HOME" node scripts/run-tmux-smoke.js T2 + - name: Responses wire (real OpenAI) + # Optional second secret; skipped when absent. The secrets context is + # not available in step-level `if`, so read the job env mirror. + if: env.OPENAI_API_KEY != '' + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_RESPONSES_HOME" node scripts/run-tmux-smoke.js T2 + - name: Interrupted-turn scenario (real DeepSeek, Chat Completions) + working-directory: apps/tui + env: + DEVO_TUI_TEST_TIMEOUT_MS: "180000" + run: DEVO_HOME="$DEVO_LIVE_CHAT_HOME" node scripts/run-tmux-smoke.js T3 + check: + needs: changes + if: needs.changes.outputs.rust == 'true' name: Check runs-on: ubuntu-latest steps: @@ -21,6 +304,8 @@ jobs: - run: cargo check --workspace --all-targets test: + needs: changes + if: needs.changes.outputs.rust == 'true' name: Test runs-on: ubuntu-latest steps: @@ -30,6 +315,8 @@ jobs: - run: cargo test --workspace --jobs 1 fmt: + needs: changes + if: needs.changes.outputs.rust == 'true' name: Rustfmt runs-on: ubuntu-latest steps: @@ -39,18 +326,22 @@ jobs: components: rustfmt - run: cargo fmt --all -- --check - # clippy: - # name: Clippy - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # with: - # components: clippy - # - uses: Swatinem/rust-cache@v2 - # - run: cargo clippy --workspace --all-targets -- -D warnings + clippy: + needs: changes + if: needs.changes.outputs.rust == 'true' + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings doc: + needs: changes + if: needs.changes.outputs.rust == 'true' name: Doc runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffc65249..a6e37d5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,12 @@ jobs: name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: include: - os: ubuntu-latest target: x86_64-unknown-linux-musl + asset_target: x86_64-linux-musl ext: "" package_cli: true package_desktop: true @@ -27,6 +29,7 @@ jobs: electron_args: --linux --x64 - os: ubuntu-latest target: aarch64-unknown-linux-musl + asset_target: aarch64-linux-musl ext: "" package_cli: true package_desktop: true @@ -64,13 +67,16 @@ jobs: - os: windows-latest target: aarch64-pc-windows-msvc ext: ".exe" - package_cli: false + package_cli: true package_desktop: true desktop_platform: windows desktop_arch: arm64 rg_target: aarch64-pc-windows-msvc electron_args: --win --arm64 + env: + RELEASE_TARGET: ${{ matrix.asset_target || matrix.target }} + steps: - uses: actions/checkout@v4 @@ -95,58 +101,29 @@ jobs: if: runner.os == 'Linux' run: | cargo zigbuild --release --target ${{ matrix.target }} -p devo-cli - cargo zigbuild --release --target ${{ matrix.target }} -p devo-code-search-mcp - name: Build release binary (macOS) if: runner.os == 'macOS' run: | cargo build --release --target ${{ matrix.target }} -p devo-cli - cargo build --release --target ${{ matrix.target }} -p devo-code-search-mcp - name: Build release binary (Windows) if: runner.os == 'Windows' run: | - cargo build --release --target ${{ matrix.target }} -p devo-cli - cargo build --release --target ${{ matrix.target }} -p devo-code-search-mcp + cargo build --release --target ${{ matrix.target }} -p devo-cli -p devo-windows-sandbox --bins - name: Strip symbols (macOS) if: runner.os == 'macOS' run: | strip target/${{ matrix.target }}/release/devo${{ matrix.ext }} - strip target/${{ matrix.target }}/release/devo-code-search-mcp${{ matrix.ext }} - - # ── CLI archive (skipped for desktop-only targets) ──────────────────── - - name: Prepare CLI staging directory - if: matrix.package_cli - shell: bash - run: | - staging=devo-${{ github.ref_name }}-${{ matrix.target }} - mkdir -p "$staging" - cp target/${{ matrix.target }}/release/devo${{ matrix.ext }} "$staging/" - cp target/${{ matrix.target }}/release/devo-code-search-mcp${{ matrix.ext }} "$staging/" - cp README.md LICENSE "$staging/" 2>/dev/null || true - echo "STAGING=$staging" >> "$GITHUB_ENV" - - - name: Create tar.gz archive (Linux/macOS) - if: matrix.package_cli && runner.os != 'Windows' - run: tar czf "${{ env.STAGING }}.tar.gz" "${{ env.STAGING }}" - - - name: Create zip archive (Windows) - if: matrix.package_cli && runner.os == 'Windows' - shell: pwsh - run: | - $staging = "$env:STAGING" - Compress-Archive -Path "$staging/*" -DestinationPath "$staging.zip" - - uses: actions/upload-artifact@v4 - if: matrix.package_cli + - uses: oven-sh/setup-bun@v2 with: - name: devo-${{ github.ref_name }}-${{ matrix.target }} - path: devo-${{ github.ref_name }}-${{ matrix.target }}.* + bun-version: "1.4.0" - # ── Desktop package (reuses the binary compiled above) ──────────────── - - uses: oven-sh/setup-bun@v2 - if: matrix.package_desktop + - uses: astral-sh/setup-uv@v6 + with: + version: "0.11.13" - name: Install Linux packaging dependencies if: matrix.package_desktop && runner.os == 'Linux' @@ -193,6 +170,48 @@ jobs: Get-Item $rg.FullName | Select-Object FullName, Length "DEVO_DESKTOP_RUNTIME_RG_BIN=$($rg.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + # ── CLI archive (skipped for desktop-only targets) ──────────────────── + - name: Prepare CLI staging directory + if: matrix.package_cli + shell: bash + run: | + staging=devo-tui-${{ github.ref_name }}-${{ env.RELEASE_TARGET }} + bun scripts/runtime/bundle.ts "${{ matrix.target }}" "$staging" "$DEVO_DESKTOP_RUNTIME_RG_BIN" "target/${{ matrix.target }}/release" + echo "STAGING=$staging" >> "$GITHUB_ENV" + + - name: Verify private runtimes outside the checkout + shell: bash + run: | + bun scripts/runtime/smoke.ts "${{ env.STAGING }}" + + - name: Create online app and cached runtime packs + shell: bash + run: bun scripts/runtime/packs.ts "${{ env.STAGING }}" online-assets + + - name: Create tar.gz archive (Linux/macOS) + if: matrix.package_cli && runner.os != 'Windows' + run: tar czf "${{ env.STAGING }}.tar.gz" "${{ env.STAGING }}" + + - name: Create zip archive (Windows) + if: matrix.package_cli && runner.os == 'Windows' + shell: pwsh + run: | + $staging = "$env:STAGING" + Compress-Archive -Path "$staging/*" -DestinationPath "$staging.zip" + + - uses: actions/upload-artifact@v4 + if: matrix.package_cli + with: + name: devo-tui-${{ github.ref_name }}-${{ env.RELEASE_TARGET }} + path: ${{ env.STAGING }}.* + + - uses: actions/upload-artifact@v4 + if: matrix.package_cli + with: + name: devo-online-${{ matrix.target }} + path: online-assets/* + + # ── Desktop package (reuses the binary compiled above) ──────────────── - name: Install desktop dependencies if: matrix.package_desktop working-directory: apps/desktop @@ -206,7 +225,7 @@ jobs: - name: Stage desktop runtime from release binary if: matrix.package_desktop working-directory: apps/desktop - run: bun scripts/prepare-runtime.ts --target ${{ matrix.target }} + run: bun scripts/prepare-runtime.ts --target ${{ matrix.target }} --bundle-dir ../../${{ env.STAGING }} - name: Package desktop with electron-builder if: matrix.package_desktop @@ -240,33 +259,29 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - name: Download all artifacts uses: actions/download-artifact@v4 with: - merge-multiple: true path: artifacts - name: Collect individual release assets - run: | - mkdir -p release-assets - find artifacts -maxdepth 1 -type f \ - \( -name '*.AppImage' \ - -o -name '*.blockmap' \ - -o -name '*.deb' \ - -o -name '*.dmg' \ - -o -name '*.exe' \ - -o -name '*.rpm' \ - -o -name '*.tar.gz' \ - -o -name '*.yml' \ - -o -name '*.zip' \) \ - -exec cp {} release-assets/ \; - find release-assets -maxdepth 1 -type f -print | sort + run: bun apps/desktop/scripts/collect-release-assets.ts artifacts release-assets "${{ github.ref_name }}" - name: Generate release notes run: | cat > release-notes.md <<'NOTES' ## Install + The online installer downloads the app and caches checksum-pinned private Node/Python runtime packs for reuse across upgrades. No system Node.js, Python, npm, pip, or Rust installation is required. Complete terminal archives remain self-contained and portable; keep their extracted files together. + + Terminal archives use the `devo-tui-` prefix; graphical installers use `devo-desktop-`. The terminal command remains `devo`. + + For offline installation, transfer the complete archive and use its included installer with `--offline` (Unix) or `-Offline` (Windows). Cloud inference still requires connectivity; use a compatible preloaded local or LAN model for disconnected use. + ### Linux / macOS ```bash diff --git a/.github/workflows/runtime-bundles.yml b/.github/workflows/runtime-bundles.yml new file mode 100644 index 00000000..4c5f369a --- /dev/null +++ b/.github/workflows/runtime-bundles.yml @@ -0,0 +1,62 @@ +name: Runtime bundles + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + +jobs: + runtime: + name: Private runtimes (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + ext: "" + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + ext: "" + - os: macos-latest + target: aarch64-apple-darwin + ext: "" + - os: macos-latest + target: x86_64-apple-darwin + ext: "" + - os: windows-latest + target: x86_64-pc-windows-msvc + ext: ".exe" + - os: windows-latest + target: aarch64-pc-windows-msvc + ext: ".exe" + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + - uses: astral-sh/setup-uv@v6 + with: + version: "0.11.13" + - name: Packaging and installer unit tests + run: bun test scripts/runtime + - name: Create backend placeholders for runtime-only packaging checks + shell: bash + run: | + mkdir -p fixture-bin + printf 'backend checked by Rust CI and release builds\n' > fixture-bin/devo${{ matrix.ext }} + printf 'ripgrep checked by release builds\n' > fixture-bin/rg${{ matrix.ext }} + if [ "${{ matrix.ext }}" = ".exe" ]; then + for helper in devo-windows-sandbox-setup devo-command-runner; do + printf 'sandbox helper checked by Rust CI and release builds\n' > "fixture-bin/$helper.exe" + done + fi + - name: Build target runtime bundle + shell: bash + run: bun scripts/runtime/bundle.ts "${{ matrix.target }}" runtime-test-bundle "fixture-bin/rg${{ matrix.ext }}" fixture-bin + - name: Relocate and exercise private Node/Python without system runtimes or network + run: bun scripts/runtime/smoke.ts runtime-test-bundle + - name: Build and verify shared runtime installation + run: bun scripts/runtime/shared-smoke.ts runtime-test-bundle diff --git a/.gitignore b/.gitignore index 49a05167..918474c2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ node_modules /apps/desktop/out/ /apps/desktop/resources/runtime/ /apps/desktop/packages/devo-ai-sdk/src/v2/generated/ + +# Session import test fixtures may contain local rollout data. +import-fixture* diff --git a/AGENTS.md b/AGENTS.md index 63c49083..2cb8951a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,51 @@ This repository is a Rust-based coding agent, currently called `devo`. - Never rely on Windows-style paths being interpreted correctly on Unix, or Unix-style paths on Windows. - Always use platform-native path formats in tests so they align with `std::path::Path` semantics. +### InteractiveMode product TUI (tmux / psmux) + +When verifying the **product** path (`target/debug/devo` → Node InteractiveMode), use a real TTY under **tmux** or **psmux** (Windows: winget `marlocarlo.psmux`; set `DEVO_TMUX_BIN` if it is not on `PATH`). Do **not** assert exact model reply text — assert turn activity (user bubble, thinking, assistant output, and/or tool use). + +```bash +# Session name may be any label; this example uses the binary path as the name. +tmux new-session -d -s .\target\debug\devo -x 80 -y 24 + +# Wait for startup, then capture output +sleep 3 && tmux capture-pane -t .\target\debug\devo -p + +# Send input +tmux send-keys -t .\target\debug\devo "your prompt here" Enter + +# Send special keys +tmux send-keys -t .\target\debug\devo Escape +tmux send-keys -t .\target\debug\devo C-o # ctrl+o + +# Cleanup +tmux kill-session -t .\target\debug\devo +``` + +On Windows PowerShell with psmux, prefer an absolute binary path and an explicit cwd, for example: + +```powershell +$tmux = $env:DEVO_TMUX_BIN # or full path to psmux.exe +$devo = (Resolve-Path .\target\debug\devo.exe).Path +& $tmux new-session -d -s devo-repro -c (Get-Location) -x 100 -y 30 $devo +Start-Sleep -Seconds 4 +& $tmux capture-pane -t devo-repro -p +& $tmux send-keys -t devo-repro "Say hello in one short sentence." Enter +Start-Sleep -Seconds 30 +& $tmux capture-pane -t devo-repro -p +& $tmux kill-session -t devo-repro +``` + +Slash commands to spot-check under the same TTY path (do not assert exact model text): + +- `/model` — opens model selector (Esc to close) +- `/effort` — opens effort selector +- `/system-prompt` — prints server-owned system prompt note in chat +- `/goal` — status line (“No active goal” or current objective); `/goal ` sets; `/goal pause|resume|clear` mutate + +Scenario harness (markers / T1–T3): `apps/tui` → `npm run test:tmux` (`scripts/tmux-tui-smoke.ps1` / `.sh`). + ## Protocol and Session Settings Per L2-DES-APP-008 and L2-DES-CONV-002 (both Approved): @@ -46,6 +91,6 @@ Per L2-DES-APP-008 and L2-DES-CONV-002 (both Approved): - The Native protocol (`crates/protocol/src/native/`) is the single retained surface. New features land on Native only; legacy-shaped handlers only shrink and are deleted in Phase E. During migration, legacy handlers translate into the Native path — never maintain parallel implementations. - External protocols (ACP, future A2A) are pure adapters: transport + projection, zero business logic. Their wire behavior is pinned by `protocol-lock.json` and must not drift mid-migration. - Session settings changes go through canonical `session/metadata/update` with `SessionSettingsPatch` (partial semantics: only present fields change). Do not add per-concern settings methods. -- Settings writes are persist-first: field-level rollout lines (`InternalRecordV2::SessionSettings`) written synchronously by the handler, which never waits on the session actor; actor notification is best-effort (`SessionHandle::notify_*`). Replay prefers field lines over whole-record `SessionMeta` values. +- Settings writes are persist-first: field-level rollout lines (`InternalRecord::SessionSettings`) written synchronously by the handler, which never waits on the session actor; actor notification is best-effort (`SessionHandle::notify_*`). Replay prefers field lines over whole-record `SessionMeta` values. - Mid-turn effect rides the turn-inline override (`TurnInlineState.live_turn_settings`, `sandbox_profile_live`). Every live setting must declare its decision point and mid-turn semantics in the DD-6 promise matrix of L2-DES-CONV-002 before implementation. - Session actor vs turn isolation: see `L2-DES-SERVER-002` and `crates/server/AGENTS.md`. Actor mailbox commands must stay short; turns run on a spawned task with `TurnWorkingSet` and re-enter via `MergeTurn`. diff --git a/Cargo.lock b/Cargo.lock index 562bb706..49a3dba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cipher", "cpufeatures 0.2.17", ] @@ -83,10 +83,8 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.4", - "getrandom 0.3.4", + "cfg-if", "once_cell", - "serde", "version_check", "zerocopy", ] @@ -108,7 +106,7 @@ checksum = "d8cf9afc79c83d514444b55df3935d317da54b1ce3b17a133c646889cc260de8" dependencies = [ "allocative_derive", "bumpalo", - "ctor 1.0.9", + "ctor", "hashbrown 0.16.1", "num-bigint", ] @@ -139,43 +137,13 @@ dependencies = [ "libc", ] -[[package]] -name = "anndists" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8396b473aa0bceed68fb32462505387ea39fa47c7029417e0a49f10592b036" -dependencies = [ - "anyhow", - "cfg-if 1.0.4", - "cpu-time", - "env_logger", - "lazy_static", - "log", - "num-traits", - "num_cpus", - "rayon", -] - [[package]] name = "annotate-snippets" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" dependencies = [ - "unicode-width 0.1.14", -] - -[[package]] -name = "ansi-to-tui" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" -dependencies = [ - "nom 7.1.3", - "ratatui", - "simdutf8", - "smallvec", - "thiserror 1.0.69", + "unicode-width", ] [[package]] @@ -234,36 +202,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "image", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "wl-clipboard-rs", - "x11rb", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -344,13 +282,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ "autocfg", - "cfg-if 1.0.4", + "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -378,10 +316,10 @@ dependencies = [ "async-signal", "async-task", "blocking", - "cfg-if 1.0.4", + "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -404,10 +342,10 @@ dependencies = [ "async-io", "async-lock", "atomic-waker", - "cfg-if 1.0.4", + "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -554,12 +492,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -584,15 +516,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -629,7 +552,7 @@ dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if 1.0.4", + "cfg-if", "constant_time_eq", "digest 0.10.7", "rayon-core", @@ -689,31 +612,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "bon" -version = "3.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f04f6fef12d70d42a77b1433c9e0f065238479a6cefc4f5bab105e9873a3c3" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d0bd4c2f75335ad98052a37efb54f428b492f64340257143b3429c8a508fa7b" -dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - [[package]] name = "borsh" version = "1.6.1" @@ -740,7 +638,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", - "regex-automata", "serde", ] @@ -776,12 +673,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.1" @@ -821,21 +712,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -857,12 +733,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.4" @@ -887,7 +757,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1035,15 +905,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "combine" version = "4.6.7" @@ -1054,35 +915,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" -dependencies = [ - "castaway", - "cfg-if 1.0.4", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "compact_str" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" -dependencies = [ - "castaway", - "cfg-if 1.0.4", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1092,30 +924,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width 0.2.1", - "windows-sys 0.59.0", -] - -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "console-api" version = "0.9.0" @@ -1168,21 +976,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "const-str" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" - -[[package]] -name = "const_panic" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" -dependencies = [ - "typewit", -] - [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1233,25 +1026,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "countio" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" -dependencies = [ - "futures-io", -] - -[[package]] -name = "cpu-time" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1291,7 +1065,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -1346,37 +1120,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995" -dependencies = [ - "bitflags 2.13.0", - "crossterm_winapi", - "futures-core", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -1396,37 +1139,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "ctor" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - [[package]] name = "ctor" version = "1.0.9" @@ -1437,12 +1149,6 @@ dependencies = [ "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "darling" version = "0.20.11" @@ -1512,22 +1218,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dary_heap" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" -dependencies = [ - "serde", -] - [[package]] name = "dashmap" version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -1625,37 +1322,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "1.0.0" @@ -1709,7 +1375,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "devo-arg0" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "clap", @@ -1729,21 +1395,21 @@ dependencies = [ [[package]] name = "devo-cli" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "async-trait", "clap", - "colored 2.2.0", + "colored", "devo-arg0", "devo-core", + "devo-kernel", "devo-mcp", "devo-protocol", "devo-provider", "devo-safety", "devo-server", "devo-tasks", - "devo-tui", "devo-util-paths", "devo-windows-sandbox", "futures", @@ -1758,7 +1424,7 @@ dependencies = [ [[package]] name = "devo-client" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "chrono", @@ -1774,62 +1440,9 @@ dependencies = [ "tracing", ] -[[package]] -name = "devo-code-search-mcp" -version = "0.1.38" -dependencies = [ - "anyhow", - "bm25", - "devo-network-proxy", - "devo-util-paths", - "dirs", - "hf-hub", - "hnsw_rs", - "ignore", - "model2vec", - "notify", - "pathdiff", - "pretty_assertions", - "rayon", - "reqwest 0.13.4", - "rmcp", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tree-sitter", - "tree-sitter-bash", - "tree-sitter-c", - "tree-sitter-c-sharp", - "tree-sitter-containerfile", - "tree-sitter-cpp", - "tree-sitter-go", - "tree-sitter-ini", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-json", - "tree-sitter-kotlin-ng", - "tree-sitter-lua", - "tree-sitter-php", - "tree-sitter-powershell", - "tree-sitter-python", - "tree-sitter-r", - "tree-sitter-ruby", - "tree-sitter-rust", - "tree-sitter-scala", - "tree-sitter-sequel", - "tree-sitter-swift", - "tree-sitter-toml-ng", - "tree-sitter-typescript", - "tree-sitter-xml", - "tree-sitter-yaml", -] - [[package]] name = "devo-config" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "devo-protocol", @@ -1845,7 +1458,7 @@ dependencies = [ [[package]] name = "devo-core" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "async-trait", @@ -1854,9 +1467,11 @@ dependencies = [ "chrono", "devo-config", "devo-execpolicy", + "devo-kernel", "devo-network-proxy", "devo-protocol", "devo-provider", + "devo-rmcp-client", "devo-safety", "devo-sandbox", "devo-skills", @@ -1869,9 +1484,11 @@ dependencies = [ "iana-time-zone", "portable-pty", "pretty_assertions", + "process-wrap", "rand 0.9.4", "regex", "reqwest 0.12.28", + "rmcp", "serde", "serde_json", "sha2 0.10.9", @@ -1891,7 +1508,7 @@ dependencies = [ [[package]] name = "devo-execpolicy" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "clap", @@ -1908,7 +1525,7 @@ dependencies = [ [[package]] name = "devo-file-search" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "clap", @@ -1922,9 +1539,44 @@ dependencies = [ "tokio", ] +[[package]] +name = "devo-harness" +version = "0.2.15" +dependencies = [ + "chrono", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "devo-kernel" +version = "0.2.15" +dependencies = [ + "anyhow", + "devo-sandbox", + "devo-util-paths", + "devo-util-process", + "devo-windows-sandbox", + "dunce", + "futures", + "libc", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "devo-keyring-store" -version = "0.1.38" +version = "0.2.15" dependencies = [ "keyring", "tracing", @@ -1932,7 +1584,7 @@ dependencies = [ [[package]] name = "devo-linux-sandbox" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "clap", @@ -1944,7 +1596,7 @@ dependencies = [ [[package]] name = "devo-mcp" -version = "0.1.38" +version = "0.2.15" dependencies = [ "async-trait", "chrono", @@ -1960,7 +1612,7 @@ dependencies = [ [[package]] name = "devo-network-proxy" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "pretty_assertions", @@ -1972,7 +1624,7 @@ dependencies = [ [[package]] name = "devo-protocol" -version = "0.1.38" +version = "0.2.15" dependencies = [ "agent-client-protocol", "chrono", @@ -1982,7 +1634,7 @@ dependencies = [ "serde_json", "smol_str 0.3.2", "strum 0.27.2", - "strum_macros 0.28.0", + "strum_macros", "thiserror 2.0.18", "tracing", "ts-rs", @@ -1991,11 +1643,12 @@ dependencies = [ [[package]] name = "devo-provider" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "async-stream", "async-trait", + "bytes", "devo-network-proxy", "devo-protocol", "futures", @@ -2008,11 +1661,12 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "tracing-subscriber", ] [[package]] name = "devo-rmcp-client" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "axum", @@ -2027,6 +1681,7 @@ dependencies = [ "keyring", "oauth2", "pretty_assertions", + "process-wrap", "reqwest 0.12.28", "rmcp", "serde", @@ -2046,7 +1701,7 @@ dependencies = [ [[package]] name = "devo-safety" -version = "0.1.38" +version = "0.2.15" dependencies = [ "async-trait", "devo-config", @@ -2066,7 +1721,7 @@ dependencies = [ [[package]] name = "devo-sandbox" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "chrono", @@ -2092,7 +1747,7 @@ dependencies = [ [[package]] name = "devo-sandbox-network-proxy" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "devo-sandbox", @@ -2104,7 +1759,7 @@ dependencies = [ [[package]] name = "devo-server" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "async-trait", @@ -2115,6 +1770,8 @@ dependencies = [ "devo-core", "devo-execpolicy", "devo-file-search", + "devo-harness", + "devo-kernel", "devo-mcp", "devo-protocol", "devo-provider", @@ -2129,8 +1786,10 @@ dependencies = [ "fs2", "futures", "jsonrepair", + "libc", "lru", "pretty_assertions", + "reqwest 0.12.28", "rusqlite", "serde", "serde_json", @@ -2146,11 +1805,12 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.61.2", ] [[package]] name = "devo-skills" -version = "0.1.38" +version = "0.2.15" dependencies = [ "chrono", "include_dir", @@ -2166,108 +1826,66 @@ dependencies = [ ] [[package]] -name = "devo-tasks" -version = "0.1.38" +name = "devo-stress-tools" +version = "0.2.15" dependencies = [ "anyhow", - "async-trait", "chrono", - "pretty_assertions", + "clap", + "devo-core", + "devo-protocol", "serde", "serde_json", - "thiserror 2.0.18", "tokio", - "tracing", "uuid", ] [[package]] -name = "devo-tools" -version = "0.1.38" +name = "devo-tasks" +version = "0.2.15" dependencies = [ + "anyhow", "async-trait", - "devo-protocol", + "chrono", "pretty_assertions", "serde", "serde_json", - "smol_str 0.3.2", "thiserror 2.0.18", "tokio", - "tokio-util", + "tracing", + "uuid", ] [[package]] -name = "devo-tui" -version = "0.1.38" +name = "devo-tools" +version = "0.2.15" dependencies = [ - "ansi-to-tui", - "anyhow", - "arboard", - "base64 0.22.1", - "chrono", - "crossterm", - "derive_more 2.1.1", - "devo-client", - "devo-core", + "async-trait", + "devo-kernel", "devo-protocol", - "devo-provider", - "devo-sandbox", - "devo-server", - "devo-util-fuzzy", - "devo-util-git", - "devo-util-paths", - "devo-util-shell-command", - "diffy", - "dirs", - "futures", - "image", - "insta", - "itertools 0.14.0", - "lazy_static", - "libc", - "pathdiff", "pretty_assertions", - "pulldown-cmark", - "rand 0.9.4", - "ratatui", - "ratatui-macros", - "regex", - "regex-lite", "serde", "serde_json", - "shlex 1.3.0", - "strum 0.27.2", - "strum_macros 0.28.0", - "supports-color", - "syntect", + "sha2 0.10.9", + "smol_str 0.3.2", "tempfile", - "term-maths", - "textwrap 0.16.2", + "thiserror 2.0.18", "tokio", - "tokio-stream", "tokio-util", - "toml", - "tracing", - "two-face", - "unicode-segmentation", - "unicode-width 0.2.1", - "url", - "urlencoding", - "vt100", - "windows-sys 0.52.0", - "winsplit", + "uuid", + "windows-sys 0.61.2", ] [[package]] name = "devo-util-fuzzy" -version = "0.1.38" +version = "0.2.15" dependencies = [ "pretty_assertions", ] [[package]] name = "devo-util-git" -version = "0.1.38" +version = "0.2.15" dependencies = [ "assert_matches", "devo-util-paths", @@ -2285,7 +1903,7 @@ dependencies = [ [[package]] name = "devo-util-paths" -version = "0.1.38" +version = "0.2.15" dependencies = [ "dirs", "pretty_assertions", @@ -2298,7 +1916,7 @@ dependencies = [ [[package]] name = "devo-util-process" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "devo-sandbox", @@ -2308,6 +1926,7 @@ dependencies = [ "log", "portable-pty", "pretty_assertions", + "process-wrap", "shared_library", "tokio", "winapi", @@ -2315,7 +1934,7 @@ dependencies = [ [[package]] name = "devo-util-shell-command" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "base64 0.22.1", @@ -2335,7 +1954,7 @@ dependencies = [ [[package]] name = "devo-windows-sandbox" -version = "0.1.38" +version = "0.2.15" dependencies = [ "anyhow", "base64 0.22.1", @@ -2354,7 +1973,7 @@ dependencies = [ "serde_json", "shared_library", "strum 0.27.2", - "strum_macros 0.28.0", + "strum_macros", "tempfile", "tokio", "tracing", @@ -2416,7 +2035,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "dirs-sys-next", ] @@ -2443,16 +2062,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.0", - "objc2", -] - [[package]] name = "display_container" version = "0.9.0" @@ -2486,21 +2095,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -2551,21 +2145,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if 1.0.4", -] - [[package]] name = "endi" version = "1.1.1" @@ -2578,18 +2157,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -2627,10 +2194,7 @@ version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream", - "anstyle", "env_filter", - "jiff", "log", ] @@ -2676,15 +2240,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "esaxx-rs" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" -dependencies = [ - "cc", -] - [[package]] name = "event-listener" version = "5.4.1" @@ -2713,7 +2268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom 7.1.3", + "nom", "pin-project-lite", ] @@ -2746,32 +2301,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - [[package]] name = "fd-lock" version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ - "cfg-if 1.0.4", - "rustix 1.1.4", + "cfg-if", + "rustix", "windows-sys 0.59.0", ] -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "filedescriptor" version = "0.8.3" @@ -2863,15 +2403,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures" version = "0.3.32" @@ -3001,15 +2532,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "gearhash" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" -dependencies = [ - "cfg-if 0.1.10", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -3020,35 +2542,16 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width 0.2.1", -] - [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -3058,7 +2561,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "r-efi 5.3.0", @@ -3072,34 +2575,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "cfg-if 1.0.4", - "js-sys", + "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", - "wasm-bindgen", -] - -[[package]] -name = "git-version" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" -dependencies = [ - "git-version-macro", -] - -[[package]] -name = "git-version-macro" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", ] [[package]] @@ -3140,17 +2621,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if 1.0.4", - "crunchy", - "zerocopy", -] - [[package]] name = "hash32" version = "0.2.1" @@ -3218,16 +2688,10 @@ dependencies = [ "base64 0.21.7", "byteorder", "flate2", - "nom 7.1.3", + "nom", "num-traits", ] -[[package]] -name = "heapify" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" - [[package]] name = "heapless" version = "0.7.17" @@ -3261,94 +2725,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "hmac", ] [[package]] -name = "hf-xet" -version = "1.5.2" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "async-trait", - "bytes", - "http", - "more-asserts", - "serde", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", - "xet-client", - "xet-core-structures", - "xet-data", - "xet-runtime", -] - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hnsw_rs" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a5258f079b97bf2e8311ff9579e903c899dcbac0d9a138d62e9a066778bd07" -dependencies = [ - "anndists", - "anyhow", - "bincode", - "cfg-if 1.0.4", - "cpu-time", - "env_logger", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "lazy_static", - "log", - "mmap-rs", - "num-traits", - "num_cpus", - "parking_lot", - "rand 0.9.4", - "rayon", - "serde", + "digest 0.10.7", ] [[package]] @@ -3490,11 +2881,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3651,20 +3040,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", - "tiff", -] - [[package]] name = "include_dir" version = "0.7.4" @@ -3713,48 +3088,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width 0.2.1", - "web-time", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "inotify" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" -dependencies = [ - "bitflags 2.13.0", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "inout" version = "0.1.4" @@ -3765,31 +3098,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console 0.16.3", - "once_cell", - "similar", - "tempfile", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "inventory" version = "0.3.24" @@ -3805,27 +3113,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -3841,37 +3134,13 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jiff" -version = "0.2.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "jni" version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "combine", "jni-macros", "jni-sys", @@ -3930,7 +3199,7 @@ version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "futures-util", "once_cell", "wasm-bindgen", @@ -3976,43 +3245,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "konst" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" -dependencies = [ - "const_panic", - "konst_proc_macros", - "typewit", -] - -[[package]] -name = "konst_proc_macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" - -[[package]] -name = "kqueue" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags 2.13.0", - "libc", -] - [[package]] name = "landlock" version = "0.4.5" @@ -4077,12 +3309,6 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linktime-proc-macro" version = "0.2.0" @@ -4099,12 +3325,6 @@ dependencies = [ "libc", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4204,40 +3424,6 @@ dependencies = [ "serde_repr", ] -[[package]] -name = "lz4_flex" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" -dependencies = [ - "twox-hash", -] - -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - -[[package]] -name = "macro_rules_attribute" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" -dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", -] - -[[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" - [[package]] name = "maplit" version = "1.0.2" @@ -4259,16 +3445,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - [[package]] name = "memchr" version = "2.8.2" @@ -4290,16 +3466,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4323,80 +3489,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "mmap-rs" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ecce9d566cb9234ae3db9e249c8b55665feaaf32b0859ff1e27e310d2beb3d8" -dependencies = [ - "bitflags 2.13.0", - "combine", - "libc", - "mach2", - "nix 0.30.1", - "sysctl", - "thiserror 2.0.18", - "widestring", - "windows 0.48.0", -] - -[[package]] -name = "model2vec" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d79c724c4305abafc47358bbf8db61fef84509fd45aa1f1c95d5adf428bb8275" -dependencies = [ - "anyhow", - "half", - "ndarray", - "safetensors", - "serde_json", - "tokenizers", -] - -[[package]] -name = "monostate" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" -dependencies = [ - "monostate-impl", - "serde", - "serde_core", -] - -[[package]] -name = "monostate-impl" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "more-asserts" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "multimap" version = "0.10.1" @@ -4406,21 +3502,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ndarray" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] - [[package]] name = "ndk-context" version = "0.1.1" @@ -4443,7 +3524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.13.0", - "cfg-if 1.0.4", + "cfg-if", "cfg_aliases 0.1.1", "libc", ] @@ -4455,24 +3536,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.0", - "cfg-if 1.0.4", + "cfg-if", "cfg_aliases 0.2.1", "libc", "memoffset", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.13.0", - "cfg-if 1.0.4", - "cfg_aliases 0.2.1", - "libc", -] - [[package]] name = "nix" version = "0.31.3" @@ -4480,7 +3549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.0", - "cfg-if 1.0.4", + "cfg-if", "cfg_aliases 0.2.1", "libc", ] @@ -4495,15 +3564,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "nono" version = "0.53.0" @@ -4534,46 +3594,10 @@ dependencies = [ ] [[package]] -name = "notify" -version = "8.2.0" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.13.0", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ "windows-sys 0.61.2", ] @@ -4677,22 +3701,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "oauth2" version = "5.0.0" @@ -4722,42 +3730,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-core-graphics", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.0", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -4772,37 +3744,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", ] [[package]] @@ -4817,34 +3758,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "oneshot" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" - -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags 2.13.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "openssl-probe" version = "0.2.1" @@ -4867,25 +3780,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "os_str_bytes" -version = "6.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" -dependencies = [ - "memchr", -] - [[package]] name = "pagable" version = "0.4.1" @@ -4955,7 +3849,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "redox_syscall", "smallvec", @@ -4974,12 +3868,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pem" version = "3.0.6" @@ -5005,17 +3893,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap 2.14.0", -] - [[package]] name = "pin-project" version = "1.1.13" @@ -5059,61 +3936,20 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml 0.38.4", - "serde", - "time", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "portable-pty" version = "0.9.0" @@ -5242,7 +4078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.117", @@ -5257,55 +4093,6 @@ dependencies = [ "prost", ] -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.0", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - [[package]] name = "quickcheck" version = "1.1.0" @@ -5343,7 +4130,6 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -5480,41 +4266,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "ratatui" -version = "0.29.0" -source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" -dependencies = [ - "bitflags 2.13.0", - "cassowary", - "compact_str 0.8.2", - "crossterm", - "indoc", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum 0.26.3", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.1", -] - -[[package]] -name = "ratatui-macros" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fef540f80dbe8a0773266fa6077788ceb65ef624cdbf36e131aaf90b4a52df4" -dependencies = [ - "ratatui", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - [[package]] name = "rayon" version = "1.12.0" @@ -5525,17 +4276,6 @@ dependencies = [ "rayon-core", ] -[[package]] -name = "rayon-cond" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" -dependencies = [ - "either", - "itertools 0.14.0", - "rayon", -] - [[package]] name = "rayon-core" version = "1.13.0" @@ -5546,15 +4286,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "redb" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" -dependencies = [ - "libc", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -5629,12 +4360,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - [[package]] name = "regex-syntax" version = "0.8.11" @@ -5688,7 +4413,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -5701,40 +4426,27 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-util", "js-sys", "log", - "mime", - "mime_guess", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", "web-sys", ] @@ -5748,26 +4460,12 @@ dependencies = [ "futures-core", "futures-timer", "mime", - "nom 7.1.3", + "nom", "pin-project-lite", "reqwest 0.12.28", "thiserror 1.0.69", ] -[[package]] -name = "reqwest-middleware" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" -dependencies = [ - "anyhow", - "async-trait", - "http", - "reqwest 0.13.4", - "thiserror 2.0.18", - "tower-service", -] - [[package]] name = "ring" version = "0.17.14" @@ -5775,7 +4473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.2.17", "libc", "untrusted 0.9.0", @@ -5845,12 +4543,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rust-latex-parser" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b0949524549f0f83d8a14c71e9b378c45307a77bb6ea6bb969d1399d5ae134" - [[package]] name = "rust-stemmers" version = "1.2.0" @@ -5878,28 +4570,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] @@ -5908,7 +4587,6 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -5939,33 +4617,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework 3.7.0", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -5991,7 +4642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ "bitflags 2.13.0", - "cfg-if 1.0.4", + "cfg-if", "clipboard-win", "fd-lock", "home", @@ -6001,7 +4652,7 @@ dependencies = [ "nix 0.28.0", "radix_trie", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", "utf8parse", "windows-sys 0.52.0", ] @@ -6018,22 +4669,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" -[[package]] -name = "safe-transmute" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" - -[[package]] -name = "safetensors" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "same-file" version = "1.0.6" @@ -6408,7 +5043,7 @@ version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "windows-sys 0.61.2", ] @@ -6444,7 +5079,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -6455,10 +5090,9 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -6467,20 +5101,11 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -6506,17 +5131,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "bstr", - "dirs", - "os_str_bytes", -] - [[package]] name = "shlex" version = "1.3.0" @@ -6529,27 +5143,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6751,12 +5344,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "slab" version = "0.4.12" @@ -6769,12 +5356,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "smawk" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" - [[package]] name = "smol_str" version = "0.1.24" @@ -6810,7 +5391,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94bf565ee1681b4473aa5a9d71d807347c28021bd1d8947cb626b02f42a0141f" dependencies = [ - "itertools 0.14.0", + "itertools", "quickcheck", ] @@ -6833,18 +5414,6 @@ dependencies = [ "der", ] -[[package]] -name = "spm_precompiled" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" -dependencies = [ - "base64 0.13.1", - "nom 7.1.3", - "serde", - "unicode-segmentation", -] - [[package]] name = "sse-stream" version = "0.2.3" @@ -6886,7 +5455,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap 2.14.0", "inventory", - "itertools 0.14.0", + "itertools", "maplit", "memoffset", "num-bigint", @@ -6905,7 +5474,7 @@ dependencies = [ "static_assertions", "strong_hash", "strsim 0.10.0", - "textwrap 0.11.0", + "textwrap", "thiserror 2.0.18", ] @@ -6976,16 +5545,6 @@ dependencies = [ "lock_free_hashtable", ] -[[package]] -name = "statrs" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" -dependencies = [ - "approx", - "num-traits", -] - [[package]] name = "stop-words" version = "0.9.0" @@ -7033,15 +5592,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - [[package]] name = "strum" version = "0.27.2" @@ -7054,20 +5604,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -7088,15 +5625,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - [[package]] name = "symlink" version = "0.1.0" @@ -7145,76 +5673,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "flate2", - "fnv", - "once_cell", - "onig", - "plist", - "regex-syntax", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "walkdir", - "yaml-rust", -] - -[[package]] -name = "sysctl" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" -dependencies = [ - "bitflags 2.13.0", - "byteorder", - "enum-as-inner", - "libc", - "thiserror 1.0.69", - "walkdir", -] - -[[package]] -name = "sysinfo" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.62.2", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "take_mut" version = "0.2.2" @@ -7230,19 +5688,10 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "term-maths" -version = "1.0.0" -dependencies = [ - "rust-latex-parser", - "unicode-segmentation", - "unicode-width 0.2.1", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -7258,18 +5707,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" dependencies = [ - "unicode-width 0.1.14", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", - "unicode-linebreak", - "unicode-width 0.2.1", + "unicode-width", ] [[package]] @@ -7318,21 +5756,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if 1.0.4", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", + "cfg-if", ] [[package]] @@ -7423,40 +5847,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokenizers" -version = "0.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" -dependencies = [ - "ahash", - "aho-corasick", - "compact_str 0.9.1", - "dary_heap", - "derive_builder", - "esaxx-rs", - "getrandom 0.3.4", - "indicatif", - "itertools 0.14.0", - "log", - "macro_rules_attribute", - "monostate", - "onig", - "paste", - "rand 0.9.4", - "rayon", - "rayon-cond", - "regex", - "regex-syntax", - "serde", - "serde_json", - "spm_precompiled", - "thiserror 2.0.18", - "unicode-normalization-alignments", - "unicode-segmentation", - "unicode_categories", -] - [[package]] name = "tokio" version = "1.52.3" @@ -7486,17 +5876,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-retry" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" -dependencies = [ - "pin-project-lite", - "rand 0.10.1", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7516,7 +5895,6 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", - "tokio-util", ] [[package]] @@ -7541,7 +5919,6 @@ dependencies = [ "futures-core", "futures-sink", "pin-project-lite", - "slab", "tokio", ] @@ -7818,297 +6195,46 @@ dependencies = [ ] [[package]] -name = "tree-sitter-c" -version = "0.24.2" +name = "tree-sitter-language" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" -dependencies = [ - "cc", - "tree-sitter-language", -] +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" [[package]] -name = "tree-sitter-c-sharp" -version = "0.23.5" +name = "triomphe" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" dependencies = [ - "cc", - "tree-sitter-language", + "serde", + "stable_deref_trait", ] [[package]] -name = "tree-sitter-containerfile" -version = "0.8.0" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1555ef425ecf06d7f65d58b3f9d3db3bb7fbd4ad135b4e7e40f194b0859ea16f" -dependencies = [ - "cc", - "tree-sitter-language", -] +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "tree-sitter-cpp" -version = "0.23.4" +name = "ts-rs" +version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ - "cc", - "tree-sitter-language", + "chrono", + "serde_json", + "smol_str 0.3.2", + "thiserror 2.0.18", + "ts-rs-macros", + "uuid", ] [[package]] -name = "tree-sitter-go" -version = "0.25.0" +name = "ts-rs-macros" +version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-ini" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387f79682cd53b7c0a5777c96e601a02b9965a787984ef86dbb8952bdab2d62f" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-java" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-javascript" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-json" -version = "0.24.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-kotlin-ng" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" - -[[package]] -name = "tree-sitter-lua" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-php" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-powershell" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3faf304d44b9ddd4a7d97804bb8de7daf564336dd5a526dc6de5b39238243022" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-python" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-r" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "429133cbda9f8a46e03ef3aae6abb6c3d22875f8585cad472138101bfd517255" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-ruby" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-rust" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-scala" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de5a4a7ff23a55474ce6a741d52aaeca7a82fe9421bb982b86e98c6ac8629397" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-sequel" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-swift" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-toml-ng" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-typescript" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-xml" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e670041f591d994f54d597ddcd8f4ebc930e282c4c76a42268743b71f0c8b6b3" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-yaml" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - -[[package]] -name = "triomphe" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" -dependencies = [ - "serde", - "stable_deref_trait", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ts-rs" -version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" -dependencies = [ - "chrono", - "serde_json", - "smol_str 0.3.2", - "thiserror 2.0.18", - "ts-rs-macros", - "uuid", -] - -[[package]] -name = "ts-rs-macros" -version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" dependencies = [ "proc-macro2", "quote", @@ -8133,23 +6259,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "two-face" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34" -dependencies = [ - "serde", - "serde_derive", - "syntect", -] - -[[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" - [[package]] name = "typeid" version = "1.0.3" @@ -8162,12 +6271,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "typewit" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" - [[package]] name = "typify" version = "0.6.2" @@ -8226,74 +6329,30 @@ dependencies = [ "winapi", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-normalization-alignments" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" -dependencies = [ - "smallvec", -] - [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" -[[package]] -name = "unicode-width" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" - [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -8379,27 +6438,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vt100" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" -dependencies = [ - "itoa", - "unicode-width 0.2.1", - "vte", -] - -[[package]] -name = "vte" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" -dependencies = [ - "arrayvec", - "memchr", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -8425,15 +6463,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -8452,22 +6481,13 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -8500,150 +6520,67 @@ version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "smallvec", - "wayland-sys", + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", ] [[package]] -name = "wayland-client" -version = "0.31.14" +name = "wasm-bindgen-shared" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ - "bitflags 2.13.0", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", + "unicode-ident", ] [[package]] -name = "wayland-protocols" -version = "0.32.12" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", + "leb128fmt", + "wasmparser", ] [[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "wayland-scanner" -version = "0.31.10" +name = "wasm-streams" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ - "proc-macro2", - "quick-xml 0.39.2", - "quote", + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "wayland-sys" -version = "0.31.11" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "pkg-config", + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", ] [[package]] @@ -8682,15 +6619,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -8700,12 +6628,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "which" version = "8.0.3" @@ -8715,25 +6637,6 @@ dependencies = [ "libc", ] -[[package]] -name = "whoami" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -8765,15 +6668,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows" version = "0.58.0" @@ -8902,17 +6796,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.2.0" @@ -8986,21 +6869,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -9043,12 +6911,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -9061,12 +6923,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -9079,12 +6935,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -9109,12 +6959,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -9127,12 +6971,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -9145,12 +6983,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -9163,12 +6995,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -9208,12 +7034,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winsplit" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab703352da6a72f35c39a533526393725640575bb211f61987a2748323ad956" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -9302,47 +7122,12 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wl-clipboard-rs" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.4", - "thiserror 2.0.18", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix 1.1.4", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "x509-cert" version = "0.2.5" @@ -9378,162 +7163,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "xet-client" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "bytes", - "clap", - "crc32fast", - "futures", - "http", - "hyper", - "lazy_static", - "more-asserts", - "rand 0.10.1", - "redb", - "reqwest 0.13.4", - "reqwest-middleware", - "serde", - "serde_json", - "serde_repr", - "statrs", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tracing", - "tracing-subscriber", - "url", - "urlencoding", - "web-time", - "xet-core-structures", - "xet-runtime", -] - -[[package]] -name = "xet-core-structures" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" -dependencies = [ - "async-trait", - "base64 0.22.1", - "blake3", - "bytemuck", - "bytes", - "clap", - "countio", - "csv", - "futures", - "futures-util", - "getrandom 0.4.2", - "heapify", - "itertools 0.14.0", - "lazy_static", - "lz4_flex", - "more-asserts", - "rand 0.10.1", - "regex", - "safe-transmute", - "serde", - "static_assertions", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", - "web-time", - "xet-runtime", -] - -[[package]] -name = "xet-data" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "clap", - "gearhash", - "http", - "itertools 0.14.0", - "lazy_static", - "more-asserts", - "rand 0.10.1", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "url", - "uuid", - "walkdir", - "xet-client", - "xet-core-structures", - "xet-runtime", -] - -[[package]] -name = "xet-runtime" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "colored 3.1.1", - "const-str", - "ctor 0.6.3", - "dirs", - "futures", - "git-version", - "humantime", - "konst", - "lazy_static", - "libc", - "more-asserts", - "oneshot", - "pin-project", - "rand 0.10.1", - "reqwest 0.13.4", - "serde", - "serde_json", - "shellexpand", - "sysinfo", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "tracing-appender", - "tracing-subscriber", - "whoami", - "winapi", -] - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yansi" version = "1.0.1" @@ -9725,21 +7354,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] - [[package]] name = "zvariant" version = "4.2.0" diff --git a/Cargo.toml b/Cargo.toml index d6119668..66bf2bfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "crates/core", - "crates/code-search-mcp", "crates/execpolicy", "crates/tools", "crates/provider", @@ -17,7 +16,6 @@ members = [ "crates/client", "crates/config", "crates/server", - "crates/tui", "crates/utils/fuzzy", "crates/utils/git", "crates/utils/paths", @@ -28,6 +26,9 @@ members = [ "crates/skills", "crates/rmcp-client", "crates/keyring-store", + "crates/kernel", + "crates/harness", + "crates/stress-tools", ] resolver = "3" @@ -37,7 +38,7 @@ resolver = "3" edition = "2024" license = "MIT" rust-version = "1.88.0" -version = "0.1.38" +version = "0.2.15" [workspace.dependencies] agent-client-protocol = "0.14.0" @@ -55,16 +56,16 @@ chrono = "0.4.43" clap = { version = "4", features = ["derive"] } colored = "2" crossbeam-channel = "0.5.15" -crossterm = { version = "0.28", features = ["event-stream"] } derive_more = "2" devo-arg0 = { path = "crates/arg0" } devo-client = { path = "crates/client" } -devo-code-search-mcp = { path = "crates/code-search-mcp" } devo-config = { path = "crates/config" } devo-core = { path = "crates/core" } devo-execpolicy = { path = "crates/execpolicy" } devo-file-search = { path = "crates/file-search" } devo-keyring-store = { path = "crates/keyring-store" } +devo-kernel = { path = "crates/kernel" } +devo-harness = { path = "crates/harness" } devo-mcp = { path = "crates/mcp" } devo-network-proxy = { path = "crates/network-proxy" } devo-protocol = { path = "crates/protocol" } @@ -79,7 +80,6 @@ devo-server = { path = "crates/server" } devo-skills = { path = "crates/skills" } devo-tasks = { path = "crates/tasks" } devo-tools = { path = "crates/tools" } -devo-tui = { path = "crates/tui" } devo-util-fuzzy = { path = "crates/utils/fuzzy" } devo-util-git = { path = "crates/utils/git" } devo-util-paths = { path = "crates/utils/paths" } @@ -115,8 +115,6 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.13.4" rand = "0.9" rayon = "1.12.0" -ratatui = "0.29.0" -ratatui-macros = "0.6.0" regex = "1" regex-lite = "0.1.8" reqwest = { version = "0.12", default-features = false, features = [ @@ -141,7 +139,6 @@ strum = "0.27.2" strum_macros = "0.28.0" supports-color = "3.0.2" tempfile = "3.23.0" -term-maths = "1.0.0" textwrap = "0.16" thiserror = "2" tiny_http = "0.12" @@ -202,7 +199,7 @@ windows-sys = { version = "0.61", features = [ "Win32_UI_WindowsAndMessaging", ] } -[patch.crates-io] -crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } -ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } -term-maths = { path = "vendor/term-maths" } +# Opt-in iteration profile; use dev for full variable/parameter debugger data. +[profile.dev-fast] +inherits = "dev" +debug = "line-tables-only" diff --git a/README.ja.md b/README.ja.md index 7d8891d7..8fdd0caf 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,276 +1,77 @@
-Devo desktop coding agent app icon and wordmark +Devo
-
+# Devo — オープンソースの再帰型エージェント -**Devo は、Desktop app、terminal TUI/CLI、model-neutral な Rust runtime を備えた open-source coding agent です。プライベート、エンタープライズ、OpenAI 互換モデル環境で、DeepSeek、Qwen、Kimi、Anthropic 互換 API、ローカルゲートウェイ、独自モデル endpoint に接続できます。** +Devo は**状態を保持する Python ワークスペース**、**子エージェント**、 +**自由に選べるモデルプロバイダー**で複数ステップのタスクを進めます。 +ローカルの Rust ランタイムを、デスクトップアプリまたはターミナル TUI/CLI から利用できます。 -[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers) -[![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls) [![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions) [![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[English](./README.md) | [简体中文](./README.zh-Hans.md) | [繁體中文](./README.zh-Hant.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) - -[Devo を選ぶ理由](#devo-を選ぶ理由) · [スクリーンショット](#スクリーンショット) · [機能](#機能) · [検証済みモデル](#検証済みモデル) · [検証済みプラットフォーム](#検証済みプラットフォーム) · [インストール](#インストール) · [クイックスタート](#クイックスタート) · [ドキュメント](#docs) - -
- ---- - -## スクリーンショット - -

- Devo terminal TUI coding agent がローカルリポジトリでモデル、コンテキスト、token 状態を表示している画面 -

- -

- Devo desktop coding agent app がリポジトリの会話、プロジェクトサイドバー、モデル制御を表示している画面 -

- -## Devo を選ぶ理由 - -Devo は、単一のホスト型モデルエコシステムに縛られず、モデル選択、 -Desktop 体験、terminal workflow、ランタイムの動作、ワークスペースでの -実行境界を自分たちで制御したいチームのための coding agent です。 - -- **任意のモデルを接続** - provider/model binding により、OpenAI 互換 Chat - Completions、OpenAI 互換 Responses、Anthropic Messages、DeepSeek、 - Qwen、Kimi、またはプライベートモデルゲートウェイを接続できます。 -- **プライベート環境やイントラネット環境に対応** - 単一のローカル Rust - バイナリとして動作し、オフラインインストール経路をサポートし、 - ホスト型 agent service に依存せず内部 endpoint を指定できます。 -- **Desktop と terminal の両方に対応** - Desktop app で視覚的なオンボーディングと - 日常の coding を行い、端末ネイティブな自動化、remote shell、scriptable workflow が - 必要なときは CLI/TUI を使えます。 -- **Agent Runtime として拡張可能** - MCP server、再利用可能な skills、 - 監査可能なセッション、権限管理、マルチエージェント flow は、一回限りの - prompt ではなくランタイム機能です。 - -## 機能 - -- **Model-neutral provider runtime** - provider/model binding により、OpenAI 互換、 - Anthropic 互換、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 - OpenRouter、またはローカルエンドポイントを利用できます。 -- **MCP サポート** - [Model Context Protocol](https://modelcontextprotocol.io/) - サーバーを通じて外部ツールとコンテキストを接続できます。CLI で - `devo mcp add|list|enable|disable|remove` により追加・管理できます( - [設定](./docs/configuration.ja.md#mcp-サーバー) を参照)。 -- **Skill サポート** - 再利用可能なワークフロー、手順、スクリプト、参照資料を - [Agent Skills](https://agentskills.io/) としてパッケージ化できます。 -- **長時間タスクのサポート** - 複数ターンにまたがる作業でも Devo が自動的にコンテキストを管理し、 - タスクが大きくなっても流れを失いにくくします。 -- **マルチエージェントサポート** - 専門エージェントに作業を分割しつつ、セッション内で調整状況を可視化します。 -- **Plan Mode** - 実装を始める前に、大きなタスクを明確な複数ステップの計画へ分解します。 -- **並列ツール呼び出し** - 独立した複数のツールを並列に実行し、モデルの待ち時間を減らして作業を進めます。 -- **権限付きツール実行** - センシティブなツール呼び出しがワークスペースに触れる前にレビューできます。 -- **監査可能なセッション** - モデル出力、ツール呼び出し、承認、token 使用量、セッション履歴を - 確認および再開できる形で保持します。 -- **コストとコンテキストの可視化** - プロバイダーが提供する場合、入力/出力 token、cached token、 - コンテキストウィンドウ使用量を表示します。 -- **軽量な Rust ランタイム** - Rust で構築され、メモリ使用量が小さく、コンパクトなローカルランタイムを備えます。 -- **組み込みセマンティックコード検索(MCP)** - 同梱のオプション MCP サーバー - (`code_search` / `devo-code-search-mcp`)。**既定では無効**です。ローカル CPU の - コード埋め込みモデルを実行し、dense retrieval と BM25 を組み合わせて、grep/find - のみのエージェントよりコード検索コンテキストを削減します。 - `devo mcp enable code_search` または TUI `/mcps` で有効化します。 - -## 検証済みモデル - -

- DeepSeek v4 Flash / Pro - GLM 5.2 - MiniMax M3 - Qwen3 Coder Next - Kimi K3 -

- -Devo の組み込みモデルカタログには、Qwen、Kimi、MiniMax、GLM、DeepSeek の検証済みモデル定義が含まれています。 -プロバイダーのエンドポイントは provider/model binding で引き続き設定できます。 - -## 検証済みプラットフォーム - -

- macOS 検証済み - Linux 検証済み - Windows 検証済み -

+[English](./README.md) · [简体中文](./README.zh-Hans.md) · [繁體中文](./README.zh-Hant.md) · [日本語](./README.ja.md) · [Русский](./README.ru.md) -Devo は macOS、Linux、Windows、Kylin OS で検証済みです。 +## Claude Code・Codex との違い -### 中国のエンタープライズユーザー向け +| エージェント | 主な特徴 | モデル接続 | +| --- | --- | --- | +| **Devo** | プログラムによる再帰的な実行で、開発やその他の複数ステップのタスクを処理。 | 互換クラウドプロバイダー、ローカルエンドポイント、プライベートゲートウェイ。 | +| [Claude Code](https://code.claude.com/docs/en/overview) | Claude を中心とした開発ワークフロー。ターミナル、IDE、デスクトップ、Web に対応。 | Anthropic または対応クラウドプロバイダー経由の Claude。 | +| [Codex](https://learn.chatgpt.com/docs/codex/cli) | OpenAI の開発エージェント。ターミナル、IDE、アプリ、クラウドに対応。 | 標準は OpenAI モデル。CLI は[カスタム・ローカルプロバイダー](https://learn.chatgpt.com/docs/config-file/config-advanced#custom-model-providers)にも対応。 | -

- Kylin OS 検証済み - HarmonyOS サポートはロードマップ上 -

+Devo の `ipython` ツールは、呼び出し間で変数や中間結果を保持します。 +Python で大量の出力を絞り込み、先行作業を再利用し、独立したサブタスクを +専用のセッションと Python 状態を持つ子エージェントに委任できます。 +MCP、スキル、権限制御、セッション再開にも対応しています。 -中国のエンタープライズ環境では国産 OS が実際のデプロイ要件になることが多いため、Kylin OS の対応状況を明記しています。 -HarmonyOS サポートはロードマップ上にあります。HarmonyOS デバイスを持つコントリビューターによる、そのプラットフォーム向けのビルド、テスト、リリースを歓迎します。 - -## インストール - -Devo には 2 つのインストール形態があります。グラフィカルな coding agent -workspace が必要なら Desktop app、shell-first な開発には端末ネイティブな -TUI/CLI を選んでください。両方を同じマシンにインストールすることもできます。 - -### オプション 1: Desktop App - -グラフィカルな Devo 体験を使いたい場合は、まず -[GitHub Releases](https://github.com/7df-lab/devo/releases/latest) -から最新の Devo Desktop package をダウンロードし、OS とアーキテクチャに合う -asset を選んでください: - -- **macOS** - `devo-desktop-...-mac-...` の `.dmg` または `.zip` asset をダウンロードします。 -- **Windows** - `devo-desktop-...-windows-...` の `.exe` asset をダウンロードします。 -- **Linux** - `devo-desktop-...-linux-...` の `.AppImage`、`.deb`、または - `.rpm` asset をダウンロードします。 - -**macOS に「Devo」は壊れているため開けません、と表示される場合がありますが、これは想定された動作です。** -現在の macOS Desktop builds は署名されていないため、インストール後に次のコマンドを実行すると -macOS でアプリを起動できます: - -```bash -sudo xattr -dr com.apple.quarantine /Applications/Devo.app -``` - -### オプション 2: TUI / CLI +## クイックスタート -端末 TUI を使いたい場合、shell automation が必要な場合、または Desktop app と -併用したい場合は、端末ネイティブな `devo` command をインストールしてください。 +**デスクトップ:** [GitHub Releases](https://github.com/7df-lab/devo/releases/latest) +から OS とアーキテクチャに合うパッケージをダウンロードし、初期設定でモデルプロバイダーを接続します。 -Linux / macOS: +**ターミナル — Linux / macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh ``` -Windows: +**ターミナル — Windows PowerShell:** ```powershell irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex ``` -オンラインインストーラーは `devo` を Devo home ディレクトリに配置し、高速なリポジトリ検索に使う -`rg` sidecar をインストールします。また、`code_search` が使うローカルモデルの任意設定にも対応しています。 - -
-任意: ローカルの code_search モデルを事前インストール - -インストール時に Hugging Face モデルをダウンロードしたい場合だけ使用してください。 - -Linux / macOS: - -```bash -curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh -s -- --install-code-search-model -``` - -Windows: - -```powershell -$env:DEVO_INSTALL_CODE_SEARCH_MODEL = "1"; irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex -``` - -
- -既存のインストールを最新 release にアップグレードするには: - -```bash -devo upgrade -``` - -アップグレードコマンドは同じプラットフォーム用インストーラーを実行し、 -インストーラーは `Version: v0.1.12 -> v0.1.15` のようにバージョン遷移を表示します。 - -イントラネット環境やオフライン環境でインストールする場合は、 -[オフラインインストール](./docs/offline-installation.ja.md) を参照してください。 - -## クイックスタート - -プロバイダーを設定し、リポジトリを開いて TUI を起動します: +作業ディレクトリで起動します。初回はプロバイダー設定が開きます。 ```bash -cd /path/to/your/repo -devo onboard +cd /path/to/your/workspace +devo ``` -便利なコマンド: - -```bash -devo # 現在のリポジトリで対話型 TUI を起動 -devo resume -``` +`devo resume ` で再開、`devo upgrade` で更新できます。 +ターミナルのパッケージには Node.js と Python が含まれています。 +Windows のシェルコマンドには [Git for Windows](https://gitforwindows.org/) が必要です。 +初回利用時にはサンドボックス設定の確認も一度承認してください。 ## 設定 -`devo onboard` が推奨されるセットアップ方法です。provider/model binding を -`config.toml` に書き、API key をユーザースコープの `auth.json` に保存します。 - -自分の API key とカスタムモデルを手動で設定する場合: - -1. `config.toml` で `[model.]` パラメータ、`[providers.]`、 - `[model_bindings.]` を定義します。 -2. シークレットを `DEVO_HOME/auth.json` に置き、`[providers.].credential` - からその credential id を参照します — API key 自体を `config.toml` に - 書かないでください。 -3. エンドポイントのプロトコルに合わせて `invocation_method` を - `openai_chat_completions`、`openai_responses`、`anthropic_messages` の - いずれかに設定します。 - -完全な作業例(カスタムモデルパラメータ + API key)とプロトコルの説明は -[設定](./docs/configuration.ja.md#自分の-api-key-を使う) を参照してください。 - -## Docs - -- [オフラインインストール](./docs/offline-installation.ja.md) -- [設定](./docs/configuration.ja.md) - -## よくある質問 - -### プロジェクトの状態は? - -Devo は pre-1.0 で、活発に開発されています。ローカル評価、実験、コントリビューターによる利用には適していますが、 -公開 API と設定は今後も変更される可能性があります。 - -### どのモデルがサポートされていますか? - -組み込みモデルメタデータは現在、Qwen、Kimi、MiniMax、GLM、DeepSeek ファミリーをカバーしています。 -OpenAI 互換 Chat Completions、OpenAI 互換 Responses、または Anthropic Messages API をサポートするモデルエンドポイントであれば、 -provider/model binding を通じて接続できます。 - -### 自分の API key を使うには? - -`devo onboard` を使うか、ユーザースコープの `auth.json` を編集し、 -`config.toml` の `[providers.].credential` からその credential id を参照します。 -詳細は [設定](./docs/configuration.ja.md#自分の-api-key-を使う) を参照してください。 - -### Desktop app と TUI/CLI のどちらを使うべきですか? - -視覚的なオンボーディング、セッション閲覧、グラフィカルな coding workspace が -必要な場合は Desktop app を使ってください。端末ネイティブな自動化、remote shell -workflow、または既存の command-line setup 内で動く coding agent が必要な場合は -TUI/CLI を使ってください。どちらの surface も同じローカル Devo runtime を対象にしています。 - -## コントリビュート - -プロジェクトはまだ初期段階であり、コントリビューションを歓迎します: - -- client/server runtime、provider layer、safety model、TUI に関するアーキテクチャフィードバック。 -- ドキュメントと翻訳。 -- Provider、model、wire API の対応範囲。 -- 検証コマンドと回帰テストを伴う、焦点を絞った修正。 - -変更について議論するには issue または pull request を開いてください。 +**OpenAI 互換 Chat Completions**、**OpenAI 互換 Responses**、 +**Anthropic Messages** API に対応しています。組み込みカタログには +DeepSeek、Qwen、Kimi、GLM、MiniMax が含まれます。 +`/model` でモデルを切り替え、`devo onboard` でプロバイダーを再設定できます。 -## ライセンス +手動設定、認証情報、MCP、オフラインインストール、リリースミラーは +[ユーザーガイド(英語)](./docs/usage.md)を参照してください。 -このプロジェクトは [MIT License](./LICENSE) のもとで公開されています。 +## ドキュメントと貢献 ---- +- [ユーザーガイド(英語)](./docs/usage.md) — インストール、プロバイダー、設定、オフライン利用。 +- [TUI ガイド(英語)](./apps/tui/README.md) — ターミナル操作とコマンド。 +- [貢献ガイド(英語)](./CONTRIBUTING.md) — 問題報告、ドキュメント改善、小規模な修正を歓迎します。 -**Devo が役に立った場合は、star をご検討ください。** +Devo は **pre-1.0** です。API や設定は変更される場合があります。[MIT ライセンス](./LICENSE)。 diff --git a/README.md b/README.md index ebe1c040..9cd0a600 100644 --- a/README.md +++ b/README.md @@ -1,296 +1,85 @@
-Devo desktop coding agent app icon and wordmark +Devo
-
+# Devo — Open-source recursive agent -**Devo is an open-source coding agent with a Desktop app, terminal TUI/CLI, and model-neutral Rust runtime for private, enterprise, and OpenAI-compatible model environments. Connect DeepSeek, Qwen, Kimi, Anthropic-compatible APIs, local gateways, or your own model endpoint.** +Devo tackles multi-step work with a **persistent Python workspace**, **child +agents**, and **your choice of model provider**. Use the Desktop app or terminal +TUI/CLI, powered by a local Rust runtime. -[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers) -[![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls) [![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions) [![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases) -[![AUR version](https://img.shields.io/aur/version/devo-bin?color=%231793d1&label=AUR&logo=arch-linux&logoColor=%23fff&style=flat-square)](https://aur.archlinux.org/packages/devo-bin/) - -[English](./README.md) | [简体中文](./README.zh-Hans.md) | [繁體中文](./README.zh-Hant.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) - -[Why Devo](#why-devo) · [Screenshots](#screenshots) · [Features](#features) · [Tested Models](#tested-models) · [Tested Platforms](#tested-platforms) · [Install](#installation) · [Quick Start](#quick-start) · [Docs](#docs) - -
- ---- - -## Screenshots - -

- Devo terminal TUI coding agent running in a local repository with model, context, and token status -

- -

- Devo desktop coding agent app showing a repository conversation, project sidebar, and model controls -

- -## Why Devo - -Devo is for teams that need a coding agent outside a single hosted model -ecosystem. It keeps the desktop experience, terminal workflow, model choice, -runtime behavior, and workspace execution under your control. - -- **Bring your own model** - Connect OpenAI-compatible Chat Completions, - OpenAI-compatible Responses, Anthropic Messages, DeepSeek, Qwen, Kimi, or - private model gateways through provider/model bindings. -- **Works in private and intranet environments** - Run a single local Rust - binary, support offline installation paths, and point Devo at internal - endpoints without depending on a hosted agent service. -- **One agent across Desktop and terminal** - Use the Desktop app for visual - onboarding and daily coding, or the CLI/TUI for terminal-native automation, - remote shells, and scriptable workflows. -- **Built for agent runtime extensibility** - MCP servers, reusable skills, - auditable sessions, permissions, and multi-agent flows are runtime features - rather than one-off prompts. - -## Features - -- **Model-neutral provider runtime** - Use provider/model bindings for - OpenAI-compatible, Anthropic-compatible, DeepSeek, Qwen, Kimi, GLM, MiniMax, - Xiaomi MiMo, OpenRouter, or local endpoints. -- **MCP support** - Connect external tools and context through - [Model Context Protocol](https://modelcontextprotocol.io/) servers. Add and - manage servers from the CLI with `devo mcp add|list|enable|disable|remove` - (see [Configuration](./docs/configuration.md#mcp-servers)). -- **Skill support** - Package repeatable workflows, instructions, scripts, and - references as reusable [Agent Skills](https://agentskills.io/). -- **Long-running task support** - Let Devo manage context automatically across - multi-turn work instead of losing the thread as tasks grow. -- **Multi-agent support** - Split work across specialized agents while keeping - coordination visible in the session. -- **Plan Mode** - Break larger tasks into clear multi-step plans before - implementation starts. -- **Parallel tool calls** - Run multiple independent tools in parallel so - models spend less time waiting and more time making progress. -- **Permissioned tool execution** - Review sensitive tool calls before they - touch your workspace. -- **Auditable sessions** - Keep model output, tool calls, approvals, token - usage, and session history inspectable and resumable. -- **Cost and context visibility** - Show input/output tokens, cached tokens, and - context-window usage where providers expose them. -- **Lightweight Rust runtime** - Built in Rust with low memory overhead and a - compact local runtime. -- **Built-in semantic code search (MCP)** - Optional bundled MCP server - (`code_search` / `devo-code-search-mcp`), **disabled by default**. Runs a - local CPU code-embedding model and combines dense retrieval with BM25 keyword - matching to reduce code-search context versus grep/find-only agents. Enable - with `devo mcp enable code_search` or TUI `/mcps`. - -## Tested Models - -

- DeepSeek v4 Flash / Pro - GLM 5.2 - MiniMax M3 - Qwen3 Coder Next - Kimi K3 -

- -Devo's built-in model catalog includes tested model definitions for Qwen, Kimi, -MiniMax, GLM, and DeepSeek. Provider endpoints remain configurable through -provider/model bindings. - -## Tested Platforms +[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -

- macOS tested - Linux tested - Windows tested -

+[English](./README.md) · [简体中文](./README.zh-Hans.md) · [繁體中文](./README.zh-Hant.md) · [日本語](./README.ja.md) · [Русский](./README.ru.md) -Devo has been tested on macOS, Linux, Windows, and Kylin OS. +## Devo vs. Claude Code and Codex -### For Chinese Enterprise Users +| Agent | Main emphasis | Models | +| --- | --- | --- | +| **Devo** | Programmable, recursive execution for coding and other multi-step tasks. | Compatible cloud providers, local endpoints, and private gateways. | +| [Claude Code](https://code.claude.com/docs/en/overview) | Claude-centered development across terminal, IDE, desktop, and web. | Claude through Anthropic or supported cloud providers. | +| [Codex](https://learn.chatgpt.com/docs/codex/cli) | OpenAI's coding agent across terminal, IDE, app, and cloud. | OpenAI models by default; the CLI also supports [custom and local providers](https://learn.chatgpt.com/docs/config-file/config-advanced#custom-model-providers). | -

- Kylin OS tested - HarmonyOS support on the road -

+Devo's `ipython` tool keeps variables and intermediate results across calls. +The agent can filter large outputs in Python, reuse earlier work, and delegate +independent subtasks to child agents with their own sessions and Python state. +MCP, skills, permissions, and resumable sessions round out the workflow. -Kylin OS coverage is called out because domestic operating systems are often -part of real deployment requirements in Chinese enterprise environments. -HarmonyOS support is on the roadmap; contributors with HarmonyOS devices are -welcome to build, test, and publish releases for that platform. +## Quick start -## Installation +**Desktop:** Download the package for your OS and architecture from +[GitHub Releases](https://github.com/7df-lab/devo/releases/latest), then connect +a model provider in the setup flow. -Devo can be installed in two forms. Pick the Desktop app for a graphical coding -agent workspace, the terminal-native TUI/CLI for shell-first development, or -install both on the same machine. - -### Option 1: Desktop App - -Start here if you want the graphical Devo experience. Download the latest Devo -Desktop package from [GitHub Releases](https://github.com/7df-lab/devo/releases/latest), -then choose the asset that matches your operating system and architecture: - -- **macOS** - download the `devo-desktop-...-mac-...` `.dmg` or `.zip` asset. -- **Windows** - download the `devo-desktop-...-windows-...` `.exe` asset. -- **Linux** - download the `devo-desktop-...-linux-...` `.AppImage`, `.deb`, or - `.rpm` asset. - -**If macOS reports that `Devo.app` is damaged and cannot be opened, this is -expected.** Current macOS Desktop builds are unsigned, so after installing, -run the following command so macOS can launch the app: - -```bash -sudo xattr -dr com.apple.quarantine /Applications/Devo.app -``` - -### Option 2: TUI / CLI - -Install the terminal-native `devo` command if you prefer the TUI, want shell -automation, or want to use Devo alongside the Desktop app. - -Linux / macOS: +**Terminal — Linux / macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh ``` -Windows: +**Terminal — Windows PowerShell:** ```powershell irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex ``` -The online installer places `devo` under the Devo home directory, installs the -`rg` sidecar used for fast repository search, and supports optional setup for -the local model used by `code_search`. - -
-Optional: preinstall the local code_search model - -Use this only if you want the Hugging Face model downloaded during installation. - -Linux / macOS: +Start in your workspace; first launch opens provider setup: ```bash -curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh -s -- --install-code-search-model -``` - -Windows: - -```powershell -$env:DEVO_INSTALL_CODE_SEARCH_MODEL = "1"; irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex +cd /path/to/your/workspace +devo ``` -
+Resume with `devo resume `; upgrade with `devo upgrade`. +Terminal bundles include Node.js and Python. On Windows, install +[Git for Windows](https://gitforwindows.org/) for shell commands and approve +the one-time sandbox setup prompt. -Upgrade an existing installation to the latest release: - -```bash -devo upgrade -``` - -The upgrade command runs the same platform installer, and the installer prints -the version transition, for example `Version: v0.1.12 -> v0.1.15`. - -For air-gapped or intranet installs, see -[Offline Installation](./docs/offline-installation.md). - -## Quick Start - -Configure a provider, open a repository, and start the TUI: - -```bash -cd /path/to/your/repo -devo onboard -``` - -Useful commands: - -```bash -devo # start the interactive TUI in the current repo -devo resume -``` +> **Temporarily disabled.** OS sandboxing and the permission picker are +> disabled repo-wide: every process runs with full user permissions and new +> sessions default to full-access. Revert by flipping +> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`), +> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop), +> and the default-preset literals back to their asking defaults. ## Configuration -`devo onboard` is the recommended setup path. It writes provider and model -bindings to `config.toml` and stores your API key in user-scoped `auth.json`. - -To bring your own key with a custom model manually: - -1. Define `[model.]` parameters, `[providers.]`, and - `[model_bindings.]` in `config.toml`. -2. Put the secret in `DEVO_HOME/auth.json` and reference that credential id from - `[providers.].credential` — never put the API key itself in - `config.toml`. -3. Set `invocation_method` to match the endpoint protocol: - `openai_chat_completions`, `openai_responses`, or `anthropic_messages`. - -Full worked example (custom model parameters + API key) and protocol details: -[Configuration](./docs/configuration.md#bring-your-own-api-key). - -## Slow Providers - -Provider responses are not subject to an application-level response timeout, so -local models may take as long as necessary to load or generate output. Provider -connections still have an internal connection-establishment deadline, and every -request can be cancelled by the user. - -## Docs - -- [Offline Installation](./docs/offline-installation.md) -- [Configuration](./docs/configuration.md) - -## FAQ - -### What is the project status? - -Devo is pre-1.0 and actively developed. It is ready for local evaluation, -experiments, and contributor use; public APIs and configuration may still -change. - -### What models are supported? - -Built-in model metadata currently covers Qwen, Kimi, MiniMax, GLM, and DeepSeek -families. Any model endpoint that supports OpenAI-compatible Chat Completions, -OpenAI-compatible Responses, or the Anthropic Messages API can be connected through -provider/model bindings. - -### How do I bring my own API key? - -Use `devo onboard`, or manually define a custom `[model.]`, provider, and -binding in `config.toml`, store the key in user-scoped `auth.json`, and set -`invocation_method` to `openai_chat_completions`, `openai_responses`, or -`anthropic_messages`. See -[Configuration](./docs/configuration.md#bring-your-own-api-key). - -### Should I use the Desktop app or the TUI/CLI? - -Use the Desktop app when you want visual onboarding, session browsing, and a -graphical coding workspace. Use the TUI/CLI when you want terminal-native -automation, remote shell workflows, or a coding agent that stays inside your -existing command-line setup. Both surfaces target the same local Devo runtime. - -## Contributing - -Contributions are welcome while the project is still early: - -- Architecture feedback on the client/server runtime, provider layer, safety - model, and TUI. -- Documentation and translations. -- Provider, model, and wire API coverage. -- Focused fixes with validation commands and regression tests. - -Open an issue or pull request to discuss changes. +Connect endpoints using **OpenAI-compatible Chat Completions**, **OpenAI-compatible +Responses**, or **Anthropic Messages** APIs. The bundled catalog includes +DeepSeek, Qwen, Kimi, GLM, and MiniMax. Use `/model` to switch models or +`devo onboard` to revisit provider setup. -## License +For manual configuration, credentials, MCP, offline installation, and release +mirrors, see the [user guide](./docs/usage.md). -This project is licensed under the [MIT License](./LICENSE). +## Docs and contributing ---- +- [User guide](./docs/usage.md) — installation, providers, configuration, and offline use. +- [TUI guide](./apps/tui/README.md) — terminal controls and commands. +- [Contributing](./CONTRIBUTING.md) — issues, documentation, and focused fixes welcome. -**If you find Devo useful, please consider giving it a star.** +Devo is **pre-1.0**; APIs and configuration may change. [MIT License](./LICENSE). diff --git a/README.ru.md b/README.ru.md index 9e19ec9e..580eeb6c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,290 +1,79 @@
-Devo desktop coding agent app icon and wordmark +Devo
-
+# Devo — рекурсивный агент с открытым исходным кодом -**Devo - open-source coding agent с Desktop app, terminal TUI/CLI и модельно-нейтральным Rust runtime для приватных, корпоративных и OpenAI-совместимых модельных сред. Подключайте DeepSeek, Qwen, Kimi, Anthropic-совместимые API, локальные шлюзы или собственные endpoint моделей.** +Devo решает многошаговые задачи с помощью **Python-среды с сохранением состояния**, +**дочерних агентов** и **модельного провайдера на ваш выбор**. +Десктопное приложение и терминальный TUI/CLI работают на локальном Rust runtime. -[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers) -[![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls) [![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions) [![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[English](./README.md) | [简体中文](./README.zh-Hans.md) | [繁體中文](./README.zh-Hant.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) - -[Почему Devo](#почему-devo) · [Скриншоты](#скриншоты) · [Возможности](#возможности) · [Проверенные модели](#проверенные-модели) · [Проверенные платформы](#проверенные-платформы) · [Установка](#установка) · [Быстрый старт](#быстрый-старт) · [Документация](#docs) - -
- ---- - -## Скриншоты - -

- Devo terminal TUI coding agent работает в локальном репозитории и показывает модель, контекст и token status -

- -

- Devo desktop coding agent app показывает беседу по репозиторию, боковую панель проекта и управление моделью -

- -## Почему Devo - -Devo предназначен для команд, которым нужен coding agent вне единой hosted -экосистемы моделей. Он оставляет Desktop experience, terminal workflow, выбор -модели, поведение runtime и выполнение в workspace под вашим контролем. - -- **Подключайте свою модель** - Через provider/model bindings можно подключать - OpenAI-compatible Chat Completions, OpenAI-compatible Responses, Anthropic - Messages, DeepSeek, Qwen, Kimi или приватные model gateways. -- **Подходит для приватных и intranet-сред** - Devo запускается как единый - локальный Rust binary, поддерживает offline installation paths и может - указывать на внутренние endpoint без зависимости от hosted agent service. -- **Один agent для Desktop и terminal** - Используйте Desktop app для - визуального onboarding и повседневного coding, либо CLI/TUI для - terminal-native automation, remote shell и scriptable workflows. -- **Расширяемый agent runtime** - MCP servers, reusable skills, аудируемые - сессии, permissions и multi-agent flows являются возможностями runtime, а не - одноразовыми prompt. - -## Возможности - -- **Модельно-нейтральный provider runtime** - Используйте provider/model bindings - для OpenAI-совместимых, Anthropic-совместимых, DeepSeek, Qwen, Kimi, GLM, - MiniMax, Xiaomi MiMo, OpenRouter или локальных endpoint. -- **Поддержка MCP** - Подключайте внешние инструменты и контекст через серверы - [Model Context Protocol](https://modelcontextprotocol.io/). Управляйте через - CLI: `devo mcp add|list|enable|disable|remove` (см. - [Конфигурацию](./docs/configuration.ru.md#mcp-серверы)). -- **Поддержка Skill** - Упаковывайте повторяемые workflow, инструкции, скрипты - и справочные материалы как переиспользуемые - [Agent Skills](https://agentskills.io/). -- **Поддержка долгих задач** - Позвольте Devo автоматически управлять контекстом - в многошаговой работе, чтобы не терять ход задачи по мере ее роста. -- **Поддержка нескольких агентов** - Разделяйте работу между специализированными - агентами, сохраняя координацию видимой в сессии. -- **Plan Mode** - Разбивайте крупные задачи на понятные многошаговые планы до - начала реализации. -- **Параллельные вызовы инструментов** - Запускайте несколько независимых - инструментов параллельно, чтобы модели меньше ждали и быстрее продвигались. -- **Выполнение инструментов с разрешениями** - Проверяйте чувствительные вызовы - инструментов до того, как они затронут рабочую область. -- **Аудируемые сессии** - Храните вывод модели, вызовы инструментов, approvals, - расход token и историю сессии в виде, пригодном для проверки и возобновления. -- **Видимость стоимости и контекста** - Показывайте input/output token, - cached token и использование context window там, где провайдеры это раскрывают. -- **Легковесный Rust runtime** - Построен на Rust, с малым расходом памяти и - компактным локальным runtime. -- **Встроенный семантический поиск по коду (MCP)** - Опциональный bundled MCP - сервер (`code_search` / `devo-code-search-mcp`), **по умолчанию выключен**. - Запускает локальную CPU-модель эмбеддингов и сочетает dense retrieval с BM25, - сокращая контекст поиска по сравнению с агентами только на grep/find. Включите - через `devo mcp enable code_search` или TUI `/mcps`. - -## Проверенные модели - -

- DeepSeek v4 Flash / Pro - GLM 5.2 - MiniMax M3 - Qwen3 Coder Next - Kimi K3 -

- -Встроенный каталог моделей Devo содержит проверенные определения моделей для -Qwen, Kimi, MiniMax, GLM и DeepSeek. Endpoint поставщиков остаются настраиваемыми -через provider/model bindings. - -## Проверенные платформы - -

- macOS проверен - Linux проверен - Windows проверен -

+[English](./README.md) · [简体中文](./README.zh-Hans.md) · [繁體中文](./README.zh-Hant.md) · [日本語](./README.ja.md) · [Русский](./README.ru.md) -Devo протестирован на macOS, Linux, Windows и Kylin OS. +## Отличия от Claude Code и Codex -### Для китайских корпоративных пользователей +| Агент | Основной акцент | Модели | +| --- | --- | --- | +| **Devo** | Программируемое рекурсивное выполнение задач разработки и других многошаговых задач. | Совместимые облачные провайдеры, локальные эндпоинты и приватные шлюзы. | +| [Claude Code](https://code.claude.com/docs/en/overview) | Разработка с Claude в терминале, IDE, десктопном приложении и браузере. | Claude через Anthropic или поддерживаемых облачных провайдеров. | +| [Codex](https://learn.chatgpt.com/docs/codex/cli) | Агент OpenAI для разработки в терминале, IDE, приложении и облаке. | По умолчанию модели OpenAI; CLI также поддерживает [собственных и локальных провайдеров](https://learn.chatgpt.com/docs/config-file/config-advanced#custom-model-providers). | -

- Kylin OS проверен - Поддержка HarmonyOS в планах -

+Инструмент `ipython` сохраняет переменные и промежуточные результаты между вызовами. +Агент может фильтровать большие объёмы вывода в Python, повторно использовать +результаты и делегировать независимые подзадачи дочерним агентам с отдельными +сессиями и состоянием Python. Также доступны MCP, навыки, контроль разрешений +и возобновление сессий. -Поддержка Kylin OS выделена отдельно, потому что отечественные операционные системы -часто являются реальным требованием при внедрении в китайских корпоративных средах. -Поддержка HarmonyOS находится в roadmap; мы приветствуем вклад участников с -устройствами HarmonyOS, которые смогут собрать, протестировать и опубликовать -релизы для этой платформы. - -## Установка - -Devo можно установить в двух формах. Выберите Desktop app для графического -coding agent workspace, terminal-native TUI/CLI для shell-first разработки или -установите оба варианта на одной машине. - -### Вариант 1: Desktop App - -Начните здесь, если хотите использовать графический интерфейс Devo. Скачайте -последний Devo Desktop package со страницы -[GitHub Releases](https://github.com/7df-lab/devo/releases/latest), затем -выберите asset для вашей операционной системы и архитектуры: - -- **macOS** - скачайте `.dmg` или `.zip` asset вида - `devo-desktop-...-mac-...`. -- **Windows** - скачайте `.exe` asset вида `devo-desktop-...-windows-...`. -- **Linux** - скачайте `.AppImage`, `.deb` или `.rpm` asset вида - `devo-desktop-...-linux-...`. - -**Если macOS сообщает, что `Devo.app` повреждено и не может быть открыто, это -ожидаемо.** Текущие macOS Desktop builds не подписаны, поэтому после установки -выполните следующую команду, чтобы macOS могла запустить приложение: - -```bash -sudo xattr -dr com.apple.quarantine /Applications/Devo.app -``` - -### Вариант 2: TUI / CLI +## Быстрый старт -Установите terminal-native команду `devo`, если предпочитаете TUI, хотите -shell automation или хотите использовать Devo вместе с Desktop app. +**Десктоп:** Скачайте пакет для вашей ОС и архитектуры из +[GitHub Releases](https://github.com/7df-lab/devo/releases/latest) и подключите +модельного провайдера при первоначальной настройке. -Linux / macOS: +**Терминал — Linux / macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh ``` -Windows: +**Терминал — Windows PowerShell:** ```powershell irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex ``` -Онлайн-установщик размещает `devo` в Devo home directory, устанавливает -вспомогательный `rg` sidecar для быстрого поиска по репозиторию и поддерживает -дополнительную настройку локальной модели, которую использует `code_search`. - -
-Необязательно: предварительно установить локальную модель code_search - -Используйте это только если хотите скачать модель Hugging Face во время установки. - -Linux / macOS: - -```bash -curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh -s -- --install-code-search-model -``` - -Windows: - -```powershell -$env:DEVO_INSTALL_CODE_SEARCH_MODEL = "1"; irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex -``` - -
- -Обновление существующей установки до последнего release: - -```bash -devo upgrade -``` - -Команда обновления запускает тот же установщик для текущей платформы, а -установщик выводит переход версии, например `Version: v0.1.12 -> v0.1.15`. - -Для intranet-сред или установки без доступа к сети см. -[Офлайн-установку](./docs/offline-installation.ru.md). - -## Быстрый старт - -Настройте provider, откройте репозиторий и запустите TUI: +Запустите Devo в рабочей директории; при первом запуске откроется настройка провайдера: ```bash -cd /path/to/your/repo -devo onboard +cd /path/to/your/workspace +devo ``` -Полезные команды: - -```bash -devo # запустить интерактивный TUI в текущем репозитории -devo resume -``` +Возобновление: `devo resume `; обновление: `devo upgrade`. +Терминальные пакеты включают Node.js и Python. В Windows для команд оболочки +нужен [Git for Windows](https://gitforwindows.org/); при первом использовании +также подтвердите однократную настройку песочницы. ## Конфигурация -`devo onboard` - рекомендуемый путь настройки. Он записывает provider и model -bindings в `config.toml` и сохраняет API key в пользовательском `auth.json`. - -Чтобы вручную подключить свой ключ и кастомную модель: - -1. Определите параметры `[model.]`, `[providers.]` и - `[model_bindings.]` в `config.toml`. -2. Положите секрет в `DEVO_HOME/auth.json` и ссылайтесь на этот credential id из - `[providers.].credential` — не пишите сам API key в `config.toml`. -3. Установите `invocation_method` в соответствии с протоколом endpoint: - `openai_chat_completions`, `openai_responses` или `anthropic_messages`. - -Полный пример (параметры кастомной модели + API key) и описание протоколов: -[Конфигурация](./docs/configuration.ru.md#свой-api-key). - -## Docs - -- [Офлайн-установка](./docs/offline-installation.ru.md) -- [Конфигурация](./docs/configuration.ru.md) - -## Часто задаваемые вопросы - -### Каков статус проекта? - -Devo находится на стадии pre-1.0 и активно развивается. Он готов для локальной -оценки, экспериментов и использования участниками проекта; публичные API и -конфигурация еще могут меняться. - -### Какие модели поддерживаются? - -Встроенные метаданные моделей сейчас покрывают семейства Qwen, Kimi, MiniMax, -GLM и DeepSeek. Любой endpoint модели, который поддерживает OpenAI-compatible -Chat Completions, OpenAI-compatible Responses или Anthropic Messages API, можно -подключить через provider/model bindings. - -### Как подключить свой API key? - -Используйте `devo onboard` или отредактируйте пользовательский `auth.json` и -укажите этот credential id в `[providers.].credential` в `config.toml`. См. -[Конфигурацию](./docs/configuration.ru.md#свой-api-key). - -### Что выбрать: Desktop app или TUI/CLI? - -Используйте Desktop app, если вам нужны visual onboarding, просмотр сессий и -графический coding workspace. Используйте TUI/CLI, если вам нужны -terminal-native automation, remote shell workflows или coding agent внутри -существующей command-line setup. Оба интерфейса работают с одним локальным -Devo runtime. - -## Участие в разработке - -Вклад приветствуется, пока проект остается ранним: - -- Архитектурная обратная связь по client/server runtime, provider layer, safety - model и TUI. -- Документация и переводы. -- Покрытие Provider, model и wire API. -- Точечные исправления с командами проверки и регрессионными тестами. - -Откройте issue или pull request, чтобы обсудить изменения. +Поддерживаются API **OpenAI-совместимый Chat Completions**, +**OpenAI-совместимый Responses** и **Anthropic Messages**. Встроенный каталог +включает DeepSeek, Qwen, Kimi, GLM и MiniMax. +Переключайте модели через `/model`, повторно настраивайте провайдера через `devo onboard`. -## Лицензия +Ручная конфигурация, учётные данные, MCP, офлайн-установка и зеркала релизов +описаны в [руководстве пользователя (на английском)](./docs/usage.md). -Проект распространяется по [MIT License](./LICENSE). +## Документация и участие ---- +- [Руководство пользователя (на английском)](./docs/usage.md) — установка, провайдеры, конфигурация и офлайн-работа. +- [Руководство TUI (на английском)](./apps/tui/README.md) — управление терминалом и команды. +- [Участие в разработке (на английском)](./CONTRIBUTING.md) — приветствуются сообщения об ошибках, улучшения документации и небольшие исправления. -**Если Devo оказался полезен, пожалуйста, поставьте ему star.** +Devo находится на стадии **pre-1.0**; API и конфигурация могут меняться. [Лицензия MIT](./LICENSE). diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 0962f7c5..60bdda60 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -1,270 +1,72 @@
-Devo desktop coding agent app icon and wordmark +Devo
-
+# Devo — 开源递归智能体 -**Devo 是开源 coding agent,提供 Desktop app、终端 TUI/CLI 和模型中立的 Rust runtime,面向私有化、企业内网和 OpenAI 兼容模型环境。可接入 DeepSeek、Qwen、Kimi、Anthropic 兼容 API、本地网关或自定义模型端点。** +Devo 通过**持久化 Python 工作区**、**子智能体**和**可自由选择的模型提供商** +处理多步骤任务。提供桌面应用和终端 TUI/CLI,由本地 Rust 运行时驱动。 -[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers) -[![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls) [![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions) [![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[English](./README.md) | [简体中文](./README.zh-Hans.md) | [繁體中文](./README.zh-Hant.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) - -[为什么选择 Devo](#为什么选择-devo) · [截图](#截图) · [功能](#功能) · [已测试模型](#已测试模型) · [已测试平台](#已测试平台) · [安装](#安装) · [快速开始](#快速开始) · [文档](#docs) - -
- ---- - -## 截图 - -

- Devo 终端 TUI coding agent 在本地仓库中显示模型、上下文和 token 状态 -

- -

- Devo desktop coding agent app 展示仓库对话、项目侧边栏和模型控制 -

- -## 为什么选择 Devo - -Devo 面向那些不想绑定单一托管模型生态、需要掌控模型选择、运行时行为和 -Desktop 体验、终端工作流以及工作区执行边界的团队。 - -- **接入任意模型** - 通过 provider/model 绑定接入 OpenAI 兼容 Chat - Completions、OpenAI 兼容 Responses、Anthropic Messages、DeepSeek、 - Qwen、Kimi 或私有模型网关。 -- **适合私有化和内网环境** - 以单一本地 Rust 二进制运行,支持离线安装路径, - 可以指向内部端点,不依赖托管式 agent 服务。 -- **Desktop 与终端双入口** - 用 Desktop app 完成可视化上手和日常编码,也可以在需要 - 终端原生自动化、远程 shell 或脚本化流程时使用 CLI/TUI。 -- **面向 Agent Runtime 扩展** - MCP server、可复用 skills、可审计会话、权限控制 - 和多 agent 流程都是运行时能力,不是一次性 prompt。 - -## 功能 - -- **模型中立的 provider runtime** - 通过 provider/model 绑定接入 OpenAI 兼容、 - Anthropic 兼容、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 - OpenRouter 或本地端点。 -- **MCP 支持** - 通过 - [Model Context Protocol](https://modelcontextprotocol.io/) 服务器连接外部工具和上下文。 - 可用 CLI 管理:`devo mcp add|list|enable|disable|remove`(见 - [配置](./docs/configuration.zh-Hans.md#mcp-服务器))。 -- **Skill 支持** - 将可复用工作流、说明、脚本和参考资料打包成可复用的 - [Agent Skills](https://agentskills.io/)。 -- **长任务支持** - 让 Devo 在多轮工作中自动管理上下文,避免任务变长后丢失上下文。 -- **多代理支持** - 将工作拆分给专门代理,同时在会话中保持协调过程可见。 -- **Plan Mode** - 在实现开始前,把较大的任务拆成清晰的多步骤计划。 -- **并行工具调用** - 并行运行多个相互独立的工具,让模型少等待、多推进。 -- **带权限的工具执行** - 在敏感工具调用触碰工作区前进行审查。 -- **可审计会话** - 保留模型输出、工具调用、审批、token 用量和会话历史, - 方便检查和恢复。 -- **成本和上下文可见性** - 在提供商支持时显示输入/输出 token、缓存 token - 和上下文窗口用量。 -- **轻量级 Rust 运行时** - 使用 Rust 构建,内存开销低,本地运行时紧凑。 -- **内置语义代码搜索(MCP)** - 可选的捆绑 MCP 服务器(`code_search` / - `devo-code-search-mcp`),**默认关闭**。在本地 CPU 上运行代码嵌入模型,并结合 - 密集检索与 BM25 关键词匹配,相比仅用 grep/find 的代理减少代码搜索上下文。用 - `devo mcp enable code_search` 或 TUI `/mcps` 启用。 - -## 已测试模型 - -

- DeepSeek v4 Flash / Pro - GLM 5.2 - MiniMax M3 - Qwen3 Coder Next - Kimi K3 -

- -Devo 的内置模型目录包含 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 的已测试模型定义。 -Provider 端点仍可通过 provider/model 绑定配置。 - -## 已测试平台 - -

- macOS 已测试 - Linux 已测试 - Windows 已测试 -

+[English](./README.md) · [简体中文](./README.zh-Hans.md) · [繁體中文](./README.zh-Hant.md) · [日本語](./README.ja.md) · [Русский](./README.ru.md) -Devo 已在 macOS、Linux、Windows 和麒麟操作系统上测试。 +## 与 Claude Code、Codex 的区别 -### 面向中国企业用户 +| 智能体 | 核心侧重 | 模型接入 | +| --- | --- | --- | +| **Devo** | 通过可编程、递归执行完成编码及其他多步骤任务。 | 兼容的云端提供商、本地端点和私有网关。 | +| [Claude Code](https://code.claude.com/docs/en/overview) | 围绕 Claude 的开发工作流,覆盖终端、IDE、桌面和网页。 | 通过 Anthropic 或受支持的云平台接入 Claude。 | +| [Codex](https://learn.chatgpt.com/docs/codex/cli) | OpenAI 的编码智能体,覆盖终端、IDE、应用和云端。 | 默认使用 OpenAI 模型;CLI 也支持[自定义和本地提供商](https://learn.chatgpt.com/docs/config-file/config-advanced#custom-model-providers)。 | -

- 麒麟操作系统已测试 - HarmonyOS 支持已在路线图中 -

+Devo 的 `ipython` 工具会在调用之间保留变量和中间结果。智能体可以用 Python +筛选大量输出、复用已有工作,并将独立子任务交给拥有各自会话和 Python 状态的 +子智能体。还支持 MCP、技能、权限控制和会话恢复。 -之所以单独标出麒麟操作系统覆盖,是因为在中国企业环境中,国产操作系统经常是实际部署要求的一部分。 -HarmonyOS 支持已在路线图中;欢迎拥有 HarmonyOS 设备的贡献者为该平台构建、测试并发布版本。 - -## 安装 - -Devo 有两种安装形态。需要图形化 coding agent 工作区时选择 Desktop app; -偏好 shell-first 开发时选择终端原生的 TUI/CLI;也可以在同一台机器上同时安装两者。 - -### 选项一:Desktop App - -如果你想使用图形化 Devo 体验,请从 -[GitHub Releases](https://github.com/7df-lab/devo/releases/latest) -下载最新的 Devo Desktop 安装包,并选择与你的操作系统和架构匹配的 asset: - -- **macOS** - 下载 `devo-desktop-...-mac-...` 的 `.dmg` 或 `.zip` asset。 -- **Windows** - 下载 `devo-desktop-...-windows-...` 的 `.exe` asset。 -- **Linux** - 下载 `devo-desktop-...-linux-...` 的 `.AppImage`、`.deb` - 或 `.rpm` asset。 - -**如果 macOS 显示以下错误,这是正常现象:“Devo”已损坏,无法打开。**当前 -macOS Desktop builds 尚未签名,因此安装后需要执行下面的命令,macOS 才能启动应用: - -```bash -sudo xattr -dr com.apple.quarantine /Applications/Devo.app -``` - -### 选项二:TUI / CLI +## 快速开始 -如果你更喜欢终端 TUI、需要 shell 自动化,或希望和 Desktop app 搭配使用, -请安装终端原生的 `devo` 命令。 +**桌面应用:** 从 [GitHub Releases](https://github.com/7df-lab/devo/releases/latest) +下载适合操作系统和架构的安装包,在首次设置中连接模型提供商。 -Linux / macOS: +**终端 — Linux / macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh ``` -Windows: +**终端 — Windows PowerShell:** ```powershell irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex ``` -在线安装器会把 `devo` 放到 Devo home 目录下,安装用于快速仓库搜索的 -`rg` sidecar,并支持可选安装 `code_search` 使用的本地模型。 - -
-可选:预安装本地 code_search 模型 - -仅在希望安装阶段就下载 Hugging Face 模型时使用。 - -Linux / macOS: - -```bash -curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh -s -- --install-code-search-model -``` - -Windows: - -```powershell -$env:DEVO_INSTALL_CODE_SEARCH_MODEL = "1"; irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex -``` - -
- -将现有安装升级到最新 release: - -```bash -devo upgrade -``` - -升级命令会执行同一套平台安装器,安装器会打印版本变化,例如 -`Version: v0.1.12 -> v0.1.15`。 - -如需在内网或无网络环境中安装,请参阅 -[离线安装](./docs/offline-installation.zh-Hans.md)。 - -## 快速开始 - -配置 provider,打开一个仓库,然后启动 TUI: +在工作目录启动;首次运行会打开提供商设置: ```bash -cd /path/to/your/repo -devo onboard +cd /path/to/your/workspace +devo ``` -常用命令: - -```bash -devo # 在当前仓库启动交互式 TUI -devo resume -``` +用 `devo resume ` 恢复会话,用 `devo upgrade` 升级。 +终端安装包已包含 Node.js 和 Python。Windows 执行 shell 命令需要 +[Git for Windows](https://gitforwindows.org/),首次使用时还需批准一次沙箱设置提示。 ## 配置 -`devo onboard` 是推荐的设置路径。它会把 provider 和 model binding 写入 -`config.toml`,并把 API key 保存在用户级 `auth.json`。 - -如需手动接入自有 API key 与自定义模型: - -1. 在 `config.toml` 中定义 `[model.]` 参数、`[providers.]` 和 - `[model_bindings.]`。 -2. 把密钥写入 `DEVO_HOME/auth.json`,并在 `[providers.].credential` 中引用该 - credential id — 不要把 API key 本身写进 `config.toml`。 -3. 将 `invocation_method` 设为与端点协议一致:`openai_chat_completions`、 - `openai_responses` 或 `anthropic_messages`。 - -完整示例(自定义模型参数 + API key)与协议说明见 -[配置](./docs/configuration.zh-Hans.md#接入自有-api-key)。 - -## 响应较慢的 Provider - -Provider 响应没有应用层总超时,因此本地模型可以按需要花费时间加载或生成输出。 -Provider 连接建立仍有内部期限,并且用户可以随时取消请求。 - -## Docs - -- [离线安装](./docs/offline-installation.zh-Hans.md) -- [配置](./docs/configuration.zh-Hans.md) - -## 常见问题 - -### 项目状态如何? - -Devo 仍处于 1.0 之前并在积极开发中。它已经适合本地评估、实验和贡献者使用; -公共 API 和配置仍可能变化。 - -### 支持哪些模型? - -内置模型元数据目前覆盖 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 系列。 -任何支持 OpenAI 兼容 Chat Completions、OpenAI 兼容 Responses 或 -Anthropic Messages API 的模型端点,都可以通过 provider/model 绑定接入。 - -### 如何接入自有 API key? - -使用 `devo onboard`,或在 `config.toml` 中手动定义自定义 `[model.]`、 -provider 与 binding,把 key 存入用户级 `auth.json`,并将 `invocation_method` -设为 `openai_chat_completions`、`openai_responses` 或 `anthropic_messages`。详见 -[配置](./docs/configuration.zh-Hans.md#接入自有-api-key)。 - -### 应该使用 Desktop app 还是 TUI/CLI? - -如果你需要可视化上手、会话浏览和图形化 coding workspace,请使用 Desktop app。 -如果你需要终端原生自动化、远程 shell 工作流,或希望 coding agent 留在现有命令行环境中, -请使用 TUI/CLI。两种入口都面向同一个本地 Devo runtime。 - -## 参与贡献 - -项目仍处于早期阶段,欢迎贡献: - -- 关于 client/server runtime、provider layer、safety model 和 TUI 的架构反馈。 -- 文档和翻译。 -- Provider、model 和 wire API 覆盖。 -- 带验证命令和回归测试的聚焦修复。 - -请打开 issue 或 pull request 讨论变更。 +支持 **OpenAI 兼容 Chat Completions**、**OpenAI 兼容 Responses** 和 +**Anthropic Messages** API。内置目录包含 DeepSeek、Qwen、Kimi、GLM 和 MiniMax。 +用 `/model` 切换模型,用 `devo onboard` 重新配置提供商。 -## 许可证 +手动配置、凭据、MCP、离线安装和发布镜像见[使用指南(英文)](./docs/usage.md)。 -本项目采用 [MIT License](./LICENSE) 授权。 +## 文档与贡献 ---- +- [使用指南(英文)](./docs/usage.md) — 安装、提供商、配置与离线使用。 +- [TUI 指南(英文)](./apps/tui/README.md) — 终端操作与命令。 +- [贡献指南(英文)](./CONTRIBUTING.md) — 欢迎问题反馈、文档改进和小范围修复。 -**如果你觉得 Devo 有用,请考虑给它一个 star。** +Devo 仍处于 **pre-1.0** 阶段,API 和配置可能变化。[MIT 许可证](./LICENSE)。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 244e4d02..5cb1a06c 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -1,265 +1,72 @@
-Devo desktop coding agent app icon and wordmark +Devo
-
+# Devo — 開源遞迴智慧代理 -**Devo 是開源 coding agent,提供 Desktop app、終端機 TUI/CLI 和模型中立的 Rust runtime,面向私有化、企業內網和 OpenAI 相容模型環境。可接入 DeepSeek、Qwen、Kimi、Anthropic 相容 API、本地閘道或自訂模型端點。** +Devo 透過**持久化 Python 工作區**、**子代理**和**可自由選擇的模型供應商** +處理多步驟任務。提供桌面應用程式和終端 TUI/CLI,由本機 Rust 執行環境驅動。 -[![Stars](https://img.shields.io/github/stars/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/stargazers) -[![Language](https://img.shields.io/badge/language-Rust-E57324?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/7df-lab/devo/pulls) [![CI](https://img.shields.io/github/actions/workflow/status/7df-lab/devo/ci.yml?branch=main&style=flat-square)](https://github.com/7df-lab/devo/actions) [![Release](https://img.shields.io/github/v/release/7df-lab/devo?style=flat-square)](https://github.com/7df-lab/devo/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](./LICENSE) -[English](./README.md) | [简体中文](./README.zh-Hans.md) | [繁體中文](./README.zh-Hant.md) | [日本語](./README.ja.md) | [Русский](./README.ru.md) - -[為什麼選擇 Devo](#為什麼選擇-devo) · [截圖](#截圖) · [功能](#功能) · [已測試模型](#已測試模型) · [已測試平台](#已測試平台) · [安裝](#安裝) · [快速開始](#快速開始) · [文件](#docs) - -
- ---- - -## 截圖 - -

- Devo 終端機 TUI coding agent 在本地儲存庫中顯示模型、上下文和 token 狀態 -

- -

- Devo desktop coding agent app 展示儲存庫對話、專案側邊欄和模型控制 -

- -## 為什麼選擇 Devo - -Devo 面向那些不想綁定單一託管模型生態、需要掌控模型選擇、執行階段行為和 -Desktop 體驗、終端機工作流以及工作區執行邊界的團隊。 - -- **接入任意模型** - 透過 provider/model 綁定接入 OpenAI 相容 Chat - Completions、OpenAI 相容 Responses、Anthropic Messages、DeepSeek、 - Qwen、Kimi 或私有模型閘道。 -- **適合私有化和內網環境** - 以單一本地 Rust 二進位檔執行,支援離線安裝路徑, - 可以指向內部端點,不依賴託管式 agent 服務。 -- **Desktop 與終端機雙入口** - 用 Desktop app 完成可視化上手和日常編碼,也可以在需要 - 終端機原生自動化、遠端 shell 或腳本化流程時使用 CLI/TUI。 -- **面向 Agent Runtime 擴展** - MCP server、可重用 skills、可稽核會話、權限控制 - 和多 agent 流程都是執行階段能力,不是一次性 prompt。 - -## 功能 - -- **模型中立的 provider runtime** - 透過 provider/model 綁定接入 OpenAI 相容、 - Anthropic 相容、DeepSeek、Qwen、Kimi、GLM、MiniMax、Xiaomi MiMo、 - OpenRouter 或本地端點。 -- **MCP 支援** - 透過 - [Model Context Protocol](https://modelcontextprotocol.io/) 伺服器連接外部工具和上下文。 - 可用 CLI 管理:`devo mcp add|list|enable|disable|remove`(見 - [配置](./docs/configuration.zh-Hant.md#mcp-伺服器))。 -- **Skill 支援** - 將可重複工作流程、說明、腳本和參考資料打包成可重用的 - [Agent Skills](https://agentskills.io/)。 -- **長時間任務支援** - 讓 Devo 在多輪工作中自動管理上下文,避免任務變長後丟失脈絡。 -- **多代理支援** - 將工作拆分給專門代理,同時在會話中保持協調過程可見。 -- **Plan Mode** - 在實作開始前,把較大的任務拆成清晰的多步驟計畫。 -- **平行工具呼叫** - 平行執行多個相互獨立的工具,讓模型少等待、多推進。 -- **帶權限的工具執行** - 在敏感工具呼叫觸碰工作區前進行審查。 -- **可稽核會話** - 保留模型輸出、工具呼叫、審批、token 用量和會話歷史, - 方便檢查和恢復。 -- **成本和上下文可見性** - 在供應商支援時顯示輸入/輸出 token、快取 token - 和上下文視窗用量。 -- **輕量級 Rust 執行階段** - 使用 Rust 建構,記憶體開銷低,本地執行階段緊湊。 -- **內建語義程式碼搜尋(MCP)** - 可選的捆綁 MCP 伺服器(`code_search` / - `devo-code-search-mcp`),**預設關閉**。在本地 CPU 執行程式碼嵌入模型,並結合 - 密集檢索與 BM25 關鍵字比對,相比僅用 grep/find 的代理減少程式碼搜尋上下文。用 - `devo mcp enable code_search` 或 TUI `/mcps` 啟用。 - -## 已測試模型 - -

- DeepSeek v4 Flash / Pro - GLM 5.2 - MiniMax M3 - Qwen3 Coder Next - Kimi K3 -

- -Devo 的內建模型目錄包含 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 的已測試模型定義。 -Provider 端點仍可透過 provider/model 綁定配置。 - -## 已測試平台 - -

- macOS 已測試 - Linux 已測試 - Windows 已測試 -

+[English](./README.md) · [简体中文](./README.zh-Hans.md) · [繁體中文](./README.zh-Hant.md) · [日本語](./README.ja.md) · [Русский](./README.ru.md) -Devo 已在 macOS、Linux、Windows 和麒麟作業系統上測試。 +## 與 Claude Code、Codex 的差異 -### 面向中國企業使用者 +| 代理 | 核心側重 | 模型接入 | +| --- | --- | --- | +| **Devo** | 透過可程式化、遞迴執行完成程式開發及其他多步驟任務。 | 相容的雲端供應商、本機端點和私有閘道。 | +| [Claude Code](https://code.claude.com/docs/en/overview) | 圍繞 Claude 的開發工作流程,涵蓋終端、IDE、桌面和網頁。 | 透過 Anthropic 或支援的雲端平台接入 Claude。 | +| [Codex](https://learn.chatgpt.com/docs/codex/cli) | OpenAI 的程式開發代理,涵蓋終端、IDE、應用程式和雲端。 | 預設使用 OpenAI 模型;CLI 也支援[自訂和本機供應商](https://learn.chatgpt.com/docs/config-file/config-advanced#custom-model-providers)。 | -

- 麒麟作業系統已測試 - HarmonyOS 支援已在路線圖中 -

+Devo 的 `ipython` 工具會在呼叫之間保留變數和中間結果。代理可以用 Python +篩選大量輸出、重用既有工作,並將獨立子任務交給擁有各自工作階段和 Python 狀態的 +子代理。也支援 MCP、技能、權限控制和工作階段恢復。 -之所以單獨標出麒麟作業系統覆蓋,是因為在中國企業環境中,國產作業系統經常是實際部署要求的一部分。 -HarmonyOS 支援已在路線圖中;歡迎擁有 HarmonyOS 裝置的貢獻者為該平台建構、測試並發布版本。 - -## 安裝 - -Devo 有兩種安裝形態。需要圖形化 coding agent 工作區時選擇 Desktop app; -偏好 shell-first 開發時選擇終端機原生的 TUI/CLI;也可以在同一台機器上同時安裝兩者。 - -### 選項一:Desktop App - -如果你想使用圖形化 Devo 體驗,請從 -[GitHub Releases](https://github.com/7df-lab/devo/releases/latest) -下載最新的 Devo Desktop 安裝包,並選擇與你的作業系統和架構匹配的 asset: - -- **macOS** - 下載 `devo-desktop-...-mac-...` 的 `.dmg` 或 `.zip` asset。 -- **Windows** - 下載 `devo-desktop-...-windows-...` 的 `.exe` asset。 -- **Linux** - 下載 `devo-desktop-...-linux-...` 的 `.AppImage`、`.deb` - 或 `.rpm` asset。 - -**如果 macOS 提示「Devo」已損壞,無法打開,這是正常現象。**目前 macOS -Desktop builds 尚未簽名,因此安裝後需要執行下面的命令,macOS 才能啟動應用: - -```bash -sudo xattr -dr com.apple.quarantine /Applications/Devo.app -``` - -### 選項二:TUI / CLI +## 快速開始 -如果你更喜歡終端機 TUI、需要 shell 自動化,或希望和 Desktop app 搭配使用, -請安裝終端機原生的 `devo` 命令。 +**桌面應用程式:** 從 [GitHub Releases](https://github.com/7df-lab/devo/releases/latest) +下載適合作業系統和架構的安裝套件,在首次設定中連接模型供應商。 -Linux / macOS: +**終端 — Linux / macOS:** ```bash curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh ``` -Windows: +**終端 — Windows PowerShell:** ```powershell irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex ``` -線上安裝器會把 `devo` 放到 Devo home 目錄下,安裝用於快速儲存庫搜尋的 -`rg` sidecar,並支援可選安裝 `code_search` 使用的本地模型。 - -
-可選:預安裝本地 code_search 模型 - -僅在希望安裝階段就下載 Hugging Face 模型時使用。 - -Linux / macOS: - -```bash -curl -fsSL https://raw.githubusercontent.com/7df-lab/devo/main/install.sh | sh -s -- --install-code-search-model -``` - -Windows: - -```powershell -$env:DEVO_INSTALL_CODE_SEARCH_MODEL = "1"; irm 'https://raw.githubusercontent.com/7df-lab/devo/main/install.ps1' | iex -``` - -
- -將現有安裝升級到最新 release: - -```bash -devo upgrade -``` - -升級命令會執行同一套平台安裝器,安裝器會列印版本變化,例如 -`Version: v0.1.12 -> v0.1.15`。 - -如需在內網或無網路環境中安裝,請參閱 -[離線安裝](./docs/offline-installation.zh-Hant.md)。 - -## 快速開始 - -配置 provider,開啟一個儲存庫,然後啟動 TUI: - -```bash -cd /path/to/your/repo -devo onboard -``` - -常用命令: +在工作目錄啟動;首次執行會開啟供應商設定: ```bash -devo # 在目前儲存庫啟動互動式 TUI -devo resume +cd /path/to/your/workspace +devo ``` -## 配置 - -`devo onboard` 是推薦的設定路徑。它會把 provider 和 model binding 寫入 -`config.toml`,並把 API key 保存在使用者級 `auth.json`。 - -如需手動接入自有 API key 與自訂模型: - -1. 在 `config.toml` 中定義 `[model.]` 參數、`[providers.]` 和 - `[model_bindings.]`。 -2. 把密鑰寫入 `DEVO_HOME/auth.json`,並在 `[providers.].credential` 中引用該 - credential id — 不要把 API key 本身寫進 `config.toml`。 -3. 將 `invocation_method` 設為與端點協議一致:`openai_chat_completions`、 - `openai_responses` 或 `anthropic_messages`。 - -完整範例(自訂模型參數 + API key)與協議說明見 -[配置](./docs/configuration.zh-Hant.md#接入自有-api-key)。 - -## Docs - -- [離線安裝](./docs/offline-installation.zh-Hant.md) -- [配置](./docs/configuration.zh-Hant.md) - -## 常見問題 - -### 專案狀態如何? - -Devo 仍處於 1.0 之前並在積極開發中。它已經適合本地評估、實驗和貢獻者使用; -公共 API 和配置仍可能變更。 - -### 支援哪些模型? - -內建模型中繼資料目前覆蓋 Qwen、Kimi、MiniMax、GLM 和 DeepSeek 系列。 -任何支援 OpenAI 相容 Chat Completions、OpenAI 相容 Responses 或 -Anthropic Messages API 的模型端點,都可以透過 provider/model 綁定接入。 - -### 如何接入自有 API key? - -使用 `devo onboard`,或在 `config.toml` 中手動定義自訂 `[model.]`、 -provider 與 binding,把 key 存入使用者級 `auth.json`,並將 `invocation_method` -設為 `openai_chat_completions`、`openai_responses` 或 `anthropic_messages`。詳見 -[配置](./docs/configuration.zh-Hant.md#接入自有-api-key)。 - -### 應該使用 Desktop app 還是 TUI/CLI? - -如果你需要可視化上手、會話瀏覽和圖形化 coding workspace,請使用 Desktop app。 -如果你需要終端機原生自動化、遠端 shell 工作流,或希望 coding agent 留在現有命令列環境中, -請使用 TUI/CLI。兩種入口都面向同一個本地 Devo runtime。 - -## 參與貢獻 - -專案仍處於早期階段,歡迎貢獻: +用 `devo resume ` 恢復工作階段,用 `devo upgrade` 升級。 +終端安裝套件已包含 Node.js 和 Python。Windows 執行 shell 命令需要 +[Git for Windows](https://gitforwindows.org/),首次使用時也需核准一次沙箱設定提示。 -- 關於 client/server runtime、provider layer、safety model 和 TUI 的架構回饋。 -- 文件和翻譯。 -- Provider、model 和 wire API 覆蓋。 -- 帶驗證命令和迴歸測試的聚焦修復。 +## 設定 -請開啟 issue 或 pull request 討論變更。 +支援 **OpenAI 相容 Chat Completions**、**OpenAI 相容 Responses** 和 +**Anthropic Messages** API。內建目錄包含 DeepSeek、Qwen、Kimi、GLM 和 MiniMax。 +用 `/model` 切換模型,用 `devo onboard` 重新設定供應商。 -## 授權 +手動設定、憑證、MCP、離線安裝和發行鏡像請見[使用指南(英文)](./docs/usage.md)。 -本專案採用 [MIT License](./LICENSE) 授權。 +## 文件與貢獻 ---- +- [使用指南(英文)](./docs/usage.md) — 安裝、供應商、設定與離線使用。 +- [TUI 指南(英文)](./apps/tui/README.md) — 終端操作與命令。 +- [貢獻指南(英文)](./CONTRIBUTING.md) — 歡迎問題回報、文件改進和小範圍修正。 -**如果你覺得 Devo 有用,請考慮給它一個 star。** +Devo 仍處於 **pre-1.0** 階段,API 和設定可能變動。[MIT 授權條款](./LICENSE)。 diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index ec2a3c6d..690f4af7 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -53,6 +53,7 @@ "electron": "^43.4.1", "electron-builder": "^26.0.0", "electron-vite": "^5.0.0", + "happy-dom": "^20.14.5", "rcedit": "^5.0.2", "shiki": "^3.22.0", "tailwindcss": "^4.0.0", @@ -666,6 +667,8 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "https://registry.npmmirror.com/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/ws": ["@types/ws@8.18.1", "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="], @@ -764,6 +767,8 @@ "buffer-from": ["buffer-from@1.1.2", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "builder-util": ["builder-util@26.15.3", "https://registry.npmmirror.com/builder-util/-/builder-util-26.15.3.tgz", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="], "builder-util-runtime": ["builder-util-runtime@9.7.0", "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], @@ -1062,7 +1067,7 @@ "enquirer": ["enquirer@2.4.1", "https://registry.npmmirror.com/enquirer/-/enquirer-2.4.1.tgz", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1208,6 +1213,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -2126,6 +2133,8 @@ "webcrypto-core": ["webcrypto-core@1.9.2", "https://registry.npmmirror.com/webcrypto-core/-/webcrypto-core-1.9.2.tgz", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@6.0.1", "https://registry.npmmirror.com/which/-/which-6.0.1.tgz", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2326,6 +2335,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.4.0.tgz", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], "postcss/nanoid": ["nanoid@3.3.15", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], diff --git a/apps/desktop/docs/development.md b/apps/desktop/docs/development.md new file mode 100644 index 00000000..f85349c8 --- /dev/null +++ b/apps/desktop/docs/development.md @@ -0,0 +1,41 @@ +# Desktop development + +From `apps/desktop`, run `bun run dev`. This regenerates Native protocol +types, builds the Rust runtime, and starts Electron with the renderer dev +server. `bun run preview` uses the same runtime preparation for a preview +of the production renderer. + +## Windows executable locks + +Windows prevents Cargo from replacing an executable while it is running. +Desktop development therefore builds the CLI and Windows sandbox helpers +in `target/desktop-dev`, rather than `target/debug`. If `CARGO_TARGET_DIR` +is set, the desktop build uses a `desktop-dev` subdirectory there. +The first build in this directory needs to compile its own dependencies. + +Each Electron launch receives a separate temporary copy of the CLI, +sandbox helpers, and runtime DLLs through `DEVO_DESKTOP_DEVO_BIN`. +Rebuilding with `bun run dev:cli` while the app is running leaves that +instance on its current runtime. Restart `bun run dev` to use the new build. +Existing CLI sessions using `target/debug/devo.exe` can remain open. + +An explicit `DEVO_DESKTOP_DEVO_BIN` override still selects that runtime. +Temporary copies are removed when the launcher exits normally. An abnormal +termination or a surviving child process can leave a temporary +`devo-desktop-dev-*` directory until the process exits and it can be removed. +Packaged applications continue to use their bundled runtime. + +## Removing a project folder + +Removing a folder from Desktop interrupts its active sessions, discards their +queued and steering inputs, and permanently deletes their histories. The +workspace directory and its files remain on disk. Other project folders and +their sessions remain available. + +The renderer deletes sessions with up to eight concurrent requests. The +runtime cancels a session's subagent tree together, allows a 500 ms turn +finalization window, then aborts unresponsive turns. Kernel shutdown also has +a 500 ms graceful window before forced termination. Background commands are +stopped across connections even when their session has no active model turn. +Confirmed deletions leave the sidebar immediately. Failed deletions retain +the folder and its connection so removal can be retried. diff --git a/apps/desktop/docs/session-messages.md b/apps/desktop/docs/session-messages.md new file mode 100644 index 00000000..11a9a67f --- /dev/null +++ b/apps/desktop/docs/session-messages.md @@ -0,0 +1,88 @@ +# Prompt submission and user messages + +The Native SDK's `session.promptAsync` returns `{ outcome: "started", turnId }` +when `session/queue/push` starts a turn and supplies its identifier. A queued +prompt returns `{ outcome: "queued", queueItemId }` instead. `turnId` is optional +when the response has no turn object. + +An optimistic user item uses that turn identifier. Native user-item +notifications may arrive before the submission response, so the message store +reconciles both arrival orders by turn ID. It keeps identical prompts submitted +in separate turns distinct. Queued prompts stay in the composer queue until +their turn starts. + +Accepted steering input is a Native user-message item in the active turn. +It remains a separate user bubble after completion and history reload; +sharing a turn ID with the original prompt does not merge the two bubbles. + +## Turn activity + +Answers remain visible in the transcript. The outer activity summary collapses +all thoughts and tools in the turn, including activity separated by assistant +commentary. Opening “Worked for 1s” reveals +individually collapsed Thought, Python, and other tool rows. Each row has its +own expand control: opening Thought does not open Python, and vice versa. +The group closes when the working turn finishes. +The outer summary shows Thinking, Running, or Waiting during execution and +elapsed time after completion. Failures, lost connections, and deliberate +stops remain explicit. + +Python source and its matching output share one panel, paired by Native `callId`. Unmatched +results remain visible. Long source and output scroll independently, and the +text Copy control copies the complete source. Completed rows omit repeated +Done labels and decorative icons. A normal fresh-kernel notice is omitted; +actionable warnings and provider failures remain in the transcript. + +## Model and provider selection + +The composer model control and `/model` open the same searchable picker in new +and existing chats. Search matches model names, identifiers, and provider names. +Ready to use shows configured connections and credential-backed providers; +All models also includes providers that need connection. Availability of an +individual model still depends on the provider account. + +Unconnected model rows open the existing API-key or desktop OAuth connection +flow. Completing it selects the requested model and refreshes provider data. +Catalog refetches do not restart OAuth or hide a completed connection. +Effort choices retain the server-authored values. + +Opening the picker refreshes stale remote catalog data in the background. +Refresh fetches the configured remote source immediately through Native +`model/catalog/refresh`. Saved models remain selectable during a refresh or +network failure, with Retry available. Offline mode is respected. +The list renders 60 matches at a time and supports Show more, arrow keys, +Enter selection, and Escape dismissal. + +## Conversation search + +The command palette searches Native session summaries as the query changes, +independent of the sidebar's loaded pages. Queries of two or more characters +are debounced by 250 ms and return at most 50 matches. Results merge into the +session store without changing sidebar pagination or live session status. +Stale responses are ignored when the query changes or the palette closes. + + +## Shared model and provider controls + +Settings has one **Models & providers** destination. Its Models view embeds the +same `ModelSelector` used by new and existing chat composers: Ready to use, +All models, search, keyboard selection, inline connection dialogs, reasoning +choices, and server-owned remote refresh share one implementation. Connections +and Model configuration retain provider credentials and advanced model editing. +The old `/settings/models` route remains an alias of this destination. + +New-chat defaults in Settings apply to the last selected project (or global +defaults before selecting a project). New chats read Native defaults; local +project agent preferences cannot shadow model or effort choices made in Settings. +Existing chats retain their session-specific selections. + +Default model and effort writes use SDK `model.preferences.write`, backed by +Native `model/preferences/write`, without creating a session. SDK writes are +ordered so a rapid model selection followed by effort is validated in that +order. Errors reject the caller, and subsequent selections can retry. Existing +chat changes continue through `session/metadata/update`. + +The server projects each model's reasoning capability from the loaded catalog; +listing effort choices does not reload the remote overlay or resolve a complete +turn for every row. Genuine session creation/resume have a 60-second Desktop +transport budget to allow Windows cold startup. diff --git a/apps/desktop/docs/windows-desktop-qa.md b/apps/desktop/docs/windows-desktop-qa.md new file mode 100644 index 00000000..251896ce --- /dev/null +++ b/apps/desktop/docs/windows-desktop-qa.md @@ -0,0 +1,295 @@ +# Windows desktop QA + +## Model picker and remote catalog QA + +Tested through the actual Electron UI on 2026-10-09: + +- `/model` opened the same picker in new chats and existing conversations. +- Ready to use and All models showed connected and unconnected providers. + Provider/model search narrowed ChatGPT results; Enter selected the model. + The refreshed catalog showed 1,279 entries with 60 rows rendered initially. +- Selecting an unconnected ChatGPT model opened the existing browser OAuth + flow. Authentication completed in the user's browser; Computer Use did not + automate authentication. The desktop subsequently showed 14 ChatGPT models + as ready. Fixed an OAuth state reset caused by catalog object refetches. +- Independently submitted a message using GPT-5.6 Luna and observed a completed + assistant reply. A separate Codex turn executed real Python with a 25-second + asyncio wait. Queued a message during execution and clicked Steer. Native + persistence recorded it as a `steer` user item in the same active turn, and + the completed answer acknowledged it. Another queued follow-up completed. + Renderer reload preserved both follow-ups, replies, and the selected model. +- Clicked Refresh in Electron. The Rust server downloaded + `https://models.dev/api.json`; cache metadata recorded 24 overlapping providers + and 770 converted remote models. The picker remained usable and returned from + Updating to Refresh. Built-in entries and user overrides remain merged. +- The ChatGPT account rejected GPT-5.4 with an explicit unsupported-model error; + GPT-5.6 Luna was used for successful conversation and tool QA. A global catalog + is not a guarantee of account-specific model access. + +Validation for this addition: 865 desktop tests passed with one platform skip; +TypeScript passed in both checkouts. Native catalog integration tests passed +for HTTP fetching, TTL reuse, forced refresh, offline policy, and fallback +after failure. Seven protocol generation tests passed. Windows workspace +Clippy and Cargo build passed with `RUSTFLAGS=-Dwarnings`, including the user's +main checkout build. + +Tested on Windows on 2026-10-09 using an isolated checkout, backend home, +Electron profile, and temporary Git project. The existing user checkout and +open desktop session were preserved. This covers the desktop renderer, +Electron main/preload, Native protocol, Rust runtime, and Python kernel. + +## Actual Electron interaction + +These checks used Windows computer use against the running Electron window. +They were supplemented by protocol traces and filesystem checks, rather than +replaced by scripted browser end-to-end tests. + +| Scenario | Observed outcome | +| --- | --- | +| Start `bun run dev` | Native runtime connected; main, preload, and renderer started. | +| Rebuild with running Windows runtimes | Two original checkout CLI processes remained running. The new `bun run dev` launched from a unique runtime copy, and a forced `bun run dev:cli` rebuild succeeded with the app still open. The isolated runtime also rebuilt with `RUSTFLAGS=-Dwarnings` while Electron was connected. | +| Add a temporary Git project | Native folder chooser accepted the project; sidebar and composer opened. | +| Select a connected model | Found and fixed missing inherited provider models in Native preferences. | +| Send a greeting with Markdown | Assistant text, table, and Python code rendered; one user bubble. | +| Run a 90-second Python cell | Running activity, Stop control, and Python source/output were visible. | +| Send another message during work | Queue entry appeared with Edit/Remove/Steer controls. | +| Steer the queued message | Native trace recorded `entry: steer` in the active turn; assistant subsequently answered the follow-up. | +| Earlier automatic queue and steer checks | Queued follow-up drained automatically; a separate steer was acknowledged during active work. | +| Edit a fixture file | Agent wrote the requested 26-byte text; Changes displayed the Git diff. | +| Navigate with an unsent draft | Draft survived leaving and returning to the conversation. | +| Reload and restart with a draft | Found reload loss; fixed eager storage initialization and verified persistence. | +| Restart the runtime | Fixed stale actor/subscription caches and stopped event forwarding; a fresh Electron retest resumed the session, displayed one provider error, and returned to idle. | +| Distinguish duplicate model names | Found indistinguishable configured connections; duplicate names now show a readable model/connection identifier. | +| Restore Python state after editing | Found a closed write handle reopening and truncating the fixture; blocked file reducers and verified the repaired fixture remained intact after restore. | +| Provider failure | HTTP 402 was visible as “Insufficient provider balance,” with expandable details. | +| Funded recovery | A new real Electron request produced assistant output, Python output, and a fixture read after balance was replenished. No new 402 occurred. | +| Funded automatic queue | Enter queued a follow-up during a 60-second Python cell; it drained automatically and produced its own Python result and assistant reply. | +| Mouse submission during work | Clicked Queue message during a fenced 150-second Python cell. Edited and resubmitted the first queued entry, then queued and steered a second. The steer produced 402; the edited queue drained automatically and produced 301. Stop remains separately available. | +| Completed activity label | Removed the tool suffix: completed activity displays “Worked for 1s,” with Python details inside the disclosure. | +| Separate inner cells | Clicked Worked to reveal collapsed Thought and Python rows. Opened each independently and collapsed the whole group with Worked. Python source and output stay paired. | +| Steering visibility after completion | A fresh steer during a 90-second Python wait remained a user bubble after completion, runtime restart, and renderer reload. The assistant applied it and produced 503. The reported disappearance was not reproduced in this checkout; live/replay regression tests preserve it. | +| Funded Stop and recovery | Clicked Stop during a 120-second cell; activity displayed Stopped and the composer returned to idle. A fresh Python request read the intact 26-byte fixture and produced 603. | +| Python after runtime restart | Found that asyncio was omitted from snapshots but not recreated by bootstrap. Added its bootstrap import and regression coverage for fresh and restored kernels. | +| Light/dark and narrow chat column | Reviewed wrapping, composer controls, warning text, activity disclosures, and diff layout. | +| Search, copy, stop/recovery | Exercised in the earlier live Electron pass associated with this PR. | + +The initial isolated QA home had no provisioned OS sandbox, so its warning +was accurate. A subsequent setup request was canceled at Windows administrator +consent. The QA home now reuses the user's valid encrypted provisioning +artifacts and their access rules, preserving destination ownership. A real +product-binary probe ran as `DevoSandboxOffline`: a workspace write succeeded, +an outside write was denied, and outbound TCP was denied with Windows error +10013. The outside sentinel remained unchanged. After restarting the runtime, +a fresh real Electron Python turn reported the token `DevoSandboxOffline`, +used the fenced kernel, and emitted no new unfenced warning. Historical +warnings remain attached to their original turns. + +Funded Electron requests now succeed. The earlier 402 checks remain coverage +of provider failure handling, rather than a current provider-balance blocker. + +## Fixes and regression coverage + +- Replace Windows asyncio's socket wakeup with an IOCP named pipe, avoiding + both blocked loopback TCP and writable-temp-directory probes. +- Accept startup stderr until the kernel ready event; retain complete startup + diagnostics on failure. +- Skip disk-backed file handles in Python snapshots, including nested handles; + reject legacy file reducers before they reopen or truncate files. Preserve + other bindings and in-memory buffers. +- Retain one session rollout lock per process until its last owner drops, + while preserving cross-process exclusion on Windows. +- Include inherited models for configured providers in Native preferences. +- Reinitialize loaded sessions after transport closure; retry a missing actor + with the same prompt and idempotency key, and recreate its subscription. +- Keep Electron Native event listeners attached across backend replacement; + reconnect project event streams after closure. +- Persist drafts before initial composer reads; retain input when submission + fails. +- Allow mouse submission of follow-ups during work with an explicit Queue + message button; keep Stop independent of submission readiness. +- Simplify completed activity summaries to elapsed time without a tool suffix. +- Keep separate, independently expandable Thought/tool cells inside the + collapsed Worked summary; test actual disclosure interaction. +- Recreate asyncio during kernel bootstrap so snapshot restoration retains + the documented asynchronous Python namespace. +- Display actual warning text and keep replayed provider errors scoped to + their original turn. +- Persist actual turn counts instead of Unix timestamps. +- Normalize generated-file comparisons across line endings, use native paths + in Windows fixtures, create required test workspaces, and exercise the CLI + sandbox entry point instead of relaunching a Rust test harness. +- Map generic ACL masks consistently, fix Windows-only Clippy warnings, and + use bounded observable waits in runtime concurrency tests. +- Terminate the command runner's child when parent IPC closes or sends an + invalid frame; test both disconnect paths using a real child process. +- Resolve Windows short-path fixture aliases and route protocol validation + imports to the freshly generated SDK source on clean installs. + +## Repeatable validation + +### Removing a folder with working sessions + +Windows computer use created three chats in a new temporary project. Each +ran a real Python cell with `await asyncio.sleep(600)`. A fourth message was +queued while the third cell was running. Clicking the project's Remove +confirmation returned to the home screen in 817 ms, including computer use +observation overhead. + +All three Python processes and their sandbox command runners exited. The +three session index rows, pending messages, and rollout files were gone; +the project's `keep.txt` file and three chats in another project remained. +Reloading Electron did not restore the deleted folder or its sessions. + +Regression tests cover the eight-request deletion pool, partial failures, +empty folders, twelve simultaneous Native turns with queued and steering +inputs, deletion broadcasts, forced abortion of twelve noncooperative tasks, +completion waiter cleanup, and termination of a live Python cell. Linux coverage also checks deletion +of an idle session whose background command belongs to another connection. +The confirmation now explains interruption, and the Windows project menu +uses “Show in Explorer.” + +The latest desktop suite passed 852 tests with one platform skip. TypeScript +and the production main/preload/renderer build passed. The full Rust workspace +run passed 2,413 tests with 56 ignored and zero failures. Workspace build and +Clippy passed with warnings treated as errors. + +### Activity hierarchy follow-up + +Windows computer use verified a completed turn's Worked summary opens separate +Thought and Python rows. Thought expanded without Python code, Python expanded +with Thought closed, and closing Worked removed both inner controls. A new live +35-second Python turn included assistant commentary between reasoning and tool +execution. Its activity stayed in one group. Leaving Python expanded while +running and letting the turn finish closed the group under Worked for 36s; +commentary and the final answer stayed visible. The older combined panel in the +user checkout was updated with the same independent rows and turn grouping. + +Before the folder deletion fix, the local serial Rust workspace run passed 2,410 tests with 56 ignored +and no failures. Desktop tests passed 847 tests with one platform skip; +TypeScript passed. All 11 Python tests passed. Workspace build and Clippy +completed with warnings treated as errors; rustfmt was clean. + +From the repository root, with `RUSTFLAGS=-Dwarnings`: + +```text +cargo build --workspace --jobs 2 +cargo test --workspace --jobs 2 --no-fail-fast -- --test-threads=1 +cargo clippy --workspace --all-targets --jobs 2 -- -Dwarnings +cargo fmt --all -- --check +``` + +From `apps/desktop`: + +```text +bun run test +bunx tsc --noEmit +bun run build +bun run dev +``` + +Python, with `dill==0.4.1` installed and +`PYTHONPATH=crates/kernel/rlm-runtime/src`: + +```text +python -m unittest discover -s crates/kernel/rlm-runtime/tests -v +``` + +CI includes desktop tests/types, Windows Rust check/Clippy, Python 3.11/3.13 +kernel tests, and the existing Linux Rust build/test/format/lint/docs jobs. +The PR is kept as a single amended commit. + +Windows desktop development uses a separate Cargo output and per-launch +runtime copies to avoid rebuilding a locked executable. See +[Desktop development](development.md) for paths, overrides, and cleanup. + +## Settings QA and connection retention (2026-10-09) + +QA used the actual Electron UI through Windows computer use, with a separate +Electron profile, DEVO_HOME, Git workspace, local provider, MCP echo server, and +user skill. User checkout changes were synchronized without discarding unrelated +work. Existing user Electron processes and credentials were preserved. + +- Full Electron close/reopen retained ChatGPT, DeepSeek, and a custom provider in + Ready to use. GPT-5.6 Luna completed a new real request after reopening. +- General: tested themes, display mode, thinking preference, opaque window + recreation, and open destinations. Fixed internal IDs appearing as labels and + development relaunch quitting Vite before its replacement window could load. +- Notifications: tested completion mode and permission/question/error toggles; + preferences persisted in the QA profile across process restarts. +- About: development update checks now explain their unavailability and disable + the action, instead of silently doing nothing. +- Servers: runtime restart succeeded. Invalid custom proxy input now disables + Restart; valid SOCKS input enables it. System proxy was restored for live QA. +- Providers/Models: created a local connection, discovered a second remote model, + verified the open dialog refreshed, toggled a model, and saved its display name. + Provider details include inherited models; failed actions show retryable errors. + Actual ChatGPT remote discovery exposed and fixed OAuth bearer/account handling, + sparse endpoint/protocol inheritance, provider naming, and hidden-model handling. +- MCP: the QA echo server advertised a tool, disabled state survived restart, + and reenable returned it to ready. Skills: the QA user skill's disabled state + survived restart and could be reenabled. +- Worktrees: replaced SDK placeholders with Native Git operations; list uses + actual linked checkouts and Windows names. Reset/remove require confirmation, + refuse dirty/primary/working checkouts, and show failures. Real Git lifecycle + tests cover create/list/reset/remove. UI testing found inherited stdin blocking + Git for Windows; commands now explicitly isolate stdin from Native transport. +- Setup: runtime check detected bundled 0.1.39, and Re-run Setup completed all + onboarding steps and returned to the application without losing connections. + Migration restore was not applied to the user's configuration. + +Final local Desktop validation: 878 tests passed, one platform skip, zero failures; +TypeScript and the production build passed. Rust persistence, discovery (nine), +real Git lifecycle, and Python goal-completion regressions passed. Windows builds +and Clippy ran with warnings denied. The CI goal-completion fixture now allows +30 seconds for real Python startup and reports unexpected provider requests. + + +## Unified model controls and effort save follow-up (2026-10-09) + +- Consolidated Settings Models/Providers into Models & providers, embedding the + composer picker. Connections and advanced model configuration remain in this + destination; old model links still work. +- Actual Electron UI selected GPT-6 Luna and Low effort in Settings, showed Saved + for new chats, then showed GPT-6 Luna / Low in the new-chat composer. +- Cold-start QA reproduced model/preferences/read exceeding the old 10-second + transport budget and delaying other RPCs. Effort projection now reads the loaded + catalog instead of resolving full turn configuration repeatedly. +- Default selection writes use the canonical preference RPC directly, serialize + model/effort saves, preserve qualified model IDs, and allow retry after errors. + Local project model history cannot override Native defaults on a new chat. +- 886 Desktop tests passed, one platform skip, zero failures; TypeScript passed. + The Native stdio regression saved effort before creating a chat and verified + the complete response and an empty session list. Windows Cargo build passed + with warnings denied. + + +## Session loading and console QA (2026-10-09) + +- Used an isolated Electron profile, Native home, and 120-session folder with a + 2,400-item transcript. Actual computer-use QA covered pagination, folder + collapse/reopen, rapid navigation, Settings, real Codex Python execution, + queued input, live steering, and conversation restoration after restart. +- Initial transcript RPCs fell from twelve 200-item pages to one 160-item tail + page. Older history loads in bounded increments. Idle sessions use a folder + roster instead of individual replay subscriptions; queue readiness does not + load transcripts. Individual warm-reload observations were 5.4s before and + 2.5s after, without claiming a general benchmark. +- Fixed a queued-resume self-deadlock, recovery reads under the connection + barrier, startup folder loading before connection readiness, stale navigation + completion state, deprecated Jotai imports, shared state updates during layout + rendering, and Worktrees errors for ordinary folders. +- The final Electron capture contained zero renderer errors and zero Native + request failures; the Vite development CSP warning remains expected. Queued + and steered instructions remained visible after completion and restart, and + the real model followed both instructions. +- Local validation: 896 Desktop tests passed, one platform skip, zero failures; + TypeScript, production build, Rust formatting, and workspace Clippy passed. + Rust server library: 475 passed, two ignored; Native concurrency integration: + two passed; protocol generation: seven passed. The user checkout's plain + Cargo build passed with `RUSTFLAGS=-Dwarnings`. +- A pre-fix deadlock reproducer remains running in the isolated QA worktree: + repository instructions prohibit interrupting Rust commands. Later regression + tests have request timeouts and pass; the old reproducer does not lock the + user checkout's executable. + +The Native cursor and subscription contract is implemented in the server session handlers. diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index fe132bb3..a6fa63b4 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -12,9 +12,9 @@ files: - "node_modules/electron-liquid-glass/**/*" # Bundle the private Devo runtime into the app package. -# macOS: Contents/Resources/runtime/bin/devo -# Linux: resources/runtime/bin/devo -# Windows: resources/runtime/bin/devo.exe +# macOS: Contents/Resources/runtime/devo +# Linux: resources/runtime/devo +# Windows: resources/runtime/devo.exe extraResources: - from: resources/runtime to: runtime diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6e240478..c559e22e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@devo/desktop", "productName": "Devo", - "version": "0.1.38", + "version": "0.2.15", "description": "AI-powered coding assistant desktop app", "author": "Devo", "license": "MIT", @@ -17,24 +17,24 @@ "scripts": { "sync:version": "bun scripts/sync-cargo-version.ts", "brand:electron-dev": "bun scripts/brand-electron-dev.mjs", - "dev:cli": "cargo build --manifest-path ../../Cargo.toml -p devo-cli --bin devo", + "dev:cli": "bun scripts/dev.ts --build-only", "gen:protocol-types": "cargo run --manifest-path ../../Cargo.toml -p devo-protocol --bin generate-native-ts -- packages/devo-ai-sdk/src/v2/generated", "gen:acp-types": "bun run gen:protocol-types", "gen:protocol-types:check": "bun scripts/check-protocol-types.mjs", "gen:acp-types:check": "bun run gen:protocol-types:check", - "dev": "bun run sync:version && bun run gen:protocol-types && bun run dev:cli && bun run brand:electron-dev && electron-vite dev", - "dev:wayland": "bun run sync:version && bun run gen:protocol-types && bun run dev:cli && bun run brand:electron-dev && electron-vite dev -- --ozone-platform=wayland --enable-features=WaylandWindowDecorations --disable-features=WaylandFractionalScaleV1", + "dev": "bun run sync:version && bun run gen:protocol-types && bun run brand:electron-dev && bun scripts/dev.ts dev", + "dev:wayland": "bun run sync:version && bun run gen:protocol-types && bun run brand:electron-dev && bun scripts/dev.ts dev -- --ozone-platform=wayland --enable-features=WaylandWindowDecorations --disable-features=WaylandFractionalScaleV1", "dev:web": "bun run sync:version && bun run gen:protocol-types && vite --config src/renderer/vite.web.config.ts", - "runtime:host": "cargo build --manifest-path ../../Cargo.toml --release -p devo-cli --bin devo && bun scripts/prepare-runtime.ts", + "runtime:host": "cargo build --manifest-path ../../Cargo.toml --release -p devo-cli -p devo-windows-sandbox --bins && bun scripts/prepare-runtime.ts", "runtime:prebuilt": "bun scripts/prepare-runtime.ts", "runtime:mac:arm64": "cargo build --manifest-path ../../Cargo.toml --release --target aarch64-apple-darwin -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target aarch64-apple-darwin", "runtime:mac:x64": "cargo build --manifest-path ../../Cargo.toml --release --target x86_64-apple-darwin -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target x86_64-apple-darwin", "runtime:linux:arm64": "cargo zigbuild --manifest-path ../../Cargo.toml --release --target aarch64-unknown-linux-musl -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target aarch64-unknown-linux-musl", "runtime:linux:x64": "cargo zigbuild --manifest-path ../../Cargo.toml --release --target x86_64-unknown-linux-musl -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target x86_64-unknown-linux-musl", - "runtime:win:arm64": "cargo build --manifest-path ../../Cargo.toml --release --target aarch64-pc-windows-msvc -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target aarch64-pc-windows-msvc", - "runtime:win:x64": "cargo build --manifest-path ../../Cargo.toml --release --target x86_64-pc-windows-msvc -p devo-cli --bin devo && bun scripts/prepare-runtime.ts --target x86_64-pc-windows-msvc", + "runtime:win:arm64": "cargo build --manifest-path ../../Cargo.toml --release --target aarch64-pc-windows-msvc -p devo-cli -p devo-windows-sandbox --bins && bun scripts/prepare-runtime.ts --target aarch64-pc-windows-msvc", + "runtime:win:x64": "cargo build --manifest-path ../../Cargo.toml --release --target x86_64-pc-windows-msvc -p devo-cli -p devo-windows-sandbox --bins && bun scripts/prepare-runtime.ts --target x86_64-pc-windows-msvc", "build": "bun run sync:version && bun run gen:protocol-types && electron-vite build", - "preview": "bun run sync:version && bun run gen:protocol-types && bun run dev:cli && bun run brand:electron-dev && electron-vite preview", + "preview": "bun run sync:version && bun run gen:protocol-types && bun run brand:electron-dev && bun scripts/dev.ts preview", "package": "bun run build && bun run runtime:host && electron-builder --config electron-builder.yml --publish never", "package:linux": "bun run build && bun run runtime:host && electron-builder --linux --config electron-builder.yml --publish never", "package:linux:arm64": "bun run build && bun run runtime:linux:arm64 && electron-builder --linux --arm64 --config electron-builder.yml --publish never", @@ -52,7 +52,7 @@ "rebuild": "electron-rebuild", "lint": "biome check .", "check-types": "bun run gen:protocol-types && tsc --noEmit", - "test": "bun run gen:protocol-types && bun test", + "test": "bun run gen:protocol-types && bun test --isolate", "clean": "rm -rf out release dist node_modules .turbo packages/devo-ai-sdk/src/v2/generated" }, "dependencies": { @@ -107,6 +107,7 @@ "electron": "^43.4.1", "electron-builder": "^26.0.0", "electron-vite": "^5.0.0", + "happy-dom": "^20.14.5", "rcedit": "^5.0.2", "shiki": "^3.22.0", "tailwindcss": "^4.0.0", diff --git a/apps/desktop/packages/configconv/src/converter/from-canonical/to-cursor.ts b/apps/desktop/packages/configconv/src/converter/from-canonical/to-cursor.ts index f37e4702..b58ca446 100644 --- a/apps/desktop/packages/configconv/src/converter/from-canonical/to-cursor.ts +++ b/apps/desktop/packages/configconv/src/converter/from-canonical/to-cursor.ts @@ -3,6 +3,7 @@ * * Produces Cursor-compatible configuration files from canonical representation. */ +import { join } from "node:path" import type { CanonicalAgentFile, CanonicalCommandFile, @@ -46,14 +47,14 @@ export function canonicalToCursor(scan: CanonicalScanResult): CanonicalConversio // ─── Global agents -> ~/.cursor/agents/ ────────────────────────── for (const agent of scan.global.agents) { const { content, report } = convertAgentToCursor(agent) - result.agents.set(`${paths.cursorGlobalAgentsDir()}/${agent.name}.md`, content) + result.agents.set(join(paths.cursorGlobalAgentsDir(), `${agent.name}.md`), content) reports.push(report) } // ─── Global commands -> ~/.cursor/commands/ ────────────────────── for (const cmd of scan.global.commands) { const { content, report } = convertCommandToCursor(cmd) - result.commands.set(`${paths.cursorGlobalCommandsDir()}/${cmd.name}.md`, content) + result.commands.set(join(paths.cursorGlobalCommandsDir(), `${cmd.name}.md`), content) reports.push(report) } @@ -91,14 +92,14 @@ function convertProjectToCursor( // Agents -> .cursor/agents/*.md for (const agent of project.agents) { const { content, report: agentReport } = convertAgentToCursor(agent) - result.agents.set(`${paths.cursorProjectAgentsDir(project.path)}/${agent.name}.md`, content) + result.agents.set(join(paths.cursorProjectAgentsDir(project.path), `${agent.name}.md`), content) report.converted.push(...agentReport.converted) } // Commands -> .cursor/commands/*.md for (const cmd of project.commands) { const { content, report: cmdReport } = convertCommandToCursor(cmd) - result.commands.set(`${paths.cursorProjectCommandsDir(project.path)}/${cmd.name}.md`, content) + result.commands.set(join(paths.cursorProjectCommandsDir(project.path), `${cmd.name}.md`), content) report.converted.push(...cmdReport.converted) } @@ -195,7 +196,7 @@ function convertRuleToCursorMdc( } const fileName = sanitizeRuleName(rule.name) - const targetPath = `${paths.cursorProjectRulesDir(projectPath)}/${fileName}.mdc` + const targetPath = join(paths.cursorProjectRulesDir(projectPath), `${fileName}.mdc`) const content = serializeFrontmatter( frontmatter, rule.content.includes("---\n") ? extractBody(rule.content) : rule.content, diff --git a/apps/desktop/packages/configconv/test/converter/from-canonical-cursor.test.ts b/apps/desktop/packages/configconv/test/converter/from-canonical-cursor.test.ts index 7ecf2d0a..090e140f 100644 --- a/apps/desktop/packages/configconv/test/converter/from-canonical-cursor.test.ts +++ b/apps/desktop/packages/configconv/test/converter/from-canonical-cursor.test.ts @@ -2,6 +2,8 @@ * Tests for Canonical -> Cursor conversion. */ import { describe, expect, test } from "bun:test" +import { tmpdir } from "node:os" +import { join } from "node:path" import { canonicalToCursor } from "../../src/converter/from-canonical/to-cursor" import type { CanonicalScanResult } from "../../src/types/canonical" @@ -18,6 +20,8 @@ function emptyCanonical(): CanonicalScanResult { } } +const projectPath = join(tmpdir(), "devo-cursor-conversion") + describe("canonicalToCursor", () => { test("converts empty scan result", () => { const result = canonicalToCursor(emptyCanonical()) @@ -81,11 +85,11 @@ describe("canonicalToCursor", () => { test("generates .mdc rule files from canonical rules", () => { const scan = emptyCanonical() scan.projects.push({ - path: "/test/project", + path: projectPath, mcpServers: {}, rules: [ { - path: "/test/project/CLAUDE.md", + path: join(projectPath, "CLAUDE.md"), name: "CLAUDE.md", content: "# Project Rules\n\nUse TypeScript strict mode.", alwaysApply: true, @@ -105,7 +109,7 @@ describe("canonicalToCursor", () => { expect(ruleEntries.length).toBe(1) const [path, content] = ruleEntries[0] - expect(path).toContain(".cursor/rules/") + expect(path).toBe(join(projectPath, ".cursor", "rules", "claude.mdc")) expect(path).toEndWith(".mdc") expect(content).toContain("alwaysApply") }) @@ -113,11 +117,11 @@ describe("canonicalToCursor", () => { test("generates .mdc rule files with globs for file-scoped rules", () => { const scan = emptyCanonical() scan.projects.push({ - path: "/test/project", + path: projectPath, mcpServers: {}, rules: [ { - path: "/test/project/.devo/rules/api.md", + path: join(projectPath, ".devo", "rules", "api.md"), name: "api-rules", content: "Use NestJS patterns for the API.", globs: "api/src/**/*.ts", @@ -144,14 +148,14 @@ describe("canonicalToCursor", () => { test("converts agents to Cursor format (minimal frontmatter)", () => { const scan = emptyCanonical() scan.projects.push({ - path: "/test/project", + path: projectPath, mcpServers: {}, rules: [], skills: [], commands: [], agents: [ { - path: "/test/project/.devo/agents/build.md", + path: join(projectPath, ".devo", "agents", "build.md"), name: "build", content: "---\ndescription: Build agent\n---\n\nYou are a build agent.", frontmatter: { description: "Build agent" }, @@ -167,21 +171,21 @@ describe("canonicalToCursor", () => { expect(result.agents.size).toBe(1) const agentEntries = [...result.agents.entries()] const [path, content] = agentEntries[0] - expect(path).toContain(".cursor/agents/build.md") + expect(path).toBe(join(projectPath, ".cursor", "agents", "build.md")) expect(content).toContain("build") }) test("converts commands to plain markdown (no frontmatter)", () => { const scan = emptyCanonical() scan.projects.push({ - path: "/test/project", + path: projectPath, mcpServers: {}, rules: [], skills: [], agents: [], commands: [ { - path: "/test/project/.devo/commands/commit.md", + path: join(projectPath, ".devo", "commands", "commit.md"), name: "commit", content: "---\ndescription: Commit changes\n---\n\nCommit all staged changes.", frontmatter: { description: "Commit changes" }, @@ -196,7 +200,7 @@ describe("canonicalToCursor", () => { expect(result.commands.size).toBe(1) const cmdEntries = [...result.commands.entries()] const [path, content] = cmdEntries[0] - expect(path).toContain(".cursor/commands/commit.md") + expect(path).toBe(join(projectPath, ".cursor", "commands", "commit.md")) // Cursor commands are plain markdown expect(content).toBe("Commit all staged changes.") expect(content).not.toContain("---") @@ -220,7 +224,7 @@ describe("canonicalToCursor", () => { test("converts project MCP to .cursor/mcp.json", () => { const scan = emptyCanonical() scan.projects.push({ - path: "/test/project", + path: projectPath, mcpServers: { LocalDB: { type: "local", @@ -237,7 +241,7 @@ describe("canonicalToCursor", () => { const result = canonicalToCursor(scan) expect(result.projectConfigs.size).toBe(1) - const config = result.projectConfigs.get("/test/project") as { + const config = result.projectConfigs.get(projectPath) as { mcpServers: Record } expect(config.mcpServers.LocalDB.command).toBe("node") @@ -249,7 +253,7 @@ describe("canonicalToCursor", () => { TestServer: { type: "remote", url: "https://test.com/mcp" }, } scan.projects.push({ - path: "/test/project", + path: projectPath, mcpServers: {}, rules: [ { diff --git a/apps/desktop/packages/configconv/test/converter/universal.test.ts b/apps/desktop/packages/configconv/test/converter/universal.test.ts index f9682869..37280bd8 100644 --- a/apps/desktop/packages/configconv/test/converter/universal.test.ts +++ b/apps/desktop/packages/configconv/test/converter/universal.test.ts @@ -2,6 +2,8 @@ * Tests for the universal converter (cross-format conversions). */ import { describe, expect, test } from "bun:test" +import { tmpdir } from "node:os" +import { join } from "node:path" import { formatName, fromCanonical, @@ -12,6 +14,8 @@ import { import type { CursorScanResult } from "../../src/types/cursor" import type { ScanResult } from "../../src/types/scan-result" +const projectPath = join(tmpdir(), "devo-cursor-conversion") + describe("universalConvert", () => { test("Claude Code -> Cursor: converts MCP servers", () => { const ccScan: ScanResult = { @@ -21,7 +25,7 @@ describe("universalConvert", () => { }, projects: [ { - path: "/test/project", + path: projectPath, agents: [], commands: [], skills: [], @@ -43,7 +47,7 @@ describe("universalConvert", () => { // Project MCP should be converted expect(result.projectConfigs.size).toBe(1) - const projectConfig = result.projectConfigs.get("/test/project") as { + const projectConfig = result.projectConfigs.get(projectPath) as { mcpServers: Record }> } expect(projectConfig.mcpServers.TestServer.command).toBe("npx") @@ -54,13 +58,13 @@ describe("universalConvert", () => { global: { skills: [] }, projects: [ { - path: "/test/project", + path: projectPath, agents: [], commands: [], skills: [], projectMcpServers: {}, claudeMd: "# Project Instructions\n\nUse TypeScript.", - claudeMdPath: "/test/project/CLAUDE.md", + claudeMdPath: join(projectPath, "CLAUDE.md"), }, ], } @@ -70,7 +74,7 @@ describe("universalConvert", () => { expect(result.rules.size).toBeGreaterThan(0) const ruleEntries = [...result.rules.entries()] const hasProjectRule = ruleEntries.some(([path]) => - path.includes("/test/project/.cursor/rules/"), + path.includes(join(projectPath, ".cursor", "rules")), ) expect(hasProjectRule).toBe(true) }) @@ -80,10 +84,10 @@ describe("universalConvert", () => { global: { skills: [] }, projects: [ { - path: "/test/project", + path: projectPath, agents: [ { - path: "/test/project/.claude/agents/review.md", + path: join(projectPath, ".claude", "agents", "review.md"), name: "review", content: "---\nname: review\ndescription: Code reviewer\n---\n\nReview the code.", frontmatter: { name: "review", description: "Code reviewer" }, @@ -101,7 +105,7 @@ describe("universalConvert", () => { expect(result.agents.size).toBe(1) const agentEntries = [...result.agents.entries()] - expect(agentEntries[0][0]).toContain(".cursor/agents/review.md") + expect(agentEntries[0][0]).toBe(join(projectPath, ".cursor", "agents", "review.md")) }) test("Cursor -> Devo: converts MCP servers", () => { @@ -133,10 +137,10 @@ describe("universalConvert", () => { global: { skills: [], commands: [], agents: [] }, projects: [ { - path: "/test/project", + path: projectPath, rules: [ { - path: "/test/project/.cursor/rules/always.mdc", + path: join(projectPath, ".cursor", "rules", "always.mdc"), name: "always", content: "---\nalwaysApply: true\n---\n\nAlways apply this.", frontmatter: { alwaysApply: true }, @@ -164,10 +168,10 @@ describe("universalConvert", () => { global: { skills: [], commands: [], agents: [] }, projects: [ { - path: "/test/project", + path: projectPath, rules: [ { - path: "/test/project/.cursor/rules/main.mdc", + path: join(projectPath, ".cursor", "rules", "main.mdc"), name: "main", content: "---\nalwaysApply: true\n---\n\nUse tabs for indentation.", frontmatter: { alwaysApply: true }, @@ -195,10 +199,10 @@ describe("universalConvert", () => { global: { skills: [], commands: [], agents: [] }, projects: [ { - path: "/test/project", + path: projectPath, rules: [ { - path: "/test/project/.cursor/rules/api.mdc", + path: join(projectPath, ".cursor", "rules", "api.mdc"), name: "api", content: '---\nglobs: "api/**/*.ts"\nalwaysApply: false\n---\n\nAPI-specific rules.', frontmatter: { globs: "api/**/*.ts", alwaysApply: false }, diff --git a/apps/desktop/packages/configconv/test/writer/history.test.ts b/apps/desktop/packages/configconv/test/writer/history.test.ts index a65716cc..1eb10820 100644 --- a/apps/desktop/packages/configconv/test/writer/history.test.ts +++ b/apps/desktop/packages/configconv/test/writer/history.test.ts @@ -8,7 +8,7 @@ import { Database } from "bun:sqlite" import { afterEach, describe, expect, test } from "bun:test" import { mkdir, rm } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { join, sep } from "node:path" import type { ConvertedSession } from "../../src/types/conversion-result" import { writeHistorySessionsDetailed } from "../../src/writer/history" @@ -333,10 +333,10 @@ describe("writeHistorySessionsDetailed (legacy)", () => { expect(result.totalProcessed).toBe(1) // Should have written project, session, message, and part files - const projectFiles = result.filesWritten.filter((f) => f.includes("/project/")) - const sessionFiles = result.filesWritten.filter((f) => f.includes("/session/")) - const messageFiles = result.filesWritten.filter((f) => f.includes("/message/")) - const partFiles = result.filesWritten.filter((f) => f.includes("/part/")) + const projectFiles = result.filesWritten.filter((f) => f.includes(`${sep}project${sep}`)) + const sessionFiles = result.filesWritten.filter((f) => f.includes(`${sep}session${sep}`)) + const messageFiles = result.filesWritten.filter((f) => f.includes(`${sep}message${sep}`)) + const partFiles = result.filesWritten.filter((f) => f.includes(`${sep}part${sep}`)) expect(projectFiles).toHaveLength(1) expect(sessionFiles).toHaveLength(1) expect(messageFiles).toHaveLength(1) diff --git a/apps/desktop/packages/devo-ai-sdk/package.json b/apps/desktop/packages/devo-ai-sdk/package.json index 64adb248..a4c4293e 100644 --- a/apps/desktop/packages/devo-ai-sdk/package.json +++ b/apps/desktop/packages/devo-ai-sdk/package.json @@ -7,7 +7,8 @@ "exports": { "./v2/client": "./src/v2/client.ts", "./v2/protocol-validation": "./src/v2/protocol-validation.ts", - "./v2/native-client-support": "./src/v2/native-client-support.ts" + "./v2/native-client-support": "./src/v2/native-client-support.ts", + "./v2/native-item": "./src/v2/native-item.ts" }, "dependencies": { "ajv": "^8.20.0" diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts index ddb6ff6b..7b0da9b4 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-compact.test.ts @@ -3,6 +3,7 @@ import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEve class FakeTransport implements DevoNativeTransport { readonly requests: Array<{ method: string; params: unknown }> = [] + private listeners: Array<(event: DevoNativeTransportEvent) => void> = [] constructor(private readonly handler: (method: string, params: unknown) => unknown) {} @@ -14,13 +15,19 @@ class FakeTransport implements DevoNativeTransport { async respond(): Promise {} subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { - void listener - return () => {} + this.listeners.push(listener) + return () => { + this.listeners = this.listeners.filter((candidate) => candidate !== listener) + } } connected(): boolean { return true } + + emit(event: DevoNativeTransportEvent): void { + for (const listener of this.listeners) listener(event) + } } describe("session.summarize", () => { @@ -49,7 +56,7 @@ describe("session.summarize", () => { }) const client = createDevoClient({ directory: "/repo", transport }) - await client.session.summarize({ sessionID: "session-1" }) + await client.session.summarize({ sessionId: "session-1" }) expect(transport.requests.map((request) => request.method)).toEqual([ "initialize", @@ -59,4 +66,125 @@ describe("session.summarize", () => { expect(transport.requests.at(-1)?.params).toEqual({ sessionId: "session-1" }) expect(transport.requests.some((request) => request.method === "turn/start")).toBe(false) }) + + test("persists distinct start and complete compaction transcript markers", async () => { + const session = { + id: "session-1", + version: 1, + cwd: "/repo", + title: "Compact session", + parent: null, + createdAt: "2026-08-24T00:00:00Z", + lastActivityAt: "2026-08-24T00:00:00Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-08-24T00:00:00Z", + }, + } + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [session], nextCursor: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + if (method === "session/resume") { + return { session, lastContextOccupancy: null } + } + if (method === "session/messages/list" || method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "session/queue/list") { + return { entries: [] } + } + if (method === "context/usage/read") { + return { occupancy: null } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + await client.event.subscribe() + + transport.emit({ + type: "notification", + method: "context/compactionStarted", + params: { + sessionId: "session-1", + turnId: "turn-compact", + trigger: "manual", + }, + }) + transport.emit({ + type: "notification", + method: "item/started", + params: { + item: { + id: "item-compact-1", + sessionId: "session-1", + turnId: "turn-compact", + seq: 1, + revision: 1, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:00.000Z", + state: "running", + item: { + type: "contextCompaction", + trigger: "manual", + summary: "Compaction started", + }, + }, + }, + }) + transport.emit({ + type: "notification", + method: "item/completed", + params: { + item: { + id: "item-compact-1", + sessionId: "session-1", + turnId: "turn-compact", + seq: 1, + revision: 2, + createdAt: "2026-08-24T00:00:00.000Z", + updatedAt: "2026-08-24T00:00:02.000Z", + state: "completed", + item: { + type: "contextCompaction", + trigger: "manual", + summary: "Context compacted", + }, + }, + }, + }) + + const { data } = await client.session.messages({ sessionId: "session-1" }) + const compaction = data.find((entry) => entry.info.item?.type === "contextCompaction") + expect(compaction?.info.id).toBe("item-compact-1") + expect(compaction?.info.state).toBe("completed") + expect(compaction?.info.item?.summary).toBe("Context compacted") + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts index ac977323..54fd6e03 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-config-options.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test" import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" import type { SessionConfigOption } from "./native-client-support" import type { + ProviderInfo, + ProviderModelInfo, + ProviderUpsertParams, ProviderValidateParams, - ProviderVendor, - ProviderVendorUpsertParams, } from "./generated/native" class FakeTransport implements DevoNativeTransport { @@ -90,52 +91,31 @@ const configOptions = [ }, ] satisfies SessionConfigOption[] -const providerVendor = { - name: "openai", - base_url: "https://api.openai.com/v1", - credential: "openai_api_key", - headers: null, - wire_apis: ["openai_chat_completions"], - enabled: true, -} satisfies ProviderVendor - -const canonicalProviderVendor = { +const provider = { + id: "openai", name: "openai", baseUrl: "https://api.openai.com/v1", credential: "openai_api_key", wireApis: ["openai_chat_completions"], + models: { + "gpt-4o": { + name: "GPT-4o", + wireApi: "openai_chat_completions", + } satisfies ProviderModelInfo, + }, enabled: true, -} - -const canonicalModelBinding = { - bindingId: "openai-gpt-4o", - modelSlug: "gpt-4o", - provider: "openai", - requestModel: "gpt-4o", - displayName: "GPT-4o", - invocationMethod: "openai_chat_completions", - enabled: true, -} +} satisfies ProviderInfo const providerValidateParams = { - provider_vendor: providerVendor, - model_binding: { - binding_id: "openai-gpt-4o", - model_slug: "gpt-4o", - provider: "openai", - request_model: "gpt-4o", - display_name: "GPT-4o", - invocation_method: "openai_chat_completions", - default_reasoning_effort: null, - enabled: true, - }, - api_key: "secret", + provider, + model: "gpt-4o", + apiKey: "secret", } satisfies ProviderValidateParams const providerUpsertParams = { ...providerValidateParams, - default_model_binding: "openai-gpt-4o", -} satisfies ProviderVendorUpsertParams + defaultModel: "openai/gpt-4o", +} satisfies ProviderUpsertParams describe("Native desktop SDK config option cache", () => { test("loads cold-start config options from model/preferences/read when no session cache exists", async () => { @@ -195,11 +175,17 @@ describe("Native desktop SDK config option cache", () => { ]) }) - test("lists provider vendors through the server provider API", async () => { + test("lists provider Connections and templates through the server provider API", async () => { const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult if (method === "provider/list") { expect(params).toEqual({}) - return { providers: [canonicalProviderVendor] } + return { + providers: [provider], + templateProviderIds: [], + connectedProviderIds: ["openai"], + connectionModels: { openai: { "gpt-4o": provider.models["gpt-4o"] } }, + } } throw new Error(`unexpected request ${method}`) }) @@ -207,18 +193,20 @@ describe("Native desktop SDK config option cache", () => { const result = await client.provider.list() - expect(result.data).toEqual({ provider_vendors: [providerVendor] }) - expect(transport.requests.map((request) => request.method)).toEqual(["provider/list"]) + expect(result.data).toEqual({ + providers: [provider], + templateProviderIds: [], + connectedProviderIds: ["openai"], + connectionModels: { openai: { "gpt-4o": provider.models["gpt-4o"] } }, + }) + expect(transport.requests.map((request) => request.method)).toEqual(["initialize", "provider/list"]) }) test("validates provider candidates through the server provider API", async () => { const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult if (method === "provider/validate") { - expect(params).toEqual({ - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, - apiKey: "secret", - }) + expect(params).toEqual(providerValidateParams) return { replyPreview: "OK" } } throw new Error(`unexpected request ${method}`) @@ -227,11 +215,11 @@ describe("Native desktop SDK config option cache", () => { const result = await client.provider.validate(providerValidateParams) - expect(result.data).toEqual({ reply_preview: "OK" }) - expect(transport.requests.map((request) => request.method)).toEqual(["provider/validate"]) + expect(result.data).toEqual({ replyPreview: "OK" }) + expect(transport.requests.map((request) => request.method)).toEqual(["initialize", "provider/validate"]) }) - test("upserts provider vendors and clears cached model config", async () => { + test("upserts a provider Connection and clears cached model config", async () => { let modelPreferencesReadCalls = 0 const updatedPreferences = { model: "openai-gpt-4o", @@ -250,15 +238,10 @@ describe("Native desktop SDK config option cache", () => { } } if (method === "provider/upsert") { - expect(params).toEqual({ - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, - defaultModelBinding: "openai-gpt-4o", - apiKey: "secret", - }) + expect(params).toEqual(providerUpsertParams) return { - providerVendor: canonicalProviderVendor, - modelBinding: canonicalModelBinding, + provider, + defaultModel: "openai/gpt-4o", } } throw new Error(`unexpected request ${method}`) @@ -277,3 +260,65 @@ describe("Native desktop SDK config option cache", () => { ]) }) }) + + +describe("Native default model preferences", () => { + test("saving effort without a chat never creates or resumes a session", async () => { + const transport = new FakeTransport((method, params) => { + if (method === "initialize") return initializeResult + if (method === "model/preferences/write") { + expect(params).toEqual({ cwd: "/repo", patch: { reasoningEffort: "high" } }) + return { preferences: { ...modelPreferences, reasoningEffort: "high" } } + } + throw new Error(`unexpected request ${method}`) + }) + const client = createDevoClient({ directory: "/repo", transport }) + await client.model.preferences.write({ patch: { reasoningEffort: "high" } }) + expect((await client.config.providers()).data.providers[0].models["test-openai"].currentVariant).toBe("high") + expect(transport.requests.map(request => request.method)).toEqual(["initialize", "model/preferences/write"]) + }) + + test("rapid model and effort choices write in order without session startup", async () => { + let finishModel!: () => void + const held = new Promise(resolve => { finishModel = resolve }) + let started!: () => void + const firstStarted = new Promise(resolve => { started = resolve }) + const writes: unknown[] = [] + const transport = new FakeTransport(async (method, params) => { + if (method === "initialize") return initializeResult + if (method !== "model/preferences/write") throw new Error(`unexpected request ${method}`) + writes.push(params) + if (writes.length === 1) { started(); await held } + return { preferences: { ...modelPreferences, model: "alt-openai", reasoningEffort: "max" } } + }) + const client = createDevoClient({ directory: "/repo", transport }) + const model = client.model.preferences.write({ patch: { model: "alt-openai" } }) + await firstStarted + const effort = client.model.preferences.write({ patch: { reasoningEffort: "max" } }) + await Promise.resolve() + expect(writes).toEqual([{ cwd: "/repo", patch: { model: "alt-openai" } }]) + finishModel() + await Promise.all([model, effort]) + expect(writes).toEqual([ + { cwd: "/repo", patch: { model: "alt-openai" } }, + { cwd: "/repo", patch: { reasoningEffort: "max" } }, + ]) + expect(transport.requests.map(request => request.method)).toEqual(["initialize", "model/preferences/write", "model/preferences/write"]) + }) + + test("failed saves remain visible and the next choice can retry", async () => { + let attempt = 0 + const transport = new FakeTransport(method => { + if (method === "initialize") return initializeResult + if (method !== "model/preferences/write") throw new Error(`unexpected request ${method}`) + if (++attempt === 1) throw new Error("Preference write failed") + return { preferences: modelPreferences } + }) + const client = createDevoClient({ transport }) + await expect(client.model.preferences.write({ patch: { reasoningEffort: "high" } })).rejects.toThrow("Preference write failed") + await client.model.preferences.write({ patch: { reasoningEffort: "medium" } }) + expect(transport.requests.filter(request => request.method === "model/preferences/write").map(request => request.params)).toEqual([ + { patch: { reasoningEffort: "high" } }, { patch: { reasoningEffort: "medium" } }, + ]) + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-dispose.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-dispose.test.ts new file mode 100644 index 00000000..24ed18f6 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-dispose.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" + +class FakeTransport implements DevoNativeTransport { + readonly listeners = new Set<(event: DevoNativeTransportEvent) => void>() + unsubscribeCalls = 0 + + async request(method: string): Promise { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [], nextCursor: null } + } + throw new Error(`unexpected request ${method}`) + } + + async respond(): Promise {} + + subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { + this.listeners.add(listener) + return () => { + this.unsubscribeCalls += 1 + this.listeners.delete(listener) + } + } + + connected(): boolean { + return true + } +} + +describe("NativeClient disposal", () => { + test("unsubscribes once and closes an active event stream", async () => { + const transport = new FakeTransport() + const client = createDevoClient({ transport }) + const { stream } = await client.event.subscribe() + const pendingNext = stream[Symbol.asyncIterator]().next() + + expect(transport.listeners.size).toBe(1) + client.dispose() + + expect(transport.listeners.size).toBe(0) + expect(transport.unsubscribeCalls).toBe(1) + expect(await pendingNext).toEqual({ value: undefined, done: true }) + + client.dispose() + expect(transport.unsubscribeCalls).toBe(1) + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-initialize.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-initialize.test.ts new file mode 100644 index 00000000..1e86bc4f --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-initialize.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" + +class FakeTransport implements DevoNativeTransport { + readonly requests: string[] = [] + readonly listeners = new Set<(event: DevoNativeTransportEvent) => void>() + private rejectFirstInitialize!: (error: Error) => void + readonly firstInitialize = new Promise((_resolve, reject) => { + this.rejectFirstInitialize = reject + }) + + async request(method: string): Promise { + this.requests.push(method) + if (method === "initialize") { + if (this.requests.filter((request) => request === "initialize").length === 1) { + return this.firstInitialize + } + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") return { data: [], nextCursor: null } + if (method === "subscription/create") return { subscriptionId: "folder", snapshots: [], cursors: [] } + throw new Error(`unexpected request ${method}`) + } + + failFirstInitialize(error: Error): void { + this.rejectFirstInitialize(error) + } + + async respond(): Promise {} + + subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + connected(): boolean { + return true + } +} + +describe("NativeClient initialize retry", () => { + test("shares pending initialize, retries after rejection without a closed event, then caches success", async () => { + const transport = new FakeTransport() + const firstClient = createDevoClient({ directory: "/repo", transport }) + const secondClient = createDevoClient({ directory: "/repo", transport }) + try { + const firstList = firstClient.session.list() + const firstSubscribe = secondClient.event.subscribe() + // Let both clients reach the same still-pending initialize request. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(transport.requests).toEqual(["initialize"]) + + const transientError = new Error("temporary initialize failure") + transport.failFirstInitialize(transientError) + expect(await Promise.allSettled([firstList, firstSubscribe])).toEqual([ + { status: "rejected", reason: transientError }, + { status: "rejected", reason: transientError }, + ]) + expect(transport.requests).toEqual(["initialize"]) + expect(transport.listeners.size).toBe(2) // No transport closed event occurred. + + expect(await secondClient.event.subscribe()).toEqual({ stream: expect.anything() }) + expect(await firstClient.session.list()).toEqual({ data: [] }) + expect(transport.requests).toEqual([ + "initialize", "initialize", "subscription/create", "session/list", + ]) + await firstClient.event.subscribe() + await secondClient.session.list() + expect(transport.requests.filter((method) => method === "initialize")).toHaveLength(2) + } finally { + firstClient.dispose() + secondClient.dispose() + } + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-model-directory.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-model-directory.test.ts new file mode 100644 index 00000000..d89d6d88 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-model-directory.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport } from "./client" + +test("projects Native model/credential reads and remote refresh without legacy calls", async () => { + const requests: Array<{ method: string; params: unknown }> = [] + const models = { + models: [ + { + slug: "custom/test", + displayName: "Custom Test", + providerId: "custom", + modelId: "test", + provider: "openai_responses", + contextWindow: 128000, + reasoningCapability: { levels: [] }, + inputModalities: ["text"], + }, + ], + } + const credentials = { + credentials: [{ id: "custom-auth", provider: "custom", masked: "***", kind: "oauth" }], + } + const transport: DevoNativeTransport = { + connected: () => true, + respond: async () => {}, + subscribe: () => () => {}, + request: async (method, params) => { + requests.push({ method, params }) + switch (method) { + case "initialize": + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + case "model/list": + return models + case "credential/list": + return credentials + case "model/catalog/refresh": + return { status: "updated" } + default: + throw new Error(`Unexpected ${method}`) + } + }, + } + const client = createDevoClient({ transport }) + expect(await client.model.list()).toEqual({ data: models }) + expect(await client.credential.list()).toEqual({ data: credentials }) + expect(await client.model.refreshCatalog({ policy: "force" })).toEqual({ + data: { status: "updated" }, + }) + expect(requests.filter((request) => request.method !== "initialize")).toEqual([ + { method: "model/list", params: {} }, + { method: "credential/list", params: {} }, + { method: "model/catalog/refresh", params: { policy: "force" } }, + ]) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts index 8d333c70..d48d9ff9 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-native-interactions.test.ts @@ -14,8 +14,10 @@ class FakeNativeTransport implements DevoNativeTransport { pendingControlRequests: unknown[] = [] subscriptionCursors: Array<{ streamId: string; seq: number }> = [] subscriptionSnapshots: unknown[] = [] + subscriptionReplay: unknown[] = [] sessionItems: unknown[] = [] resumeSession?: unknown + resumeRecovery?: unknown async request(method: string, params?: unknown, directory?: string): Promise { this.requests.push({ method, params, directory }) @@ -33,6 +35,7 @@ class FakeNativeTransport implements DevoNativeTransport { cursors: this.subscriptionCursors, pendingControlRequests: this.pendingControlRequests, snapshots: this.subscriptionSnapshots, + replay: this.subscriptionReplay, } case "subscription/ack": return { serverTimeMs: 1 } @@ -82,14 +85,15 @@ class FakeNativeTransport implements DevoNativeTransport { } case "session/queue/remove": return {} - case "session/queue/steer": - return { itemId: "item-steer-1" } + case "turn/steer": + return { outcome: "injected", itemId: "item-steer-1" } case "session/message/edit": return editedMessageResult case "session/resume": return { session: this.resumeSession ?? nativeSession, lastContextOccupancy: nativeOccupancy, + ...(this.resumeRecovery ? { recovery: this.resumeRecovery } : {}), } case "session/items/list": return { data: this.sessionItems, nextCursor: null } @@ -134,11 +138,23 @@ const nativeWorkspaceView = { scope: "uncommitted", status: "ready", workspaceRoot: "/repo", + base: { kind: "branch", baseBranch: "main", mergeBase: "abc123", head: "def456" }, coverage: "git_visible", attribution: "git_working_tree", changeSetStatus: "finalized", - files: [], - stats: { files_changed: 0, additions: 0, deletions: 0 }, + files: [ + { + path: "a", + status: "modified", + additions: 1, + deletions: 1, + binary: false, + diffTruncated: false, + oldText: "old\n", + newText: "new\n", + }, + ], + stats: { filesChanged: 1, additions: 1, deletions: 1 }, unifiedDiff: "diff --git a/a b/a\n", warnings: [], generatedAt: "2026-08-22T00:00:00Z", @@ -292,7 +308,7 @@ async function nextPayloadOfType(stream: AsyncIterator, type: string): Prom } describe("Native desktop SDK interactions", () => { - test("global event consumers subscribe to every existing Native session", async () => { + test("project event consumers subscribe to a lightweight Native folder roster", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) @@ -300,16 +316,12 @@ describe("Native desktop SDK interactions", () => { expect(transport.requests).toEqual([ { method: "initialize", params: DESKTOP_INITIALIZE_PARAMS, directory: "/repo" }, - { - method: "session/list", - params: { cwds: ["/repo"] }, - directory: "/repo", - }, { method: "subscription/create", params: { - selectors: [{ kind: "session", sessionId: "session-1" }], + selectors: [{ kind: "sessionsByCwd", cwd: "/repo" }], includeSnapshot: true, + replay: "snapshotOnly", after: [], }, directory: "/repo", @@ -338,6 +350,7 @@ describe("Native desktop SDK interactions", () => { params: { selectors: [{ kind: "session", sessionId: "session-1" }], includeSnapshot: true, + replay: "snapshotOnly", after: [], }, directory: "/repo", @@ -364,7 +377,7 @@ describe("Native desktop SDK interactions", () => { expect((await client.app.setSkillEnabled({ path: "/skills/review", enabled: false })).data).toEqual([ { ...nativeSkill, enabled: false }, ]) - expect((await client.session.diff({ sessionID: "session-1" })).data).toEqual([ + expect((await client.session.diff({ sessionId: "session-1" })).data).toEqual([ { diff: "diff --git a/a b/a\n" }, ]) expect(transport.requests.slice(1)).toEqual([ @@ -393,6 +406,18 @@ describe("Native desktop SDK interactions", () => { ]) }) + test("returns canonical camelCase workspace views without reshaping", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + + const result = await client.workspace.changes.read({ + sessionId: "session-1", + scopes: ["uncommitted"], + }) + + expect(result.data).toEqual({ views: [nativeWorkspaceView] }) + }) + test("registers approval before the item event and responds to its JSON-RPC id", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) @@ -413,8 +438,7 @@ describe("Native desktop SDK interactions", () => { const asked = await nextPayloadOfType(stream, "permission.asked") expect(asked.properties).toEqual({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + sessionId: "session-1", permission: "Run cargo test", metadata: { tool: "process", @@ -427,11 +451,12 @@ describe("Native desktop SDK interactions", () => { availableScopes: ["once", "session", "commandPrefixPersist"], commandPattern: ["cargo", "test", "*"], commandPrefix: ["cargo", "test"], + answerable: true, }, }) await client.permission.reply({ - requestID: "approval-1", + requestId: "approval-1", reply: "commandPrefixPersist", }) expect(transport.responses).toEqual([ @@ -465,8 +490,7 @@ describe("Native desktop SDK interactions", () => { const asked = await nextPayloadOfType(stream, "question.asked") expect(asked.properties).toEqual({ id: "input-1", - requestID: "input-1", - sessionID: "session-1", + sessionId: "session-1", questions: [ { id: "environment", @@ -479,7 +503,7 @@ describe("Native desktop SDK interactions", () => { ], }) - await client.question.reply({ requestID: "input-1", answers: [["Local"]] }) + await client.question.reply({ requestId: "input-1", answers: [["Local"]] }) expect(transport.responses).toEqual([ { id: "rpc-input", @@ -513,10 +537,10 @@ describe("Native desktop SDK interactions", () => { }) expect((await nextPayloadOfType(stream, "question.replied")).properties).toEqual({ - sessionID: "session-1", - requestID: "input-1", + sessionId: "session-1", + requestId: "input-1", }) - await client.question.reply({ requestID: "input-1", answers: [["Local"]] }) + await client.question.reply({ requestId: "input-1", answers: [["Local"]] }) expect(transport.responses).toEqual([]) }) @@ -538,9 +562,9 @@ describe("Native desktop SDK interactions", () => { await client.session.create() const asked = await nextPayloadOfType(stream, "permission.asked") - expect(asked.properties.requestID).toBe("approval-1") + expect(asked.properties.id).toBe("approval-1") expect(asked.properties.metadata.answerable).toBe(true) - await client.permission.reply({ requestID: "approval-1", reply: "once" }) + await client.permission.reply({ requestId: "approval-1", reply: "once" }) expect(transport.responses[0]?.id).toBe("reissued-approval") }) @@ -564,8 +588,7 @@ describe("Native desktop SDK interactions", () => { const asked = await nextPayloadOfType(stream, "question.asked") expect(asked.properties).toEqual({ id: "input-1", - requestID: "input-1", - sessionID: "session-1", + sessionId: "session-1", questions: [ { id: "environment", @@ -577,7 +600,7 @@ describe("Native desktop SDK interactions", () => { }, ], }) - await client.question.reply({ requestID: "input-1", answers: [["Local"]] }) + await client.question.reply({ requestId: "input-1", answers: [["Local"]] }) expect(transport.responses[0]?.id).toBe("reissued-input") }) @@ -598,21 +621,21 @@ describe("Native desktop SDK interactions", () => { const client = createDevoClient({ directory: "/repo", transport }) const stream = (await client.global.event()).stream[Symbol.asyncIterator]() - await client.session.messages({ sessionID: "session-1" }) + await client.session.messages({ sessionId: "session-1" }) const asked = await nextPayloadOfType(stream, "permission.asked") - expect(asked.properties.requestID).toBe("approval-1") - expect(asked.properties.sessionID).toBe("session-1") + expect(asked.properties.id).toBe("approval-1") + expect(asked.properties.sessionId).toBe("session-1") expect(asked.properties.metadata.availableScopes).toEqual([ "once", "session", "commandPrefixPersist", ]) expect(asked.properties.metadata.answerable).toBe(true) - await client.permission.reply({ requestID: "approval-1", reply: "once" }) + await client.permission.reply({ requestId: "approval-1", reply: "once" }) expect(transport.responses[0]?.id).toBe("reissued-approval") }) - test("normalizes snake_case approval scopes from legacy rollout items", async () => { + test("ignores non-canonical snake_case approval scopes", async () => { const transport = new FakeNativeTransport() transport.sessionItems = [ { @@ -626,13 +649,9 @@ describe("Native desktop SDK interactions", () => { const client = createDevoClient({ directory: "/repo", transport }) const stream = (await client.global.event()).stream[Symbol.asyncIterator]() - await client.session.messages({ sessionID: "session-1" }) + await client.session.messages({ sessionId: "session-1" }) const asked = await nextPayloadOfType(stream, "permission.asked") - expect(asked.properties.metadata.availableScopes).toEqual([ - "once", - "pathPrefix", - "commandPrefixPersist", - ]) + expect(asked.properties.metadata.availableScopes).toEqual(["once"]) }) test("restores a waiting user-input item from session history after restart", async () => { @@ -641,10 +660,10 @@ describe("Native desktop SDK interactions", () => { const client = createDevoClient({ directory: "/repo", transport }) const stream = (await client.global.event()).stream[Symbol.asyncIterator]() - await client.session.messages({ sessionID: "session-1" }) + await client.session.messages({ sessionId: "session-1" }) const asked = await nextPayloadOfType(stream, "question.asked") - expect(asked.properties.requestID).toBe("input-1") - expect(asked.properties.sessionID).toBe("session-1") + expect(asked.properties.id).toBe("input-1") + expect(asked.properties.sessionId).toBe("session-1") }) test("disconnect clears stale interactions and creates a fresh event stream", async () => { @@ -656,7 +675,7 @@ describe("Native desktop SDK interactions", () => { transport.emit({ type: "closed", error: "transport stopped" }) expect((await oldStream.next()).done).toBe(true) - await client.permission.reply({ requestID: "approval-1", reply: "once" }) + await client.permission.reply({ requestId: "approval-1", reply: "once" }) expect(transport.responses).toEqual([]) const newStream = (await client.global.event()).stream[Symbol.asyncIterator]() @@ -665,9 +684,10 @@ describe("Native desktop SDK interactions", () => { expect( transport.requests.filter((request) => request.method === "subscription/create").at(-1)?.params, ).toEqual({ - selectors: [{ kind: "session", sessionId: "session-1" }], + selectors: [{ kind: "sessionsByCwd", cwd: "/repo" }], includeSnapshot: true, - after: [{ streamId: "session:session-1", seq: 7 }], + replay: "snapshotOnly", + after: [], }) }) @@ -703,7 +723,7 @@ describe("Native desktop SDK interactions", () => { params: { item: { ...envelope, revision: 2, state: "completed" } }, }) - const parts: string[] = [] + const texts: string[] = [] const deadline = Date.now() + 1_000 while (Date.now() < deadline) { const result = await Promise.race([ @@ -712,15 +732,19 @@ describe("Native desktop SDK interactions", () => { setTimeout(() => resolve({ done: false, value: { payload: { type: "timeout" } } }), 25), ), ]) - if (result.value?.payload?.type === "message.part.updated") { - const partText = result.value.payload.properties.part.text - if (typeof partText === "string") parts.push(partText) + if (result.value?.payload?.type === "item.updated") { + const info = result.value.payload.properties.info + const content = info?.item?.content + const partText = Array.isArray(content) + ? content.map((part: any) => part?.text ?? "").join("\n") + : "" + if (partText) texts.push(partText) } - if (result.value?.payload?.type === "timeout" && parts.length > 0) break + if (result.value?.payload?.type === "timeout" && texts.length > 0) break } - expect(parts.at(-1)).toBe(text) - expect(parts.some((part) => part === `${text}${text}`)).toBe(false) + expect(texts.at(-1)).toBe(text) + expect(texts.some((part) => part === `${text}${text}`)).toBe(false) }) test("completes a write tool when the matching FileChange item finishes", async () => { @@ -784,10 +808,10 @@ describe("Native desktop SDK interactions", () => { ), ]) const payload = result.value?.payload - if (payload?.type === "message.part.updated") { - const part = payload.properties.part - if (part?.callID === callId || part?.tool === "write") { - status = part.state?.status ?? "" + if (payload?.type === "item.updated") { + const info = payload.properties.info + if (info?.item?.type === "fileChange" && info?.item?.callId === callId) { + status = info.state ?? "" if (status === "completed") break } } @@ -836,7 +860,7 @@ describe("Native desktop SDK interactions", () => { }, }) - let part: any + let info: any const deadline = Date.now() + 1_000 while (Date.now() < deadline) { const result = await Promise.race([ @@ -846,24 +870,24 @@ describe("Native desktop SDK interactions", () => { ), ]) const payload = result.value?.payload - if (payload?.type === "message.part.updated") { - const next = payload.properties.part - if (next?.callID === callId) { - part = next - if (next.state?.status === "completed") break + if (payload?.type === "item.updated") { + const next = payload.properties.info + if (next?.item?.callId === callId && next?.item?.type === "fileChange") { + info = next + if (next.state === "completed") break } } - if (payload?.type === "timeout" && part) break + if (payload?.type === "timeout" && info) break } expect({ - tool: part?.tool, - status: part?.state?.status, - path: part?.state?.input?.path, - changeType: part?.state?.input?.changeType, - unifiedDiff: part?.state?.input?.unifiedDiff, + type: info?.item?.type, + status: info?.state, + path: info?.item?.changes?.[0]?.path, + changeType: info?.item?.changes?.[0]?.change?.type, + unifiedDiff: info?.item?.changes?.[0]?.change?.unifiedDiff, }).toEqual({ - tool: "edit", + type: "fileChange", status: "completed", path: "/repo/src/lib.rs", changeType: "update", @@ -928,7 +952,8 @@ describe("Native desktop SDK interactions", () => { }, }) - let part: any + let callInfo: any + let changeInfo: any const deadline = Date.now() + 1_000 while (Date.now() < deadline) { const result = await Promise.race([ @@ -938,22 +963,23 @@ describe("Native desktop SDK interactions", () => { ), ]) const payload = result.value?.payload - if (payload?.type === "message.part.updated") { - const next = payload.properties.part - if (next?.callID === callId) { - part = next - if (next.state?.status === "completed") break + if (payload?.type === "item.updated") { + const next = payload.properties.info + if (next?.item?.callId === callId && next?.item?.type === "toolCall") callInfo = next + if (next?.item?.callId === callId && next?.item?.type === "fileChange") { + changeInfo = next + if (next.state === "completed") break } } - if (payload?.type === "timeout" && part?.state?.status === "completed") break + if (payload?.type === "timeout" && changeInfo) break } expect({ - tool: part?.tool, - oldString: part?.state?.input?.oldString, - newString: part?.state?.input?.newString, - unifiedDiff: part?.state?.input?.unifiedDiff, - changeType: part?.state?.input?.changeType, + tool: callInfo?.item?.toolName, + oldString: callInfo?.item?.input?.oldString, + newString: callInfo?.item?.input?.newString, + unifiedDiff: changeInfo?.item?.changes?.[0]?.change?.unifiedDiff, + changeType: changeInfo?.item?.changes?.[0]?.change?.type, }).toEqual({ tool: "edit", oldString: "old", @@ -1013,7 +1039,7 @@ describe("Native desktop SDK interactions", () => { }, }) - let part: any + let info: any const deadline = Date.now() + 1_000 while (Date.now() < deadline) { const result = await Promise.race([ @@ -1023,30 +1049,26 @@ describe("Native desktop SDK interactions", () => { ), ]) const payload = result.value?.payload - if (payload?.type === "message.part.updated") { - const next = payload.properties.part - if (next?.callID === callId) { - part = next - if (next.state?.status === "completed") break + if (payload?.type === "item.updated") { + const next = payload.properties.info + if (next?.item?.callId === callId && next?.item?.type === "toolResult") { + info = next + if (next.state === "completed") break } } - if (payload?.type === "timeout" && part) break + if (payload?.type === "timeout" && info) break } expect({ - tool: part?.tool, - path: part?.state?.input?.path, - changeType: part?.state?.input?.changeType, - oldString: part?.state?.input?.oldString, - newString: part?.state?.input?.newString, - unifiedDiff: part?.state?.input?.unifiedDiff, + type: info?.item?.type, + callId: info?.item?.callId, + diff: info?.item?.output?.diff, + filePath: info?.item?.output?.files?.[0]?.filePath, }).toEqual({ - tool: "edit", - path: "C:/Users/lenovo/Desktop/hello.py", - changeType: "update", - oldString: "old\n", - newString: "new\n", - unifiedDiff, + type: "toolResult", + callId, + diff: unifiedDiff, + filePath: "C:/Users/lenovo/Desktop/hello.py", }) }) @@ -1084,7 +1106,7 @@ describe("Native desktop SDK interactions", () => { }, }) - let part: any + let info: any const deadline = Date.now() + 1_000 while (Date.now() < deadline) { const result = await Promise.race([ @@ -1094,20 +1116,21 @@ describe("Native desktop SDK interactions", () => { ), ]) const payload = result.value?.payload - if (payload?.type === "message.part.updated") { - const next = payload.properties.part - if (next?.callID === callId) { - part = next - if (next.state?.status === "completed") break + if (payload?.type === "item.updated") { + const next = payload.properties.info + if (next?.item?.callId === callId && next?.item?.type === "toolResult") { + info = next + if (next.state === "completed") break } } - if (payload?.type === "timeout" && part) break + if (payload?.type === "timeout" && info) break } expect({ - output: part?.state?.output, - hasRealNewline: typeof part?.state?.output === "string" && part.state.output.includes("\n"), - notJsonBlob: typeof part?.state?.output === "string" && !part.state.output.trim().startsWith("{"), + output: info?.item?.displayContent, + hasRealNewline: typeof info?.item?.displayContent === "string" && info.item.displayContent.includes("\n"), + notJsonBlob: + typeof info?.item?.displayContent === "string" && !info.item.displayContent.trim().startsWith("{"), }).toEqual({ output: displayContent, hasRealNewline: true, @@ -1120,10 +1143,11 @@ describe("Native desktop SDK interactions", () => { const client = createDevoClient({ directory: "/repo", transport }) await client.session.create() - await client.session.promptAsync({ - sessionID: "session-1", + const result = await client.session.promptAsync({ + sessionId: "session-1", parts: [{ type: "text", text: "hello" }], }) + expect(result.data).toEqual({ outcome: "started", turnId: nativeTurnInProgress.id }) expect(transport.requests.some((request) => request.method === "session/queue/push")).toBe( true, @@ -1139,6 +1163,84 @@ describe("Native desktop SDK interactions", () => { expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) }) + test("turn/completed with turn.error emits session.error for Desktop UI", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + await client.session.create() + + await client.session.promptAsync({ + sessionId: "session-1", + parts: [{ type: "text", text: "hello" }], + }) + + transport.emit({ + type: "notification", + method: "item/assistantMessage/delta", + params: { + sessionId: nativeSession.id, + itemId: "item-assistant-1", + delta: "partial", + }, + }) + await nextPayloadOfType(stream, "item.updated") + + transport.emit({ + type: "notification", + method: "turn/completed", + params: { + turn: { + ...nativeTurnInProgress, + status: "failed", + completedAt: "2026-08-24T00:00:08Z", + error: { + errorCode: "PROVIDER_TEMPORARY_FAILURE", + message: "HTTP 429: rate limit exceeded", + retryable: true, + }, + }, + }, + }) + + const errorEvent = await nextPayloadOfType(stream, "session.error") + expect(errorEvent.properties).toEqual({ + sessionId: nativeSession.id, + turnId: nativeTurnInProgress.id, + error: { + name: "PROVIDER_TEMPORARY_FAILURE", + data: { + message: "HTTP 429: rate limit exceeded", + code: "PROVIDER_TEMPORARY_FAILURE", + }, + }, + }) + + const updated = await nextPayloadOfType(stream, "item.updated") + expect(updated.properties.info.item?.type).toBe("assistantMessage") + expect(updated.properties.info.state).toBe("failed") + expect(updated.properties.info.item?.error).toEqual({ + name: "PROVIDER_TEMPORARY_FAILURE", + data: { + message: "HTTP 429: rate limit exceeded", + code: "PROVIDER_TEMPORARY_FAILURE", + }, + }) + + // Follow-up completed projection without error must not wipe the failure. + transport.emit({ + type: "notification", + method: "turn/completed", + params: { + turn: { + ...nativeTurnInProgress, + status: "failed", + completedAt: "2026-08-24T00:00:08Z", + }, + }, + }) + expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) + }) + test("queues follow-up input without forcing idle when a turn is already active", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) @@ -1146,13 +1248,13 @@ describe("Native desktop SDK interactions", () => { await client.session.create() await client.session.promptAsync({ - sessionID: "session-1", + sessionId: "session-1", parts: [{ type: "text", text: "hello" }], }) expect((await client.session.status()).data["session-1"]).toEqual({ type: "busy" }) await client.session.promptAsync({ - sessionID: "session-1", + sessionId: "session-1", parts: [{ type: "text", text: "queue me" }], }) @@ -1193,7 +1295,7 @@ describe("Native desktop SDK interactions", () => { const queueEvent = await nextPayloadOfType(stream, "session.queue.updated") expect(queueEvent.properties).toEqual( expect.objectContaining({ - sessionID: "session-1", + sessionId: "session-1", change: "sync", entries: [ expect.objectContaining({ @@ -1205,19 +1307,60 @@ describe("Native desktop SDK interactions", () => { ) const activeTurnEvent = await nextPayloadOfType(stream, "session.activeTurn") expect(activeTurnEvent.properties).toEqual({ - sessionID: "session-1", - turnID: nativeTurnInProgress.id, + sessionId: "session-1", + turnId: nativeTurnInProgress.id, }) expect((await client.session.status()).data["session-1"]).toEqual({ type: "busy" }) }) + test("subscription replay of turn/started does not busy idle snapshot", async () => { + const transport = new FakeNativeTransport() + transport.subscriptionSnapshots = [ + { + streamId: "session:session-1", + barrierSeq: 2, + data: { + kind: "session", + session: { ...nativeSession, status: "idle", activeTurnId: null }, + queue: [], + activeTurn: null, + }, + }, + ] + transport.subscriptionReplay = [ + { + event: { + eventId: "evt_orphan_started", + streamId: "session:session-1", + seq: 1, + emittedAt: "2026-08-30T07:00:00.000Z", + persisted: true, + schemaVersion: 1, + }, + notification: { + method: "turn/started", + params: { + turn: { + ...nativeTurnInProgress, + sessionId: "session-1", + }, + }, + }, + }, + ] + const client = createDevoClient({ directory: "/repo", transport }) + await client.session.create() + + expect((await client.session.status()).data["session-1"]).toEqual({ type: "idle" }) + }) + test("session/list does not clear busy status while a turn is in flight", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) await client.session.create() await client.session.promptAsync({ - sessionID: "session-1", + sessionId: "session-1", parts: [{ type: "text", text: "hello" }], }) expect((await client.session.status()).data["session-1"]).toEqual({ type: "busy" }) @@ -1242,24 +1385,59 @@ describe("Native desktop SDK interactions", () => { expect(await nextPayloadOfType(stream, "context.usage.updated")).toEqual({ type: "context.usage.updated", properties: { - sessionID: nativeSession.id, + sessionId: nativeSession.id, occupancy: nativeOccupancy, }, }) }) + test("projects last-query display total from turn/usage/updated", async () => { + const transport = new FakeNativeTransport() + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + transport.emit({ + type: "notification", + method: "turn/usage/updated", + params: { + sessionId: nativeSession.id, + turnId: "turn-1", + usage: { + query: { + totalTokens: 48_000, + inputTokens: 40_000, + outputTokens: 8_000, + }, + overhead: { totalTokens: 0 }, + }, + lastQueryInputTokens: 40_000, + contextWindow: 190_000, + }, + }) + + expect(await nextPayloadOfType(stream, "session.usage.updated")).toEqual({ + type: "session.usage.updated", + properties: { + sessionId: nativeSession.id, + used: 48_000, + size: 190_000, + cost: 0, + }, + }) + }) + test("reads context occupancy through context/usage/read", async () => { const transport = new FakeNativeTransport() const client = createDevoClient({ directory: "/repo", transport }) const stream = (await client.global.event()).stream[Symbol.asyncIterator]() - const result = await client.context.usage.read({ sessionID: nativeSession.id }) + const result = await client.context.usage.read({ sessionId: nativeSession.id }) expect(result.data).toEqual(nativeOccupancy) expect(transport.requests.some((request) => request.method === "context/usage/read")).toBe(true) expect(await nextPayloadOfType(stream, "context.usage.updated")).toEqual({ type: "context.usage.updated", properties: { - sessionID: nativeSession.id, + sessionId: nativeSession.id, occupancy: nativeOccupancy, }, }) @@ -1299,14 +1477,14 @@ describe("Native desktop SDK interactions", () => { }, ] const client = createDevoClient({ directory: "/repo", transport }) - const { data } = await client.session.messages({ sessionID: nativeSession.id }) - const user = data.find((entry) => entry.info.role === "user") - const assistant = data.find((entry) => entry.info.role === "assistant") - expect(user?.info.time.created).toBe(Date.parse("2026-08-24T00:00:00.000Z")) - expect(assistant?.info.time.created).toBe(Date.parse("2026-08-24T00:00:02.000Z")) - expect(assistant?.info.time.completed).toBe(Date.parse("2026-08-24T00:00:14.000Z")) + const { data } = await client.session.messages({ sessionId: nativeSession.id }) + const user = data.find((entry) => entry.info.item?.type === "userMessage") + const assistant = data.find((entry) => entry.info.item?.type === "assistantMessage") + expect(Date.parse(String(user?.info.createdAt))).toBe(Date.parse("2026-08-24T00:00:00.000Z")) + expect(Date.parse(String(assistant?.info.createdAt))).toBe(Date.parse("2026-08-24T00:00:02.000Z")) + expect(Date.parse(String(assistant?.info.updatedAt))).toBe(Date.parse("2026-08-24T00:00:14.000Z")) expect( - (assistant?.info.time.completed ?? 0) - (user?.info.time.created ?? 0), + Date.parse(String(assistant?.info.updatedAt)) - Date.parse(String(user?.info.createdAt)), ).toBe(14_000) }) @@ -1344,14 +1522,13 @@ describe("Native desktop SDK interactions", () => { }, ] const client = createDevoClient({ directory: "/repo", transport }) - const { data } = await client.session.messages({ sessionID: nativeSession.id }) - const reasoning = data.find((entry) => - entry.parts.some((part) => part.type === "reasoning"), - ) - const part = reasoning?.parts.find((candidate) => candidate.type === "reasoning") - expect(part?.time?.start).toBe(Date.parse("2026-08-24T01:00:01.000Z")) - expect(part?.time?.end).toBe(Date.parse("2026-08-24T01:00:15.000Z")) - expect((part?.time?.end ?? 0) - (part?.time?.start ?? 0)).toBe(14_000) + const { data } = await client.session.messages({ sessionId: nativeSession.id }) + const reasoning = data.find((entry) => entry.info.item?.type === "reasoning") + expect(Date.parse(String(reasoning?.info.createdAt))).toBe(Date.parse("2026-08-24T01:00:01.000Z")) + expect(Date.parse(String(reasoning?.info.updatedAt))).toBe(Date.parse("2026-08-24T01:00:15.000Z")) + expect( + Date.parse(String(reasoning?.info.updatedAt)) - Date.parse(String(reasoning?.info.createdAt)), + ).toBe(14_000) }) test("closes reasoning part interval from live started to completed", async () => { @@ -1394,13 +1571,12 @@ describe("Native desktop SDK interactions", () => { }, }) - const { data } = await client.session.messages({ sessionID: nativeSession.id }) + const { data } = await client.session.messages({ sessionId: nativeSession.id }) const reasoning = data.find((entry) => entry.info.id === "item-reasoning-live") - const part = reasoning?.parts.find((candidate) => candidate.type === "reasoning") - expect(part?.text).toBe("done thinking") - expect(part?.time?.start).toBe(Date.parse("2026-08-24T02:00:00.000Z")) - expect(part?.time?.end).toBe(Date.parse("2026-08-24T02:00:08.000Z")) - expect(reasoning?.info.time.completed).toBe(Date.parse("2026-08-24T02:00:08.000Z")) + expect(reasoning?.info.item?.text).toBe("done thinking") + expect(Date.parse(String(reasoning?.info.createdAt))).toBe(Date.parse("2026-08-24T02:00:00.000Z")) + expect(Date.parse(String(reasoning?.info.updatedAt))).toBe(Date.parse("2026-08-24T02:00:08.000Z")) + expect(reasoning?.info.state).toBe("completed") }) test("turn start/complete bumps lastActivity and emits session.updated for sidebar sync", async () => { @@ -1409,7 +1585,7 @@ describe("Native desktop SDK interactions", () => { const stream = (await client.global.event()).stream[Symbol.asyncIterator]() await client.session.create() - const before = (await client.session.get({ sessionID: nativeSession.id })).data + const before = (await client.session.get({ sessionId: nativeSession.id })).data expect(before?.time.lastActivity).toBe(Date.parse("2026-08-22T00:00:00Z")) transport.emit({ @@ -1432,7 +1608,7 @@ describe("Native desktop SDK interactions", () => { Date.parse("2026-08-24T00:00:08Z"), ) - const after = (await client.session.get({ sessionID: nativeSession.id })).data + const after = (await client.session.get({ sessionId: nativeSession.id })).data expect(after?.time.lastActivity).toBe(Date.parse("2026-08-24T00:00:08Z")) expect(after?.time.updated).toBe(Date.parse("2026-08-24T00:00:08Z")) }) @@ -1446,7 +1622,7 @@ describe("Native desktop SDK interactions", () => { } const client = createDevoClient({ directory: "/repo", transport }) const stream = (await client.global.event()).stream[Symbol.asyncIterator]() - await client.session.messages({ sessionID: nativeSession.id }) + await client.session.messages({ sessionId: nativeSession.id }) // The cold session/list snapshot carries the base model; resume is // authoritative for persisted per-session selections, so its enriched @@ -1463,8 +1639,8 @@ describe("Native desktop SDK interactions", () => { const client = createDevoClient({ directory: "/repo", transport }) await client.session.create() await client.session.editMessage({ - sessionID: nativeSession.id, - itemID: "item-user-1", + sessionId: nativeSession.id, + itemId: "item-user-1", text: "edited", }) const request = transport.requests.find((entry) => entry.method === "session/message/edit") @@ -1526,12 +1702,14 @@ describe("Native desktop SDK interactions", () => { }, ] const client = createDevoClient({ directory: "/repo", transport }) - const loaded = await client.session.messages({ sessionID: nativeSession.id }) + const loaded = await client.session.messages({ sessionId: nativeSession.id }) expect(loaded.data.map((entry) => entry.info.id).sort()).toEqual([ "item-assistant-1", "item-user-1", ]) const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + // Native history reads exclude the superseded branch. + transport.sessionItems = [] transport.emit({ type: "notification", method: "turn/superseded", @@ -1543,15 +1721,15 @@ describe("Native desktop SDK interactions", () => { reason: "message_edit_previous", }, }) - expect(await nextPayloadOfType(stream, "message.removed")).toEqual({ - type: "message.removed", - properties: { sessionID: nativeSession.id, messageID: "item-user-1" }, + expect(await nextPayloadOfType(stream, "item.removed")).toEqual({ + type: "item.removed", + properties: { sessionId: nativeSession.id, itemId: "item-user-1" }, }) - expect(await nextPayloadOfType(stream, "message.removed")).toEqual({ - type: "message.removed", - properties: { sessionID: nativeSession.id, messageID: "item-assistant-1" }, + expect(await nextPayloadOfType(stream, "item.removed")).toEqual({ + type: "item.removed", + properties: { sessionId: nativeSession.id, itemId: "item-assistant-1" }, }) - const remaining = await client.session.messages({ sessionID: nativeSession.id }) + const remaining = await client.session.messages({ sessionId: nativeSession.id }) expect(remaining.data).toEqual([]) }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts index 6e1dcedb..6f7ef9f1 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts @@ -86,6 +86,9 @@ describe("session.fork", () => { if (method === "session/items/list") { return { data: [], nextCursor: null } } + if (method === "session/queue/list") { + return { entries: [] } + } if (method === "subscription/create") { return { subscriptionId: "sub-1", cursors: [] } } @@ -98,14 +101,14 @@ describe("session.fork", () => { }) const result = await client.session.fork({ - sessionID: "parent-session", + sessionId: "parent-session", atTurnId: "turn-2", cut: "before", }) expect(result.data.id).toBe("child-session") expect(result.data.forkFromId).toBe("parent-session") - expect(result.data.parentID).toBeUndefined() + expect(result.data.parentId).toBeUndefined() expect(transport.requests.some((request) => request.method === "session/fork")).toBe(true) }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts new file mode 100644 index 00000000..b0b5ab66 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-not-found.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test" +import { + createDevoClient, + isSessionNotFoundError, + type DevoNativeTransport, + type DevoNativeTransportEvent, +} from "./client" + +class FakeTransport implements DevoNativeTransport { + readonly requests: Array<{ method: string; params: unknown }> = [] + private listener?: (event: DevoNativeTransportEvent) => void + + constructor(private readonly handler: (method: string, params: unknown) => unknown) {} + + async request(method: string, params?: unknown): Promise { + this.requests.push({ method, params }) + return this.handler(method, params) + } + + async respond(): Promise {} + + subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { + this.listener = listener + return () => { + this.listener = undefined + } + } + + emit(event: DevoNativeTransportEvent): void { + this.listener?.(event) + } + + connected(): boolean { + return true + } +} + +const nativeSession = { + id: "missing-session", + version: 1, + cwd: "/repo", + title: "Missing", + parent: null, + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }, +} + +describe("isSessionNotFoundError", () => { + test("matches server message and SessionNotFound code", () => { + expect(isSessionNotFoundError(new Error("session does not exist"))).toBe(true) + const coded = new Error("gone") as Error & { code?: string } + coded.code = "SessionNotFound" + expect(isSessionNotFoundError(coded)).toBe(true) + expect(isSessionNotFoundError(new Error("timeout"))).toBe(false) + }) +}) + +describe("session.messages soft-handles missing sessions", () => { + test("returns empty messages and emits session.deleted when resume fails", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + if (method === "session/resume") { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const deletedIds: string[] = [] + const subscription = await client.event.subscribe() + const consumer = (async () => { + for await (const globalEvent of subscription.stream) { + if (globalEvent.payload?.type === "session.deleted") { + deletedIds.push(String(globalEvent.payload.properties?.info?.id ?? "")) + break + } + } + })() + + const result = await client.session.messages({ sessionId: "missing-session" }) + expect(result.data).toEqual([]) + await consumer + expect(deletedIds).toEqual(["missing-session"]) + expect(transport.requests.some((request) => request.method === "session/resume")).toBe(true) + }) +}) + +describe("session.queue.list resumes cold historical sessions", () => { + test("loads via session/resume before queue/list", async () => { + let resumed = false + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "session/resume") { + resumed = true + return { session: nativeSession } + } + if (method === "session/items/list") { + return { data: [], nextCursor: null } + } + if (method === "session/queue/list") { + if (!resumed) { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + return { + entries: [ + { + queueItemId: "q1", + position: 0, + preview: "hello", + input: [{ type: "text", text: "hello" }], + enqueuedAt: "2026-01-01T00:00:00.000Z", + }, + ], + } + } + if (method === "subscription/create") { + return { subscriptionId: "sub-1", snapshots: [], replay: [], cursors: [] } + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const result = await client.session.queue.list({ sessionId: "missing-session" }) + expect(resumed).toBe(true) + expect(result.data.entries).toHaveLength(1) + expect(result.data.entries[0]?.queueItemId).toBe("q1") + const methods = transport.requests.map((request) => request.method) + expect(methods.indexOf("session/resume")).toBeLessThan(methods.indexOf("session/queue/list")) + }) + + test("returns empty entries when the session is truly gone", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [nativeSession], nextOffset: null } + } + if (method === "session/resume") { + const error = new Error("session does not exist") as Error & { code?: string } + error.code = "SessionNotFound" + throw error + } + throw new Error(`unexpected method ${method}`) + }) + + const client = createDevoClient({ directory: "/repo", transport }) + const result = await client.session.queue.list({ sessionId: "missing-session" }) + expect(result.data.entries).toEqual([]) + }) +}) + + +describe("runtime restart", () => { + for (const operation of ["queue list", "send prompt", "evicted actor"] as const) { + test(`${operation} resumes a previously loaded session after transport closes`, async () => { + let resumed = false + let restart = false + const transport = new FakeTransport((method) => { + if (method === "initialize") return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + if (method === "session/list") return { data: [nativeSession], nextCursor: null } + if (method === "session/resume") { + resumed = true + return { session: nativeSession } + } + if (method === "session/items/list") return { data: [], nextCursor: null } + if (method === "subscription/create") return { subscriptionId: restart ? "sub-new" : "sub-old", snapshots: [], replay: [], cursors: [] } + if (method === "session/queue/list" || method === "session/queue/push") { + if (!resumed) throw new Error("session does not exist") + return method === "session/queue/list" ? { entries: [] } : { outcome: "started", turn: { id: "recovered-turn", sessionId: nativeSession.id, sequence: 1, kind: "regular", status: "inProgress", model: nativeSession.model, startedAt: nativeSession.createdAt } } + } + throw new Error(`unexpected method ${method}`) + }) + const client = createDevoClient({ directory: "/repo", transport }) + await client.session.messages({ sessionId: nativeSession.id }) + resumed = false + restart = true + const beforeRestart = transport.requests.length + if (operation !== "evicted actor") transport.emit({ type: "closed" }) + if (operation === "queue list") { + expect(await client.session.queue.list({ sessionId: nativeSession.id })).toEqual({ data: { entries: [] } }) + } else { + expect(await client.session.promptAsync({ sessionId: nativeSession.id, parts: [{ type: "text", text: "after restart" }] })).toEqual({ data: { outcome: "started", turnId: "recovered-turn" } }) + } + expect(transport.requests.slice(beforeRestart).map(({ method }) => method)).toEqual( + operation === "evicted actor" + ? ["session/queue/push", "session/resume", "subscription/create", "session/queue/push"] + : ["initialize", "session/resume", "subscription/create", + operation === "queue list" ? "session/queue/list" : "session/queue/push"], + ) + if (operation === "evicted actor") { + expect(transport.requests.filter(({ method }) => method === "session/queue/push") + .map(({ params }) => params)).toEqual([ + transport.requests.at(-1)?.params, transport.requests.at(-1)?.params, + ]) + } + client.dispose() + }) + } +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-performance.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-performance.test.ts new file mode 100644 index 00000000..d27add4a --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-performance.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" + +const nativeSession = { + id: "session-1", + version: 1, + cwd: "/repo", + title: "Native session", + parent: null, + createdAt: "2026-08-22T00:00:00Z", + lastActivityAt: "2026-08-22T00:00:00Z", + status: "idle", + flags: [], + archived: false, + ephemeral: false, + model: { provider: "test", model: "test-model" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 0, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-08-22T00:00:00Z", + }, +} + +class HistoryTransport implements DevoNativeTransport { + readonly requests: Array<{ method: string; params: any }> = [] + readonly listeners = new Set<(event: DevoNativeTransportEvent) => void>() + readonly history = Array.from({ length: 2400 }, (_, index) => ({ + id: `item-${index + 1}`, sessionId: nativeSession.id, turnId: "turn-1", + seq: index + 1, revision: 1, createdAt: nativeSession.createdAt, updatedAt: nativeSession.createdAt, + state: "completed", item: { type: "assistantMessage", text: `Message ${index + 1}` }, + })) + firstPage?: Promise + failPage = false + recovery: unknown[] = [] + async request(method: string, params?: any): Promise { + this.requests.push({ method, params }) + switch (method) { + case "initialize": return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + case "session/list": return { data: [nativeSession], nextCursor: null } + case "session/resume": return { session: nativeSession } + case "session/queue/list": return { entries: [] } + case "subscription/create": return { + subscriptionId: `sub-${this.requests.length}`, cursors: [], replay: [], pendingControlRequests: [], + recoverySnapshots: params.selectors[0].kind === "session" ? this.recovery : [], + snapshots: params.selectors[0].kind === "sessionsByCwd" ? [{ streamId: "cwd:/repo", barrierSeq: 0, data: { kind: "sessionsList", sessions: Array.from({ length: 120 }, (_, i) => ({ ...nativeSession, id: `session-${i + 1}` })) } }] : [], + } + case "session/items/list": { + await this.firstPage + if (this.failPage) throw new Error("temporary history read failure") + const end = params.cursor === "tail" ? this.history.length : Number(params.cursor.slice(7)) - 1 + const start = Math.max(0, end - params.limit) + return { data: this.history.slice(start, end), nextCursor: start ? `before:${start + 1}` : null } + } + default: throw new Error(`unexpected request ${method}`) + } + } + async respond(): Promise {} + subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + connected(): boolean { return true } + emit(event: DevoNativeTransportEvent): void { for (const listener of this.listeners) listener(event) } + pages(): unknown[] { return this.requests.filter(r => r.method === "session/items/list").map(r => r.params) } +} + +const tailPage = { sessionId: nativeSession.id, cursor: "tail", limit: 160 } +const olderPage = { sessionId: nativeSession.id, cursor: "before:2241", limit: 100 } + +describe("bounded Native session loading", () => { + test("identical session-list reads share one request", async () => { + const transport = new HistoryTransport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + const [first, second] = await Promise.all([client.session.list({ limit: 5 }), client.session.list({ limit: 5 })]) + expect(first).toEqual(second) + expect(transport.requests.filter(r => r.method === "session/list")).toHaveLength(1) + } finally { client.dispose() } + }) + + test("rosters preserve canonical selections already restored by resume", async () => { + const transport = new HistoryTransport() + const request = transport.request.bind(transport) + transport.request = async (method, params) => { + if (method === "session/resume") return { session: { ...nativeSession, model: { provider: "codex", model: "qa-model" }, settings: { permissionProfile: "default", reasoningEffort: "high" } } } + return request(method, params) + } + const client = createDevoClient({ directory: "/repo", transport }) + try { + await client.session.queue.list({ sessionId: nativeSession.id }) + const restored = await client.session.get({ sessionId: nativeSession.id }) + await client.event.subscribe() + expect(await client.session.get({ sessionId: nativeSession.id })).toEqual(restored) + } finally { client.dispose() } + }) + test("superseding a turn invalidates the tail window", async () => { + const transport = new HistoryTransport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + transport.history.splice(-200) + transport.emit({ type: "notification", method: "turn/superseded", params: { sessionId: nativeSession.id, supersededTurnId: "turn-1" } }) + const result = await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + expect(result.data.map(m => m.info.id)).toEqual(transport.history.slice(-160).map(m => m.id)) + expect(transport.pages()).toEqual([tailPage, tailPage]) + } finally { client.dispose() } + }) + test("120 idle sessions need one roster and no transcripts or actors", async () => { + const transport = new HistoryTransport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + await Promise.all([client.event.subscribe(), client.event.subscribe()]) + expect(transport.requests).toEqual([ + { method: "initialize", params: expect.anything() }, + { method: "subscription/create", params: { selectors: [{ kind: "sessionsByCwd", cwd: "/repo" }], includeSnapshot: true, replay: "snapshotOnly", after: [] } }, + ]) + transport.emit({ type: "notification", method: "session/statusChanged", params: { sessionId: "session-50", status: "active" } }) + await Bun.sleep(0) + expect(transport.requests.slice(2)).toEqual([ + { method: "subscription/create", params: { selectors: [{ kind: "session", sessionId: "session-50" }], includeSnapshot: true, replay: "snapshotOnly", after: [] } }, + ]) + } finally { client.dispose() } + }) + test("queue readiness skips history; expanding downloads only missing rows", async () => { + const transport = new HistoryTransport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + await client.session.queue.list({ sessionId: nativeSession.id }) + expect(transport.pages()).toEqual([]) + const first = await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + expect(first.data.map(m => m.info.id)).toEqual(transport.history.slice(-160).map(m => m.id)) + const expanded = await client.session.messages({ sessionId: nativeSession.id, limit: 260 }) + expect(expanded.data.map(m => m.info.id)).toEqual(transport.history.slice(-260).map(m => m.id)) + await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + await client.session.queue.list({ sessionId: nativeSession.id }) + expect(transport.pages()).toEqual([tailPage, olderPage]) + expect(transport.requests.filter(r => r.method === "session/resume")).toHaveLength(1) + } finally { client.dispose() } + }) + test("concurrent wider reads join the first page then fetch the remaining window", async () => { + const transport = new HistoryTransport() + let release!: () => void + transport.firstPage = new Promise(resolve => { release = resolve }) + const client = createDevoClient({ directory: "/repo", transport }) + try { + const first = client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + const second = client.session.messages({ sessionId: nativeSession.id, limit: 260 }) + await Bun.sleep(0) + release() + expect((await first).data).toHaveLength(160) + expect((await second).data).toHaveLength(260) + expect(transport.pages()).toEqual([tailPage, olderPage]) + } finally { client.dispose() } + }) + test("failed reads retry and reconnect invalidates the history cursor", async () => { + const transport = new HistoryTransport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + transport.failPage = true + await expect(client.session.messages({ sessionId: nativeSession.id, limit: 160 })).rejects.toThrow("temporary history read failure") + transport.failPage = false + await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + transport.emit({ type: "closed" }) + await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + expect(transport.pages()).toEqual([tailPage, tailPage, tailPage]) + expect(transport.requests.filter(r => r.method === "session/resume")).toHaveLength(2) + } finally { client.dispose() } + }) + test("full history remains available and exhausted cursors are cached", async () => { + const transport = new HistoryTransport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + expect((await client.session.messages({ sessionId: nativeSession.id })).data).toHaveLength(2400) + expect(transport.pages()).toHaveLength(12) + expect((await client.session.messages({ sessionId: nativeSession.id, limit: 3000 })).data).toHaveLength(2400) + expect(transport.pages()).toHaveLength(12) + } finally { client.dispose() } + }) + test("snapshot bootstrap restores in-flight text before history arrives", async () => { + const transport = new HistoryTransport() + const live = { ...transport.history.at(-1)!, id: "live-item", seq: 2401, state: "running", item: { type: "assistantMessage", text: "Already streamed" } } + transport.recovery = [{ item: live, accumulated: [] }] + const client = createDevoClient({ directory: "/repo", transport }) + try { + const result = await client.session.messages({ sessionId: nativeSession.id, limit: 160 }) + expect(result.data.at(-1)).toEqual({ info: live }) + expect(transport.pages()).toHaveLength(1) + } finally { client.dispose() } + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-subscription-replay.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-subscription-replay.test.ts index d361d045..49644d9b 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-subscription-replay.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-subscription-replay.test.ts @@ -168,7 +168,7 @@ describe("subscription replay forward compatibility", () => { const client = createDevoClient({ directory: "/repo", transport }) await client.session.updateSettings({ - sessionID: "session-1", + sessionId: "session-1", modelID: "test-model", reasoningEffort: "high", mode: "plan", @@ -179,7 +179,7 @@ describe("subscription replay forward compatibility", () => { // An empty patch must not hit the wire at all. const before = transport.requests.length - await client.session.updateSettings({ sessionID: "session-1" }) + await client.session.updateSettings({ sessionId: "session-1" }) expect(transport.requests.length).toBe(before) }) @@ -196,7 +196,7 @@ describe("subscription replay forward compatibility", () => { }) const client = createDevoClient({ directory: "/repo", transport }) - await client.session.updateSettings({ sessionID: "session-1", modelID: "test-model" }) + await client.session.updateSettings({ sessionId: "session-1", modelID: "test-model" }) const calls = transport.requests.map((request) => request.method) expect(calls).not.toContain("session/resume") expect(calls.filter((method) => method === "session/metadata/update").length).toBe(1) @@ -225,9 +225,9 @@ describe("subscription replay forward compatibility", () => { }) const client = createDevoClient({ directory: "/repo", transport }) - const first = client.session.updateSettings({ sessionID: "session-1", modelID: "model-a" }) + const first = client.session.updateSettings({ sessionId: "session-1", modelID: "model-a" }) await Promise.resolve() - const second = client.session.updateSettings({ sessionID: "session-1", mode: "plan" }) + const second = client.session.updateSettings({ sessionId: "session-1", mode: "plan" }) releaseFirst?.() await Promise.all([first, second]) @@ -252,10 +252,10 @@ describe("subscription replay forward compatibility", () => { const client = createDevoClient({ directory: "/repo", transport }) await expect( - client.session.updateSettings({ sessionID: "session-1", modelID: "test-model" }), + client.session.updateSettings({ sessionId: "session-1", modelID: "test-model" }), ).rejects.toThrow("rejected") failed = false - await client.session.retrySettings({ sessionID: "session-1" }) + await client.session.retrySettings({ sessionId: "session-1" }) expect( transport.requests.filter((request) => request.method === "session/metadata/update"), ).toHaveLength(2) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-worktree.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-worktree.test.ts new file mode 100644 index 00000000..1e662d3e --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-worktree.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport } from "./client" + +test("worktree lifecycle projects real Native results instead of placeholder success", async () => { + const calls: Array<{ method: string; params: unknown }> = [] + const cwd = "C:/QA/repository" + const directory = "C:/QA/worktree" + const info = { directory, name: "qa", branch: "devo/qa" } + const transport: DevoNativeTransport = { connected: () => true, respond: async () => {}, subscribe: () => () => {}, + request: async (method, params) => { + calls.push({ method, params }) + if (method === "initialize") return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + if (method === "workspace/worktree/list") return { worktrees: [info] } + if (method === "workspace/worktree/create") return info + return { directory } + } } + const client = createDevoClient({ transport, directory: cwd }) + expect([await client.worktree.list(), await client.worktree.create({ worktreeCreateInput: { name: "qa" } }), + await client.worktree.reset({ worktreeResetInput: { directory } }), await client.worktree.remove({ worktreeRemoveInput: { directory } })]) + .toEqual([{ data: [directory] }, { data: info }, { data: { directory } }, { data: { directory } }]) + expect(calls.filter((call) => call.method !== "initialize")).toEqual([ + { method: "workspace/worktree/list", params: { cwd } }, + { method: "workspace/worktree/create", params: { cwd, name: "qa" } }, + { method: "workspace/worktree/reset", params: { cwd, directory } }, + { method: "workspace/worktree/remove", params: { cwd, directory } } ]) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index f805fe8f..bbf9d81b 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -7,31 +7,58 @@ import { createIpcTransport, defaultCwd, permissionOptionId, - partTime, providerDataFromConfigOptions, questionInfoFromNative, sessionErrorEvent, stableId, - textFromUpdate, - toolCallIdFromUpdate, - toolPartFromUpdate, } from "./native-client-support" +import { + type NativeItemEnvelope, + envelopeFromWire, + mergeNativeEnvelope, + nativeItemType, + recentNativeItems, +} from "./native-item" +export type { NativeItemEnvelope } from "./native-item" +export { + assistantOrReasoningText, + compareNativeItems, + envelopeFromWire, + isUserMessageItem, + mergeNativeEnvelope, + nativeItemType, + recentNativeItems, + sortedNativeItems, + userMessageText, +} from "./native-item" import type { + ModelListResult, + CredentialListResult, + ModelCatalogRefreshParams, + ModelCatalogRefreshResult, + ProviderDisconnectParams, + ProviderDisconnectResult, + ProviderDiscoverParams, + ProviderDiscoverResult, + ProviderInfo, + ProviderListResult, + ProviderModelInfo, + ProviderModelRemoveParams, + ProviderModelRemoveResult, + ProviderModelVariant, + ProviderUpsertParams, + ProviderUpsertResult, ProviderValidateParams, ProviderValidateResult, - ProviderVendorListResult, - ProviderVendorUpsertParams, - ProviderVendorUpsertResult, - InputItem, + InputModality, + UserInput, TurnStartResult, WorkspaceChangeCoverage, WorkspaceChangeScope, WorkspaceChangeSetStatus, - WorkspaceChangeStats, WorkspaceChangeViewStatus, WorkspaceChangesReadParams, WorkspaceChangesReadResult, - WorkspaceChangesUpdatedPayload, WorkspaceDiffDetail, SessionInterruptParams, } from "./generated/native" @@ -140,8 +167,7 @@ export type QuestionInfo = { export type QuestionRequest = { id: string - requestID: string - sessionID: string + sessionId: string questions: QuestionInfo[] } @@ -163,14 +189,31 @@ export type ToolStateCompleted = any export type UserMessage = any export type Worktree = any export type { - ProviderModelBinding, + ModelInfo, + CredentialInfo, + ModelListResult, + CredentialListResult, + ModelCatalogRefreshParams, + ModelCatalogRefreshResult, + ProviderDisconnectParams, + ProviderDisconnectResult, + ProviderDiscoverParams, + ProviderDiscoverResult, + ProviderInfo, + ProviderListResult, + ProviderModelInfo, + ProviderModelRemoveParams, + ProviderModelRemoveResult, + ProviderModelVariant, + ProviderUpsertParams, + ProviderUpsertResult, ProviderValidateParams, ProviderValidateResult, - ProviderVendor, - ProviderVendorListResult, - ProviderVendorUpsertParams, - ProviderVendorUpsertResult, ProviderWireApi, + InputModality, + ReasoningCapability, + ReasoningEffort, + ReasoningLevelChoice, WorkspaceChangeAttribution, WorkspaceChangeBase, WorkspaceChangeCoverage, @@ -183,36 +226,53 @@ export type { WorkspaceChangedFileStatus, WorkspaceChangesReadParams, WorkspaceChangesReadResult, - WorkspaceChangesUpdatedPayload, WorkspaceDiffDetail, } from "./generated/native" -export type WorkspaceChangesReadOptions = { - sessionID: string - cwd?: string - scopes: WorkspaceChangeScope[] - baseBranch?: string - turnID?: string - diffDetail?: WorkspaceDiffDetail - maxDiffBytes?: number | bigint -} - -export type WorkspaceChangesUpdatedEventProperties = { - sessionID: string - turnID: string +/** Native `workspace/changes/updated` notification params (wire camelCase). */ +type WorkspaceChangesUpdatedNotification = { + sessionId: string + turnId: string scope: WorkspaceChangeScope status: WorkspaceChangeViewStatus coverage: WorkspaceChangeCoverage changeSetStatus: WorkspaceChangeSetStatus - stats: { - filesChanged: number - additions: number - deletions: number - } + stats: { filesChanged: number; additions: number; deletions: number } version: number generatedAt: string } +// ── Canonical provider/model catalog types (L2-DES-MODEL-002) ── + +/** Canonical provider/model types generated from the Native protocol schema. */ +export type CatalogWireApi = ProviderWireApi +export type CatalogModelVariant = ProviderModelVariant +export type CatalogModelInfo = ProviderModelInfo +export type CatalogProviderInfo = ProviderInfo +export type ProviderCatalogListResult = ProviderListResult +export type CatalogProviderUpsertParams = ProviderUpsertParams +export type CatalogProviderUpsertResult = ProviderUpsertResult +export type CatalogProviderDisconnectParams = ProviderDisconnectParams +export type CatalogProviderDisconnectResult = ProviderDisconnectResult +export type CatalogProviderModelRemoveParams = ProviderModelRemoveParams +export type CatalogProviderModelRemoveResult = ProviderModelRemoveResult +export type CatalogProviderValidateParams = ProviderValidateParams +export type CatalogProviderValidateResult = ProviderValidateResult +export type CatalogProviderDiscoverParams = ProviderDiscoverParams +export type CatalogProviderDiscoverResult = ProviderDiscoverResult +/** + * First-party workspace/changes/read options — Native wire names (`sessionId`, + * `turnId`, …). Chat item view models and permission/question events use the + * same camelCase identity fields (`sessionId`/`itemId`/`turnId`/`requestId`). + */ +export type WorkspaceChangesReadOptions = Omit & { + /** Optional; defaults to summary when omitted. */ + diffDetail?: WorkspaceDiffDetail +} + +/** Native `workspace/changes/updated` event properties (wire camelCase). */ +export type WorkspaceChangesUpdatedEventProperties = WorkspaceChangesUpdatedNotification + interface GlobalEvent { directory: string payload: Event @@ -234,16 +294,104 @@ type PendingPermission = { native?: boolean } -function partCacheKey(sessionId: string, messageId: string): string { - return `${sessionId}\u001f${messageId}` +function objectRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined +} + +/** Canonical Native plan-entry statuses — no snake_case reshape. */ +function planStatus(status: string): string { + switch (status) { + case "completed": + case "inProgress": + case "cancelled": + case "pending": + return status + default: + return "pending" + } } -function renderedNativeItemKey(sessionId: string, itemId: string): string { - return partCacheKey(sessionId, itemId) +type MappedPlanEntry = { content: string; status: string } + +function mapPlanEntry(entry: unknown): MappedPlanEntry | null { + const value = objectRecord(entry) ?? {} + const content = String(value.step ?? value.content ?? value.title ?? "").trim() + if (!content) return null + return { + content, + status: planStatus(String(value.status ?? "pending")), + } } -function objectRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined +/** + * Expand a single Plan entry when the server historically stored the whole + * `update_plan` output as one `step` (JSON object, JSON array, or Mixed text + * with a trailing pretty-printed plan array). Structured entries pass through. + */ +function expandPlanEntries(entries: unknown[]): MappedPlanEntry[] { + const mapped = entries.map(mapPlanEntry).filter((entry): entry is MappedPlanEntry => entry !== null) + if (mapped.length !== 1) return mapped + const only = mapped[0] + const expanded = expandPlanEntriesFromBlob(only.content) + return expanded.length > 0 ? expanded : mapped +} + +function expandPlanEntriesFromBlob(content: string): MappedPlanEntry[] { + const trimmed = content.trim() + if (!trimmed) return [] + + const tryParsePlan = (value: unknown): MappedPlanEntry[] => { + const plan = Array.isArray(value) + ? value + : Array.isArray(objectRecord(value)?.plan) + ? (objectRecord(value)?.plan as unknown[]) + : null + if (!plan) return [] + return plan.map(mapPlanEntry).filter((entry): entry is MappedPlanEntry => entry !== null) + } + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return tryParsePlan(JSON.parse(trimmed) as unknown) + } catch { + // Fall through to Mixed-text extraction. + } + } + + // Mixed tool text: "\n\n[ { status, step }, ... ]" + const arrayStart = trimmed.indexOf("\n[") + if (arrayStart >= 0) { + const maybeArray = trimmed.slice(arrayStart + 1).trim() + if (maybeArray.startsWith("[")) { + try { + return tryParsePlan(JSON.parse(maybeArray) as unknown) + } catch { + return [] + } + } + } + + // Embedded update_plan-shaped objects inside prose. + if ( + /"status"\s*:\s*"(?:pending|completed|inProgress|cancelled)"/.test(trimmed) && + /"(?:step|content)"\s*:/.test(trimmed) + ) { + const objectStart = trimmed.indexOf("{") + const arrayStartInline = trimmed.indexOf("[") + const start = + objectStart >= 0 && (arrayStartInline < 0 || objectStart < arrayStartInline) + ? objectStart + : arrayStartInline + if (start >= 0) { + try { + return tryParsePlan(JSON.parse(trimmed.slice(start)) as unknown) + } catch { + return [] + } + } + } + + return [] } const KNOWN_PERMISSION_SCOPES: PermissionResponse[] = [ @@ -257,27 +405,14 @@ const KNOWN_PERMISSION_SCOPES: PermissionResponse[] = [ "commandPrefixPersist", ] -function snakeCaseToCamelCase(value: string): string { - return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) -} - -export function normalizeApprovalScope(scope: string): PermissionResponse | null { - for (const candidate of [scope, snakeCaseToCamelCase(scope)]) { - if ((KNOWN_PERMISSION_SCOPES as string[]).includes(candidate)) { - return candidate as PermissionResponse - } - } - return null -} - -export function normalizeApprovalScopes(scopes: string[] | undefined): PermissionResponse[] { +/** Drop unknown tokens; keep only canonical Native approval scopes (no snake_case aliases). */ +function knownApprovalScopes(scopes: string[] | undefined): PermissionResponse[] { const normalized: PermissionResponse[] = [] const seen = new Set() for (const scope of scopes ?? ["once"]) { - const value = normalizeApprovalScope(scope) - if (value && !seen.has(value)) { - seen.add(value) - normalized.push(value) + if ((KNOWN_PERMISSION_SCOPES as string[]).includes(scope) && !seen.has(scope)) { + seen.add(scope) + normalized.push(scope as PermissionResponse) } } return normalized.length > 0 ? normalized : ["once"] @@ -309,227 +444,6 @@ function stringOrUndefined(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } -/** Maps a Native FileChangeKind entry into flat tool-input fields the UI can render. */ -function mapFileChangeKind(change: Record | undefined): { - changeType?: string - content?: string - unifiedDiff?: string - movePath?: string -} { - if (!change) return {} - const changeType = typeof change.type === "string" ? change.type : undefined - const content = typeof change.content === "string" ? change.content : undefined - const unifiedDiff = - typeof change.unifiedDiff === "string" - ? change.unifiedDiff - : typeof change.unified_diff === "string" - ? change.unified_diff - : undefined - const movePath = - typeof change.movePath === "string" - ? change.movePath - : typeof change.move_path === "string" - ? change.move_path - : undefined - return { changeType, content, unifiedDiff, movePath } -} - -/** - * Project Native `fileChange` items into tool-part input. - * Preserves change kind + unifiedDiff so the desktop row can show - * Writing/Added or Editing/Edited with an expandable diff — not a generic tool. - */ -function fileChangeInput(item: Record): Record | undefined { - const changes = Array.isArray(item.changes) ? item.changes : [] - if (changes.length === 0) return undefined - - const mapped = changes.map((entry) => { - const record = objectRecord(entry) ?? {} - const path = typeof record.path === "string" ? record.path : undefined - const kind = mapFileChangeKind(objectRecord(record.change)) - return { - path, - ...kind, - ...(typeof record.content === "string" && kind.content == null - ? { content: record.content } - : {}), - } - }) - - const first = mapped[0] - if (!first) return undefined - const path = first.path - const hasPayload = - path != null || - first.content != null || - first.unifiedDiff != null || - first.changeType != null - if (!hasPayload) return undefined - - const unifiedDiffs = mapped - .map((entry) => entry.unifiedDiff) - .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) - - return { - ...(path ? { filePath: path, path } : {}), - ...(first.changeType ? { changeType: first.changeType } : {}), - ...(first.content != null ? { content: first.content } : {}), - ...(first.unifiedDiff != null ? { unifiedDiff: first.unifiedDiff } : {}), - ...(first.movePath ? { movePath: first.movePath } : {}), - ...(mapped.length > 1 ? { changes: mapped } : {}), - ...(unifiedDiffs.length > 1 ? { unifiedDiff: unifiedDiffs.join("\n") } : {}), - } -} - -/** - * History often stores file tools as a nameless ToolResult whose `output` is - * `{ diff, files: [{ kind, filePath, oldContent, postContent, ... }] }` — - * the same shape the TUI parses on resume. Project that into flat tool input - * so desktop can render Writing/Added / Editing/Edited instead of generic JSON. - */ -function fileChangeInputFromToolOutput(output: unknown): Record | undefined { - const record = - typeof output === "string" - ? (() => { - try { - return objectRecord(JSON.parse(output)) - } catch { - return undefined - } - })() - : objectRecord(output) - if (!record) return undefined - const files = Array.isArray(record.files) ? record.files : [] - if (files.length === 0) return undefined - - const topDiff = - typeof record.diff === "string" - ? record.diff - : typeof record.patch === "string" - ? record.patch - : undefined - - const mapped = files.map((entry) => { - const file = objectRecord(entry) ?? {} - const path = - typeof file.filePath === "string" - ? file.filePath - : typeof file.path === "string" - ? file.path - : undefined - const kind = typeof file.kind === "string" ? file.kind : undefined - const changeType = - kind === "add" || kind === "update" || kind === "delete" || kind === "move" - ? kind === "move" - ? "update" - : kind - : undefined - const content = typeof file.content === "string" ? file.content : undefined - const oldString = - typeof file.oldContent === "string" - ? file.oldContent - : typeof file.preContent === "string" - ? file.preContent - : typeof file.pre_content === "string" - ? file.pre_content - : undefined - const newString = - typeof file.postContent === "string" - ? file.postContent - : typeof file.post_content === "string" - ? file.post_content - : changeType === "update" || changeType === "add" - ? content - : undefined - const unifiedDiff = - typeof file.diff === "string" - ? file.diff - : typeof file.patch === "string" - ? file.patch - : undefined - const movePath = - typeof file.movePath === "string" - ? file.movePath - : typeof file.move_path === "string" - ? file.move_path - : undefined - return { - path, - changeType, - content: changeType === "add" || changeType === "delete" ? content : undefined, - oldString, - newString, - unifiedDiff, - movePath, - } - }) - - const first = mapped[0] - if (!first?.path && !first?.unifiedDiff && !first?.content && !first?.oldString) { - return undefined - } - - const unifiedDiff = first.unifiedDiff ?? topDiff - return { - ...(first.path ? { filePath: first.path, path: first.path } : {}), - ...(first.changeType ? { changeType: first.changeType } : {}), - ...(first.content != null ? { content: first.content } : {}), - ...(first.oldString != null ? { oldString: first.oldString } : {}), - ...(first.newString != null ? { newString: first.newString } : {}), - ...(unifiedDiff ? { unifiedDiff } : {}), - ...(first.movePath ? { movePath: first.movePath } : {}), - ...(mapped.length > 1 ? { changes: mapped } : {}), - } -} - -/** Infer write vs edit from Native fileChange entries when toolName is absent. */ -function fileChangeToolKind(item: Record): "write" | "edit" | undefined { - const changes = Array.isArray(item.changes) ? item.changes : [] - const first = objectRecord(changes[0]) - const change = objectRecord(first?.change) - const changeType = typeof change?.type === "string" ? change.type : undefined - if (changeType === "add") return "write" - if (changeType === "update" || changeType === "delete") return "edit" - return undefined -} - -function fileChangeToolKindFromInput( - input: Record | undefined, -): "write" | "edit" | undefined { - if (!input) return undefined - const changeType = typeof input.changeType === "string" ? input.changeType : undefined - if (changeType === "add") return "write" - if (changeType === "update" || changeType === "delete") return "edit" - if (typeof input.unifiedDiff === "string" || typeof input.oldString === "string") return "edit" - if (typeof input.content === "string" && (input.path != null || input.filePath != null)) { - return "write" - } - return undefined -} - -/** Prefer displayContent; unwrap Mixed `{ output: string }` without JSON.stringify. */ -function toolResultDisplayOutput(item: Record): unknown { - if (typeof item.displayContent === "string" && item.displayContent.length > 0) { - return item.displayContent - } - if (typeof item.display_content === "string" && item.display_content.length > 0) { - return item.display_content - } - return unwrapToolOutputValue(item.output) -} - -function unwrapToolOutputValue(output: unknown): unknown { - if (typeof output === "string") return output - const record = objectRecord(output) - if (!record) return output - // Mixed tool result: text lives under `output` / `text`; keep file-change - // metadata objects intact so callers can project `files[]`. - if (Array.isArray(record.files)) return output - if (typeof record.output === "string") return record.output - if (typeof record.text === "string") return record.text - return output -} - function numberFromProtocol(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) return value if (typeof value === "bigint") return Number(value) @@ -551,10 +465,8 @@ function contextOccupancyFromProtocol(value: unknown): ContextOccupancyWire | nu if (!occupancy) return null const rawCategories = Array.isArray(occupancy.categories) ? occupancy.categories : [] return { - totalTokens: numberFromProtocol(occupancy.totalTokens ?? occupancy.total_tokens), - contextWindowTokens: numberFromProtocol( - occupancy.contextWindowTokens ?? occupancy.context_window_tokens, - ), + totalTokens: numberFromProtocol(occupancy.totalTokens), + contextWindowTokens: numberFromProtocol(occupancy.contextWindowTokens), categories: rawCategories.flatMap((entry) => { const category = objectRecord(entry) const id = String(category?.id ?? "") @@ -563,74 +475,13 @@ function contextOccupancyFromProtocol(value: unknown): ContextOccupancyWire | nu { id, tokens: numberFromProtocol(category?.tokens), - shareBps: numberFromProtocol(category?.shareBps ?? category?.share_bps), + shareBps: numberFromProtocol(category?.shareBps), }, ] }), } } -function workspaceChangeStats(value: unknown): WorkspaceChangeStats { - const stats = objectRecord(value) - return { - files_changed: numberFromProtocol(stats?.files_changed ?? stats?.filesChanged), - additions: numberFromProtocol(stats?.additions), - deletions: numberFromProtocol(stats?.deletions), - } -} - -// ── Canonical provider conversions (ratified #11) ── - -function canonicalProviderVendorWire(vendor: ProviderVendor): Record { - return { - name: vendor.name, - ...(vendor.base_url != null ? { baseUrl: vendor.base_url } : {}), - ...(vendor.credential != null ? { credential: vendor.credential } : {}), - ...(vendor.headers != null ? { headers: vendor.headers } : {}), - wireApis: vendor.wire_apis, - enabled: vendor.enabled, - } -} - -function canonicalModelBindingWire(binding: ProviderModelBinding): Record { - return { - bindingId: binding.binding_id, - modelSlug: binding.model_slug, - provider: binding.provider, - requestModel: binding.request_model, - ...(binding.display_name != null ? { displayName: binding.display_name } : {}), - invocationMethod: binding.invocation_method, - ...(binding.default_reasoning_effort != null - ? { defaultReasoningEffort: binding.default_reasoning_effort } - : {}), - enabled: binding.enabled, - } -} - -function legacyProviderVendorFromCanonical(vendor: Record): ProviderVendor { - return { - name: String(vendor.name ?? ""), - base_url: (vendor.baseUrl as string | null) ?? null, - credential: (vendor.credential as string | null) ?? null, - headers: (vendor.headers as string | null) ?? null, - wire_apis: (vendor.wireApis ?? []) as ProviderVendor["wire_apis"], - enabled: Boolean(vendor.enabled), - } -} - -function legacyModelBindingFromCanonical(binding: Record): ProviderModelBinding { - return { - binding_id: String(binding.bindingId ?? ""), - model_slug: String(binding.modelSlug ?? ""), - provider: String(binding.provider ?? ""), - request_model: String(binding.requestModel ?? ""), - display_name: (binding.displayName as string | null) ?? null, - invocation_method: binding.invocationMethod as ProviderModelBinding["invocation_method"], - default_reasoning_effort: (binding.defaultReasoningEffort as string | null) ?? null, - enabled: Boolean(binding.enabled), - } -} - /** Canonical `model/preferences` wire shape (ratified #12). */ type PreferencesOptionWire = { value: string @@ -690,73 +541,6 @@ function sessionConfigOptionsFromModelPreferences(preferences: ModelPreferencesW return options } -/** Canonical (camelCase) workspace change view → legacy generated shape. */ -function legacyWorkspaceChangeViewFromCanonical( view: Record, -): WorkspaceChangeView { - const base = objectRecord(view.base) - return { - scope: view.scope as WorkspaceChangeView["scope"], - status: view.status as WorkspaceChangeView["status"], - workspace_root: String(view.workspaceRoot ?? ""), - base: base ? legacyWorkspaceChangeBaseFromCanonical(base) : undefined, - coverage: view.coverage as WorkspaceChangeView["coverage"], - attribution: view.attribution as WorkspaceChangeView["attribution"], - change_set_status: view.changeSetStatus as WorkspaceChangeView["change_set_status"], - files: (view.files ?? []) as WorkspaceChangeView["files"], - stats: workspaceChangeStats(view.stats), - unified_diff: typeof view.unifiedDiff === "string" ? view.unifiedDiff : undefined, - warnings: (view.warnings ?? []) as string[], - generated_at: String(view.generatedAt ?? ""), - } -} - -function legacyWorkspaceChangeBaseFromCanonical( - base: Record, -): WorkspaceChangeBase { - if (base.kind === "branch") { - return { - kind: "branch", - base_branch: String(base.baseBranch ?? ""), - merge_base: String(base.mergeBase ?? ""), - head: String(base.head ?? ""), - } as WorkspaceChangeBase - } - if (base.kind === "turn_checkpoint") { - return { - kind: "turn_checkpoint", - turn_id: String(base.turnId ?? ""), - checkpoint_id: String(base.checkpointId ?? ""), - backend: base.backend, - } as WorkspaceChangeBase - } - return { - kind: "head", - head: typeof base.head === "string" ? base.head : undefined, - } as WorkspaceChangeBase -} - -function workspaceChangesUpdatedEventProperties( - payload: WorkspaceChangesUpdatedPayload, -): WorkspaceChangesUpdatedEventProperties { - const value = payload as unknown as Record - const stats = objectRecord(value.stats) ?? {} - return { - sessionID: value.sessionId ?? value.session_id, - turnID: value.turnId ?? value.turn_id, - scope: payload.scope, - status: payload.status, - coverage: payload.coverage, - changeSetStatus: value.changeSetStatus ?? value.change_set_status, - stats: { - filesChanged: numberFromProtocol(stats.filesChanged ?? stats.files_changed), - additions: numberFromProtocol(stats.additions), - deletions: numberFromProtocol(stats.deletions), - }, - version: numberFromProtocol(payload.version), - generatedAt: value.generatedAt ?? value.generated_at, - } -} - function parseTimestampMs(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value if (typeof value !== "string") return undefined @@ -773,30 +557,6 @@ function parseTitleState(value: unknown): string | undefined { return undefined } -/** Wire timestamps from a native ItemEnvelope into appendText/appendTool updates. */ -function nativeItemTimingFields( - envelope: Record, - options: { includeCompletedAt?: boolean } = {}, -): Record { - const turnId = typeof envelope.turnId === "string" ? envelope.turnId : "" - const fields: Record = {} - if (turnId) fields._meta = { [DEVO_TURN_ID_META]: turnId } - if (typeof envelope.createdAt === "string") fields.createdAt = envelope.createdAt - if (options.includeCompletedAt && typeof envelope.updatedAt === "string") { - fields.completedAt = envelope.updatedAt - } - return fields -} - -function updateEventTimeMs(update: Record, fallback: number): number { - return ( - parseTimestampMs(update.completedAt) ?? - parseTimestampMs(update.createdAt) ?? - updateHistoryCreatedAt(update) ?? - fallback - ) -} - type LoadedSessionLimit = number | null type SessionSettingsPatch = { modelID?: string @@ -818,17 +578,6 @@ type SessionSettingsQueue = { } const SESSION_SETTINGS_RETRY_DELAYS_MS = [250, 1_000, 2_000] as const -const HISTORY_MESSAGE_ID_RE = /^(?:tool-)?history-(\d+)$/ -const DEVO_TURN_ID_META = "devo/turnId" -const DEVO_HISTORY_INDEX_META = "devo/historyIndex" -const DEVO_PARENT_MESSAGE_ID_META = "devo/parentMessageId" -const DEVO_ITEM_KIND_META = "devo/itemKind" -const DEVO_RESEARCH_ARTIFACT_TYPE_META = "devo/researchArtifactType" -const DEVO_RESEARCH_ARTIFACT_TITLE_META = "devo/researchArtifactTitle" -const DEVO_COMPACTION_STATUS_META = "devo/compactionStatus" -const COMPACTION_STARTED_LABEL = "Compacting context" -const COMPACTION_COMPLETED_LABEL = "Context compacted" - type PromptPartInput = { type: string text?: string @@ -851,7 +600,7 @@ function pathFromFileUri(uri: string): string | null { } export type PromptAsyncOutcome = - | { outcome: "started" } + | { outcome: "started"; turnId?: string } | { outcome: "queued"; queueItemId: string } export type QueueWireEntry = { @@ -886,8 +635,8 @@ function parseQueueWireEntries(value: unknown): QueueWireEntry[] { .sort((left, right) => left.position - right.position) } -function inputItemsFromPromptParts(parts: PromptPartInput[]): InputItem[] { - const input: InputItem[] = [] +function userInputsFromPromptParts(parts: PromptPartInput[]): UserInput[] { + const input: UserInput[] = [] const text = parts .map((part) => (part.type === "text" ? (part.text ?? "") : "")) .join("\n") @@ -901,8 +650,7 @@ function inputItemsFromPromptParts(parts: PromptPartInput[]): InputItem[] { if (path) { input.push({ type: "mention", - path, - name: part.filename ?? path.split(/[\\/]/).pop() ?? path, + uri: path, }) continue } @@ -942,6 +690,39 @@ function errorRecord(error: unknown): Record | undefined { } } +/** Map a native turn failure onto the Desktop session/assistant error shape. */ +function assistantErrorFromTurnFailure( + turnStatus: string, + turnError: Record | undefined, +): { name: string; data: Record } | undefined { + const message = + typeof turnError?.message === "string" && turnError.message.trim() + ? turnError.message.trim() + : undefined + if (!message) { + // Follow-up `turn/completed` after TurnFailed has status failed but no + // error payload — do not invent a generic message that would clobber UI. + return undefined + } + const code = + typeof turnError?.errorCode === "string" + ? turnError.errorCode + : typeof turnError?.error_code === "string" + ? turnError.error_code + : turnStatus === "failed" + ? "TurnFailed" + : "Error" + const details = objectRecord(turnError?.details) + return { + name: code, + data: { + message, + ...(code !== "Error" ? { code } : {}), + ...(details ?? {}), + }, + } +} + function settingsErrorCode(error: unknown): string | undefined { const record = errorRecord(error) if (typeof record?.code === "string") return record.code @@ -956,6 +737,24 @@ function settingsErrorCode(error: unknown): string | undefined { return undefined } +/** True when the Native server reports the session is gone / never existed. */ +export function isSessionNotFoundError(error: unknown): boolean { + const code = settingsErrorCode(error) + const normalizedCode = code?.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (normalizedCode === "session_not_found") return true + const record = errorRecord(error) + if (typeof record?.code === "string") { + const recordCode = record.code.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + if (recordCode === "session_not_found") return true + } + const message = error instanceof Error ? error.message : String(error) + return ( + /session does not exist/i.test(message) || + /^session .+ not found$/i.test(message) || + /session id is not addressable by this server/i.test(message) + ) +} + function isTransientSessionSettingsError(error: unknown): boolean { const record = errorRecord(error) const code = settingsErrorCode(error) @@ -977,83 +776,6 @@ function waitForSessionSettingsRetry(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)) } -function historyMessageCreatedAt(messageId: string): number | undefined { - const match = HISTORY_MESSAGE_ID_RE.exec(messageId) - if (!match) return undefined - const index = Number.parseInt(match[1], 10) - return Number.isFinite(index) ? index + 1 : undefined -} - -function updateMeta(update: Record): Record | undefined { - return objectRecord(update._meta) -} - -function updateMetaString(update: Record, key: string): string | undefined { - const value = updateMeta(update)?.[key] - return typeof value === "string" && value ? value : undefined -} - -function textPartMetadataFromUpdate( - update: Record, - existingPart?: Record, -): Record | undefined { - const existing = objectRecord(existingPart?.metadata) - const metadata = existing ? { ...existing } : {} - const meta = updateMeta(update) - if (meta?.[DEVO_ITEM_KIND_META] === "research_artifact") { - metadata[DEVO_ITEM_KIND_META] = "research_artifact" - const artifactType = meta[DEVO_RESEARCH_ARTIFACT_TYPE_META] - if (typeof artifactType === "string" && artifactType) { - metadata[DEVO_RESEARCH_ARTIFACT_TYPE_META] = artifactType - } - const title = meta[DEVO_RESEARCH_ARTIFACT_TITLE_META] - if (typeof title === "string" && title) { - metadata[DEVO_RESEARCH_ARTIFACT_TITLE_META] = title - } - } - if (meta?.[DEVO_ITEM_KIND_META] === "context_compaction") { - metadata[DEVO_ITEM_KIND_META] = "context_compaction" - const status = meta[DEVO_COMPACTION_STATUS_META] - if (typeof status === "string" && status) { - metadata[DEVO_COMPACTION_STATUS_META] = status - } - } - if (meta?.[DEVO_ITEM_KIND_META] === "proposed_plan" || meta?.[DEVO_ITEM_KIND_META] === "plan") { - metadata[DEVO_ITEM_KIND_META] = meta[DEVO_ITEM_KIND_META] - if (meta.planEntries !== undefined) metadata.planEntries = meta.planEntries - } - return Object.keys(metadata).length > 0 ? metadata : undefined -} - -function updateHistoryCreatedAt(update: Record): number | undefined { - const value = updateMeta(update)?.[DEVO_HISTORY_INDEX_META] - const index = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseInt(value, 10) - : undefined - return typeof index === "number" && Number.isFinite(index) && index >= 0 - ? Math.floor(index) + 1 - : undefined -} - -function messageCreatedAt(message: Message): number { - const historyCreated = historyMessageCreatedAt(message.id) - if (historyCreated !== undefined) return historyCreated - const created = message.time?.created - return typeof created === "number" && Number.isFinite(created) ? created : 0 -} - -function compareMessages(left: Message, right: Message): number { - const byCreated = messageCreatedAt(left) - messageCreatedAt(right) - return byCreated === 0 ? left.id.localeCompare(right.id) : byCreated -} - -function sortedMessages(messages: Message[]): Message[] { - return [...messages].sort(compareMessages) -} - const initializePromises = new WeakMap>() export const DESKTOP_INITIALIZE_PARAMS = { @@ -1070,49 +792,60 @@ export const DESKTOP_INITIALIZE_PARAMS = { }, } as const -function recentMessages(messages: Message[], limit: number | undefined): Message[] { - const sorted = sortedMessages(messages) - if (limit === undefined || sorted.length <= limit) return sorted - let start = sorted.length - limit - while (start > 0 && sorted[start].role !== "user") { - start -= 1 - } - return sorted.slice(start) -} class NativeClient { private transport: DevoNativeTransport | null = null private openPromise: Promise | null = null private initialized = false + private disposed = false + private unsubscribeTransport: (() => void) | null = null private events = new AsyncEventQueue() private sessions = new Map() private sessionDirectories = new Map() private sessionStatuses = new Map() private promptStartedAtBySession = new Map() - private messages = new Map() - private parts = new Map() + /** Session transcript: Native ItemEnvelope keyed by itemId (no Message/Part dual). */ + private items = new Map>() private loadedSessionLimits = new Map() - private lastUserMessageBySession = new Map() - private userMessageByTurn = new Map() - private messageTurnIds = new Map() + private historyCursors = new Map() + private resumedSessions = new Set() + private sessionResumes = new Map>() + private subscriptionLoads = new Map>() + private directorySubscriptions = new Map>() + private sessionLists = new Map>() private configOptionsBySession = new Map() private configOptionsByDirectory = new Map() private pendingPermissions = new Map() private pendingQuestions = new Map() private subscriptions = new Map }>() private subscriptionCursors = new Map>() - private renderedNativeItems = new Set() private turnSessions = new Map() private activeTurnIds = new Map() private queueEntriesBySession = new Map() + /** Native itemId → callId (for command output deltas). */ private nativeItemCallIds = new Map() private sessionDiscovery = new Map>() private sessionLoads = new Map>() private sessionSettingsQueues = new Map() private lastEventTime = 0 private referenceSearchSession: ReferenceSearchSession | null = null + /** >0 while applying subscription create replay — must not bump sidebar sort keys. */ + private subscriptionReplayDepth = 0 constructor(private readonly options: CreateDevoClientOptions) {} + + dispose(): void { + if (this.disposed) return + this.disposed = true + const unsubscribe = this.unsubscribeTransport + this.unsubscribeTransport = null + try { + unsubscribe?.() + } finally { + this.events.close() + } + } + project = { list: async () => ({ data: await this.listProjects() }), } @@ -1123,76 +856,86 @@ class NativeClient { status: async () => ({ data: Object.fromEntries(this.sessionStatuses) }), create: async (_params?: { title?: string }) => ({ data: await this.createSession() }), promptAsync: async (params: { - sessionID: string + sessionId: string parts: PromptPartInput[] model?: unknown agent?: string variant?: string collaborationMode?: string }) => { - const directory = this.sessionDirectories.get(params.sessionID) ?? this.options.directory ?? defaultCwd() - this.lastUserMessageBySession.delete(params.sessionID) + const directory = this.sessionDirectories.get(params.sessionId) ?? this.options.directory ?? defaultCwd() const activityAt = Math.max(Date.now(), this.lastEventTime + 1) - this.touchNativeSessionActivity(params.sessionID, activityAt) - const wasBusy = this.sessionStatuses.get(params.sessionID)?.type === "busy" + this.touchNativeSessionActivity(params.sessionId, activityAt) + const wasBusy = this.sessionStatuses.get(params.sessionId)?.type === "busy" try { const result = await this.pushSessionQueue({ - sessionID: params.sessionID, + sessionId: params.sessionId, parts: params.parts, collaborationMode: params.collaborationMode, }) if (result.outcome === "started") { const promptStartedAt = Math.max(Date.now(), this.lastEventTime + 1) - this.promptStartedAtBySession.set(params.sessionID, promptStartedAt) + this.promptStartedAtBySession.set(params.sessionId, promptStartedAt) const busyStatus = { type: "busy" } - this.sessionStatuses.set(params.sessionID, busyStatus) + this.sessionStatuses.set(params.sessionId, busyStatus) this.emit(directory, { type: "session.status", - properties: { sessionID: params.sessionID, status: busyStatus }, + properties: { sessionId: params.sessionId, status: busyStatus }, }) } return { data: result } } catch (error) { - if (!wasBusy && !this.activeTurnIds.has(params.sessionID)) { - this.promptStartedAtBySession.delete(params.sessionID) - this.completeOpenAssistantMessages(params.sessionID, directory, activityAt) + if (!wasBusy && !this.activeTurnIds.has(params.sessionId)) { + this.promptStartedAtBySession.delete(params.sessionId) + this.completeOpenAssistantMessages(params.sessionId, directory, activityAt) const idleStatus = { type: "idle" } - this.sessionStatuses.set(params.sessionID, idleStatus) + this.sessionStatuses.set(params.sessionId, idleStatus) this.emit(directory, { type: "session.status", - properties: { sessionID: params.sessionID, status: idleStatus }, + properties: { sessionId: params.sessionId, status: idleStatus }, }) } - this.emit(directory, sessionErrorEvent(params.sessionID, error)) + this.emit(directory, sessionErrorEvent(params.sessionId, error)) throw error } }, queue: { - list: async (params: { sessionID: string }) => { - await this.ensureSessionSubscription(params.sessionID) - const result = (await this.requestCanonical("session/queue/list", { - sessionId: params.sessionID, - })) as { entries?: unknown } - const entries = parseQueueWireEntries(result.entries) - this.emitQueueSnapshot(params.sessionID, entries, "sync") - return { data: { entries } } + list: async (params: { sessionId: string }) => { + // Historical sessions show up in session/list but are not + // addressable until session/resume. Composer refresh races + // message load on open, so wait for load before queue/list. + try { + await this.ensureSessionReady(params.sessionId) + const result = (await this.requestCanonical("session/queue/list", { + sessionId: params.sessionId, + })) as { entries?: unknown } + const entries = parseQueueWireEntries(result.entries) + this.emitQueueSnapshot(params.sessionId, entries, "sync") + return { data: { entries } } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.emitQueueSnapshot(params.sessionId, [], "sync") + return { data: { entries: [] } } + } + throw error + } }, push: async (params: { - sessionID: string + sessionId: string parts: PromptPartInput[] collaborationMode?: string }) => ({ data: await this.pushSessionQueue(params) }), update: async (params: { - sessionID: string + sessionId: string queueItemId: string parts?: PromptPartInput[] position?: number }) => { const result = (await this.requestCanonical("session/queue/update", { - sessionId: params.sessionID, + sessionId: params.sessionId, queueItemId: params.queueItemId, ...(params.parts - ? { input: inputItemsFromPromptParts(params.parts) } + ? { input: userInputsFromPromptParts(params.parts) } : {}), ...(params.position !== undefined ? { position: params.position } : {}), })) as { entry?: unknown } @@ -1200,7 +943,7 @@ class NativeClient { if (entry) { const entries = parseQueueWireEntries([entry]) if (entries[0]) { - const current = this.queueEntriesForSession(params.sessionID) + const current = this.queueEntriesForSession(params.sessionId) const next = current.map((item) => item.queueItemId === entries[0].queueItemId ? entries[0] : item, ) @@ -1208,7 +951,7 @@ class NativeClient { next.push(entries[0]) } this.emitQueueSnapshot( - params.sessionID, + params.sessionId, next.sort((left, right) => left.position - right.position), "updated", ) @@ -1216,56 +959,57 @@ class NativeClient { } return { data: result } }, - remove: async (params: { sessionID: string; queueItemId: string }) => { + remove: async (params: { sessionId: string; queueItemId: string }) => { await this.requestCanonical("session/queue/remove", { - sessionId: params.sessionID, + sessionId: params.sessionId, queueItemId: params.queueItemId, }) return { data: null } }, - steer: async (params: { sessionID: string; queueItemId: string }) => { - const expectedTurnId = this.activeTurnIds.get(params.sessionID) - if (!expectedTurnId) { - throw new Error("No active turn to steer") - } - const result = await this.requestCanonical("session/queue/steer", { - sessionId: params.sessionID, - queueItemId: params.queueItemId, - expectedTurnId, - }) - return { data: result } - }, }, - editMessage: async (params: { sessionID: string; itemID: string; text: string }) => { - const directory = this.sessionDirectories.get(params.sessionID) ?? this.options.directory ?? defaultCwd() + steer: async (params: { sessionId: string; parts: PromptPartInput[] }) => { + const expectedTurnId = this.activeTurnIds.get(params.sessionId) + if (!expectedTurnId) { + throw new Error("No active turn to steer") + } + const result = await this.requestCanonical("turn/steer", { + sessionId: params.sessionId, + expectedTurnId, + input: userInputsFromPromptParts(params.parts), + idempotencyKey: crypto.randomUUID(), + }) + return { data: result } + }, + editMessage: async (params: { sessionId: string; itemId: string; text: string }) => { + const directory = this.sessionDirectories.get(params.sessionId) ?? this.options.directory ?? defaultCwd() const promptStartedAt = Math.max(Date.now(), this.lastEventTime + 1) - this.promptStartedAtBySession.set(params.sessionID, promptStartedAt) - this.touchNativeSessionActivity(params.sessionID, promptStartedAt) + this.promptStartedAtBySession.set(params.sessionId, promptStartedAt) + this.touchNativeSessionActivity(params.sessionId, promptStartedAt) try { const result = await this.requestCanonical("session/message/edit", { - sessionId: params.sessionID, - itemId: params.itemID, + sessionId: params.sessionId, + itemId: params.itemId, expectedRevision: 0, content: [{ type: "text", text: params.text }], idempotencyKey: crypto.randomUUID(), }) - if (this.sessionStatuses.get(params.sessionID)?.type !== "busy") { + if (this.sessionStatuses.get(params.sessionId)?.type !== "busy") { const busyStatus = { type: "busy" } - this.sessionStatuses.set(params.sessionID, busyStatus) + this.sessionStatuses.set(params.sessionId, busyStatus) this.emit(directory, { type: "session.status", - properties: { sessionID: params.sessionID, status: busyStatus }, + properties: { sessionId: params.sessionId, status: busyStatus }, }) } return { data: result } } catch (error) { - this.promptStartedAtBySession.delete(params.sessionID) + this.promptStartedAtBySession.delete(params.sessionId) throw error } }, - abort: async (params: { sessionID: string }) => { + abort: async (params: { sessionId: string }) => { const interruptParams: SessionInterruptParams = { - scope: { scope: "session", sessionId: params.sessionID }, + scope: { scope: "session", sessionId: params.sessionId }, } await this.request("session/interrupt", interruptParams) }, @@ -1274,23 +1018,23 @@ class NativeClient { * metadata updates do not require a prior resume, so this path is safe * while history is still loading and returns only after the server ACKs. */ - updateSettings: async (params: SessionSettingsPatch & { sessionID: string }) => { - return { data: await this.enqueueSessionSettings(params.sessionID, params) } + updateSettings: async (params: SessionSettingsPatch & { sessionId: string }) => { + return { data: await this.enqueueSessionSettings(params.sessionId, params) } }, - retrySettings: async (params: { sessionID: string }) => { - return { data: await this.retrySessionSettings(params.sessionID) } + retrySettings: async (params: { sessionId: string }) => { + return { data: await this.retrySessionSettings(params.sessionId) } }, - update: async (params: { sessionID: string; title: string }) => { + update: async (params: { sessionId: string; title: string }) => { // Canonical session/metadata/update (L2-DES-APP-008): the title // patch on the persist-first path; result is the canonical Session. const result = (await this.requestCanonical("session/metadata/update", { - sessionId: params.sessionID, + sessionId: params.sessionId, expectedVersion: 0, title: params.title, })) as { session?: Record } const metadata = { ...(result.session ?? {}), - id: String(result.session?.id ?? params.sessionID), + id: String(result.session?.id ?? params.sessionId), title: result.session?.title ?? params.title, } const session = this.rememberNativeSession(metadata) @@ -1300,62 +1044,70 @@ class NativeClient { }) return { data: session } }, - delete: async (params: { sessionID: string }) => { - await this.requestCanonical("session/delete", { sessionId: params.sessionID }) - const { directory } = this.forgetSession(params.sessionID) - this.emitSessionDeleted(params.sessionID, directory) + delete: async (params: { sessionId: string }) => { + await this.requestCanonical("session/delete", { sessionId: params.sessionId }) + const { directory } = this.forgetSession(params.sessionId) + this.emitSessionDeleted(params.sessionId, directory) }, // `session.get` is a durable snapshot read. It intentionally does not // resume the server actor; callers that need history use `messages`, // while metadata updates can write the snapshot directly. - get: async (params: { sessionID: string }) => ({ - data: await this.getSessionById(params.sessionID), + get: async (params: { sessionId: string }) => ({ + data: await this.getSessionById(params.sessionId), }), - diff: async (params: { sessionID: string }) => { - const result = (await this.requestCanonical("workspace/changes/read", { - sessionId: params.sessionID, - scopes: ["uncommitted"], - diffDetail: "full", - maxDiffBytes: 2_000_000, - })) as { views?: Array> } - return { - data: (result.views ?? []) - .map((view) => view.unifiedDiff) - .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) - .map((diff) => ({ diff })), + diff: async (params: { sessionId: string }) => { + try { + const result = (await this.requestCanonical("workspace/changes/read", { + sessionId: params.sessionId, + scopes: ["uncommitted"], + diffDetail: "full", + maxDiffBytes: 2_000_000, + })) as { views?: Array> } + return { + data: (result.views ?? []) + .map((view) => view.unifiedDiff) + .filter((diff): diff is string => typeof diff === "string" && diff.length > 0) + .map((diff) => ({ diff })), + } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionId) + return { data: [] } + } + throw error } }, - revert: async (params: { sessionID: string }) => ({ - data: this.sessions.get(params.sessionID), + revert: async (params: { sessionId: string }) => ({ + data: this.sessions.get(params.sessionId), }), - unrevert: async (params: { sessionID: string }) => ({ - data: this.sessions.get(params.sessionID), + unrevert: async (params: { sessionId: string }) => ({ + data: this.sessions.get(params.sessionId), }), - command: async (params: { sessionID: string; command: string; arguments?: string }) => { + command: async (params: { sessionId: string; command: string; arguments?: string }) => { const suffix = params.arguments ? ` ${params.arguments}` : "" await this.session.promptAsync({ - sessionID: params.sessionID, + sessionId: params.sessionId, parts: [{ type: "text", text: `/${params.command}${suffix}` }], }) }, - summarize: async (params: { sessionID: string }) => { + summarize: async (params: { sessionId: string }) => { await this.ensureInitialized() - await this.ensureSessionSubscription(params.sessionID) + await this.ensureSessionSubscription(params.sessionId) await this.requestCanonical("session/compact/start", { - sessionId: params.sessionID, + sessionId: params.sessionId, }) }, - messages: async (params: { sessionID: string; limit?: number }) => ({ - data: await this.sessionMessages(params.sessionID, normalizedHistoryLimit(params.limit)), + messages: async (params: { sessionId: string; limit?: number }) => ({ + data: await this.sessionMessages(params.sessionId, normalizedHistoryLimit(params.limit)), }), fork: async (params: { - sessionID: string + sessionId: string atTurnId?: string cut?: "through" | "before" }) => { await this.ensureInitialized() const result = (await this.requestCanonical("session/fork", { - sessionId: params.sessionID, + sessionId: params.sessionId, ...(params.atTurnId ? { atTurnId: params.atTurnId } : {}), ...(params.cut ? { cut: params.cut } : {}), })) as { session?: Record } @@ -1376,7 +1128,7 @@ class NativeClient { turn = { start: async (params: { - sessionID: string + sessionId: string parts: PromptPartInput[] model?: unknown variant?: string @@ -1394,12 +1146,12 @@ class NativeClient { const settingsPatch: SessionSettingsPatch = { mode: params.collaborationMode, } - await this.enqueueSessionSettings(params.sessionID, settingsPatch) + await this.enqueueSessionSettings(params.sessionId, settingsPatch) } - await this.ensureSessionSubscription(params.sessionID) + await this.ensureSessionSubscription(params.sessionId) const result = (await this.requestCanonical("turn/start", { - sessionId: params.sessionID, - input: inputItemsFromPromptParts(params.parts), + sessionId: params.sessionId, + input: userInputsFromPromptParts(params.parts), idempotencyKey: crypto.randomUUID(), })) as { turn: unknown } return { data: result } @@ -1408,7 +1160,7 @@ class NativeClient { task = { startAgent: async (params: { - sessionID: string + sessionId: string prompt: string forkTurns?: string maxTurns?: number @@ -1418,7 +1170,7 @@ class NativeClient { await this.ensureInitialized() const result = (await this.requestCanonical("task/start", { kind: "agent", - sessionId: params.sessionID, + sessionId: params.sessionId, input: [{ type: "text", text: params.prompt }], ...(params.forkTurns ? { forkTurns: params.forkTurns } : {}), ...(params.maxTurns !== undefined ? { maxTurns: params.maxTurns } : {}), @@ -1435,24 +1187,24 @@ class NativeClient { } question = { - reply: async (params: { requestID: string; answers: QuestionAnswer[] }) => { - await this.respondToQuestion(params.requestID, params.answers, "question.replied") + reply: async (params: { requestId: string; answers: QuestionAnswer[] }) => { + await this.respondToQuestion(params.requestId, params.answers, "question.replied") }, - reject: async (params: { requestID: string }) => { - await this.respondToQuestion(params.requestID, [], "question.rejected") + reject: async (params: { requestId: string }) => { + await this.respondToQuestion(params.requestId, [], "question.rejected") }, } permission = { respond: async (params: { - sessionID: string - permissionID: string + sessionId: string + permissionId: string response: PermissionResponse }) => { - await this.respondToPermission(params.permissionID, params.response) + await this.respondToPermission(params.permissionId, params.response) }, - reply: async (params: { requestID: string; reply?: PermissionResponse }) => { - await this.respondToPermission(params.requestID, params.reply ?? "reject") + reply: async (params: { requestId: string; reply?: PermissionResponse }) => { + await this.respondToPermission(params.requestId, params.reply ?? "reject") }, } @@ -1481,27 +1233,37 @@ class NativeClient { workspace = { changes: { read: async (params: WorkspaceChangesReadOptions) => { - // Canonical workspace/changes/read (L2-DES-APP-008): camelCase - // wire shape; views convert back to the legacy snake shape the - // renderer consumes while it stays on generated bindings. + // Pass Native WorkspaceChangesReadParams through; only default diffDetail. const wireParams: Record = { - sessionId: params.sessionID, + sessionId: params.sessionId, scopes: params.scopes, diffDetail: params.diffDetail ?? "summary", } if (params.cwd !== undefined) wireParams.cwd = params.cwd if (params.baseBranch !== undefined) wireParams.baseBranch = params.baseBranch - if (params.turnID !== undefined) wireParams.turnId = params.turnID + if (params.turnId !== undefined) wireParams.turnId = params.turnId if (params.maxDiffBytes !== undefined) { wireParams.maxDiffBytes = Number(params.maxDiffBytes) } - const canonical = (await this.requestCanonical( + if (params.ignoreWhitespace !== undefined) { + wireParams.ignoreWhitespace = params.ignoreWhitespace + } + if (params.paths !== undefined) { + wireParams.paths = params.paths + } + if (params.includeFileSides !== undefined) { + wireParams.includeFileSides = params.includeFileSides + } + const data = (await this.requestCanonical( "workspace/changes/read", wireParams, - )) as { views?: Array> } - const data: WorkspaceChangesReadResult = { - views: (canonical.views ?? []).map(legacyWorkspaceChangeViewFromCanonical), - } + ).catch((error) => { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionId) + return { views: [] } + } + throw error + })) as WorkspaceChangesReadResult return { data } }, }, @@ -1513,43 +1275,51 @@ class NativeClient { // User requirement: Desktop's composer status area needs direct goal state // controls, while the existing /goal trigger remains available for entry. - private async canonicalGoalTransition(method: string, sessionID: string): Promise { + private async canonicalGoalTransition(method: string, sessionId: string): Promise { const current = (await this.requestCanonical("session/goal/read", { - sessionId: sessionID, + sessionId, })) as { goal?: { id?: string } | null } const expectedGoalId = current.goal?.id if (!expectedGoalId) throw new Error("session has no active goal") return this.requestCanonical(method, { - sessionId: sessionID, + sessionId, expectedGoalId, }) } goal = { - status: async (params: { sessionID: string }) => { - const result = (await this.requestCanonical("session/goal/read", { - sessionId: params.sessionID, - })) as { goal?: unknown } - return { data: result.goal } + status: async (params: { sessionId: string }) => { + try { + const result = (await this.requestCanonical("session/goal/read", { + sessionId: params.sessionId, + })) as { goal?: unknown } + return { data: result.goal } + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(params.sessionId) + return { data: null } + } + throw error + } }, - pause: async (params: { sessionID: string }) => { + pause: async (params: { sessionId: string }) => { const result = (await this.canonicalGoalTransition( "session/goal/pause", - params.sessionID, + params.sessionId, )) as { goal?: unknown } return { data: result.goal } }, - resume: async (params: { sessionID: string }) => { + resume: async (params: { sessionId: string }) => { const result = (await this.canonicalGoalTransition( "session/goal/resume", - params.sessionID, + params.sessionId, )) as { goal?: unknown } return { data: result.goal } }, - clear: async (params: { sessionID: string }) => { + clear: async (params: { sessionId: string }) => { const result = await this.canonicalGoalTransition( "session/goal/clear", - params.sessionID, + params.sessionId, ) return { data: result } }, @@ -1578,10 +1348,19 @@ class NativeClient { } worktree = { - list: async () => ({ data: [] }), - create: async (_params: unknown) => ({ data: null }), - remove: async (_params: unknown) => ({ data: null }), - reset: async (_params: unknown) => ({ data: null }), + list: async () => { + const result = await this.requestCanonical("workspace/worktree/list", { cwd: this.options.directory ?? defaultCwd() }) as { worktrees: Array<{ directory: string }> } + return { data: result.worktrees.map((worktree) => worktree.directory) } + }, + create: async (params: { worktreeCreateInput: { name: string; startCommand?: string } }) => ({ + data: await this.requestCanonical("workspace/worktree/create", { cwd: this.options.directory ?? defaultCwd(), name: params.worktreeCreateInput.name }), + }), + remove: async (params: { worktreeRemoveInput: { directory: string } }) => ({ + data: await this.requestCanonical("workspace/worktree/remove", { cwd: this.options.directory ?? defaultCwd(), directory: params.worktreeRemoveInput.directory }), + }), + reset: async (params: { worktreeResetInput: { directory: string } }) => ({ + data: await this.requestCanonical("workspace/worktree/reset", { cwd: this.options.directory ?? defaultCwd(), directory: params.worktreeResetInput.directory }), + }), } config = { @@ -1621,11 +1400,11 @@ class NativeClient { context = { usage: { - read: async (params: { sessionID: string }) => { + read: async (params: { sessionId: string }) => { const result = (await this.requestCanonical("context/usage/read", { - sessionId: params.sessionID, + sessionId: params.sessionId, })) as { occupancy?: unknown } - this.emitContextUsage(params.sessionID, result.occupancy) + this.emitContextUsage(params.sessionId, result.occupancy) return { data: result.occupancy } }, }, @@ -1647,70 +1426,77 @@ class NativeClient { }), } + private modelPreferencesWrite: Promise = Promise.resolve() + + model = { + preferences: { + write: (params: { patch: { model?: string; reasoningEffort?: string } }) => { + const write = this.modelPreferencesWrite.catch(() => undefined).then(async () => { + const result = await this.requestCanonical("model/preferences/write", { + ...params, ...(this.options.directory ? { cwd: this.options.directory } : {}), + }) as { preferences: ModelPreferencesWire } + this.rememberDirectoryConfigOptions(this.options.directory ?? defaultCwd(), + sessionConfigOptionsFromModelPreferences(result.preferences)) + return { data: result } + }) + this.modelPreferencesWrite = write + return write + }, + }, + list: async (): Promise<{ data: ModelListResult }> => ({ + data: await this.requestCanonical("model/list", {}) as ModelListResult, + }), + refreshCatalog: async (params: ModelCatalogRefreshParams): Promise<{ data: ModelCatalogRefreshResult }> => { + const data = await this.requestCanonical("model/catalog/refresh", params) as ModelCatalogRefreshResult + if (data.status === "updated") this.invalidateConfigOptionCaches() + return { data } + }, + } + + credential = { + /** Native returns credential metadata only, never secret values. */ + list: async (): Promise<{ data: CredentialListResult }> => ({ + data: await this.requestCanonical("credential/list", {}) as CredentialListResult, + }), + } + provider = { list: async () => { - // Canonical provider/list (ratified #11): camelCase wire; vendors - // convert back to the generated snake shape for callers. - const result = (await this.requestCanonical("provider/list", {})) as { - providers?: Array> - } - const data: ProviderVendorListResult = { - provider_vendors: (result.providers ?? []).map(legacyProviderVendorFromCanonical), - } + const data = (await this.requestCanonical("provider/list", {})) as ProviderListResult return { data } }, validate: async (params: ProviderValidateParams) => { - const result = (await this.requestCanonical("provider/validate", { - providerVendor: canonicalProviderVendorWire(params.provider_vendor), - modelBinding: canonicalModelBindingWire(params.model_binding), - ...(params.api_key !== undefined && params.api_key !== null - ? { apiKey: params.api_key } - : {}), - })) as { replyPreview?: string } - const data: ProviderValidateResult = { reply_preview: result.replyPreview ?? "" } + const data = (await this.requestCanonical("provider/validate", params)) as ProviderValidateResult return { data } }, - upsert: async (params: ProviderVendorUpsertParams) => { - const result = (await this.requestCanonical("provider/upsert", { - providerVendor: canonicalProviderVendorWire(params.provider_vendor), - ...(params.model_binding - ? { modelBinding: canonicalModelBindingWire(params.model_binding) } - : {}), - ...(params.default_model_binding !== undefined && params.default_model_binding !== null - ? { defaultModelBinding: params.default_model_binding } - : {}), - ...(params.api_key !== undefined && params.api_key !== null - ? { apiKey: params.api_key } - : {}), - })) as { - providerVendor?: Record - modelBinding?: Record - } - const data: ProviderVendorUpsertResult = { - provider_vendor: legacyProviderVendorFromCanonical(result.providerVendor ?? {}), - ...(result.modelBinding - ? { model_binding: legacyModelBindingFromCanonical(result.modelBinding) } - : {}), - } as ProviderVendorUpsertResult + upsert: async (params: ProviderUpsertParams) => { + const data = (await this.requestCanonical("provider/upsert", params)) as ProviderUpsertResult this.invalidateConfigOptionCaches() return { data } }, + disconnect: async (params: ProviderDisconnectParams): Promise => { + const result = (await this.requestCanonical("provider/disconnect", params)) as ProviderDisconnectResult + this.invalidateConfigOptionCaches() + return result + }, + modelRemove: async (params: ProviderModelRemoveParams): Promise => { + const result = (await this.requestCanonical("provider/model/remove", params)) as ProviderModelRemoveResult + this.invalidateConfigOptionCaches() + return result + }, + discover: async (params: ProviderDiscoverParams): Promise => { + const result = (await this.requestCanonical("provider/discover", params)) as ProviderDiscoverResult + // Discover mutates the connection model directory; composer selectors + // read model/preferences which must not keep a pre-discover snapshot. + this.invalidateConfigOptionCaches() + return result + }, auth: async () => ({ data: [] }), oauth: { authorize: async (_params: unknown) => ({ data: null }), callback: async (_params: unknown) => ({ data: null }), }, } - - auth = { - set: async (_params: unknown) => ({ data: null }), - remove: async (_params: unknown) => ({ data: null }), - } - - part = { - delete: async (_params: unknown) => ({ data: null }), - } - private async listProjects(): Promise { const sessions = await this.listSessions() const byDirectory = new Map() @@ -1746,6 +1532,17 @@ class NativeClient { } private async listSessions(params?: { limit?: number; roots?: boolean; search?: string }): Promise { + const key = JSON.stringify(params ?? {}) + const pending = this.sessionLists.get(key) + if (pending) return pending + const load = this.listSessionsOnce(params) + this.sessionLists.set(key, load) + try { return await load } finally { + if (this.sessionLists.get(key) === load) this.sessionLists.delete(key) + } + } + + private async listSessionsOnce(params?: { limit?: number; roots?: boolean; search?: string }): Promise { await this.ensureInitialized() const sessions: Session[] = [] let cursor: string | undefined @@ -1783,74 +1580,96 @@ class NativeClient { })) as { session: Record } const session = this.rememberNativeSession(result.session) await this.ensureSessionSubscription(session.id) + this.resumedSessions.add(session.id) this.emit(session.directory ?? cwd, { type: "session.created", properties: { info: session, session }, }) return session } - private async sessionMessages(sessionId: string, limit?: number): Promise> { - await this.loadSession(sessionId, limit) - const messages = recentMessages(this.messages.get(sessionId) ?? [], limit) - return messages.map((info) => ({ - info, - parts: this.parts.get(partCacheKey(sessionId, info.id)) ?? [], - })) + private async sessionMessages( + sessionId: string, + limit?: number, + ): Promise> { + try { + await this.loadSession(sessionId, limit) + } catch (error) { + if (isSessionNotFoundError(error)) return [] + throw error + } + const items = [...(this.items.get(sessionId)?.values() ?? [])] + return recentNativeItems(items, limit).map((info) => ({ info })) } private async loadSession(sessionId: string, limit?: number): Promise { - const loadedLimit = this.loadedSessionLimits.get(sessionId) - if (loadedLimitCovers(loadedLimit, limit)) return - const pending = this.sessionLoads.get(sessionId) + // A concurrent queue/read or wider history request must not claim that a + // narrower in-flight window covers it. Recheck coverage after joining. + while (!loadedLimitCovers(this.loadedSessionLimits.get(sessionId), limit)) { + const pending = this.sessionLoads.get(sessionId) + if (pending) { await pending; continue } + const load = this.loadSessionOnce(sessionId, limit) + this.sessionLoads.set(sessionId, load) + try { await load } catch (error) { + if (isSessionNotFoundError(error) && (this.sessions.has(sessionId) || this.items.has(sessionId))) { + this.dropMissingSession(sessionId) + } + throw error + } finally { + if (this.sessionLoads.get(sessionId) === load) this.sessionLoads.delete(sessionId) + } + } + } + + private dropMissingSession(sessionId: string): void { + const { directory } = this.forgetSession(sessionId) + this.subscriptions.delete(sessionId) + this.sessionSettingsQueues.delete(sessionId) + this.emitSessionDeleted(sessionId, directory) + } + + private async ensureSessionReady(sessionId: string): Promise { + if (this.resumedSessions.has(sessionId)) return + const pending = this.sessionResumes.get(sessionId) if (pending) return pending - const load = this.loadSessionOnce(sessionId, limit) - this.sessionLoads.set(sessionId, load) + const resume = this.resumeSessionOnce(sessionId) + this.sessionResumes.set(sessionId, resume) + try { await resume } finally { + if (this.sessionResumes.get(sessionId) === resume) this.sessionResumes.delete(sessionId) + } + } + + private async resumeSessionOnce(sessionId: string): Promise { try { - await load - } finally { - if (this.sessionLoads.get(sessionId) === load) this.sessionLoads.delete(sessionId) + const resumed = (await this.requestCanonical("session/resume", { sessionId })) as { + session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown + } + const enriched = this.rememberNativeSession(resumed.session) + this.emit(enriched.directory ?? this.options.directory ?? defaultCwd(), { + type: "session.updated", properties: { info: enriched, session: enriched }, + }) + this.emitContextUsage(sessionId, resumed.lastContextOccupancy ?? resumed.last_context_occupancy) + await this.ensureSessionSubscription(sessionId) + this.resumedSessions.add(sessionId) + this.sessionsNeedingResume.delete(sessionId) + } catch (error) { + if (isSessionNotFoundError(error)) this.dropMissingSession(sessionId) + throw error } } private async loadSessionOnce(sessionId: string, limit?: number): Promise { - await this.ensureInitialized() - const session = await this.getSessionById(sessionId) - const cwd = session?.directory ?? this.sessionDirectories.get(sessionId) - if (!cwd) throw new Error(`session ${sessionId} not found`) - const resumed = (await this.requestCanonical("session/resume", { - sessionId, - })) as { session: Record; lastContextOccupancy?: unknown; last_context_occupancy?: unknown } - const enriched = this.rememberNativeSession(resumed.session) - // The resume response carries the authoritative persisted model / - // settings for the session; cold `session/list` snapshots may lack - // them. Surface the enrichment so renderer session stores re-seed the - // composer — without this, the enriched snapshot stays buried in this - // client's internal cache and restored sessions fall back to defaults. - this.emit(enriched.directory ?? cwd, { - type: "session.updated", - properties: { info: enriched, session: enriched }, - }) - this.emitContextUsage( - sessionId, - resumed.lastContextOccupancy ?? resumed.last_context_occupancy, - ) - let cursor: string | undefined + await this.ensureSessionReady(sessionId) + let loaded = this.loadedSessionLimits.get(sessionId) ?? 0 + let cursor: string | null = this.historyCursors.get(sessionId) ?? "tail" do { const page = (await this.requestCanonical("session/items/list", { - sessionId, - ...(cursor ? { cursor } : {}), - limit: 500, + sessionId, cursor, limit: Math.min(200, limit === undefined ? 200 : Math.max(1, limit - loaded)), })) as { data?: Array>; nextCursor?: string | null } - for (const item of page.data ?? []) { - this.handleNativeItemEnvelope(item, nativeItemNotificationMethod(item)) - } - cursor = page.nextCursor ?? undefined - } while (cursor) - const queueResult = (await this.requestCanonical("session/queue/list", { - sessionId, - })) as { entries?: unknown } - this.emitQueueSnapshot(sessionId, parseQueueWireEntries(queueResult.entries), "sync") - await this.ensureSessionSubscription(sessionId) - this.loadedSessionLimits.set(sessionId, null) + for (const item of page.data ?? []) this.handleNativeItemEnvelope(item, nativeItemNotificationMethod(item)) + loaded += (page.data ?? []).length + cursor = page.nextCursor ?? null + this.historyCursors.set(sessionId, cursor) + this.loadedSessionLimits.set(sessionId, cursor ? loaded : null) + } while (cursor && (limit === undefined || loaded < limit)) } private async getSessionById(sessionId: string): Promise { @@ -1878,7 +1697,16 @@ class NativeClient { const usage = objectRecord(info.usage) const total = objectRecord(usage?.total) const created = parseTimestampMs(info.createdAt) ?? existing?.time.created ?? Date.now() - const updated = parseTimestampMs(info.lastActivityAt) ?? existing?.time.updated ?? created + const wireActivity = parseTimestampMs(info.lastActivityAt) + const existingActivity = Math.max(existing?.time.lastActivity ?? 0, existing?.time.updated ?? 0) + // Never let resume/snapshot lower a known activity timestamp — that alone + // can reshuffle the sidebar. Prefer the newer of wire vs in-memory. + const updated = + wireActivity != null + ? Math.max(wireActivity, existingActivity) + : existingActivity > 0 + ? existingActivity + : created const parent = objectRecord(info.parent) const forkFromId = typeof info.forkFromId === "string" @@ -1929,7 +1757,7 @@ class NativeClient { id, title: typeof info.title === "string" ? info.title : existing?.title, titleState: titleState ?? existing?.titleState ?? "Unset", - parentID: typeof parent?.sessionId === "string" ? parent.sessionId : existing?.parentID, + parentId: typeof parent?.sessionId === "string" ? parent.sessionId : existing?.parentId, forkFromId, atTurnId, time: { created, updated, lastActivity: updated }, @@ -1966,8 +1794,12 @@ class NativeClient { * (agentsAtom / sidebar) recompute without a session/list refresh. * Native turn/item traffic does not carry session/metadataUpdated for * activity-only bumps — only title updates do — so the client owns live sync. + * + * Skipped during subscription replay: historical turn/item envelopes must + * not reshuffle the sidebar when the user merely opens a session. */ private touchNativeSessionActivity(sessionId: string, at = Date.now()): void { + if (this.subscriptionReplayDepth > 0) return const session = this.sessions.get(sessionId) if (!session) return const previous = Math.max(session.time.lastActivity ?? 0, session.time.updated ?? 0) @@ -1988,22 +1820,32 @@ class NativeClient { if (!this.transport) throw new Error("Devo Native transport is not connected") if (this.initialized) return - let promise = initializePromises.get(this.transport) + const transport = this.transport + let promise = initializePromises.get(transport) if (!promise) { promise = this.request("initialize", DESKTOP_INITIALIZE_PARAMS).then(() => {}) - initializePromises.set(this.transport, promise) + initializePromises.set(transport, promise) + } + try { + await promise + } catch (error) { + // A failed handshake must not block retries on a still-open transport. + // Another caller may have already replaced this entry with a new attempt. + if (initializePromises.get(transport) === promise) initializePromises.delete(transport) + throw error } - await promise this.initialized = true } private async open(): Promise { + if (this.disposed) throw new Error("Devo Native client has been disposed") if (this.transport) return if (this.openPromise) return this.openPromise this.openPromise = Promise.resolve() .then(() => { + if (this.disposed) throw new Error("Devo Native client has been disposed") this.transport = this.options.transport ?? createIpcTransport() - this.transport.subscribe((event) => this.handleTransportEvent(event)) + this.unsubscribeTransport = this.transport.subscribe((event) => this.handleTransportEvent(event)) }) .finally(() => { this.openPromise = null @@ -2065,7 +1907,30 @@ class NativeClient { return this.referenceSearchSession } + private recoveryListeners = new Set<() => void>() + + readonly turnRecovery = { + read: async (sessionId: string) => this.request("turn/recovery/read", { sessionId }), + resume: async (sessionId: string, recovery: { turnId: string; revision: number }, idempotencyKey: string) => { + const result = await this.request("turn/resume", { + sessionId, expectedTurnId: recovery.turnId, recoveryRevision: recovery.revision, idempotencyKey, + }) + return result + }, + cancel: async (sessionId: string) => this.session.abort({ sessionId }), + subscribe: (listener: () => void) => { + this.recoveryListeners.add(listener) + return () => { this.recoveryListeners.delete(listener) } + }, + } + private handleTransportEvent(event: DevoNativeTransportEvent): void { + if (this.disposed) return + if (event.type === "closed" || (event.type === "notification" && + (event.method?.startsWith("turn/") || event.method === "session/metadataUpdated"))) { + for (const listener of this.recoveryListeners) listener() + } + if (event.type === "closed") { if (this.transport) { initializePromises.delete(this.transport) @@ -2076,6 +1941,11 @@ class NativeClient { this.pendingPermissions.clear() this.pendingQuestions.clear() this.subscriptions.clear() + for (const sessionId of this.sessions.keys()) this.sessionsNeedingResume.add(sessionId) + this.loadedSessionLimits.clear() + this.historyCursors.clear() + this.resumedSessions.clear() + this.directorySubscriptions.clear() this.turnSessions.clear() this.nativeItemCallIds.clear() this.referenceSearchSession = null @@ -2124,7 +1994,7 @@ class NativeClient { ) ?? {} const approvalId = String(value.approvalId ?? value.requestId ?? "") if (!approvalId) return true - const availableScopes = normalizeApprovalScopes( + const availableScopes = knownApprovalScopes( Array.isArray(value.availableScopes) ? value.availableScopes.map(String) : undefined, ) const existing = this.pendingPermissions.get(approvalId) @@ -2165,6 +2035,21 @@ class NativeClient { private handleNativeNotification(method: string, params: unknown): boolean { const value = objectRecord(params) ?? {} + if (method === "provider/authStale") { + const providerId = String(value.providerId ?? value.provider_id ?? "") + const reason = + typeof value.reason === "string" && value.reason.trim().length > 0 + ? value.reason + : undefined + this.emit(this.options.directory ?? defaultCwd(), { + type: "provider.authStale", + properties: { + providerId, + reason, + }, + }) + return true + } if (method === "session/created" || method === "session/metadataUpdated") { const sessionValue = objectRecord(value.session) if (sessionValue) { @@ -2179,11 +2064,22 @@ class NativeClient { if (method === "session/statusChanged") { const sessionId = String(value.sessionId ?? "") if (!sessionId) return true + // Subscription replay may include historical Active status for + // abandoned turns; live busy is seeded only from the snapshot's + // registry-backed activeTurn (and live events after ack). + if (this.subscriptionReplayDepth > 0 && String(value.status) === "active") { + return true + } const status = { type: String(value.status) === "active" ? "busy" : "idle" } + if (status.type === "busy" && this.subscriptionReplayDepth === 0) { + void this.ensureSessionSubscription(sessionId).catch(error => { + this.emit(this.sessionDirectories.get(sessionId) ?? defaultCwd(), sessionErrorEvent(sessionId, error)) + }) + } this.sessionStatuses.set(sessionId, status) this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { type: "session.status", - properties: { sessionID: sessionId, status }, + properties: { sessionId: sessionId, status }, }) return true } @@ -2224,7 +2120,7 @@ class NativeClient { } return true } - if (method === "turn/started" || method === "turn/statusChanged" || method === "turn/completed") { + if ((method === "turn/started" || method === "turn/resumed") || method === "turn/statusChanged" || method === "turn/completed") { const turn = objectRecord(value.turn) const turnId = String(turn?.id ?? value.turnId ?? "") const sessionId = String(turn?.sessionId ?? this.turnSessions.get(turnId) ?? "") @@ -2233,33 +2129,49 @@ class NativeClient { const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() const turnStatus = String(turn?.status ?? value.status ?? "") const terminal = method === "turn/completed" || ["completed", "interrupted", "failed"].includes(turnStatus) - if (method === "turn/started" && turnId) { + // Historical turn/started without a matching terminal must not + // resurrect busy after an idle/registry-empty snapshot. + if (this.subscriptionReplayDepth > 0 && !terminal) { + return true + } + if ((method === "turn/started" || method === "turn/resumed") && turnId) { this.activeTurnIds.set(sessionId, turnId) this.emit(directory, { type: "session.activeTurn", - properties: { sessionID: sessionId, turnID: turnId }, + properties: { sessionId: sessionId, turnId: turnId }, }) } const status = { type: terminal ? "idle" : "busy" } this.sessionStatuses.set(sessionId, status) - this.emit(directory, { type: "session.status", properties: { sessionID: sessionId, status } }) + this.emit(directory, { type: "session.status", properties: { sessionId: sessionId, status } }) const activityAt = parseTimestampMs(terminal ? turn?.completedAt : turn?.startedAt) ?? parseTimestampMs(turn?.updatedAt) ?? Date.now() // Start + terminal only: statusChanged mid-turn would spam session.updated. - if (method === "turn/started" || terminal) { + if ((method === "turn/started" || method === "turn/resumed") || terminal) { this.touchNativeSessionActivity(sessionId, activityAt) } if (terminal) { this.activeTurnIds.delete(sessionId) this.emit(directory, { type: "session.activeTurn", - properties: { sessionID: sessionId, turnID: null }, + properties: { sessionId: sessionId, turnId: null }, }) const startedAt = this.promptStartedAtBySession.get(sessionId) ?? 0 this.promptStartedAtBySession.delete(sessionId) - this.completeOpenAssistantMessages(sessionId, directory, startedAt) + // Native `TurnFailed` projects as `turn/completed` with `turn.error` + // (often followed by a completed notification without error). Surface + // the payload so Desktop can render session/assistant failure UI. + const turnError = objectRecord(turn?.error) + const assistantError = assistantErrorFromTurnFailure(turnStatus, turnError) + if (assistantError) { + this.emit(directory, { + type: "session.error", + properties: { sessionId: sessionId, turnId, error: assistantError }, + }) + } + this.completeOpenAssistantMessages(sessionId, directory, startedAt, assistantError) this.pendingQuestions.forEach((pending, requestId) => { if (pending.sessionId === sessionId) this.pendingQuestions.delete(requestId) }) @@ -2287,16 +2199,36 @@ class NativeClient { } return true } + if ( + method === "context/compactionStarted" || + method === "context/compactionCompleted" || + method === "context/compactionFailed" + ) { + const sessionId = String(value.sessionId ?? "") + if (!sessionId) return true + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + const status = + method === "context/compactionFailed" + ? "failed" + : method === "context/compactionCompleted" + ? "completed" + : "started" + this.emit(directory, { + type: `session.compaction.${status}`, + properties: { sessionId: sessionId }, + }) + // Prefer item/started|completed for durable transcript markers. + // context/compactionStarted has no itemId — only update session atom. + return true + } if (method === "item/assistantMessage/delta" || method === "item/reasoning/delta") { const sessionId = String(value.sessionId ?? "") const itemId = String(value.itemId ?? "") const delta = String(value.delta ?? "") if (sessionId && itemId && delta) { const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() - this.appendText(sessionId, directory, "assistant", method.includes("reasoning") ? "reasoning" : "text", { - messageId: itemId, - content: { text: delta }, - }) + const itemType = method.includes("reasoning") ? "reasoning" : "assistantMessage" + this.applyNativeTextDelta(sessionId, directory, itemId, itemType, delta) } return true } @@ -2305,13 +2237,8 @@ class NativeClient { const itemId = String(value.itemId ?? "") const delta = String(value.delta ?? "") if (sessionId && itemId && delta) { - this.appendTool(sessionId, this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { - toolCallId: this.nativeItemCallIds.get(itemId) ?? itemId, - title: "Command", - kind: "execute", - status: "in_progress", - rawOutput: delta, - }) + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + this.applyNativeCommandOutputDelta(sessionId, directory, itemId, delta) } return true } @@ -2330,7 +2257,7 @@ class NativeClient { if (pending) { this.pendingQuestions.delete(requestId) const directory = this.sessionDirectories.get(pending.sessionId) ?? this.options.directory ?? defaultCwd() - this.emit(directory, { type: "question.replied", properties: { sessionID: pending.sessionId, requestID: requestId } }) + this.emit(directory, { type: "question.replied", properties: { sessionId: pending.sessionId, requestId: requestId } }) } return true } @@ -2341,7 +2268,7 @@ class NativeClient { this.pendingPermissions.delete(approvalId) this.emit(this.sessionDirectories.get(pending.sessionId ?? "") ?? this.options.directory ?? defaultCwd(), { type: "permission.replied", - properties: { sessionID: pending.sessionId ?? String(value.sessionId ?? ""), requestID: approvalId }, + properties: { sessionId: pending.sessionId ?? String(value.sessionId ?? ""), requestId: approvalId }, }) } return true @@ -2354,14 +2281,17 @@ class NativeClient { if (method === "turn/usage/updated" || method === "session/usage/updated") { const sessionId = String(value.sessionId ?? "") const usage = objectRecord(value.usage) ?? {} - const total = objectRecord(usage.total) ?? usage + // Native usage uses the same camelCase query shape as the TUI. + const query = objectRecord(usage.query) ?? {} + const used = Number(query.totalTokens ?? 0) + const size = Number(value.contextWindow ?? 0) if (sessionId) { this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { type: "session.usage.updated", properties: { - sessionID: sessionId, - used: Number(total.totalTokens ?? value.lastQueryInputTokens ?? 0), - size: Number(value.contextWindow ?? 0), + sessionId: sessionId, + used, + size, cost: 0, }, }) @@ -2375,8 +2305,8 @@ class NativeClient { this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { type: "turn.provider_retry_status", properties: { - sessionID: sessionId, - turnID: String(value.turnId ?? ""), + sessionId: sessionId, + turnId: String(value.turnId ?? ""), attempt: Number(value.attempt ?? 0), backoffMs: Number(value.nextDelayMs ?? 0), provider: String(value.provider ?? ""), @@ -2390,7 +2320,7 @@ class NativeClient { } if (method === "workspace/changes/updated") { const payload = objectRecord(value.WorkspaceChangesUpdated) ?? value - this.handleWorkspaceChangesUpdated(payload as WorkspaceChangesUpdatedPayload) + this.handleWorkspaceChangesUpdated(payload as WorkspaceChangesUpdatedNotification) return true } if (method === "queue/updated") { @@ -2403,67 +2333,63 @@ class NativeClient { if (method === "turn/superseded") { const sessionId = String(value.sessionId ?? "") const supersededTurnId = String(value.supersededTurnId ?? "") - if (sessionId && supersededTurnId) this.removeMessagesForTurn(sessionId, supersededTurnId) + if (sessionId && supersededTurnId) this.removeItemsForTurn(sessionId, supersededTurnId) return true } return false } + /** + * Identity upsert of Native ItemEnvelope — no Message/Part projection. + * Side channels (approval, userInput, compaction status, plan todos) stay as events. + */ private handleNativeItemEnvelope(envelope: Record, method: string): void { - const id = String(envelope.id ?? "") - const sessionId = String(envelope.sessionId ?? "") - const item = objectRecord(envelope.item) - if (!id || !sessionId || !item) return + const parsed = envelopeFromWire(envelope) + if (!parsed) return + const item = parsed.item + const itemType = nativeItemType(parsed) + const directory = this.sessionDirectories.get(parsed.sessionId) ?? this.options.directory ?? defaultCwd() const completed = method === "item/completed" || - envelope.state === "completed" || - envelope.state === "failed" || - envelope.state === "interrupted" - const itemType = String(item.type ?? "") - const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() - if (itemType === "contextCompaction" || itemType === "context_compaction") { - const envelopeState = String(envelope.state ?? "") + parsed.state === "completed" || + parsed.state === "failed" || + parsed.state === "interrupted" + + if (itemType === "contextCompaction") { + const envelopeState = parsed.state const failed = envelopeState === "failed" || item.status === "failed" const status = failed ? "failed" : completed || envelopeState === "completed" ? "completed" : "started" this.emit(directory, { type: `session.compaction.${status}`, - properties: { sessionID: sessionId }, - }) - this.upsertCompaction(sessionId, directory, { - itemId: id, - status, - turnId: String(envelope.turnId ?? ""), + properties: { sessionId: parsed.sessionId }, }) - if (completed) this.renderedNativeItems.add(renderedNativeItemKey(sessionId, id)) + // Transcript marker is the Native envelope itself (no synthetic text Message). + this.emitNativeItem(directory, this.storeNativeItem(parsed)) return } if (itemType === "plan") { - this.upsertPlan(sessionId, directory, id, item, String(envelope.turnId ?? "")) - if (completed) this.renderedNativeItems.add(renderedNativeItemKey(sessionId, id)) + const entries = Array.isArray(item.entries) ? item.entries : [] + const mapped = expandPlanEntries(entries) + if (mapped.length > 0) { + this.emit(directory, { + type: "todo.updated", + properties: { sessionId: parsed.sessionId, todos: mapped }, + }) + } + this.emitNativeItem(directory, this.storeNativeItem(parsed)) return } - const renderedKey = renderedNativeItemKey(sessionId, id) - if (completed && this.renderedNativeItems.has(renderedKey)) { - // History dual-writes ToolResult then FileChange under the same item id. - // Allow a richer FileChange (or file-shaped ToolResult) to upgrade the - // nameless generic tool that won the first pass. - const canUpgrade = - itemType === "fileChange" || - (itemType === "toolResult" && fileChangeInputFromToolOutput(item.output) != null) - if (!canUpgrade) return - } - if (itemType === "approval") { const approvalId = String(item.approvalId ?? "") if (!approvalId) return - const availableScopes = normalizeApprovalScopes( + const availableScopes = knownApprovalScopes( Array.isArray(item.availableScopes) ? item.availableScopes.map(String) : undefined, ) if (item.decision) { this.pendingPermissions.delete(approvalId) this.emit(directory, { type: "permission.replied", - properties: { sessionID: sessionId, requestID: approvalId }, + properties: { sessionId: parsed.sessionId, requestId: approvalId }, }) return } @@ -2471,12 +2397,12 @@ class NativeClient { this.pendingPermissions.set(approvalId, { id: existing?.id, method: existing?.method ?? approvalMethodFromResource(item.resource), - sessionId, + sessionId: parsed.sessionId, options: existing?.options ?? [], availableScopes, native: true, }) - this.emitPermissionAsked(sessionId, directory, approvalId, item, availableScopes) + this.emitPermissionAsked(parsed.sessionId, directory, approvalId, item, availableScopes) return } if (itemType === "userInputRequest") { @@ -2488,7 +2414,7 @@ class NativeClient { if (pending) { this.emit(directory, { type: "question.replied", - properties: { sessionID: pending.sessionId || sessionId, requestID: requestId }, + properties: { sessionId: pending.sessionId || parsed.sessionId, requestId }, }) } } else { @@ -2496,164 +2422,130 @@ class NativeClient { this.pendingQuestions.set(requestId, { id: pending?.id, method: pending?.method ?? "userInput/request", - sessionId, + sessionId: parsed.sessionId, questions, }) this.emit(directory, { type: "question.asked", - properties: { id: requestId, requestID: requestId, sessionID: sessionId, questions }, + properties: { id: requestId, sessionId: parsed.sessionId, questions }, }) } return } - if (itemType === "assistantMessage" || itemType === "reasoning") { - const partType = itemType === "reasoning" ? "reasoning" : "text" - const existing = this.parts.get(partCacheKey(sessionId, id))?.some((part) => part.type === partType) - const text = String(item.text ?? "") - if (!existing) { - if (text || partType === "reasoning") { - this.appendText(sessionId, directory, "assistant", partType, { - messageId: id, - content: { text }, - allowEmpty: partType === "reasoning", - ...nativeItemTimingFields(envelope, { includeCompletedAt: completed }), - }) - } - } else if (completed) { - this.finalizeNativeAssistantItem(sessionId, directory, id, envelope, partType) - } - } else if (itemType === "userMessage") { - const text = (Array.isArray(item.content) ? item.content : []).map((part) => objectRecord(part)).filter(Boolean).filter((part) => part?.type === "text").map((part) => String(part?.text ?? "")).join("\n") - const existing = this.parts.get(partCacheKey(sessionId, id))?.some((part) => part.type === "text") - if (!existing) { - this.appendText(sessionId, directory, "user", "text", { - messageId: id, - content: { text }, - ...nativeItemTimingFields(envelope), - }) - } - } else if ( - itemType === "toolCall" || - itemType === "commandExecution" || - itemType === "toolResult" || - itemType === "fileChange" || - itemType === "hostedToolCall" - ) { - if (item.callId) this.nativeItemCallIds.set(id, String(item.callId)) - const fromFileChange = itemType === "fileChange" ? fileChangeInput(item) : undefined - const fromToolOutput = - itemType === "toolResult" || (!item.input && !fromFileChange) - ? fileChangeInputFromToolOutput(item.output) - : undefined - const projectedInput = item.input - ? objectRecord(item.input) - : (fromFileChange ?? fromToolOutput) - const inferredFileChangeKind = - (!item.toolName - ? itemType === "fileChange" - ? fileChangeToolKind(item) - : fileChangeToolKindFromInput(projectedInput) - : undefined) ?? fileChangeToolKindFromInput(projectedInput) - const toolKind = - item.toolName ?? - (itemType === "commandExecution" ? "execute" : undefined) ?? - inferredFileChangeKind - this.appendTool(sessionId, directory, { - toolCallId: item.callId, - title: - item.toolName ?? - item.command ?? - (inferredFileChangeKind === "write" - ? "Write" - : inferredFileChangeKind === "edit" - ? "Edit" - : itemType === "fileChange" - ? "Write" - : "Tool"), - ...(toolKind ? { kind: toolKind } : {}), - status: completed ? (item.isError || envelope.state === "failed" ? "failed" : "completed") : "in_progress", - rawInput: projectedInput, - rawOutput: toolResultDisplayOutput(item), - ...nativeItemTimingFields(envelope, { includeCompletedAt: completed }), - }) + + if (item.callId) this.nativeItemCallIds.set(parsed.id, String(item.callId)) + this.emitNativeItem(directory, this.storeNativeItem(parsed)) + } + + private storeNativeItem(envelope: NativeItemEnvelope): NativeItemEnvelope { + let byId = this.items.get(envelope.sessionId) + if (!byId) { + byId = new Map() + this.items.set(envelope.sessionId, byId) } - if (completed) this.renderedNativeItems.add(renderedNativeItemKey(sessionId, id)) + const merged = mergeNativeEnvelope(byId.get(envelope.id), envelope) + byId.set(envelope.id, merged) + return merged } - private finalizeNativeAssistantItem( + private emitNativeItem(directory: string, envelope: NativeItemEnvelope): void { + this.emit(directory, { + type: "item.updated", + properties: { info: envelope }, + }) + } + + private applyNativeTextDelta( sessionId: string, directory: string, - messageId: string, - envelope: Record, - partType: "reasoning" | "text", + itemId: string, + itemType: "assistantMessage" | "reasoning", + delta: string, ): void { - const item = objectRecord(envelope.item) ?? {} - const createdAt = parseTimestampMs(envelope.createdAt) - const completedAt = parseTimestampMs(envelope.updatedAt) - const messages = this.messages.get(sessionId) - const messageIndex = messages?.findIndex((message) => message.id === messageId) ?? -1 - const message = messageIndex >= 0 ? messages?.[messageIndex] : undefined - if (message && messages && message.role === "assistant" && completedAt !== undefined) { - const nextCreated = - typeof createdAt === "number" && - (typeof message.time?.created !== "number" || createdAt < message.time.created) - ? createdAt - : message.time.created - const updated = { - ...message, - time: { - ...message.time, - created: nextCreated, - completed: message.time?.completed ?? completedAt, - }, - } as Message - messages[messageIndex] = updated + const existing = this.items.get(sessionId)?.get(itemId) + const prevText = + typeof existing?.item?.text === "string" ? existing.item.text : "" + const next: NativeItemEnvelope = existing + ? { + ...existing, + updatedAt: existing.updatedAt || new Date().toISOString(), + item: { ...existing.item, type: itemType, text: prevText + delta }, + } + : { + id: itemId, + sessionId, + turnId: "", + seq: 0, + revision: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + state: "running", + item: { type: itemType, text: delta }, + } + this.emitNativeItem(directory, this.storeNativeItem(next)) + } + + private applyNativeCommandOutputDelta( + sessionId: string, + directory: string, + itemId: string, + delta: string, + ): void { + const existing = this.items.get(sessionId)?.get(itemId) + const prevOutput = + typeof existing?.item?.output === "string" + ? existing.item.output + : typeof existing?.item?.displayContent === "string" + ? existing.item.displayContent + : "" + const callId = this.nativeItemCallIds.get(itemId) ?? (existing?.item?.callId ? String(existing.item.callId) : itemId) + const next: NativeItemEnvelope = existing + ? { + ...existing, + item: { + ...existing.item, + type: existing.item.type ?? "commandExecution", + callId, + output: prevOutput + delta, + }, + } + : { + id: itemId, + sessionId, + turnId: "", + seq: 0, + revision: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + state: "running", + item: { type: "commandExecution", callId, output: delta, command: "", isError: false }, + } + this.emitNativeItem(directory, this.storeNativeItem(next)) + } + + private removeItemsForTurn(sessionId: string, turnId: string): void { + this.loadedSessionLimits.delete(sessionId) + this.historyCursors.delete(sessionId) + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + const byId = this.items.get(sessionId) + if (!byId) return + for (const [itemId, envelope] of [...byId.entries()]) { + if (envelope.turnId !== turnId) continue + byId.delete(itemId) + this.nativeItemCallIds.delete(itemId) this.emit(directory, { - type: "message.updated", - properties: { info: updated, message: updated }, + type: "item.removed", + properties: { sessionId, itemId }, }) } + } - const partId = `${messageId}-${partType === "reasoning" ? "reasoning" : "text"}` - const parts = this.parts.get(partCacheKey(sessionId, messageId)) - const partIndex = parts?.findIndex((part) => part.id === partId) ?? -1 - const existingPart = partIndex >= 0 ? parts?.[partIndex] : undefined - if (!parts || !existingPart) return - const completedText = typeof item.text === "string" ? item.text : "" - const existingText = - typeof (existingPart as { text?: unknown }).text === "string" - ? (existingPart as { text: string }).text - : "" - const nextText = - completedText && (!existingText || completedText.startsWith(existingText) || existingText.startsWith(completedText)) - ? completedText.length >= existingText.length - ? completedText - : existingText - : existingText || completedText - const start = - typeof createdAt === "number" - ? createdAt - : typeof existingPart.time?.start === "number" - ? existingPart.time.start - : (completedAt ?? this.nextEventTime()) - const updatedPart = { - ...existingPart, - text: nextText, - time: partTime(existingPart, start, { - start, - ...(completedAt !== undefined ? { end: completedAt } : {}), - }), - } as Part - parts[partIndex] = updatedPart - this.emit(directory, { type: "message.part.updated", properties: { part: updatedPart } }) - } - - private applySubscriptionSessionSnapshot(snapshot: Record): void { + private applySubscriptionSessionSnapshot(snapshot: Record): boolean { const data = objectRecord(snapshot.data) const session = objectRecord(data?.session) if (session) this.rememberNativeSession(session) const sessionId = String(session?.id ?? snapshot.sessionId ?? "") - if (!sessionId) return + if (!sessionId) return false const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() if (data?.queue !== undefined) { this.emitQueueSnapshot(sessionId, parseQueueWireEntries(data.queue), "sync") @@ -2665,44 +2557,106 @@ class NativeClient { this.sessionStatuses.set(sessionId, { type: "busy" }) this.emit(directory, { type: "session.status", - properties: { sessionID: sessionId, status: { type: "busy" } }, + properties: { sessionId: sessionId, status: { type: "busy" } }, }) this.emit(directory, { type: "session.activeTurn", - properties: { sessionID: sessionId, turnID: turnId }, + properties: { sessionId: sessionId, turnId: turnId }, }) + return true } + // Registry-empty snapshot: durable InProgress is not live work. + this.activeTurnIds.delete(sessionId) + this.sessionStatuses.set(sessionId, { type: "idle" }) + this.emit(directory, { + type: "session.status", + properties: { sessionId: sessionId, status: { type: "idle" } }, + }) + this.emit(directory, { + type: "session.activeTurn", + properties: { sessionId: sessionId, turnId: null }, + }) + return false } private async ensureSessionSubscription(sessionId: string): Promise { if (this.subscriptions.has(sessionId)) return - const after = this.subscriptionCursors.get(sessionId) ?? [] - const result = (await this.requestCanonical("subscription/create", { - selectors: [{ kind: "session", sessionId }], - includeSnapshot: true, - after, - })) as { + const pending = this.subscriptionLoads.get(sessionId) + if (pending) return pending + const load = this.subscribeSession(sessionId) + this.subscriptionLoads.set(sessionId, load) + try { await load } finally { + if (this.subscriptionLoads.get(sessionId) === load) this.subscriptionLoads.delete(sessionId) + } + } + + private async subscribeSession(sessionId: string): Promise { + if (this.subscriptions.has(sessionId)) return + let result: { subscriptionId: string snapshots?: Array> replay?: Array> cursors?: Array<{ streamId: string; seq: number }> pendingControlRequests?: Array> + recoverySnapshots?: Array<{ item: Record }> + } + try { + result = (await this.requestCanonical("subscription/create", { + selectors: [{ kind: "session", sessionId }], + includeSnapshot: true, + replay: "snapshotOnly", + after: [], + })) as typeof result + } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + return + } + throw error } const cursors = result.cursors ?? [] this.subscriptions.set(sessionId, { subscriptionId: result.subscriptionId, cursors }) this.subscriptionCursors.set(sessionId, cursors) + let snapshotHasActiveTurn = false + let sawRegistryEmptySnapshot = false for (const snapshot of result.snapshots ?? []) { - this.applySubscriptionSessionSnapshot(snapshot) + const hasActiveTurn = this.applySubscriptionSessionSnapshot(snapshot) + if (hasActiveTurn) { + snapshotHasActiveTurn = true + } else if (objectRecord(objectRecord(snapshot.data)?.session)?.id || snapshot.sessionId) { + sawRegistryEmptySnapshot = true + } } - for (const envelope of result.replay ?? []) { - const notification = objectRecord(envelope.notification) - if (notification && typeof notification.method === "string") { - this.handleNativeNotification(notification.method, notification.params) + this.subscriptionReplayDepth += 1 + try { + for (const envelope of result.replay ?? []) { + const notification = objectRecord(envelope.notification) + if (notification && typeof notification.method === "string") { + this.handleNativeNotification(notification.method, notification.params) + } + } + for (const recovery of result.recoverySnapshots ?? []) { + this.handleNativeItemEnvelope(recovery.item, nativeItemNotificationMethod(recovery.item)) + } + for (const pending of result.pendingControlRequests ?? []) { + const item = objectRecord(pending.item) + if (item) this.handleNativeItemEnvelope(item, "item/started") } + } finally { + this.subscriptionReplayDepth -= 1 } - for (const pending of result.pendingControlRequests ?? []) { - const item = objectRecord(pending.item) - if (item) this.handleNativeItemEnvelope(item, "item/started") + if (sawRegistryEmptySnapshot && !snapshotHasActiveTurn) { + const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() + this.activeTurnIds.delete(sessionId) + this.sessionStatuses.set(sessionId, { type: "idle" }) + this.emit(directory, { + type: "session.status", + properties: { sessionId: sessionId, status: { type: "idle" } }, + }) + this.emit(directory, { + type: "session.activeTurn", + properties: { sessionId: sessionId, turnId: null }, + }) } if (cursors.length > 0) { await this.requestCanonical("subscription/ack", { subscriptionId: result.subscriptionId, cursors }) @@ -2710,8 +2664,43 @@ class NativeClient { } private async ensureKnownSessionSubscriptions(): Promise { - const sessions = await this.listSessions() - for (const session of sessions) await this.ensureSessionSubscription(session.id) + const sessions = this.options.directory ? [] : await this.listSessions() + const directories = this.options.directory ? [this.options.directory] : [...new Set(sessions.map(s => s.directory).filter(Boolean))] + for (const cwd of directories) { + let load = this.directorySubscriptions.get(cwd) + if (!load) { + load = this.subscribeDirectory(cwd) + this.directorySubscriptions.set(cwd, load) + } + try { await load } catch (error) { + if (this.directorySubscriptions.get(cwd) === load) this.directorySubscriptions.delete(cwd) + throw error + } + } + for (const session of this.sessions.values()) { + if (this.options.directory && session.directory !== this.options.directory) continue + if (this.sessionStatuses.get(session.id)?.type === "busy") await this.ensureSessionSubscription(session.id) + } + } + + private async subscribeDirectory(cwd: string): Promise { + const result = await this.requestCanonical("subscription/create", { + selectors: [{ kind: "sessionsByCwd", cwd }], includeSnapshot: true, replay: "snapshotOnly", after: [], + }) as { subscriptionId: string; snapshots?: Array>; cursors?: Array<{ streamId: string; seq: number }> } + for (const snapshot of result.snapshots ?? []) { + const data = objectRecord(snapshot.data) + for (const session of (data?.sessions ?? []) as Array>) { + // Folder rosters project the lightweight index. Preserve richer + // canonical model/settings already obtained from resume or reads. + const existing = this.sessions.get(String(session.id ?? "")) + this.rememberNativeSession({ + ...session, + model: existing?.model ?? session.model, + settings: existing?.settings ?? session.settings, + }) + } + } + if (result.cursors?.length) await this.requestCanonical("subscription/ack", { subscriptionId: result.subscriptionId, cursors: result.cursors }) } private emitPermissionAsked( @@ -2728,8 +2717,7 @@ class NativeClient { type: "permission.asked", properties: { id: approvalId, - requestID: approvalId, - sessionID: sessionId, + sessionId: sessionId, permission: String(item.actionSummary ?? "Agent requested permission"), metadata: { tool: item.resource, @@ -2760,7 +2748,7 @@ class NativeClient { throw new Error("Permission request is not answerable yet; wait for the connection to restore") } this.pendingPermissions.delete(permissionId) - const scopes = normalizeApprovalScopes(pending.availableScopes) + const scopes = knownApprovalScopes(pending.availableScopes) const requestedScope = response === "always" ? ["commandPrefixPersist", "commandPrefix", "pathPrefix", "host", "tool", "session", "turn", "once"] .find((candidate) => scopes.includes(candidate as PermissionResponse)) ?? "once" @@ -2785,8 +2773,8 @@ class NativeClient { { type: "permission.replied", properties: { - sessionID: pending.sessionId ?? "", - requestID: permissionId, + sessionId: pending.sessionId ?? "", + requestId: permissionId, }, }, ) @@ -2827,8 +2815,8 @@ class NativeClient { this.emit(this.sessionDirectories.get(pending.sessionId) ?? this.options.directory ?? defaultCwd(), { type: eventType, properties: { - sessionID: pending.sessionId, - requestID: requestId, + sessionId: pending.sessionId, + requestId: requestId, }, }) } @@ -2844,48 +2832,58 @@ class NativeClient { this.sessionStatuses.has(sessionId) || this.sessionDirectories.has(sessionId) || this.loadedSessionLimits.has(sessionId) || - this.messages.has(sessionId) + this.items.has(sessionId) this.sessions.delete(sessionId) this.sessionStatuses.delete(sessionId) this.promptStartedAtBySession.delete(sessionId) this.sessionDirectories.delete(sessionId) this.loadedSessionLimits.delete(sessionId) - this.messages.delete(sessionId) - this.activeTurnIds.delete(sessionId) - this.queueEntriesBySession.delete(sessionId) - for (const [messageId, parts] of this.parts) { - if (parts.some((part) => part.sessionID === sessionId)) { - this.parts.delete(messageId) + this.historyCursors.delete(sessionId) + this.resumedSessions.delete(sessionId) + const sessionItems = this.items.get(sessionId) + if (sessionItems) { + for (const itemId of sessionItems.keys()) { + this.nativeItemCallIds.delete(itemId) } } + this.items.delete(sessionId) + this.activeTurnIds.delete(sessionId) + this.queueEntriesBySession.delete(sessionId) return { directory, known } } - private removeMessagesForTurn(sessionId: string, turnId: string): void { - const directory = this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd() - const messages = this.messages.get(sessionId) - if (!messages) return - const remaining: Message[] = [] - for (const message of messages) { - const key = partCacheKey(sessionId, message.id) - const messageTurnId = this.messageTurnIds.get(key) ?? message.turnID - if (messageTurnId !== turnId) { - remaining.push(message) + /** Mark in-flight Native assistant/reasoning/tool items completed when a turn ends. */ + private completeOpenAssistantMessages( + sessionId: string, + directory: string, + _promptStartedAt: number, + error?: { name: string; data: Record }, + ): void { + const byId = this.items.get(sessionId) + if (!byId) return + for (const [itemId, envelope] of [...byId.entries()]) { + if (envelope.state === "completed" || envelope.state === "failed" || envelope.state === "interrupted") { + continue + } + const itemType = String(envelope.item?.type ?? "") + if ( + itemType !== "assistantMessage" && + itemType !== "reasoning" && + itemType !== "toolCall" && + itemType !== "commandExecution" && + itemType !== "hostedToolCall" + ) { continue } - this.parts.delete(key) - this.messageTurnIds.delete(key) - this.renderedNativeItems.delete(renderedNativeItemKey(sessionId, message.id)) - if (this.lastUserMessageBySession.get(sessionId) === message.id) { - this.lastUserMessageBySession.delete(sessionId) + const updated: NativeItemEnvelope = { + ...envelope, + state: error ? "failed" : "completed", + updatedAt: envelope.updatedAt || new Date().toISOString(), + item: error ? { ...envelope.item, error } : envelope.item, } - this.emit(directory, { - type: "message.removed", - properties: { sessionID: sessionId, messageID: message.id }, - }) + byId.set(itemId, updated) + this.emitNativeItem(directory, updated) } - this.messages.set(sessionId, remaining) - this.userMessageByTurn.delete(this.turnKey(sessionId, turnId)) } private emitSessionDeleted(sessionId: string, directory: string): void { @@ -2896,13 +2894,27 @@ class NativeClient { } private handleWorkspaceChangesUpdated( - payload: WorkspaceChangesUpdatedPayload, + payload: WorkspaceChangesUpdatedNotification, directory?: string, ): void { - const event = workspaceChangesUpdatedEventProperties(payload) - if (!event.sessionID) return + if (!payload.sessionId) return + const event: WorkspaceChangesUpdatedEventProperties = { + sessionId: payload.sessionId, + turnId: payload.turnId, + scope: payload.scope, + status: payload.status, + coverage: payload.coverage, + changeSetStatus: payload.changeSetStatus, + stats: { + filesChanged: numberFromProtocol(payload.stats.filesChanged), + additions: numberFromProtocol(payload.stats.additions), + deletions: numberFromProtocol(payload.stats.deletions), + }, + version: numberFromProtocol(payload.version), + generatedAt: payload.generatedAt, + } const emitDirectory = - directory ?? this.sessionDirectories.get(event.sessionID) ?? this.options.directory ?? defaultCwd() + directory ?? this.sessionDirectories.get(event.sessionId) ?? this.options.directory ?? defaultCwd() this.emit(emitDirectory, { type: "workspace.changes.updated", properties: event, @@ -2931,430 +2943,12 @@ class NativeClient { type: "question.asked", properties: { id: requestId, - requestID: requestId, - sessionID: requestSessionId, + sessionId: requestSessionId, questions, }, }) } - private turnKey(sessionId: string, turnId: string): string { - return `${sessionId}\u001f${turnId}` - } - - private turnIdForUpdate(update: Record): string | undefined { - return ( - updateMetaString(update, DEVO_TURN_ID_META) ?? - (typeof update.turnId === "string" && update.turnId ? update.turnId : undefined) - ) - } - - private parentMessageIdForUpdate( - sessionId: string, - update: Record, - existingMessage?: Message, - ): string | undefined { - const turnId = this.turnIdForUpdate(update) - return ( - updateMetaString(update, DEVO_PARENT_MESSAGE_ID_META) ?? - (turnId ? this.userMessageByTurn.get(this.turnKey(sessionId, turnId)) : undefined) ?? - existingMessage?.parentID ?? - this.lastUserMessageBySession.get(sessionId) - ) - } - - private earliestMessageCreatedForTurn(sessionId: string, turnId: string): number | undefined { - let earliest: number | undefined - for (const message of this.messages.get(sessionId) ?? []) { - if (this.messageTurnIds.get(partCacheKey(sessionId, message.id)) !== turnId) continue - const created = message.time?.created - if (typeof created !== "number" || !Number.isFinite(created)) continue - earliest = earliest === undefined ? created : Math.min(earliest, created) - } - return earliest - } - - private messageCreatedAtForUpdate( - sessionId: string, - role: "assistant" | "user", - messageId: string, - update: Record, - existingMessage: Message | undefined, - now: number, - ): number { - let created = - existingMessage?.time?.created ?? - parseTimestampMs(update.createdAt) ?? - updateHistoryCreatedAt(update) ?? - historyMessageCreatedAt(messageId) ?? - now - const turnId = this.turnIdForUpdate(update) - if (role === "user" && turnId) { - const earliest = this.earliestMessageCreatedForTurn(sessionId, turnId) - if (earliest !== undefined && earliest <= created) { - created = Math.max(0, earliest - 1) - } - } - return created - } - - private rememberMessageTurn( - sessionId: string, - directory: string, - messageId: string, - role: "assistant" | "user", - update: Record, - ): void { - const turnId = this.turnIdForUpdate(update) - if (!turnId) return - this.messageTurnIds.set(partCacheKey(sessionId, messageId), turnId) - if (role !== "user") return - this.userMessageByTurn.set(this.turnKey(sessionId, turnId), messageId) - this.reparentTurnMessages(sessionId, directory, turnId, messageId) - } - - private reparentTurnMessages( - sessionId: string, - directory: string, - turnId: string, - userMessageId: string, - ): void { - const messages = this.messages.get(sessionId) - if (!messages) return - for (let index = 0; index < messages.length; index++) { - const message = messages[index] - if (message.role !== "assistant") continue - if (this.messageTurnIds.get(partCacheKey(sessionId, message.id)) !== turnId) continue - if (message.parentID === userMessageId) continue - const updated = { ...message, parentID: userMessageId } as Message - messages[index] = updated - this.emit(directory, { type: "message.updated", properties: { info: updated, message: updated } }) - } - } - - private upsertCompaction( - sessionId: string, - directory: string, - update: { - itemId: string - status: "started" | "completed" | "failed" - turnId?: string - }, - ): void { - if (update.status === "failed") return - const messageId = `compaction-${update.itemId}` - const label = update.status === "completed" ? COMPACTION_COMPLETED_LABEL : COMPACTION_STARTED_LABEL - this.replaceText(sessionId, directory, "assistant", { - messageId, - content: { text: label }, - _meta: { - [DEVO_ITEM_KIND_META]: "context_compaction", - [DEVO_COMPACTION_STATUS_META]: update.status, - ...(update.turnId ? { [DEVO_TURN_ID_META]: update.turnId } : {}), - }, - }) - } - - private upsertPlan( - sessionId: string, - directory: string, - itemId: string, - item: Record, - turnId: string, - ): void { - const entries = Array.isArray(item.entries) ? item.entries : [] - const mapped = entries.map((entry) => { - const value = objectRecord(entry) ?? {} - return { - content: String(value.step ?? value.content ?? value.title ?? ""), - status: String(value.status ?? "pending"), - } - }) - if (mapped.length === 0) return - this.emit(directory, { - type: "todo.updated", - properties: { sessionID: sessionId, todos: mapped }, - }) - const proposed = mapped.length === 1 && mapped[0].content.includes("\n") - this.replaceText(sessionId, directory, "assistant", { - messageId: itemId, - content: { - text: proposed - ? mapped[0].content - : mapped.map((entry) => `- [${entry.status}] ${entry.content}`).join("\n"), - }, - _meta: { - [DEVO_ITEM_KIND_META]: proposed ? "proposed_plan" : "plan", - planEntries: mapped, - ...(turnId ? { [DEVO_TURN_ID_META]: turnId } : {}), - }, - }) - } - - private replaceText( - sessionId: string, - directory: string, - role: "assistant" | "user", - update: Record, - ): void { - const text = textFromUpdate(update) - if (!text) return - const now = this.nextEventTime() - const messageId = - typeof update.messageId === "string" ? update.messageId : `${role}-${sessionId}-${now}` - const existingMessage = this.messages.get(sessionId)?.find((message) => message.id === messageId) - const parentID = - role === "assistant" ? this.parentMessageIdForUpdate(sessionId, update, existingMessage) : undefined - const created = this.messageCreatedAtForUpdate( - sessionId, - role, - messageId, - update, - existingMessage, - now, - ) - const turnId = this.turnIdForUpdate(update) - const completedAt = - role === "assistant" - ? (existingMessage?.time?.completed ?? parseTimestampMs(update.completedAt)) - : undefined - const message = { - ...(existingMessage ?? {}), - id: messageId, - sessionID: sessionId, - role, - ...(parentID ? { parentID } : {}), - ...(turnId ? { turnID: turnId } : {}), - time: { - ...(existingMessage?.time ?? {}), - created, - ...(completedAt !== undefined ? { completed: completedAt } : {}), - }, - } as Message - this.appendMessage(sessionId, message) - if (role === "user") this.lastUserMessageBySession.set(sessionId, messageId) - this.emit(directory, { type: "message.updated", properties: { info: message, message } }) - this.rememberMessageTurn(sessionId, directory, messageId, role, update) - - const partId = `${messageId}-text` - const existingPart = this.parts - .get(partCacheKey(sessionId, messageId)) - ?.find((part) => part.id === partId) - const partEventTime = updateEventTimeMs(update, now) - const metadata = textPartMetadataFromUpdate(update, existingPart) - const part = { - id: partId, - sessionID: sessionId, - messageID: messageId, - type: "text", - text, - ...(metadata ? { metadata } : {}), - time: partTime(existingPart, partEventTime), - } as TextPart - this.appendPart(sessionId, messageId, part) - this.emit(directory, { type: "message.part.updated", properties: { part } }) - } - - private appendText( - sessionId: string, - directory: string, - role: "assistant" | "user", - partType: "reasoning" | "text", - update: Record, - ): void { - const text = textFromUpdate(update) - if (!text && update.allowEmpty !== true) return - const now = this.nextEventTime() - const messageId = - typeof update.messageId === "string" - ? update.messageId - : `${role}-${sessionId}-${now}` - const existingMessage = this.messages.get(sessionId)?.find((message) => message.id === messageId) - const parentID = - role === "assistant" ? this.parentMessageIdForUpdate(sessionId, update, existingMessage) : undefined - const created = this.messageCreatedAtForUpdate( - sessionId, - role, - messageId, - update, - existingMessage, - now, - ) - const turnId = this.turnIdForUpdate(update) - const completedAt = - role === "assistant" - ? (existingMessage?.time?.completed ?? parseTimestampMs(update.completedAt)) - : undefined - const message = { - ...(existingMessage ?? {}), - id: messageId, - sessionID: sessionId, - role, - ...(parentID ? { parentID } : {}), - ...(turnId ? { turnID: turnId } : {}), - time: { - ...(existingMessage?.time ?? {}), - created, - ...(completedAt !== undefined ? { completed: completedAt } : {}), - }, - } as Message - this.appendMessage(sessionId, message) - if (role === "user") this.lastUserMessageBySession.set(sessionId, messageId) - this.emit(directory, { type: "message.updated", properties: { info: message, message } }) - this.rememberMessageTurn(sessionId, directory, messageId, role, update) - - const partId = `${messageId}-${partType === "reasoning" ? "reasoning" : "text"}` - const existingPart = this.parts - .get(partCacheKey(sessionId, messageId)) - ?.find((part) => part.id === partId) - const field = partType === "reasoning" ? "text" : "text" - const existingText = - messageId.startsWith("history-") || typeof existingPart?.[field] !== "string" - ? "" - : existingPart[field] - const nextText = - role === "user" && existingText && (existingText === text || existingText.endsWith(text)) - ? existingText - : role === "user" && existingText && text.startsWith(existingText) - ? text - : `${existingText}${text}` - if (existingPart && nextText === existingText && completedAt === undefined) return - const partEventTime = updateEventTimeMs(update, now) - const metadata = textPartMetadataFromUpdate(update, existingPart) - const part = { - id: partId, - sessionID: sessionId, - messageID: messageId, - type: partType, - [field]: nextText, - ...(metadata ? { metadata } : {}), - time: partTime(existingPart, partEventTime, { - start: parseTimestampMs(update.createdAt) ?? created, - ...(completedAt !== undefined ? { end: completedAt } : {}), - }), - } as TextPart | ReasoningPart - this.appendPart(sessionId, messageId, part) - this.emit(directory, { type: "message.part.updated", properties: { part } }) - } - - private appendTool(sessionId: string, directory: string, update: Record): void { - const now = this.nextEventTime() - const toolCallId = toolCallIdFromUpdate(update, now) - const messageId = `tool-${toolCallId}` - const existingMessage = this.messages.get(sessionId)?.find((message) => message.id === messageId) - const existingPart = this.parts - .get(partCacheKey(sessionId, messageId)) - ?.find((part) => part.id === `${messageId}-part`) - const parentID = this.parentMessageIdForUpdate(sessionId, update, existingMessage) - const created = this.messageCreatedAtForUpdate( - sessionId, - "assistant", - messageId, - update, - existingMessage, - now, - ) - const turnId = this.turnIdForUpdate(update) - const completedAt = - existingMessage?.time?.completed ?? parseTimestampMs(update.completedAt) - const message = { - ...(existingMessage ?? {}), - id: messageId, - sessionID: sessionId, - role: "assistant", - ...(parentID ? { parentID } : {}), - ...(turnId ? { turnID: turnId } : {}), - time: { - ...(existingMessage?.time ?? {}), - created, - ...(completedAt !== undefined ? { completed: completedAt } : {}), - }, - } as Message - const partEventTime = updateEventTimeMs(update, now) - const part = toolPartFromUpdate(sessionId, update, existingPart, partEventTime) as ToolPart - this.appendMessage(sessionId, message) - this.rememberMessageTurn(sessionId, directory, messageId, "assistant", update) - this.appendPart(sessionId, message.id, part) - this.emit(directory, { type: "message.updated", properties: { info: message, message } }) - this.emit(directory, { type: "message.part.updated", properties: { part } }) - } - - private completeOpenAssistantMessages( - sessionId: string, - directory: string, - promptStartedAt: number, - ): void { - const messages = this.messages.get(sessionId) - if (!messages) return - let completedAt: number | null = null - for (let index = 0; index < messages.length; index++) { - const message = messages[index] - if (message.role !== "assistant" || message.time.completed != null) continue - if (message.time.created < promptStartedAt) continue - completedAt ??= this.nextEventTime() - const updated = { - ...message, - time: { ...message.time, completed: completedAt }, - } as Message - messages[index] = updated - this.emit(directory, { type: "message.updated", properties: { info: updated, message: updated } }) - this.completeInFlightToolParts(sessionId, directory, updated.id, completedAt) - } - } - - private completeInFlightToolParts( - sessionId: string, - directory: string, - messageId: string, - completedAt: number, - ): void { - const parts = this.parts.get(partCacheKey(sessionId, messageId)) - if (!parts) return - for (let index = 0; index < parts.length; index++) { - const part = parts[index] - if (part.type !== "tool") continue - if (part.state.status !== "running" && part.state.status !== "pending") continue - const runningOutput = - part.state.status === "running" && typeof part.state.metadata?.output === "string" - ? part.state.metadata.output - : "" - const next = { - ...part, - state: { - ...part.state, - status: "completed", - output: runningOutput, - time: { ...part.state.time, end: part.state.time.end ?? completedAt }, - }, - } as ToolPart - parts[index] = next - this.emit(directory, { type: "message.part.updated", properties: { part: next } }) - } - } - - private appendMessage(sessionId: string, message: Message): void { - const messages = this.messages.get(sessionId) ?? [] - const index = messages.findIndex((existing) => existing.id === message.id) - if (index >= 0) { - messages[index] = message - } else { - messages.push(message) - } - this.messages.set(sessionId, messages) - } - - private appendPart(sessionId: string, messageId: string, part: Part): void { - const key = partCacheKey(sessionId, messageId) - const parts = this.parts.get(key) ?? [] - const index = parts.findIndex((existing) => existing.id === part.id) - if (index >= 0) { - parts[index] = part - } else { - parts.push(part) - } - this.parts.set(key, parts) - } - private nextEventTime(): number { const now = Date.now() const eventTime = Math.max(now, this.lastEventTime + 1) @@ -3372,39 +2966,58 @@ class NativeClient { this.emit(directory, { type: "session.queue.updated", properties: { - sessionID: sessionId, + sessionId: sessionId, change, entries, }, }) } + private readonly sessionsNeedingResume = new Set() + private async pushSessionQueue(params: { - sessionID: string + sessionId: string parts: PromptPartInput[] collaborationMode?: string }): Promise { + if (this.sessionsNeedingResume.has(params.sessionId)) { + await this.ensureSessionReady(params.sessionId) + } if (params.collaborationMode) { const settingsPatch: SessionSettingsPatch = { mode: params.collaborationMode, } - await this.enqueueSessionSettings(params.sessionID, settingsPatch) + await this.enqueueSessionSettings(params.sessionId, settingsPatch) } - await this.ensureSessionSubscription(params.sessionID) - const result = (await this.requestCanonical("session/queue/push", { - sessionId: params.sessionID, - input: inputItemsFromPromptParts(params.parts), + await this.ensureSessionSubscription(params.sessionId) + const request = { + sessionId: params.sessionId, + input: userInputsFromPromptParts(params.parts), idempotencyKey: crypto.randomUUID(), - })) as Record + } + let result: Record + try { + result = await this.requestCanonical("session/queue/push", request) as Record + } catch (error) { + if (!isSessionNotFoundError(error)) throw error + // An evicted actor or a missed transport-close event can invalidate + // the client cache. Restore once, then retry with the same input/key. + this.loadedSessionLimits.delete(params.sessionId) + this.historyCursors.delete(params.sessionId) + this.resumedSessions.delete(params.sessionId) + this.subscriptions.delete(params.sessionId) + await this.ensureSessionReady(params.sessionId) + result = await this.requestCanonical("session/queue/push", request) as Record + } const outcome = String(result.outcome ?? "") if (outcome === "queued" || result.entry) { const entry = objectRecord(result.entry) ?? result const entries = parseQueueWireEntries([entry]) if (entries[0]) { - const current = this.queueEntriesForSession(params.sessionID) + const current = this.queueEntriesForSession(params.sessionId) const merged = [...current.filter((item) => item.queueItemId !== entries[0].queueItemId), entries[0]] this.emitQueueSnapshot( - params.sessionID, + params.sessionId, merged.sort((left, right) => left.position - right.position), "added", ) @@ -3414,14 +3027,14 @@ class NativeClient { } const turn = objectRecord(result.turn) if (turn?.id) { - this.activeTurnIds.set(params.sessionID, String(turn.id)) - const directory = this.sessionDirectories.get(params.sessionID) ?? this.options.directory ?? defaultCwd() + this.activeTurnIds.set(params.sessionId, String(turn.id)) + const directory = this.sessionDirectories.get(params.sessionId) ?? this.options.directory ?? defaultCwd() this.emit(directory, { type: "session.activeTurn", - properties: { sessionID: params.sessionID, turnID: String(turn.id) }, + properties: { sessionId: params.sessionId, turnId: String(turn.id) }, }) } - return { outcome: "started" } + return { outcome: "started", ...(turn?.id ? { turnId: String(turn.id) } : {}) } } private rememberConfigOptions( @@ -3553,6 +3166,10 @@ class NativeClient { if (!result.session) throw new Error("session/metadata/update returned no session") return this.rememberNativeSession(result.session) } catch (error) { + if (isSessionNotFoundError(error)) { + this.dropMissingSession(sessionId) + throw error + } const delay = SESSION_SETTINGS_RETRY_DELAYS_MS[retry] if (delay === undefined || !isTransientSessionSettingsError(error)) throw error await waitForSessionSettingsRetry(delay) @@ -3565,7 +3182,6 @@ class NativeClient { value: string, ): Promise { await this.ensureInitialized() - const directory = this.options.directory ?? defaultCwd() // Canonical model/preferences/write (ratified #12): configId maps to // the patch fields; the result converts back to the select shape the // config UI renders. @@ -3573,13 +3189,7 @@ class NativeClient { if (configId === "model") patch.model = value else if (configId === "thought_level") patch.reasoningEffort = value else throw new Error(`unknown model config option '${configId}'`) - const params: Record = { patch } - if (this.options.directory) params.cwd = this.options.directory - const result = (await this.requestCanonical("model/preferences/write", params)) as { - preferences?: ModelPreferencesWire - } - const options = sessionConfigOptionsFromModelPreferences(result.preferences ?? {}) - this.rememberDirectoryConfigOptions(directory, options) + await this.model.preferences.write({ patch }) return this.currentConfigOptions() } @@ -3620,7 +3230,7 @@ class NativeClient { if (!occupancy) return false this.emit(this.sessionDirectories.get(sessionId) ?? this.options.directory ?? defaultCwd(), { type: "context.usage.updated", - properties: { sessionID: sessionId, occupancy }, + properties: { sessionId: sessionId, occupancy }, }) return true } diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.test.ts index cc4fa855..b65b6d40 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.test.ts @@ -38,4 +38,34 @@ describe("native tool name mapping", () => { fromInput: "request_user_input", }) }) + + test("keeps native tool names instead of collapsing to generic tool", () => { + const grep = toolPartFromUpdate( + "session-1", + { kind: "grep", title: "grep", rawInput: {} }, + undefined, + 1, + ) + const shell = toolPartFromUpdate( + "session-1", + { kind: "shell_command", title: "shell_command", rawInput: {} }, + undefined, + 1, + ) + const glob = toolPartFromUpdate( + "session-1", + { kind: "glob", title: "glob", rawInput: {} }, + undefined, + 1, + ) + expect({ + grep: grep.tool, + shell: shell.tool, + glob: glob.tool, + }).toEqual({ + grep: "grep", + shell: "shell_command", + glob: "glob", + }) + }) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts index 8adff505..6dba66d9 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/native-client-support.ts @@ -58,13 +58,32 @@ export function defaultCwd(): string { let sharedIpcTransport: DevoNativeTransport | null = null +/** Must match `DEVO_NATIVE_IPC_ERROR` in apps/desktop/src/shared/native-ipc-error.ts */ +const DEVO_NATIVE_IPC_ERROR = "$devoNativeError" + +function throwIfNativeIpcError(result: unknown): void { + if (!result || typeof result !== "object") return + const envelope = (result as Record)[DEVO_NATIVE_IPC_ERROR] + if (!envelope || typeof envelope !== "object") return + const record = envelope as Record + const error = new Error( + typeof record.message === "string" ? record.message : "Devo Native request failed", + ) as Error & { code?: string } + if (typeof record.code === "string") error.code = record.code + throw error +} + export function createIpcTransport(): DevoNativeTransport { if (sharedIpcTransport) return sharedIpcTransport const api = globalThis.window?.devo?.native if (!api) throw new Error("window.devo.native is not available") sharedIpcTransport = { - request: (method, params, directory) => api.request({ method, params, directory }), + request: async (method, params, directory) => { + const result = await api.request({ method, params, directory }) + throwIfNativeIpcError(result) + return result + }, notify: (method, params, directory) => api.notify({ method, params, directory }), respond: (id, result) => api.respond({ id, result }), subscribe: (listener) => api.subscribe(listener), @@ -172,7 +191,7 @@ export function partTime( } export function toolCallIdFromUpdate(update: Record, now: number): string { - return String(update.toolCallId ?? update.callID ?? update.id ?? `tool-${now}`) + return String(update.toolCallId ?? update.callId ?? update.id ?? `tool-${now}`) } export function toolPartFromUpdate( @@ -182,7 +201,12 @@ export function toolPartFromUpdate( now: number, ): any { const toolCallId = toolCallIdFromUpdate(update, now) - const messageID = `tool-${toolCallId}` + const itemId = + typeof update.itemId === "string" && update.itemId + ? update.itemId + : typeof existingPart?.itemId === "string" && existingPart.itemId + ? existingPart.itemId + : `tool-${toolCallId}` const legacyContent = Array.isArray(update.content) ? update.content : undefined const legacyLocations = Array.isArray(update.locations) ? update.locations : undefined const incomingInput = objectFromValue(update.rawInput ?? update.input) @@ -218,11 +242,11 @@ export function toolPartFromUpdate( time: { ...time, end: time.end ?? now }, } return { - id: `${messageID}-part`, - sessionID: sessionId, - messageID, + id: `${itemId}-part`, + sessionId: sessionId, + itemId, type: "tool", - callID: toolCallId, + callId: toolCallId, tool, state, } @@ -267,11 +291,11 @@ export function statusFromDevo(status?: string): any { } } -export function sessionErrorEvent(sessionID: string, error: unknown): any { +export function sessionErrorEvent(sessionId: string, error: unknown): any { return { type: "session.error", properties: { - sessionID, + sessionId, error: { name: "Error", data: { message: error instanceof Error ? error.message : String(error) }, @@ -303,7 +327,7 @@ function resolvedToolName( if (kind) return toolNameFromUpdateKind(kind, input) const existingTool = stringFromValue(existingPart?.tool) - if (existingTool && existingTool !== toolCallId && existingTool !== existingPart?.callID) { + if (existingTool && existingTool !== toolCallId && existingTool !== existingPart?.callId) { return existingTool } @@ -321,7 +345,7 @@ function resolvedToolTitle( if (title) return title const existingTitle = stringFromValue(existingPart?.state?.title) - if (existingTitle && existingTitle !== toolCallId && existingTitle !== existingPart?.callID) { + if (existingTitle && existingTitle !== toolCallId && existingTitle !== existingPart?.callId) { return existingTitle } @@ -340,11 +364,23 @@ function toolNameFromUpdateKind(kind: string, input: Record): s case "apply_patch": return kind === "apply_patch" ? "apply_patch" : "edit" case "search": + case "grep": return "grep" + case "glob": + case "find": + case "list": + return kind === "list" ? "list" : "glob" case "execute": - return "bash" + case "bash": + case "shell_command": + case "exec_command": + case "write_stdin": + return kind === "execute" ? "bash" : kind case "fetch": + case "webfetch": return "webfetch" + case "skill": + return "skill" case "think": return "think" case "question": @@ -372,15 +408,38 @@ function inferToolNameFromInput(input: Record, title?: string): return "read" } const normalizedTitle = title?.trim().toLowerCase() - if (normalizedTitle?.startsWith("read")) return "read" - if (normalizedTitle?.startsWith("edit") || normalizedTitle?.startsWith("patch")) return "edit" - if (normalizedTitle?.startsWith("write")) return "write" - if (normalizedTitle?.startsWith("search")) return "grep" - if (normalizedTitle?.startsWith("fetch")) return "webfetch" - if (normalizedTitle?.startsWith("run") || normalizedTitle?.startsWith("execute")) return "bash" - if (normalizedTitle === "question" || normalizedTitle === "request_user_input") { - return "request_user_input" + if (!normalizedTitle) return "tool" + switch (normalizedTitle) { + case "grep": + return "grep" + case "glob": + case "find": + return "glob" + case "list": + return "list" + case "bash": + case "shell": + case "shell_command": + case "exec_command": + case "write_stdin": + return normalizedTitle === "shell" || normalizedTitle === "bash" ? "bash" : normalizedTitle + case "skill": + return "skill" + case "webfetch": + case "web_fetch": + return "webfetch" + case "question": + case "request_user_input": + return "request_user_input" + default: + break } + if (normalizedTitle.startsWith("read")) return "read" + if (normalizedTitle.startsWith("edit") || normalizedTitle.startsWith("patch")) return "edit" + if (normalizedTitle.startsWith("write")) return "write" + if (normalizedTitle.startsWith("search") || normalizedTitle.startsWith("grep")) return "grep" + if (normalizedTitle.startsWith("fetch")) return "webfetch" + if (normalizedTitle.startsWith("run") || normalizedTitle.startsWith("execute")) return "bash" return "tool" } diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/native-item.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/native-item.ts new file mode 100644 index 00000000..8b90db45 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/native-item.ts @@ -0,0 +1,97 @@ +/** + * First-party Desktop transcript unit: Native `ItemEnvelope` wire shape. + * Atoms and chat views store this directly — no OpenCode Message/Part dual. + */ +export type NativeItemEnvelope = { + id: string + sessionId: string + turnId: string + seq: number + revision: number + createdAt: string + updatedAt: string + state: string + item: Record +} + +export function nativeItemType(envelope: NativeItemEnvelope): string { + const item = envelope.item + return typeof item?.type === "string" ? item.type : "" +} + +export function isUserMessageItem(envelope: NativeItemEnvelope): boolean { + return nativeItemType(envelope) === "userMessage" +} + +export function compareNativeItems(left: NativeItemEnvelope, right: NativeItemEnvelope): number { + const bySeq = (left.seq ?? 0) - (right.seq ?? 0) + if (bySeq !== 0) return bySeq + const byCreated = String(left.createdAt ?? "").localeCompare(String(right.createdAt ?? "")) + if (byCreated !== 0) return byCreated + return left.id.localeCompare(right.id) +} + +export function sortedNativeItems(items: Iterable): NativeItemEnvelope[] { + return [...items].sort(compareNativeItems) +} + +/** Paginate history aligned to userMessage boundaries (same UX budget as former message limit). */ +export function recentNativeItems( + items: NativeItemEnvelope[], + limit: number | undefined, +): NativeItemEnvelope[] { + const sorted = sortedNativeItems(items) + if (limit === undefined || sorted.length <= limit) return sorted + let start = sorted.length - limit + while (start > 0 && !isUserMessageItem(sorted[start])) { + start -= 1 + } + return sorted.slice(start) +} + +export function envelopeFromWire(value: Record): NativeItemEnvelope | null { + const id = String(value.id ?? "") + const sessionId = String(value.sessionId ?? "") + const item = value.item + if (!id || !sessionId || !item || typeof item !== "object") return null + return { + id, + sessionId, + turnId: String(value.turnId ?? ""), + seq: Number(value.seq ?? 0), + revision: Number(value.revision ?? 0), + createdAt: String(value.createdAt ?? ""), + updatedAt: String(value.updatedAt ?? value.createdAt ?? ""), + state: String(value.state ?? "running"), + item: item as Record, + } +} + +/** Fold a newer envelope onto an existing cache entry by revision. */ +export function mergeNativeEnvelope( + existing: NativeItemEnvelope | undefined, + incoming: NativeItemEnvelope, +): NativeItemEnvelope { + if (!existing) return incoming + if ((incoming.revision ?? 0) < (existing.revision ?? 0)) return existing + return { + ...existing, + ...incoming, + item: { ...existing.item, ...incoming.item }, + } +} + +export function userMessageText(envelope: NativeItemEnvelope): string { + const content = envelope.item.content + if (!Array.isArray(content)) return "" + return content + .map((part) => (part && typeof part === "object" ? (part as Record) : null)) + .filter((part): part is Record => Boolean(part)) + .filter((part) => part.type === "text") + .map((part) => String(part.text ?? "")) + .join("\n") +} + +export function assistantOrReasoningText(envelope: NativeItemEnvelope): string { + return typeof envelope.item.text === "string" ? envelope.item.text : "" +} diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts index 30a7525f..0ee84b13 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts @@ -169,8 +169,8 @@ describe("desktop protocol runtime validation", () => { workspaceRoot: "/repo", base: { kind: "turn_checkpoint", - turn_id: "t1", - checkpoint_id: "checkpoint-1", + turnId: "t1", + checkpointId: "checkpoint-1", backend: "git_ghost_commit", }, coverage: "git_visible", @@ -183,10 +183,10 @@ describe("desktop protocol runtime validation", () => { additions: 2, deletions: 1, binary: false, - diff_truncated: false, + diffTruncated: false, }, ], - stats: { files_changed: 1, additions: 2, deletions: 1 }, + stats: { filesChanged: 1, additions: 2, deletions: 1 }, unifiedDiff: "diff --git a/src/main.rs b/src/main.rs\n", warnings: [], generatedAt: "2026-06-26T00:00:00Z", diff --git a/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx b/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx index 72b75377..4a045aef 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/conversation.tsx @@ -9,13 +9,24 @@ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom" export { useStickToBottomContext } -export type ConversationProps = ComponentProps +export type ConversationProps = ComponentProps & { + /** + * While true, use spring resize so continuous stream height growth + * coalesces instead of fighting `instant` scroll every layout tick. + */ + streaming?: boolean +} -export const Conversation = ({ className, ...props }: ConversationProps) => ( +export const Conversation = ({ + className, + streaming = false, + resize, + ...props +}: ConversationProps) => ( diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index ca22f314..4322df7a 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -270,9 +270,17 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp ) } -export type MessageResponseProps = ComponentProps +export type MessageResponseProps = Omit, "plugins"> & { + plugins?: ComponentProps["plugins"] + /** + * Live streaming surface: skip Streamdown enter animation and heavy plugins + * (math / mermaid) until the turn is idle. + */ + streaming?: boolean +} const streamdownPlugins = { cjk, code, math, mermaid } +const streamdownPluginsStreaming = { cjk, code } // Product requirement: regular transcript Markdown tables should keep copy and // download controls, but not show a fullscreen control. Code blocks keep copy @@ -325,20 +333,24 @@ const transcriptMarkdownComponents: NonNullable ( + ({ className, streaming = false, animated, plugins, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + streaming && "devo-message-response--streaming", className, )} + animated={streaming ? false : animated} components={transcriptMarkdownComponents} controls={transcriptMarkdownControls} - plugins={streamdownPlugins} + plugins={plugins ?? (streaming ? streamdownPluginsStreaming : streamdownPlugins)} {...props} /> ), (prevProps, nextProps) => - prevProps.children === nextProps.children && prevProps.animated === nextProps.animated, + prevProps.children === nextProps.children && + prevProps.animated === nextProps.animated && + prevProps.streaming === nextProps.streaming, ) MessageResponse.displayName = "MessageResponse" diff --git a/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx b/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx index 09af2293..199a2172 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/reasoning.tsx @@ -191,18 +191,34 @@ export const ReasoningTrigger = memo( export type ReasoningContentProps = ComponentProps & { children: ReactNode animated?: boolean + streaming?: boolean } const streamdownPlugins = { cjk, code, math, mermaid } +const streamdownPluginsStreaming = { cjk, code } -export const ReasoningText = memo(({ children, animated }: { children: string; animated?: boolean }) => ( - - {children} - -)) +export const ReasoningText = memo( + ({ + children, + animated, + streaming = false, + }: { + children: string + animated?: boolean + streaming?: boolean + }) => ( + + {children} + + ), +) export const ReasoningContent = memo( - ({ className, children, animated, ...props }: ReasoningContentProps) => ( + ({ className, children, animated, streaming, ...props }: ReasoningContentProps) => ( {typeof children === "string" ? ( - {children} + + {children} + ) : ( children )} diff --git a/apps/desktop/packages/ui/src/components/command.tsx b/apps/desktop/packages/ui/src/components/command.tsx index 42f9bf65..ebcc6f05 100644 --- a/apps/desktop/packages/ui/src/components/command.tsx +++ b/apps/desktop/packages/ui/src/components/command.tsx @@ -42,7 +42,7 @@ function CommandDialog({ return ( diff --git a/apps/desktop/scripts/collect-release-assets.test.ts b/apps/desktop/scripts/collect-release-assets.test.ts new file mode 100644 index 00000000..201ca4fb --- /dev/null +++ b/apps/desktop/scripts/collect-release-assets.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +async function makeArtifacts() { + const root = await mkdtemp(join(tmpdir(), "devo-release-assets-")); + const input = join(root, "artifacts"); + const output = join(root, "release-assets"); + const files = []; + for (const arch of ["x64", "arm64"]) { + const directory = join(input, `desktop-mac-${arch}`); + await mkdir(directory, { recursive: true }); + const content = Buffer.from(`installer-${arch}`); + const file = { + url: `devo-desktop-0.2.0-mac-${arch}.zip`, + sha512: createHash("sha512").update(content).digest("base64"), + size: content.length, + }; + files.push(file); + await writeFile(join(directory, file.url), content); + await writeFile(join(directory, `${file.url}.blockmap`), "blockmap"); + await writeFile(join(directory, "builder-debug.yml"), "debug: true\n"); + await writeFile( + join(directory, "latest-mac.yml"), + Bun.YAML.stringify({ + version: "0.2.0", + files: [file], + path: file.url, + sha512: file.sha512, + }), + ); + } + return { root, input, output, files }; +} + +async function collect(input: string, output: string) { + const child = Bun.spawn({ + cmd: [process.execPath, join(import.meta.dir, "collect-release-assets.ts"), input, output, "v0.2.0"], + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +test("release collection preserves both Mac architectures and blockmaps without debug files", async () => { + const fixture = await makeArtifacts(); + try { + const result = await collect(fixture.input, fixture.output); + expect(result).toEqual({ + exitCode: 0, + stdout: "Collected 4 assets and 1 merged update manifests for v0.2.0\n", + stderr: "", + }); + expect({ + assets: (await readdir(fixture.output)).sort(), + manifest: Bun.YAML.parse(await readFile(join(fixture.output, "latest-mac.yml"), "utf8")), + }).toEqual({ + assets: [...fixture.files.flatMap(file => [file.url, `${file.url}.blockmap`]), "latest-mac.yml", "SHA256SUMS.txt"].sort(), + manifest: { + version: "0.2.0", + files: fixture.files, + path: fixture.files[0].url, + sha512: fixture.files[0].sha512, + }, + }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("release collection retains online installation indexes and verifies every referenced pack", async () => { + const fixture = await makeArtifacts(); + try { + const directory = join(fixture.input, "online"); + await mkdir(directory); + const entries = []; + for (const kind of ["app", "node", "python"]) { + const name = `devo-${kind}.tar.gz`; + const content = Buffer.from(kind); + await writeFile(join(directory, name), content); + entries.push(`${kind} ${createHash("sha256").update(content).digest("hex")} ${name}`); + } + const name = "devo-tui-v0.2.0-x86_64-pc-windows-msvc.install.txt"; + await writeFile(join(directory, name), ["devo-install-v1", ...entries, ""].join("\n")); + const result = await collect(fixture.input, fixture.output); + expect({ exit: result.exitCode, stderr: result.stderr, index: await readFile(join(fixture.output, name), "utf8") }).toEqual({ exit: 0, stderr: "", index: ["devo-install-v1", ...entries, ""].join("\n") }); + await writeFile(join(directory, "devo-node.tar.gz"), "damaged"); + const corrupt = await collect(fixture.input, fixture.output); + expect({ failed: corrupt.exitCode !== 0, checksumFailure: corrupt.stderr.includes("Runtime pack checksum or asset missing") }).toEqual({ failed: true, checksumFailure: true }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("release collection rejects assets that disagree with update checksums", async () => { + const fixture = await makeArtifacts(); + try { + await writeFile(join(fixture.input, "desktop-mac-arm64", fixture.files[1].url), "installer-ARM64"); + const result = await collect(fixture.input, fixture.output); + expect({ + exitCode: result.exitCode, + checksumError: result.stderr.includes(`Update asset checksum mismatch: ${fixture.files[1].url}`), + }).toEqual({ exitCode: 1, checksumError: true }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("release collection includes devo-tui archives in SHA256SUMS", async () => { + const fixture = await makeArtifacts(); + try { + const name = "devo-tui-v0.2.0-x86_64-unknown-linux-musl.tar.gz"; + const content = Buffer.from("terminal bundle"); + await writeFile(join(fixture.input, name), content); + const result = await collect(fixture.input, fixture.output); + expect(result).toEqual({ exitCode: 0, stdout: "Collected 5 assets and 1 merged update manifests for v0.2.0\n", stderr: "" }); + expect(await readFile(join(fixture.output, name))).toEqual(content); + const checksums = (await readFile(join(fixture.output, "SHA256SUMS.txt"), "utf8")).trim().split("\n"); + expect(checksums.filter(line => line.endsWith(` ${name}`))).toEqual([`${createHash("sha256").update(content).digest("hex")} ${name}`]); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/scripts/collect-release-assets.ts b/apps/desktop/scripts/collect-release-assets.ts new file mode 100644 index 00000000..332052cc --- /dev/null +++ b/apps/desktop/scripts/collect-release-assets.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +type UpdateFile = { + url: string; + sha512: string; + size?: number; + [key: string]: unknown; +}; + +type UpdateManifest = { + version: string; + files: UpdateFile[]; + path?: string; + [key: string]: unknown; +}; + +// Matrix jobs produce identically named update manifests. Keep their artifact +// directories separate until file lists have been merged across architectures. +const inputDir = resolve(Bun.argv[2] ?? "artifacts"); +const outputDir = resolve(Bun.argv[3] ?? "release-assets"); +const version = (Bun.argv[4] ?? "").replace(/^v/, ""); +if (!version) throw new Error("Expected release tag as the third argument"); + +const assets = new Map(); +const manifests = new Map(); +const paths = Array.from( + new Bun.Glob("**/*").scanSync({ cwd: inputDir, onlyFiles: true }), +).sort(); + +for (const relativePath of paths) { + const name = basename(relativePath); + const source = join(inputDir, relativePath); + if (/^(latest|alpha|beta)(-[a-z0-9-]+)?\.yml$/.test(name)) { + const manifest = Bun.YAML.parse( + await readFile(source, "utf8"), + ) as UpdateManifest; + if ( + manifest.version !== version || + !Array.isArray(manifest.files) || + !manifest.files.length + ) { + throw new Error(`Invalid update manifest or version: ${relativePath}`); + } + const group = manifests.get(name) ?? []; + group.push(manifest); + manifests.set(name, group); + } else if (/\.(AppImage|blockmap|deb|dmg|exe|rpm|tar\.gz|zip)$/.test(name) || /^devo-tui-v[^/]+\.install\.txt$/.test(name)) { + if (assets.has(name)) throw new Error(`Duplicate release asset: ${name}`); + assets.set(name, source); + } +} + +if (!assets.size || !manifests.size) { + throw new Error("Release assets or update manifests are missing"); +} +await mkdir(outputDir, { recursive: true }); +for (const [name, source] of assets) await copyFile(source, join(outputDir, name)); + +const checksums = new Map(); +for (const [name, group] of manifests) { + // Preserve the x64 fallback for older clients. Modern Mac clients select the + // appropriate architecture from the merged files list. + group.sort( + (a, b) => + Number(a.path?.includes("arm64") ?? false) - + Number(b.path?.includes("arm64") ?? false), + ); + const files = new Map(); + for (const manifest of group) { + for (const file of manifest.files) { + if (typeof file.url !== "string" || typeof file.sha512 !== "string") { + throw new Error(`Invalid file entry in ${name}`); + } + const previous = files.get(file.url); + if (previous && (previous.sha512 !== file.sha512 || previous.size !== file.size)) { + throw new Error(`Conflicting update metadata: ${file.url}`); + } + const source = assets.get(file.url); + if (!source) { + throw new Error(`Update metadata references a missing asset: ${file.url}`); + } + if (file.size !== undefined && (await stat(source)).size !== file.size) { + throw new Error(`Update asset size mismatch: ${file.url}`); + } + let checksum = checksums.get(file.url); + if (!checksum) { + const hash = createHash("sha512"); + for await (const chunk of createReadStream(source)) hash.update(chunk); + checksum = hash.digest("base64"); + checksums.set(file.url, checksum); + } + if (checksum !== file.sha512) { + throw new Error(`Update asset checksum mismatch: ${file.url}`); + } + files.set(file.url, file); + } + } + const merged = { ...group[0], files: Array.from(files.values()) }; + await writeFile(join(outputDir, name), Bun.YAML.stringify(merged)); +} + +const sha256Lines: string[] = []; +const sha256 = new Map(); +for (const name of (await readdir(outputDir)).sort()) { + if (name === "SHA256SUMS.txt") continue; + const hash = createHash("sha256"); + for await (const chunk of createReadStream(join(outputDir, name))) hash.update(chunk); + const digest = hash.digest("hex"); + sha256.set(name, digest); + sha256Lines.push(`${digest} ${name}`); +} +for (const name of assets.keys()) { + if (!name.endsWith(".install.txt")) continue; + const lines = (await readFile(join(outputDir, name), "utf8")).trimEnd().split("\n"); + if (lines.length !== 4 || lines[0] !== "devo-install-v1" || !name.startsWith(`devo-tui-v${version}-`)) throw new Error(`Invalid runtime installation index: ${name}`); + for (const [i, kind] of ["app", "node", "python"].entries()) { + const parts = lines[i + 1].split(" "); + if (parts.length !== 3 || parts[0] !== kind || !/^[a-f0-9]{64}$/.test(parts[1]) || sha256.get(parts[2]) !== parts[1]) throw new Error(`Runtime pack checksum or asset missing: ${name} (${kind})`); + } +} +await writeFile(join(outputDir, "SHA256SUMS.txt"), sha256Lines.join("\n") + "\n"); + +console.log( + `Collected ${assets.size} assets and ${manifests.size} merged update manifests for v${version}`, +); diff --git a/apps/desktop/scripts/desktop-package-config.test.ts b/apps/desktop/scripts/desktop-package-config.test.ts index 8bbf4805..6f5b6e04 100644 --- a/apps/desktop/scripts/desktop-package-config.test.ts +++ b/apps/desktop/scripts/desktop-package-config.test.ts @@ -32,16 +32,16 @@ describe("desktop package runtime resources", () => { usesCanonicalOwner: config.includes("owner: 7df-lab"), usesCanonicalRepo: config.includes("repo: devo"), usesCanonicalReleasePage: updaterSource.includes("https://github.com/7df-lab/devo"), - publishesBlockmaps: releaseWorkflow.includes("-o -name '*.blockmap'"), - publishesChannelMetadata: releaseWorkflow.includes("-o -name '*.yml'"), + collectsReleaseAssets: releaseWorkflow.includes("bun apps/desktop/scripts/collect-release-assets.ts"), + preservesMatrixArtifacts: !releaseWorkflow.includes("merge-multiple: true"), }).toEqual({ homepage: "https://github.com/7df-lab/devo", repositoryUrl: "https://github.com/7df-lab/devo.git", usesCanonicalOwner: true, usesCanonicalRepo: true, usesCanonicalReleasePage: true, - publishesBlockmaps: true, - publishesChannelMetadata: true, + collectsReleaseAssets: true, + preservesMatrixArtifacts: true, }) }) }) diff --git a/apps/desktop/scripts/dev-runtime.test.ts b/apps/desktop/scripts/dev-runtime.test.ts new file mode 100644 index 00000000..78d97501 --- /dev/null +++ b/apps/desktop/scripts/dev-runtime.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { devCargoBuild, stageWindowsDevRuntime } from "./dev-runtime" + +describe("desktop development runtime", () => { + test("Windows builds the CLI and sandbox helpers away from running checkout binaries", () => { + const repoRoot = join(tmpdir(), "repo") + const targetRoot = join(repoRoot, "target", "desktop-dev") + expect(devCargoBuild({ repoRoot, env: {}, platform: "win32" })).toEqual({ + command: [ + "cargo", "build", "--manifest-path", join(repoRoot, "Cargo.toml"), "-p", "devo-cli", + "-p", "devo-windows-sandbox", "--bins", "--target-dir", targetRoot, + ], + program: join(targetRoot, "debug", "devo.exe"), + }) + }) + + test("isolates the desktop output inside a custom Cargo target directory", () => { + const repoRoot = join(tmpdir(), "repo") + const targetRoot = join(tmpdir(), "custom-target", "desktop-dev") + expect( + devCargoBuild({ repoRoot, env: { CARGO_TARGET_DIR: join(tmpdir(), "custom-target") }, platform: "win32" }).program, + ).toBe(join(targetRoot, "debug", "devo.exe")) + }) + + test.each(["linux", "darwin"] as const)("retains normal Cargo output on %s", (platform) => { + const repoRoot = join(tmpdir(), "repo") + expect(devCargoBuild({ repoRoot, env: {}, platform })).toEqual({ + command: ["cargo", "build", "--manifest-path", join(repoRoot, "Cargo.toml"), "-p", "devo-cli", "--bin", "devo"], + program: undefined, + }) + }) + + test("stages independent launch generations with matching helpers and DLLs", () => { + const fixture = mkdtempSync(join(tmpdir(), "devo-dev-runtime-test-")) + try { + const source = join(fixture, "debug") + mkdirSync(source) + const files = ["devo.exe", "devo-command-runner.exe", "devo-windows-sandbox-setup.exe", "runtime.dll"] + for (const name of files) writeFileSync(join(source, name), `original ${name}`) + writeFileSync(join(source, "unrelated.pdb"), "do not stage") + const first = stageWindowsDevRuntime(join(source, "devo.exe"), fixture) + writeFileSync(join(source, "devo.exe"), "rebuilt") + const second = stageWindowsDevRuntime(join(source, "devo.exe"), fixture) + expect(first).not.toBe(second) + expect(readFileSync(first, "utf8")).toBe("original devo.exe") + expect(readFileSync(second, "utf8")).toBe("rebuilt") + expect(files.map((name) => readFileSync(join(dirname(first), name), "utf8"))) + .toEqual(files.map((name) => `original ${name}`)) + expect(readdirSync(dirname(first)).sort()).toEqual([...files].sort()) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }) + + test("removes an incomplete generation when a required sandbox helper is missing", () => { + const fixture = mkdtempSync(join(tmpdir(), "devo-dev-runtime-test-")) + try { + const source = join(fixture, "debug") + mkdirSync(source) + writeFileSync(join(source, "devo.exe"), "CLI without helpers") + expect(() => stageWindowsDevRuntime(join(source, "devo.exe"), fixture)).toThrow() + expect(readdirSync(fixture)).toEqual(["debug"]) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/scripts/dev-runtime.ts b/apps/desktop/scripts/dev-runtime.ts new file mode 100644 index 00000000..40e7fbad --- /dev/null +++ b/apps/desktop/scripts/dev-runtime.ts @@ -0,0 +1,41 @@ +import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" + +export function devCargoBuild({ + repoRoot, + env, + platform, +}: { + repoRoot: string + env: NodeJS.ProcessEnv + platform: NodeJS.Platform +}): { command: string[]; program: string | undefined } { + const command = ["cargo", "build", "--manifest-path", join(repoRoot, "Cargo.toml"), "-p", "devo-cli"] + if (platform !== "win32") return { command: [...command, "--bin", "devo"], program: undefined } + + // Older desktop/CLI instances may still lock target/debug/devo.exe. Build + // separately, then run immutable copies so future builds remain writable. + const targetRoot = resolve(repoRoot, env.CARGO_TARGET_DIR?.trim() || "target", "desktop-dev") + return { + command: [...command, "-p", "devo-windows-sandbox", "--bins", "--target-dir", targetRoot], + program: join(targetRoot, "debug", "devo.exe"), + } +} + +export function stageWindowsDevRuntime(program: string, stagingRoot = tmpdir()): string { + const sourceDir = dirname(program) + const runtimeDir = mkdtempSync(join(stagingRoot, "devo-desktop-dev-")) + const required = ["devo.exe", "devo-command-runner.exe", "devo-windows-sandbox-setup.exe"] + try { + for (const name of required) copyFileSync(join(sourceDir, name), join(runtimeDir, name)) + // Keep any dynamically linked runtime dependencies beside the executable. + for (const name of readdirSync(sourceDir)) { + if (name.toLowerCase().endsWith(".dll")) copyFileSync(join(sourceDir, name), join(runtimeDir, name)) + } + return join(runtimeDir, "devo.exe") + } catch (error) { + rmSync(runtimeDir, { recursive: true, force: true }) + throw error + } +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts new file mode 100644 index 00000000..262b6739 --- /dev/null +++ b/apps/desktop/scripts/dev.ts @@ -0,0 +1,38 @@ +import { rmSync } from "node:fs" +import { createRequire } from "node:module" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { devCargoBuild, stageWindowsDevRuntime } from "./dev-runtime" + +const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const plan = devCargoBuild({ repoRoot: resolve(desktopDir, "../.."), env: process.env, platform: process.platform }) +const build = Bun.spawn(plan.command, { cwd: desktopDir, stdin: "inherit", stdout: "inherit", stderr: "inherit" }) +const buildExit = await build.exited +if (buildExit !== 0) process.exit(buildExit) +if (process.argv.includes("--build-only")) process.exit(0) + +const override = process.env.DEVO_DESKTOP_DEVO_BIN?.trim() +const stagedProgram = !override && plan.program ? stageWindowsDevRuntime(plan.program) : undefined +try { + if (stagedProgram) console.log(`Desktop development runtime: ${stagedProgram}`) + const require = createRequire(import.meta.url) + const electronVite = resolve(dirname(require.resolve("electron-vite/package.json")), "bin/electron-vite.js") + const electron = Bun.spawn(["node", electronVite, ...process.argv.slice(2)], { + cwd: desktopDir, + env: { ...process.env, ...(stagedProgram ? { DEVO_DESKTOP_DEVO_BIN: stagedProgram } : {}) }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + process.exitCode = await electron.exited +} finally { + if (stagedProgram) { + // A surviving child may retain a Windows file lock after abnormal shutdown. + // Never delete another launch's directory or terminate its processes. + try { + rmSync(dirname(stagedProgram), { recursive: true, force: true }) + } catch (error) { + console.warn(`Could not clean development runtime ${dirname(stagedProgram)}: ${error}`) + } + } +} diff --git a/apps/desktop/scripts/prepare-runtime.test.ts b/apps/desktop/scripts/prepare-runtime.test.ts index 510a04a5..c26bfda0 100644 --- a/apps/desktop/scripts/prepare-runtime.test.ts +++ b/apps/desktop/scripts/prepare-runtime.test.ts @@ -3,8 +3,40 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os" import { join } from "node:path" import { defaultDevoSourcePath, runtimeBinaryName, stageRuntime } from "./prepare-runtime" +import { WINDOWS_SANDBOX_HELPERS } from "../../../scripts/runtime/backend" describe("prepare-runtime helpers", () => { + test("Windows staging requires and preserves both sandbox helpers", () => { + const root = mkdtempSync(join(tmpdir(), "devo-helper-staging-")) + const source = join(root, "source") + const desktopDir = join(root, "desktop") + mkdirSync(source) + writeFileSync(join(source, "devo.exe"), "backend") + writeFileSync(join(source, "rg.exe"), "ripgrep") + const options = { repoRoot: root, desktopDir, platform: "win32" as const, devoBin: join(source, "devo.exe"), rgBin: join(source, "rg.exe") } + for (const helper of WINDOWS_SANDBOX_HELPERS) { + expect(() => stageRuntime(options)).toThrow(`Windows sandbox helper not found: ${helper}`) + expect(existsSync(join(desktopDir, "resources/runtime/bin"))).toBe(false) + writeFileSync(join(source, helper), helper) + } + stageRuntime(options) + expect(WINDOWS_SANDBOX_HELPERS.map(name => readFileSync(join(desktopDir, "resources/runtime/bin", name), "utf8"))).toEqual([...WINDOWS_SANDBOX_HELPERS]) + }) + + test("a Windows bundle missing helpers cannot replace a staged Desktop runtime", () => { + const root = mkdtempSync(join(tmpdir(), "devo-helper-desktop-")) + const bundleDir = join(root, "bundle") + const desktopDir = join(root, "desktop") + mkdirSync(join(bundleDir, "runtime"), { recursive: true }) + mkdirSync(join(desktopDir, "resources/runtime"), { recursive: true }) + writeFileSync(join(desktopDir, "resources/runtime/keep.txt"), "existing") + writeFileSync(join(bundleDir, "runtime/manifest.json"), JSON.stringify({ schema: 1, target: "aarch64-pc-windows-msvc" })) + expect(() => stageRuntime({ repoRoot: root, desktopDir, bundleDir, targetTriple: "aarch64-pc-windows-msvc" })).toThrow("Windows sandbox helper not found") + expect(readFileSync(join(desktopDir, "resources/runtime/keep.txt"), "utf8")).toBe("existing") + for (const name of WINDOWS_SANDBOX_HELPERS) writeFileSync(join(bundleDir, name), name) + stageRuntime({ repoRoot: root, desktopDir, bundleDir, targetTriple: "aarch64-pc-windows-msvc" }) + expect(WINDOWS_SANDBOX_HELPERS.map(name => readFileSync(join(desktopDir, "resources/runtime", name), "utf8"))).toEqual([...WINDOWS_SANDBOX_HELPERS]) + }) test("uses platform executable names", () => { expect({ darwin: runtimeBinaryName("devo", "darwin"), @@ -45,7 +77,6 @@ describe("prepare-runtime helpers", () => { mkdirSync(targetDir, { recursive: true }) mkdirSync(desktopDir, { recursive: true }) writeFileSync(join(targetDir, "devo"), "") - writeFileSync(join(targetDir, "devo-code-search-mcp"), "") expect(() => stageRuntime({ @@ -70,7 +101,6 @@ describe("prepare-runtime helpers", () => { mkdirSync(releaseDir, { recursive: true }) writeFileSync(devoBin, "devo") writeFileSync(rgBin, "rg") - writeFileSync(join(releaseDir, "devo-code-search-mcp"), "mcp") stageRuntime({ desktopDir, @@ -82,15 +112,11 @@ describe("prepare-runtime helpers", () => { expect({ devo: readFileSync(join(desktopDir, "resources", "runtime", "bin", "devo"), "utf8"), - mcp: readFileSync( - join(desktopDir, "resources", "runtime", "bin", "devo-code-search-mcp"), - "utf8", - ), rg: readFileSync(join(desktopDir, "resources", "runtime", "bin", "rg"), "utf8"), }).toEqual({ devo: "devo", - mcp: "mcp", rg: "rg", }) }) + }) diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts index 8a76cda3..50ab1592 100644 --- a/apps/desktop/scripts/prepare-runtime.ts +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -1,7 +1,9 @@ -import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs" -import { homedir } from "node:os" +import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs" +import { homedir, tmpdir } from "node:os" import { delimiter, dirname, join } from "node:path" import { fileURLToPath } from "node:url" +import { bundleRuntime } from "../../../scripts/runtime/bundle" +import { WINDOWS_SANDBOX_HELPERS } from "../../../scripts/runtime/backend" interface DefaultSourcePathOptions { repoRoot: string @@ -10,6 +12,7 @@ interface DefaultSourcePathOptions { } interface StageRuntimeOptions extends DefaultSourcePathOptions { + bundleDir?: string devoBin?: string desktopDir: string hostArch?: NodeJS.Architecture @@ -45,22 +48,24 @@ export function defaultDevoSourcePath({ return join(repoRoot, ...targetParts, runtimeBinaryName("devo", platformForTargetTriple(targetTriple, platform))) } -export function defaultCodeSearchMcpSourcePath({ - repoRoot, - targetTriple, - platform = process.platform, -}: DefaultSourcePathOptions): string { - const targetParts = targetTriple ? ["target", targetTriple, "release"] : ["target", "release"] - return join( - repoRoot, - ...targetParts, - runtimeBinaryName("devo-code-search-mcp", platformForTargetTriple(targetTriple, platform)), - ) -} - export function stageRuntime(options: StageRuntimeOptions): void { + if (options.bundleDir) { + const manifest = JSON.parse(readFileSync(join(options.bundleDir, "runtime/manifest.json"), "utf8")) + if (manifest.schema !== 1 || manifest.target !== options.targetTriple) { + throw new Error("Desktop runtime bundle target/schema mismatch") + } + if (manifest.target.includes("windows")) { + for (const name of WINDOWS_SANDBOX_HELPERS) { + if (!existsSync(join(options.bundleDir, name))) throw new Error(`Windows sandbox helper not found: ${name}`) + } + } + const destination = join(options.desktopDir, "resources", "runtime") + rmSync(destination, { recursive: true, force: true }) + cpSync(options.bundleDir, destination, { recursive: true, dereference: true }) + console.log(`Prepared complete Desktop runtime: ${destination}`) + return + } const devoSource = options.devoBin ?? defaultDevoSourcePath(options) - const codeSearchMcpSource = defaultCodeSearchMcpSourcePath(options) const targetPlatform = platformForTargetTriple(options.targetTriple, options.platform ?? process.platform) const rgOverride = options.rgBin ?? optionalPath(process.env.DEVO_DESKTOP_RUNTIME_RG_BIN) @@ -88,29 +93,25 @@ export function stageRuntime(options: StageRuntimeOptions): void { if (!existsSync(devoSource)) { throw new Error(`Devo runtime binary not found at ${devoSource}`) } - if (!existsSync(codeSearchMcpSource)) { - throw new Error(`code_search MCP binary not found at ${codeSearchMcpSource}`) - } if (!rgSource || !existsSync(rgSource)) { throw new Error("ripgrep sidecar not found. Install rg or pass --rg-bin .") } + const helpers = targetPlatform === "win32" ? WINDOWS_SANDBOX_HELPERS : [] + for (const name of helpers) { + if (!existsSync(join(dirname(devoSource), name))) throw new Error(`Windows sandbox helper not found: ${name}`) + } const runtimeBinDir = join(options.desktopDir, "resources", "runtime", "bin") rmSync(runtimeBinDir, { recursive: true, force: true }) mkdirSync(runtimeBinDir, { recursive: true }) const devoDest = join(runtimeBinDir, runtimeBinaryName("devo", targetPlatform)) - const codeSearchMcpDest = join( - runtimeBinDir, - runtimeBinaryName("devo-code-search-mcp", targetPlatform), - ) const rgDest = join(runtimeBinDir, runtimeBinaryName("rg", targetPlatform)) copyExecutable(devoSource, devoDest, targetPlatform) - copyExecutable(codeSearchMcpSource, codeSearchMcpDest, targetPlatform) copyExecutable(rgSource, rgDest, targetPlatform) + for (const name of helpers) copyExecutable(join(dirname(devoSource), name), join(runtimeBinDir, name), targetPlatform) console.log(`Prepared Desktop runtime: ${devoDest}`) - console.log(`Prepared code_search MCP sidecar: ${codeSearchMcpDest}`) console.log(`Prepared ripgrep sidecar: ${rgDest}`) } @@ -158,12 +159,28 @@ function optionalPath(value: string | undefined): string | undefined { } if (import.meta.main) { - stageRuntime({ + const target = argValue("--target") ?? `${targetArchName(process.arch)}-${process.platform === "win32" ? "pc-windows-msvc" : process.platform === "darwin" ? "apple-darwin" : "unknown-linux-musl"}` + const options: StageRuntimeOptions = { desktopDir, repoRoot, - targetTriple: argValue("--target"), + targetTriple: target, platform: process.platform, - devoBin: argValue("--devo-bin") ?? optionalPath(process.env.DEVO_DESKTOP_RUNTIME_DEVO_BIN), + devoBin: argValue("--devo-bin") ?? optionalPath(process.env.DEVO_DESKTOP_RUNTIME_DEVO_BIN) ?? defaultDevoSourcePath({ repoRoot, targetTriple: argValue("--target") }), rgBin: argValue("--rg-bin"), - }) + } + const bundleDir = argValue("--bundle-dir") + if (bundleDir) { + stageRuntime({ ...options, bundleDir }) + } else { + const work = mkdtempSync(join(tmpdir(), "devo-desktop-runtime-")) + try { + stageRuntime({ ...options, desktopDir: work }) + const binaryDir = join(work, "resources/runtime/bin") + const output = join(work, "bundle") + await bundleRuntime(target, output, join(binaryDir, runtimeBinaryName("rg", platformForTargetTriple(target, process.platform))), binaryDir) + stageRuntime({ ...options, bundleDir: output }) + } finally { + rmSync(work, { recursive: true, force: true }) + } + } } diff --git a/apps/desktop/scripts/sync-cargo-version.test.ts b/apps/desktop/scripts/sync-cargo-version.test.ts index cbb2094b..466a3a17 100644 --- a/apps/desktop/scripts/sync-cargo-version.test.ts +++ b/apps/desktop/scripts/sync-cargo-version.test.ts @@ -9,7 +9,10 @@ import { } from "./sync-cargo-version"; async function tempDesktopDir(): Promise { - return mkdtemp(join(tmpdir(), "devo-desktop-version-")); + const repoRoot = await mkdtemp(join(tmpdir(), "devo-desktop-version-")); + const desktopDir = join(repoRoot, "apps", "desktop"); + await mkdir(desktopDir, { recursive: true }); + return desktopDir; } describe("desktop Cargo version sync", () => { diff --git a/apps/desktop/src/main/automation/executor-item-extract.test.ts b/apps/desktop/src/main/automation/executor-item-extract.test.ts new file mode 100644 index 00000000..7dbae7c7 --- /dev/null +++ b/apps/desktop/src/main/automation/executor-item-extract.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { extractAutomationItem } from "./item-extract" + +function envelope( + overrides: Partial & { item: Record }, +): NativeItemEnvelope { + return { + id: "item-1", + sessionId: "ses-1", + turnId: "turn-1", + seq: 1, + revision: 1, + createdAt: "2026-09-14T00:00:00.000Z", + updatedAt: "2026-09-14T00:00:01.000Z", + state: "completed", + ...overrides, + } +} + +describe("extractAutomationItem", () => { + test("reads terminal assistantMessage text from Native item payload", () => { + expect( + extractAutomationItem( + envelope({ + item: { type: "assistantMessage", text: "Actionable: yes\nDone." }, + }), + ), + ).toEqual({ + text: "Actionable: yes\nDone.", + toolName: null, + }) + }) + + test("ignores streaming assistantMessage deltas", () => { + expect( + extractAutomationItem( + envelope({ + state: "running", + item: { type: "assistantMessage", text: "partial" }, + }), + ), + ).toEqual({ text: null, toolName: null }) + }) + + test("reads toolName from toolCall and hostedToolCall payloads", () => { + expect( + extractAutomationItem( + envelope({ + state: "running", + item: { type: "toolCall", toolName: "bash", callId: "call-1" }, + }), + ), + ).toEqual({ text: null, toolName: "bash" }) + + expect( + extractAutomationItem( + envelope({ + item: { type: "hostedToolCall", callId: "hosted-9" }, + }), + ), + ).toEqual({ text: null, toolName: "hosted-9" }) + }) + + test("ignores userMessage and other item types", () => { + expect( + extractAutomationItem( + envelope({ + item: { + type: "userMessage", + content: [{ type: "text", text: "hello" }], + }, + }), + ), + ).toEqual({ text: null, toolName: null }) + }) +}) diff --git a/apps/desktop/src/main/automation/executor.ts b/apps/desktop/src/main/automation/executor.ts index 29160e01..a7bf12ab 100644 --- a/apps/desktop/src/main/automation/executor.ts +++ b/apps/desktop/src/main/automation/executor.ts @@ -14,13 +14,17 @@ import fs from "node:fs" import path from "node:path" -import type { DevoClient } from "@devo-ai/sdk/v2/client" +import type { DevoClient, NativeItemEnvelope } from "@devo-ai/sdk/v2/client" import { createLogger } from "../logger" import { createAutomationClient } from "./devo-client" +import { extractAutomationItem } from "./item-extract" import { getConfigDir } from "./paths" import { buildPermissionRuleset } from "./permission-policy" import type { AutomationConfig, PermissionPreset } from "./types" +export type { AutomationItemExtract } from "./item-extract" +export { extractAutomationItem } + const log = createLogger("automation-executor") /** Default timeout (in ms) for individual SDK calls (session.create, promptAsync, etc.). */ @@ -126,7 +130,7 @@ async function monitorSession( signal: AbortSignal, permissionPreset: PermissionPreset, ): Promise<{ text: string; error: string | null }> { - const textParts: string[] = [] + const textByItemId = new Map() let error: string | null = null const timeoutPromise = new Promise<"timeout">((resolve) => { @@ -145,22 +149,28 @@ async function monitorSession( // biome-ignore lint/suspicious/noExplicitAny: Native events have dynamic types not fully covered by SDK const evt = event as any - // Capture text output from the assistant - if (evt.type === "message.part.updated") { - const part = evt.properties?.part - if ( - part?.sessionID === sessionId && - part?.type === "text" && - part?.time?.end && - part?.text - ) { - textParts.push(part.text) + // Capture text / tools from Native ItemEnvelope upserts + if (evt.type === "item.updated") { + const info = evt.properties?.info as NativeItemEnvelope | undefined + if (info?.sessionId === sessionId) { + const extracted = extractAutomationItem(info) + if (extracted.text) { + textByItemId.set(info.id, extracted.text) + } + if (extracted.toolName) { + log.debug("Automation tool item", { + sessionId, + itemId: info.id, + toolName: extracted.toolName, + state: info.state, + }) + } } } // Capture errors if (evt.type === "session.error") { - if (evt.properties?.sessionID === sessionId && evt.properties?.error) { + if (evt.properties?.sessionId === sessionId && evt.properties?.error) { const errObj = evt.properties.error const errMsg = errObj.data?.message ?? errObj.name ?? "Unknown error" error = error ? `${error}\n${errMsg}` : String(errMsg) @@ -175,14 +185,14 @@ async function monitorSession( // policy remains waiting for an external controller instead of silently // changing the user's effective permissions. if (evt.type === "permission.asked" && permissionPreset === "read-only") { - if (evt.properties?.sessionID === sessionId) { + if (evt.properties?.sessionId === sessionId) { log.warn("Auto-rejecting permission request during automation", { sessionId, permission: evt.properties.permission, }) try { await client.permission.reply({ - requestID: evt.properties.id, + requestId: evt.properties.id, reply: "reject", }) } catch (rejectErr) { @@ -194,7 +204,7 @@ async function monitorSession( // Session went idle -- we're done if ( evt.type === "session.status" && - evt.properties?.sessionID === sessionId && + evt.properties?.sessionId === sessionId && evt.properties?.status?.type === "idle" ) { break @@ -216,7 +226,7 @@ async function monitorSession( if (outcome === "timeout") { log.warn("Session monitoring timed out", { sessionId, timeoutMs }) try { - await client.session.abort({ sessionID: sessionId }) + await client.session.abort({ sessionId: sessionId }) log.info("Session aborted after timeout", { sessionId }) } catch { log.warn("Failed to abort session after timeout", { sessionId }) @@ -226,7 +236,7 @@ async function monitorSession( : `Session timed out after ${Math.round(timeoutMs / 1000)}s` } - return { text: textParts.join("\n"), error } + return { text: [...textByItemId.values()].join("\n"), error } } /** @@ -305,6 +315,7 @@ export async function executeRun( const abortController = new AbortController() let worktreePath: string | null = null + let worktreeClient: DevoClient | null = null let sessionId = "" const runStartTime = Date.now() log.info("Starting execution", { @@ -360,7 +371,9 @@ export async function executeRun( const permissionRuleset = buildPermissionRuleset(config.execution.permissionPreset ?? "default") // If running in a worktree, create a client scoped to that directory - const sessionClient = worktreePath ? (createAutomationClient(worktreePath) ?? client) : client + const sessionClient = worktreePath + ? ((worktreeClient = createAutomationClient(worktreePath)) ?? client) + : client log.info("Creating session", { automationId: config.id, @@ -431,7 +444,7 @@ export async function executeRun( const promptStart = Date.now() await withTimeout( sessionClient.session.promptAsync({ - sessionID: sessionId, + sessionId: sessionId, system: systemPrompt, parts: [{ type: "text", text: config.prompt }], model, @@ -471,7 +484,7 @@ export async function executeRun( // Try to get session summary/diff info let branch: string | null = null try { - const sessionInfo = await sessionClient.session.get({ sessionID: sessionId }) + const sessionInfo = await sessionClient.session.get({ sessionId: sessionId }) // biome-ignore lint/suspicious/noExplicitAny: session response shape varies const info = sessionInfo.data as any if (info?.summary?.diffs?.length > 0) { @@ -531,5 +544,7 @@ export async function executeRun( } } finally { abortController.abort() + worktreeClient?.dispose() + client.dispose() } } diff --git a/apps/desktop/src/main/automation/item-extract.ts b/apps/desktop/src/main/automation/item-extract.ts new file mode 100644 index 00000000..d9a86eea --- /dev/null +++ b/apps/desktop/src/main/automation/item-extract.ts @@ -0,0 +1,38 @@ +/** + * Pure helpers for reading automation-relevant fields from Native ItemEnvelope + * payloads (`item.updated` events). Kept free of Electron / SDK client imports + * so unit tests can run under bun without the main-process runtime. + */ + +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { assistantOrReasoningText, nativeItemType } from "@devo-ai/sdk/v2/client" + +/** Text / tool fields pulled from a Native `item.updated` envelope payload. */ +export type AutomationItemExtract = { + text: string | null + toolName: string | null +} + +function isTerminalItemState(state: string): boolean { + return state === "completed" || state === "failed" || state === "interrupted" +} + +/** + * Extracts assistant text and tool names from a Native ItemEnvelope `item` payload. + * Streaming (non-terminal) assistant deltas are ignored so partial text is not duplicated. + */ +export function extractAutomationItem(envelope: NativeItemEnvelope): AutomationItemExtract { + const type = nativeItemType(envelope) + if (type === "assistantMessage") { + if (!isTerminalItemState(envelope.state)) { + return { text: null, toolName: null } + } + const text = assistantOrReasoningText(envelope) + return { text: text.length > 0 ? text : null, toolName: null } + } + if (type === "toolCall" || type === "hostedToolCall") { + const toolName = String(envelope.item.toolName ?? envelope.item.callId ?? "").trim() + return { text: null, toolName: toolName.length > 0 ? toolName : null } + } + return { text: null, toolName: null } +} diff --git a/apps/desktop/src/main/canonical-provider-migration.ts b/apps/desktop/src/main/canonical-provider-migration.ts new file mode 100644 index 00000000..0cb6c4a0 --- /dev/null +++ b/apps/desktop/src/main/canonical-provider-migration.ts @@ -0,0 +1,14 @@ +/** Canonical Native provider/upsert payload shared by external config importers. */ +export interface CanonicalProviderUpsertParams { + provider: { + id: string + name: string + baseUrl?: string + wireApis: string[] + models: Record + enabled: true + } + defaultModel?: string + smallModel?: string + apiKey?: string +} diff --git a/apps/desktop/src/main/claude-code-provider-migration.test.ts b/apps/desktop/src/main/claude-code-provider-migration.test.ts index dcc348af..d8c07ca6 100644 --- a/apps/desktop/src/main/claude-code-provider-migration.test.ts +++ b/apps/desktop/src/main/claude-code-provider-migration.test.ts @@ -43,7 +43,7 @@ describe("Claude Code provider migration", () => { expect(result.category?.files[0].content).not.toContain("sk-xxxxxxxx") }) - test("executes provider upserts for each unique imported model", async () => { + test("executes one canonical provider upsert for the imported Connection", async () => { const calls: Array<{ method: string; params: Record }> = [] const result = await executeClaudeCodeProviderMigration( @@ -56,36 +56,24 @@ describe("Claude Code provider migration", () => { expect(result.errors).toEqual([]) expect(result.filesWritten).toEqual([ - "provider/upsert:claude-code/deepseek-v4-pro-1m-claude-code", - "provider/upsert:claude-code/deepseek-v4-flash-claude-code", + "provider/upsert:claude-code", ]) - expect(calls).toHaveLength(2) + expect(calls).toHaveLength(1) expect(calls[0].method).toBe("provider/upsert") expect(calls[0].params).toEqual({ - provider_vendor: { + provider: { + id: "claude-code", name: "claude-code", - base_url: "https://api.deepseek.com/anthropic", - credential: null, - headers: null, - wire_apis: ["anthropic_messages"], - enabled: true, - }, - model_binding: { - binding_id: "deepseek-v4-pro-1m-claude-code", - model_slug: "deepseek-v4-pro[1m]", - provider: "claude-code", - request_model: "deepseek-v4-pro[1m]", - display_name: "deepseek-v4-pro[1m]", - invocation_method: "anthropic_messages", - default_reasoning_effort: null, + baseUrl: "https://api.deepseek.com/anthropic", + wireApis: ["anthropic_messages"], + models: { + "deepseek-v4-pro[1m]": { name: "deepseek-v4-pro[1m]" }, + "deepseek-v4-flash": { name: "deepseek-v4-flash" }, + }, enabled: true, }, - default_model_binding: "deepseek-v4-pro-1m-claude-code", - api_key: "sk-xxxxxxxx", - }) - expect(calls[1].params).toMatchObject({ - default_model_binding: undefined, - api_key: "sk-xxxxxxxx", + defaultModel: "claude-code/deepseek-v4-pro[1m]", + apiKey: "sk-xxxxxxxx", }) }) @@ -104,7 +92,7 @@ describe("Claude Code provider migration", () => { expect(calls).toEqual([]) }) - test("imports model bindings without an API key and reports manual action", async () => { + test("imports models without an API key and reports manual action", async () => { const calls: Array> = [] const result = await executeClaudeCodeProviderMigration( @@ -121,6 +109,6 @@ describe("Claude Code provider migration", () => { expect(result.errors).toEqual([]) expect(result.manualActions.some((item) => item.includes("ANTHROPIC_AUTH_TOKEN"))).toBe(true) expect(calls).toHaveLength(1) - expect(calls[0].api_key).toBeUndefined() + expect(calls[0].apiKey).toBeUndefined() }) }) diff --git a/apps/desktop/src/main/claude-code-provider-migration.ts b/apps/desktop/src/main/claude-code-provider-migration.ts index 66d6033d..2fc706af 100644 --- a/apps/desktop/src/main/claude-code-provider-migration.ts +++ b/apps/desktop/src/main/claude-code-provider-migration.ts @@ -3,6 +3,7 @@ import { formatClaudeCodeProviderSettingsPreview, } from "@devo/configconv" import type { ClaudeCodeProviderSettings, ClaudeSettings } from "@devo/configconv" +import type { CanonicalProviderUpsertParams } from "./canonical-provider-migration" interface MigrationFilePreview { path: string @@ -73,13 +74,12 @@ export async function executeClaudeCodeProviderMigration( const errors: string[] = [...diagnostics.errors] for (const params of buildProviderUpsertParams(settings)) { - const bindingId = params.model_binding.binding_id try { await requestProviderUpsert("provider/upsert", params) - filesWritten.push(`provider/upsert:${settings.providerId}/${bindingId}`) + filesWritten.push(`provider/upsert:${settings.providerId}`) } catch (error) { errors.push( - `Claude Code provider migration failed for ${params.model_binding.request_model}: ${error instanceof Error ? error.message : String(error)}`, + `Claude Code provider migration failed for ${settings.providerId}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -92,54 +92,27 @@ export async function executeClaudeCodeProviderMigration( } } -function buildProviderUpsertParams(settings: ClaudeCodeProviderSettings): Array<{ - provider_vendor: { - name: string - base_url: string | null - credential: null - headers: null - wire_apis: string[] - enabled: true - } - model_binding: { - binding_id: string - model_slug: string - provider: string - request_model: string - display_name: string - invocation_method: string - default_reasoning_effort: null - enabled: true - } - default_model_binding?: string - api_key?: string -}> { - return settings.models.map((model) => { - const bindingId = `${slugComponent(model)}-${settings.providerId}` - const params = { - provider_vendor: { +function buildProviderUpsertParams( + settings: ClaudeCodeProviderSettings, +): CanonicalProviderUpsertParams[] { + if (settings.models.length === 0) return [] + + return [ + { + provider: { + id: settings.providerId, name: settings.providerId, - base_url: settings.baseUrl ?? null, - credential: null, - headers: null, - wire_apis: [settings.wireApi], - enabled: true as const, + ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + wireApis: [settings.wireApi], + models: Object.fromEntries(settings.models.map((model) => [model, { name: model }])), + enabled: true, }, - model_binding: { - binding_id: bindingId, - model_slug: model, - provider: settings.providerId, - request_model: model, - display_name: model, - invocation_method: settings.wireApi, - default_reasoning_effort: null, - enabled: true as const, - }, - default_model_binding: model === settings.defaultModel ? bindingId : undefined, - api_key: settings.apiKey, - } - return params - }) + ...(settings.defaultModel + ? { defaultModel: `${settings.providerId}/${settings.defaultModel}` } + : {}), + ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), + }, + ] } function diagnosticsFor(settings: ClaudeCodeProviderSettings): { @@ -152,7 +125,7 @@ function diagnosticsFor(settings: ClaudeCodeProviderSettings): { if (settings.models.length === 0) { warnings.push( - "Claude Code settings did not include ANTHROPIC_MODEL or default Anthropic model env vars; no provider model bindings were imported.", + "Claude Code settings did not include ANTHROPIC_MODEL or default Anthropic model env vars; no provider Connection was imported.", ) } if (!settings.apiKey) { @@ -173,19 +146,6 @@ function readClaudeCodeSettings(scanResult: unknown): ClaudeSettings | undefined return isRecord(global.settings) ? (global.settings as ClaudeSettings) : undefined } -function slugComponent(value: string): string { - let out = "" - for (const ch of value) { - if (/[a-zA-Z0-9]/.test(ch)) { - out += ch.toLowerCase() - } else if (!out.endsWith("-")) { - out += "-" - } - } - const slug = out.replace(/^-+|-+$/g, "") - return slug || "model" -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/desktop/src/main/devo-manager-lifecycle.test.ts b/apps/desktop/src/main/devo-manager-lifecycle.test.ts new file mode 100644 index 00000000..09ae8280 --- /dev/null +++ b/apps/desktop/src/main/devo-manager-lifecycle.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import type { NativeTransportEvent } from "./native-stdio-client" + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +const envGates: Deferred[] = [] +const initializeGates: Deferred[] = [] +const clients: FakeClient[] = [] +let watcherStarts = 0 +let watcherStops = 0 + +class FakeClient { + readonly pidValue = 100 + clients.length + readonly listeners = new Set<(event: NativeTransportEvent) => void>() + starts = 0 + stops = 0 + initializeCalls = 0 + constructor(_options: unknown) { + clients.push(this) + } + start(): void { + this.starts++ + } + stop(): void { + this.stops++ + } + connected(): boolean { + return this.starts > 0 && this.stops === 0 + } + pid(): number { + return this.pidValue + } + subscribe(listener: (event: NativeTransportEvent) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + request(method: string): Promise { + if (method !== "initialize") throw new Error(`unexpected request: ${method}`) + this.initializeCalls++ + return initializeGates.shift()?.promise ?? Promise.resolve({}) + } + emitClosed(): void { + for (const listener of this.listeners) listener({ type: "closed", error: "old process closed" }) + } + emit(event: NativeTransportEvent): void { + for (const listener of this.listeners) listener(event) + } +} + +mock.module("electron", () => ({ + app: { getAppPath: () => "/fake/app", isPackaged: false }, +})) +mock.module("./shell-env", () => ({ waitForEnv: () => envGates.shift()?.promise ?? Promise.resolve() })) +mock.module("./native-stdio-client", () => ({ StdioNativeClient: FakeClient })) +mock.module("./devo-program", () => ({ resolveDevoProgram: () => "/fake/devo" })) +mock.module("./settings-store", () => ({ getSettings: () => ({ servers: {} }) })) +mock.module("./native-traffic-log", () => ({ + DEVO_HOME_ENV: "DEVO_HOME", + PROTOCOL_TRACE_ENV: "DEVO_PROTOCOL_TRACE", + PROTOCOL_TRACE_FILE_ENV: "DEVO_PROTOCOL_TRACE_FILE", + createNativeTrafficLoggerFromEnv: () => ({ getState: () => ({}), record: () => {} }), +})) +mock.module("./notification-watcher", () => ({ + startNotificationWatcher: () => { watcherStarts++ }, + stopNotificationWatcher: () => { watcherStops++ }, +})) +mock.module("./logger", () => ({ + createLogger: () => ({ info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }), +})) + +const manager = await import("./devo-manager") + +afterEach(() => { + manager.stopServer() + envGates.length = 0 + initializeGates.length = 0 + clients.length = 0 + watcherStarts = 0 + watcherStops = 0 +}) + +describe("desktop server lifecycle", () => { + test("forwards events across backend replacement and ignores the old child", async () => { + const events: unknown[] = [] + const unsubscribe = manager.subscribeNative((event) => events.push(event)) + await manager.ensureServer() + const oldChild = clients[0]! + oldChild.emitClosed() + await manager.restartServer() + const replacement = clients[1]! + oldChild.emitClosed() + const notification: NativeTransportEvent = { + type: "notification", method: "session/statusChanged", + params: { sessionId: "session-1", status: "idle" }, + } + oldChild.emit(notification) + replacement.emit(notification) + unsubscribe() + replacement.emit(notification) + expect(events).toEqual([ + { type: "closed", error: "old process closed" }, + notification, + ]) + }) + + test("stop during shell environment wait prevents spawning or publishing", async () => { + const env = deferred() + envGates.push(env) + const ready = manager.ensureServer() + const onReady = mock(() => {}) + const unsubscribe = manager.onServerReady(onReady) + expect(manager.stopServer()).toBe(false) + env.resolve() + await expect(ready).rejects.toThrow(/cancelled/) + expect(clients).toHaveLength(0) + expect(watcherStarts).toBe(0) + expect(onReady).not.toHaveBeenCalled() + expect(manager.getServerUrl()).toBeNull() + unsubscribe() + }) + + test("stop during initialize stops child and prevents stale ready publication", async () => { + const initialize = deferred() + initializeGates.push(initialize) + const onReady = mock(() => {}) + const unsubscribe = manager.onServerReady(onReady) + const ready = manager.ensureServer() + await Promise.resolve() // waitForEnv completed; initialize is pending + const child = clients[0]! + expect(child.starts).toBe(1) + expect(manager.stopServer()).toBe(true) + expect(child.stops).toBe(1) + initialize.resolve({}) + await expect(ready).rejects.toThrow(/cancelled/) + expect(child.stops).toBe(1) + expect(watcherStarts).toBe(0) + expect(onReady).not.toHaveBeenCalled() + expect(manager.getServerUrl()).toBeNull() + unsubscribe() + }) + + test("restart runs while old startup is pending and ignores old completion and close", async () => { + const oldEnv = deferred() + const newInitialize = deferred() + envGates.push(oldEnv) + initializeGates.push(newInitialize) + const oldReady = manager.ensureServer() + const newReady = manager.restartServer() + await Promise.resolve() // restart enters initialize + expect(clients).toHaveLength(1) + const concurrentReady = manager.ensureServer() + oldEnv.resolve() + await expect(oldReady).rejects.toThrow(/cancelled/) + const alsoConcurrent = manager.ensureServer() + await Promise.resolve() + expect(clients[0]?.initializeCalls).toBe(1) + newInitialize.resolve({}) + const ready = await newReady + expect(await concurrentReady).toBe(ready) + expect(await alsoConcurrent).toBe(ready) + expect(manager.getServerUrl()).toBe("stdio://local") + expect(clients).toHaveLength(1) + expect(watcherStarts).toBe(1) + }) + + test("old child completion and close cannot overwrite replacement", async () => { + const oldInitialize = deferred() + initializeGates.push(oldInitialize) + const oldReady = manager.ensureServer() + await Promise.resolve() + const oldChild = clients[0]! + const ready = await manager.restartServer() + expect(oldChild.stops).toBe(1) + oldInitialize.resolve({}) + await expect(oldReady).rejects.toThrow(/cancelled/) + oldChild.emitClosed() + expect(manager.getServerUrl()).toBe(ready.url) + expect(await manager.ensureServer()).toBe(ready) + expect(clients).toHaveLength(2) + expect(clients[1]?.stops).toBe(0) + expect(watcherStarts).toBe(1) + }) +}) diff --git a/apps/desktop/src/main/devo-manager.ts b/apps/desktop/src/main/devo-manager.ts index 2b152467..f94b97f2 100644 --- a/apps/desktop/src/main/devo-manager.ts +++ b/apps/desktop/src/main/devo-manager.ts @@ -39,17 +39,22 @@ export interface DevoServer { let stdioClient: StdioNativeClient | null = null let server: DevoServer | null = null let initializing: Promise | null = null +// Invalidate an in-flight startup when the desktop stops or restarts the server. +let lifecycleGeneration = 0 let nativeTrafficLogger: NativeTrafficLogger | null = null const serverReadyListeners = new Set<() => void>() +const nativeListeners = new Set() export async function ensureServer(): Promise { if (server && stdioClient?.connected()) return server if (initializing) return initializing - initializing = startServer().finally(() => { - initializing = null + const startup = startServer(lifecycleGeneration).finally(() => { + // An older startup must not clear a newer restart's promise. + if (initializing === startup) initializing = null }) - return initializing + initializing = startup + return startup } export function getServerUrl(): string | null { @@ -71,6 +76,8 @@ export function onServerReady(listener: () => void): () => void { } export function stopServer(): boolean { + lifecycleGeneration++ + initializing = null stopNotificationWatcher() const hadClient = stdioClient !== null stdioClient?.stop() @@ -160,8 +167,9 @@ export async function respondNative(id: JsonRpcId, result: unknown): Promise void { - const client = getOrCreateClient() - return client.subscribe(listener) + nativeListeners.add(listener) + getOrCreateClient() + return () => { nativeListeners.delete(listener) } } export function isNativeConnected(): boolean { @@ -186,23 +194,40 @@ export function getNativeTrafficLogState(): NativeTrafficLogState { return getNativeTrafficLogger().getState() } -async function startServer(): Promise { +async function startServer(generation: number): Promise { await waitForEnv() - const client = getOrCreateClient() - client.start() + if (generation !== lifecycleGeneration) throw new Error("Devo Native stdio server startup cancelled") - await initialize(client) + const client = getOrCreateClient() + try { + client.start() + await initialize(client) + if (generation !== lifecycleGeneration || stdioClient !== client) { + throw new Error("Devo Native stdio server startup cancelled") + } - server = { - url: STDIO_URL, - transport: "stdio", - pid: client.pid(), - managed: true, + const readyServer: DevoServer = { + url: STDIO_URL, + transport: "stdio", + pid: client.pid(), + managed: true, + } + server = readyServer + startNotificationWatcher(getNativeTransport()) + notifyServerReady() + log.info("Devo Native stdio server ready", { pid: readyServer.pid }) + return readyServer + } catch (error) { + // stopServer may already have stopped this child and installed a replacement. + // Only clean up a client that this startup still owns. + if (stdioClient === client) { + stopNotificationWatcher() + client.stop() + stdioClient = null + server = null + } + throw error } - startNotificationWatcher(getNativeTransport()) - notifyServerReady() - log.info("Devo Native stdio server ready", { pid: server.pid }) - return server } async function ensureClient(): Promise { @@ -218,12 +243,13 @@ function getOrCreateClient(): StdioNativeClient { isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, }) - stdioClient = new StdioNativeClient({ - program, - networkProxy: getSettings().servers.networkProxy, - trafficLogger: getNativeTrafficLogger(), - }) - stdioClient.subscribe(handleTransportEvent) + const client = new StdioNativeClient({ + program, + networkProxy: getSettings().servers.networkProxy, + trafficLogger: getNativeTrafficLogger(), + }) + stdioClient = client + client.subscribe((event) => handleTransportEvent(client, event)) } return stdioClient } @@ -237,11 +263,19 @@ function getNativeTrafficLogger(): NativeTrafficLogger { return nativeTrafficLogger } -function handleTransportEvent(event: NativeTransportEvent): void { +function handleTransportEvent(client: StdioNativeClient, event: NativeTransportEvent): void { + if (client !== stdioClient) return if (event.type === "closed") { log.warn("Devo Native stdio transport closed", { error: event.error }) server = null } + for (const listener of nativeListeners) { + try { + listener(event) + } catch (error) { + log.warn("Native transport listener failed", error) + } + } } function notifyServerReady(): void { diff --git a/apps/desktop/src/main/devo-program.test.ts b/apps/desktop/src/main/devo-program.test.ts index 4b52e2a6..89ba0c9e 100644 --- a/apps/desktop/src/main/devo-program.test.ts +++ b/apps/desktop/src/main/devo-program.test.ts @@ -3,6 +3,11 @@ import path from "node:path" import { resolveDevoProgram } from "./devo-program" describe("resolveDevoProgram", () => { + test("prefers the complete shared runtime bundle", () => { + const resourcesPath = path.join(process.cwd(), "complete-resources") + const bundled = path.join(resourcesPath, "runtime", process.platform === "win32" ? "devo.exe" : "devo") + expect(resolveDevoProgram({ appPath: process.cwd(), env: {}, isPackaged: true, resourcesPath, existsSync: () => true })).toBe(bundled) + }) test("prefers the checkout debug CLI in desktop dev mode", () => { const appPath = path.join("repo", "apps", "desktop") const checkoutDebug = path.resolve(appPath, "..", "..", "target", "debug", "devo") @@ -41,28 +46,32 @@ describe("resolveDevoProgram", () => { }) test("uses bundled runtime in packaged apps", () => { + const resourcesPath = path.join(process.cwd(), "packaged-resources") + const bundled = path.join(resourcesPath, "runtime", "bin", process.platform === "win32" ? "devo.exe" : "devo") const program = resolveDevoProgram({ appPath: "/repo/apps/desktop", env: {}, - existsSync: (candidate) => candidate === "/Applications/Devo.app/Contents/Resources/runtime/bin/devo", + existsSync: (candidate) => candidate === bundled, isPackaged: true, - resourcesPath: "/Applications/Devo.app/Contents/Resources", + resourcesPath, }) - expect(program).toBe("/Applications/Devo.app/Contents/Resources/runtime/bin/devo") + expect(program).toBe(bundled) }) test("uses bundled Windows runtime executable in packaged apps", () => { + const resourcesPath = path.join(process.cwd(), "windows-resources") + const bundled = path.join(resourcesPath, "runtime", "bin", "devo.exe") const program = resolveDevoProgram({ appPath: "/app/resources/app.asar", env: {}, - existsSync: (candidate) => candidate === "/app/resources/runtime/bin/devo.exe", + existsSync: (candidate) => candidate === bundled, isPackaged: true, platform: "win32", - resourcesPath: "/app/resources", + resourcesPath, }) - expect(program).toBe("/app/resources/runtime/bin/devo.exe") + expect(program).toBe(bundled) }) test("fails clearly when packaged runtime is missing", () => { diff --git a/apps/desktop/src/main/devo-program.ts b/apps/desktop/src/main/devo-program.ts index ac9f964d..de30f4b6 100644 --- a/apps/desktop/src/main/devo-program.ts +++ b/apps/desktop/src/main/devo-program.ts @@ -26,6 +26,8 @@ export function resolveDevoProgram({ if (isPackaged) { const runtimeRoot = resourcesPath ?? path.join(appPath, "..") + const completeBundle = path.join(runtimeRoot, "runtime", devoExecutableName(platform)) + if (existsSync(completeBundle)) return completeBundle const bundled = path.join(runtimeRoot, "runtime", "bin", devoExecutableName(platform)) if (existsSync(bundled)) return bundled throw new Error(`Bundled Devo runtime not found at ${bundled}`) diff --git a/apps/desktop/src/main/git-workspace-changes.test.ts b/apps/desktop/src/main/git-workspace-changes.test.ts new file mode 100644 index 00000000..08f520fc --- /dev/null +++ b/apps/desktop/src/main/git-workspace-changes.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { afterEach, describe, expect, test } from "bun:test" +import simpleGit from "simple-git" +import { + localWorkspaceChangesSummary, + localWorkspaceFilePatch, +} from "./git-workspace-changes" + +const temps: string[] = [] + +afterEach(async () => { + // Best-effort; temp dirs are small. + temps.length = 0 +}) + +async function initRepo(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "devo-local-changes-")) + temps.push(dir) + const git = simpleGit({ baseDir: dir }) + await git.init() + await git.addConfig("user.email", "test@example.com") + await git.addConfig("user.name", "Test") + await writeFile(path.join(dir, "tracked.txt"), "before\n") + await git.add("tracked.txt") + await git.commit("initial") + return dir +} + +describe("localWorkspaceChangesSummary", () => { + test("lists uncommitted modifications without unified_diff", async () => { + const dir = await initRepo() + await writeFile(path.join(dir, "tracked.txt"), "after\n") + await writeFile(path.join(dir, "new.txt"), "fresh\n") + + const view = await localWorkspaceChangesSummary(dir, "uncommitted") + expect(view.unified_diff).toBeNull() + expect(view.files.map((f) => f.path).sort()).toEqual(["new.txt", "tracked.txt"]) + expect(view.stats.files_changed).toBe(2) + }) + + test("loads a single-file patch on demand", async () => { + const dir = await initRepo() + await writeFile(path.join(dir, "tracked.txt"), "after\n") + + const patch = await localWorkspaceFilePatch(dir, "uncommitted", "tracked.txt") + expect(patch).toContain("tracked.txt") + expect(patch).toContain("+after") + expect(patch).toContain("-before") + }) + + test("uses fileStatus=untracked without ls-files round trip", async () => { + const dir = await initRepo() + await writeFile(path.join(dir, "fresh.txt"), "new\n") + + const patch = await localWorkspaceFilePatch(dir, "uncommitted", "fresh.txt", { + fileStatus: "untracked", + }) + expect(patch).toContain("fresh.txt") + expect(patch).toContain("+new") + }) + + test("diffs a single file against a turn checkpoint commit", async () => { + const dir = await initRepo() + const git = simpleGit({ baseDir: dir }) + const checkpoint = (await git.revparse(["HEAD"])).trim() + await writeFile(path.join(dir, "tracked.txt"), "turn-change\n") + + const patch = await localWorkspaceFilePatch(dir, "turn", "tracked.txt", { + checkpointId: checkpoint, + }) + expect(patch).toContain("tracked.txt") + expect(patch).toContain("+turn-change") + expect(patch).toContain("-before") + }) +}) \ No newline at end of file diff --git a/apps/desktop/src/main/git-workspace-changes.ts b/apps/desktop/src/main/git-workspace-changes.ts new file mode 100644 index 00000000..cbe648cf --- /dev/null +++ b/apps/desktop/src/main/git-workspace-changes.ts @@ -0,0 +1,380 @@ +import simpleGit from "simple-git" + +/** + * Local (Electron main) workspace change reads. + * + * Working-tree Summaries and per-file patches go through IPC + simple-git so + * Changes never waits on stdio RPC for a whole-tree Full diff. Turn Summaries + * still come from the server (checkpoint metadata); expand uses local + * `git diff -- ` when a checkpoint id is available. + */ + +export type LocalGitChangeScope = "uncommitted" | "staged" | "unstaged" | "branch" + +export type LocalChangedFileStatus = + | "added" + | "deleted" + | "modified" + | "renamed" + | "untracked" + | "type_changed" + | "unknown" + +export type LocalChangedFile = { + path: string + status: LocalChangedFileStatus + additions: number | null + deletions: number | null + binary: boolean + diff_truncated: boolean +} + +export type LocalWorkspaceChangeView = { + scope: LocalGitChangeScope + status: "ready" | "empty" | "unsupported" | "partial" | "error" + workspace_root: string + coverage: "git_visible" | "partial" + attribution: "git_working_tree" | "git_branch" + change_set_status: "accumulating" | "finalized" + files: LocalChangedFile[] + stats: { files_changed: number; additions: number; deletions: number } + unified_diff: string | null + warnings: string[] + generated_at: string +} + +const MAX_UNTRACKED = 500 + +function getGit(directory: string) { + return simpleGit({ baseDir: directory, trimmed: true }) +} + +function parseStatus(code: string): LocalChangedFileStatus { + switch (code[0]) { + case "A": + return "added" + case "D": + return "deleted" + case "M": + case "T": + return "modified" + case "R": + case "C": + return "renamed" + case "?": + return "untracked" + default: + return "unknown" + } +} + +function parseNameStatus(output: string): Map { + const map = new Map() + for (const line of output.split("\n")) { + const trimmed = line.trimEnd() + if (!trimmed) continue + const parts = trimmed.split("\t") + if (parts.length < 2) continue + const status = parseStatus(parts[0] ?? "") + const path = parts.length >= 3 ? (parts[2] ?? parts[1]) : parts[1] + if (path) map.set(path.replace(/\\/g, "/"), status) + } + return map +} + +function parseNumstat(output: string): Map { + const map = new Map() + for (const line of output.split("\n")) { + const trimmed = line.trimEnd() + if (!trimmed) continue + const parts = trimmed.split("\t") + if (parts.length < 3) continue + const addRaw = parts[0] ?? "0" + const delRaw = parts[1] ?? "0" + const path = parts.length >= 4 ? (parts[3] ?? parts[2]) : parts[2] + if (!path) continue + const binary = addRaw === "-" || delRaw === "-" + map.set(path.replace(/\\/g, "/"), { + additions: binary ? 0 : Number(addRaw) || 0, + deletions: binary ? 0 : Number(delRaw) || 0, + binary, + }) + } + return map +} + +async function rangeArgs( + directory: string, + scope: LocalGitChangeScope, + baseBranch?: string | null, +): Promise<{ args: string[]; includeUntracked: boolean; attribution: LocalWorkspaceChangeView["attribution"] } | null> { + const git = getGit(directory) + try { + await git.raw(["rev-parse", "--verify", "HEAD"]) + } catch { + return null + } + + switch (scope) { + case "uncommitted": + return { args: ["HEAD", "--"], includeUntracked: true, attribution: "git_working_tree" } + case "staged": + return { + args: ["--cached", "HEAD", "--"], + includeUntracked: false, + attribution: "git_working_tree", + } + case "unstaged": + return { args: ["--"], includeUntracked: true, attribution: "git_working_tree" } + case "branch": { + const branch = baseBranch?.trim() || "main" + let mergeBase = "" + try { + mergeBase = (await git.raw(["merge-base", branch, "HEAD"])).trim() + } catch { + try { + mergeBase = (await git.raw(["merge-base", `origin/${branch}`, "HEAD"])).trim() + } catch { + return null + } + } + if (!mergeBase) return null + return { + args: [mergeBase, "HEAD", "--"], + includeUntracked: false, + attribution: "git_branch", + } + } + } +} + +/** + * Fast file list for a git scope (name-status + numstat). No unified patch. + */ +export async function localWorkspaceChangesSummary( + directory: string, + scope: LocalGitChangeScope, + options: { baseBranch?: string | null; ignoreWhitespace?: boolean } = {}, +): Promise { + const generated_at = new Date().toISOString() + const ranged = await rangeArgs(directory, scope, options.baseBranch) + if (!ranged) { + return { + scope, + status: "unsupported", + workspace_root: directory, + coverage: "partial", + attribution: scope === "branch" ? "git_branch" : "git_working_tree", + change_set_status: scope === "branch" ? "finalized" : "accumulating", + files: [], + stats: { files_changed: 0, additions: 0, deletions: 0 }, + unified_diff: null, + warnings: ["not_git_or_no_head"], + generated_at, + } + } + + const git = getGit(directory) + const ws = options.ignoreWhitespace ? ["--ignore-all-space"] : [] + const [nameStatus, numstat] = await Promise.all([ + git.raw(["diff", "--name-status", "--no-textconv", "--no-ext-diff", ...ws, ...ranged.args]), + git.raw(["diff", "--numstat", "--no-textconv", "--no-ext-diff", ...ws, ...ranged.args]), + ]) + + const statuses = parseNameStatus(nameStatus) + const statsMap = parseNumstat(numstat) + const files: LocalChangedFile[] = [] + const warnings: string[] = [] + + for (const [path, status] of statuses) { + const num = statsMap.get(path) + files.push({ + path, + status, + additions: num?.additions ?? null, + deletions: num?.deletions ?? null, + binary: num?.binary ?? false, + diff_truncated: false, + }) + } + + if (ranged.includeUntracked) { + const untrackedRaw = await git.raw(["ls-files", "--others", "--exclude-standard"]) + const untracked = untrackedRaw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((path) => path.replace(/\\/g, "/")) + const total = untracked.length + for (const path of untracked.slice(0, MAX_UNTRACKED)) { + if (files.some((file) => file.path === path)) continue + files.push({ + path, + status: "untracked", + additions: null, + deletions: null, + binary: false, + diff_truncated: false, + }) + } + if (total > MAX_UNTRACKED) { + warnings.push(`untracked_files_truncated: showing ${MAX_UNTRACKED} of ${total}`) + } + } + + let additions = 0 + let deletions = 0 + for (const file of files) { + additions += file.additions ?? 0 + deletions += file.deletions ?? 0 + } + + return { + scope, + status: files.length === 0 ? "empty" : warnings.length > 0 ? "partial" : "ready", + workspace_root: directory, + coverage: warnings.length > 0 ? "partial" : "git_visible", + attribution: ranged.attribution, + change_set_status: scope === "branch" ? "finalized" : "accumulating", + files, + stats: { files_changed: files.length, additions, deletions }, + unified_diff: null, + warnings, + generated_at, + } +} + +/** + * Single-file unified diff for expand-on-demand. Avoids whole-tree Full over stdio. + * + * Pass `fileStatus` from the Summary row to skip an extra `ls-files` probe. + * Pass `checkpointId` for turn-scope diffs against a ghost/checkpoint commit. + * Pass `mergeBase` for branch scope to skip a merge-base lookup per expand. + */ +export async function localWorkspaceFilePatch( + directory: string, + scope: LocalGitChangeScope | "turn", + filePath: string, + options: { + baseBranch?: string | null + ignoreWhitespace?: boolean + fileStatus?: LocalChangedFileStatus | string | null + checkpointId?: string | null + mergeBase?: string | null + } = {}, +): Promise { + const normalized = filePath.replace(/\\/g, "/") + const git = getGit(directory) + const ws = options.ignoreWhitespace ? ["--ignore-all-space"] : [] + const statusHint = options.fileStatus ?? null + + if (statusHint === "untracked") { + return buildUntrackedPatch(directory, normalized) + } + + if (scope === "turn") { + const checkpoint = options.checkpointId?.trim() + if (!checkpoint) return "" + return ( + await git.raw([ + "diff", + "--no-textconv", + "--no-ext-diff", + ...ws, + checkpoint, + "--", + normalized, + ]) + ).trimEnd() + } + + // Working-tree scopes: do not call rangeArgs (avoids merge-base on every click). + if (scope === "uncommitted" || scope === "unstaged") { + if (statusHint == null) { + try { + const others = await git.raw([ + "ls-files", + "--others", + "--exclude-standard", + "--", + normalized, + ]) + if (others.trim().length > 0) { + return buildUntrackedPatch(directory, normalized) + } + } catch { + // fall through + } + } + const args = + scope === "unstaged" + ? ["diff", "--no-textconv", "--no-ext-diff", ...ws, "--", normalized] + : ["diff", "--no-textconv", "--no-ext-diff", ...ws, "HEAD", "--", normalized] + return (await git.raw(args)).trimEnd() + } + + if (scope === "staged") { + return ( + await git.raw([ + "diff", + "--no-textconv", + "--no-ext-diff", + ...ws, + "--cached", + "HEAD", + "--", + normalized, + ]) + ).trimEnd() + } + + // branch — match Summary (`merge-base..HEAD`), not working-tree-only. + let mergeBase = options.mergeBase?.trim() || null + if (!mergeBase) { + const ranged = await rangeArgs(directory, "branch", options.baseBranch) + if (!ranged) return "" + mergeBase = ranged.args[0] ?? null + } + if (!mergeBase) return "" + return ( + await git.raw([ + "diff", + "--no-textconv", + "--no-ext-diff", + ...ws, + mergeBase, + "HEAD", + "--", + normalized, + ]) + ).trimEnd() +} + +async function buildUntrackedPatch(directory: string, normalized: string): Promise { + const fs = await import("node:fs/promises") + const path = await import("node:path") + const abs = path.join(directory, normalized) + let content = "" + try { + content = await fs.readFile(abs, "utf8") + } catch { + return `diff --git a/${normalized} b/${normalized}\nnew file mode 100644\n--- /dev/null\n+++ b/${normalized}\n` + } + // Cap huge untracked files so expand never freezes the UI. + const maxChars = 400_000 + const truncated = content.length > maxChars + const slice = truncated ? content.slice(0, maxChars) : content + const lines = slice.split(/\r?\n/) + const body = lines.map((line) => `+${line}`).join("\n") + const count = lines.length + const parts = [ + `diff --git a/${normalized} b/${normalized}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${normalized}`, + `@@ -0,0 +1,${count} @@`, + body, + ] + if (truncated) parts.push("+/* truncated */") + return `${parts.join("\n")}\n` +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 72243ec0..198da049 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -126,12 +126,11 @@ ipcMain.handle( } else { const wrapper = Menu.buildFromTemplate([item]) menu = wrapper.items[0]?.submenu - if (menu) activePopupMenu = wrapper } if (!menu) { return { success: false } } - if (explicitSubmenu) activePopupMenu = menu + activePopupMenu = menu const popupOptions: Electron.PopupOptions = { window: win } if (typeof request.x === "number" && typeof request.y === "number") { @@ -139,7 +138,7 @@ ipcMain.handle( popupOptions.y = Math.round(request.y) } - menu.popup(popupOptions) + activePopupMenu.popup(popupOptions) return { success: true } }, ) @@ -270,7 +269,7 @@ if (isDev) { app.setPath("userData", path.join(app.getPath("appData"), "Devo Dev")) } -async function createWindow(): Promise { +async function createWindow({ bounds, hash }: { bounds?: Electron.Rectangle; hash?: string } = {}): Promise { const title = isDev ? `${appName} (Dev)` : appName const isMac = process.platform === "darwin" @@ -309,6 +308,7 @@ async function createWindow(): Promise { title, width: 1200, height: 800, + ...bounds, autoHideMenuBar: process.platform === "win32", // Transparent background for macOS glass/vibrancy tiers. Windows acrylic // keeps a non-transparent BrowserWindow so native resize/maximize work. @@ -404,9 +404,11 @@ async function createWindow(): Promise { // Dev: load from Vite dev server | Prod: load built files if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) { - win.loadURL(process.env.ELECTRON_RENDERER_URL) + const url = new URL(process.env.ELECTRON_RENDERER_URL) + if (hash) url.hash = hash + await win.loadURL(url.href) } else { - win.loadFile(path.join(__dirname, "../renderer/index.html")) + await win.loadFile(path.join(__dirname, "../renderer/index.html"), { hash }) } return win @@ -450,7 +452,17 @@ if (!gotLock) { initSettingsStore() initCredentialStore() - registerIpcHandlers() + registerIpcHandlers({ + recreateWindows: async () => { + const previousWindows = BrowserWindow.getAllWindows() + const previous = BrowserWindow.getFocusedWindow() ?? previousWindows[0] + await createWindow({ + bounds: previous?.getBounds(), + hash: previous?.webContents.getURL().split("#")[1], + }) + for (const win of previousWindows) win.destroy() + }, + }) initAutomations().catch(console.error) setMacDockIcon() createWindow() diff --git a/apps/desktop/src/main/ipc-handlers-oauth.test.ts b/apps/desktop/src/main/ipc-handlers-oauth.test.ts new file mode 100644 index 00000000..77b6a286 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers-oauth.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + return { promise, resolve, reject } +} + +type IpcHandler = ( + event: ReturnType, + request?: { providerId: string }, +) => Promise | boolean | void +const handlers = new Map() +const logins: Array> = [] +const writes: Array<{ + provider: string + access: string + gate: Deferred +}> = [] +let stored: Record = {} +const updates: Array<{ + senderId: number + channel: string + update: { phase?: string; instructions?: string } +}> = [] +const unused = () => { + throw new Error("Unrelated IPC handler invoked") +} + +const app = { isPackaged: false, relaunch: mock(() => {}), exit: mock(() => {}) } +mock.module("electron", () => ({ + app, + BrowserWindow: { getAllWindows: () => [] }, + dialog: {}, + ipcMain: { + handle: (channel: string, handler: IpcHandler) => { + handlers.set(channel, handler) + }, + on: () => {}, + }, + nativeTheme: { on: () => {} }, + net: {}, + shell: {}, + systemPreferences: { on: () => {} }, +})) +mock.module("./logger", () => ({ + createLogger: () => ({ + info: () => {}, + debug: () => {}, + warn: () => {}, + error: () => {}, + }), +})) +mock.module("./terminal-manager", () => ({ + desktopTerminalManager: { onData: () => {}, onExit: () => {} }, +})) +mock.module("./settings-store", () => ({ + getOpaqueWindows: unused, + getSettings: unused, + onSettingsChanged: () => {}, + updateSettings: unused, +})) +mock.module("./provider-oauth", () => ({ + isDesktopOAuthProviderId: (id: string) => + ["anthropic", "openai-codex"].includes(id), + loginDesktopOAuth: () => { + const gate = deferred<{ access: string }>() + logins.push(gate) + return gate.promise // Deliberately ignores abort: tests the race after the provider resolves. + }, + credentialSetParamsFromDesktopOAuth: ( + provider: string, + credential: { access: string }, + ) => ({ + provider, + kind: "oauth", + access: credential.access, + }), +})) +mock.module("./devo-manager", () => ({ + requestNative: ( + method: string, + params: { provider: string; access: string }, + ) => { + if (method !== "credential/set") + throw new Error(`Unexpected RPC: ${method}`) + const gate = deferred() + writes.push({ provider: params.provider, access: params.access, gate }) + return gate.promise.then(() => { + stored[params.provider] = params.access + }) + }, + subscribeNative: () => {}, + ensureServer: unused, + getNativeTrafficLogState: unused, + getServerUrl: unused, + isNativeConnected: unused, + notifyNative: unused, + respondNative: unused, + restartServer: unused, + stopServer: unused, +})) + +// Avoid starting unrelated Electron services when registering IPC handlers. +const stubModules: Array<[string, string]> = [ + [ + "./automation", + "acceptRun archiveRun createAutomation deleteAutomation getAutomation listAutomations listRuns markRunRead previewSchedule runNow updateAutomation", + ], + ["./credential-store", "deleteCredential getCredential storeCredential"], + ["./desktop-runtime-check", "checkDesktopRuntime"], + ["./desktop-folders", "createDesktopFolder statDesktopFolders"], + [ + "./git-service", + "applyChangesToLocal applyDiffTextToLocal checkout commitAll createBranch getDiffStat getGitRoot getRemoteUrl getStatus listBranches push stashAndCheckout stashPop", + ], + [ + "./git-workspace-changes", + "localWorkspaceChangesSummary localWorkspaceFilePatch", + ], + ["./liquid-glass", "getResolvedChromeTier resolveTitleBarOverlay"], + ["./rules-files", "createProjectAgentsMd listRuleFiles"], + ["./model-state", "readModelState updateModelRecent"], + ["./notifications", "dismissNotification updateBadgeCount"], + [ + "./onboarding", + "detectProviders executeMigration previewMigration restoreMigrationBackup scanProvider", + ], + ["./mcp-config", "openUserMcpConfigFile"], + ["./open-in-targets", "getOpenInTargets openInTarget setPreferredTarget"], + ["../shared/mcp-config", "MCP_CONFIG_OPEN_PATH"], + [ + "../shared/native-ipc-error", + "isSessionNotFoundError nativeIpcErrorEnvelope", + ], + [ + "./updater", + "checkForUpdates downloadUpdate getUpdateState installUpdate openReleasePage", + ], +] +for (const [path, names] of stubModules) { + mock.module(path, () => + Object.fromEntries(names.split(" ").map((name) => [name, unused])), + ) +} + +const { registerIpcHandlers } = await import("./ipc-handlers") +registerIpcHandlers() + +function sender(id: number) { + return { + sender: { + id, + send: ( + channel: string, + update: { phase?: string; instructions?: string }, + ) => { + updates.push({ senderId: id, channel, update }) + }, + }, + } +} +function handler(channel: string): IpcHandler { + const found = handlers.get(channel) + if (!found) throw new Error(`Missing IPC handler: ${channel}`) + return found +} +function login(id: number, providerId = "anthropic"): Promise { + return handler("provider-oauth:login")(sender(id), { + providerId, + }) as Promise +} +function cancel(id: number): boolean { + return handler("provider-oauth:cancel")(sender(id)) as boolean +} +// Advance only the promise continuations (never use timers or a live provider). +async function flush(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve() +} + +beforeEach(() => { + logins.length = 0 + writes.length = 0 + updates.length = 0 + stored = {} + app.isPackaged = false + app.relaunch.mockClear() + app.exit.mockClear() + registerIpcHandlers() +}) + +describe("Desktop window recreation", () => { + test("development recreates the window without exiting electron-vite", async () => { + const recreateWindows = mock(async () => {}) + registerIpcHandlers({ recreateWindows }) + await handler("app:relaunch")(sender(1)) + expect([recreateWindows.mock.calls.length, app.relaunch.mock.calls.length, app.exit.mock.calls.length]).toEqual([1, 0, 0]) + }) + test("a missing development recreation handler fails instead of terminating Vite", async () => { + await expect(handler("app:relaunch")(sender(1))).rejects.toThrow("Development window recreation is unavailable") + expect([app.relaunch.mock.calls.length, app.exit.mock.calls.length]).toEqual([0, 0]) + }) + test("installed builds relaunch the application", async () => { + app.isPackaged = true + const recreateWindows = mock(async () => {}) + registerIpcHandlers({ recreateWindows }) + await handler("app:relaunch")(sender(1)) + expect([recreateWindows.mock.calls.length, app.relaunch.mock.calls.length, app.exit.mock.calls.length]).toEqual([0, 1, 1]) + }) +}) + +describe("Desktop OAuth credential cancellation", () => { + test("a late OAuth response after cancel cannot start credential/set", async () => { + const pending = login(1) + expect(cancel(1)).toBe(true) + logins[0].resolve({ access: "old" }) + await expect(pending).rejects.toThrow("Login cancelled") + expect(writes).toHaveLength(0) + }) + + test("cancel during saving is refused; new login waits and then wins", async () => { + const old = login(1) + logins[0].resolve({ access: "old" }) + await flush() + expect(writes.map(({ access }) => access)).toEqual(["old"]) + expect(updates).toContainEqual({ + senderId: 1, + channel: "provider-oauth:update", + update: { phase: "saving", instructions: "Saving credential..." }, + }) + expect(cancel(1)).toBe(false) + const latest = login(1) + await flush() + expect(logins).toHaveLength(1) + expect(writes.map(({ access }) => access)).toEqual(["old"]) + writes[0].gate.resolve() + await old // Once saving starts, the prior login must not report cancellation. + await flush() + expect(logins).toHaveLength(2) + logins[1].resolve({ access: "new" }) + await flush() + expect(writes.map(({ access }) => access)).toEqual(["old", "new"]) + writes[1].gate.resolve() + await latest + expect(stored.anthropic).toBe("new") + }) + + test("cross-window writes to one provider are serialized", async () => { + const old = login(1) + logins[0].resolve({ access: "old" }) + await flush() + expect(cancel(1)).toBe(false) + const latest = login(2) + logins[1].resolve({ access: "new" }) + await flush() + expect(writes).toHaveLength(1) + writes[0].gate.resolve() + await old + await flush() + writes[1].gate.resolve() + await latest + expect(stored.anthropic).toBe("new") + }) + + test("a canceled queued login cannot write after the first set completes", async () => { + const old = login(1) + logins[0].resolve({ access: "old" }) + await flush() + const queued = login(1) + await flush() + expect(logins).toHaveLength(1) + expect(cancel(1)).toBe(false) + const queuedError = queued.then( + () => null, + (error: Error) => error, + ) + writes[0].gate.resolve() + await old + expect((await queuedError)?.message).toBe("Login cancelled") + expect(writes.map(({ access }) => access)).toEqual(["old"]) + }) + + test("cancel during pending RPC is refused and the save completes", async () => { + const pending = login(1) + logins[0].resolve({ access: "old" }) + await flush() + expect(cancel(1)).toBe(false) + writes[0].gate.resolve() + await pending + expect(stored.anthropic).toBe("old") + expect(writes).toHaveLength(1) + }) + + test("cancel while queued behind another window prevents its credential/set", async () => { + const first = login(1) + logins[0].resolve({ access: "old" }) + await flush() + const queued = login(2) + logins[1].resolve({ access: "queued" }) + await flush() + expect(writes).toHaveLength(1) + expect(cancel(2)).toBe(true) + const queuedError = queued.then( + () => null, + (error: Error) => error, + ) + writes[0].gate.resolve() + await first + expect((await queuedError)?.message).toBe("Login cancelled") + expect(writes.map(({ access }) => access)).toEqual(["old"]) + }) + + test("failed commit releases the provider queue for the next login", async () => { + const first = login(1) + logins[0].resolve({ access: "old" }) + await flush() + const latest = login(2) + logins[1].resolve({ access: "new" }) + await flush() + expect(writes).toHaveLength(1) + expect(cancel(1)).toBe(false) + writes[0].gate.reject(new Error("Server unavailable")) + await expect(first).rejects.toThrow("Server unavailable") + await flush() + expect(writes.map(({ access }) => access)).toEqual(["old", "new"]) + writes[1].gate.resolve() + await latest + expect(stored.anthropic).toBe("new") + }) + + test("an unrelated provider is not blocked by a pending write", async () => { + const first = login(1) + logins[0].resolve({ access: "a" }) + await flush() + const second = login(2, "openai-codex") + logins[1].resolve({ access: "b" }) + await flush() + expect(writes.map(({ provider }) => provider)).toEqual([ + "anthropic", + "openai-codex", + ]) + writes[1].gate.resolve() + await second + writes[0].gate.resolve() + await first + }) +}) diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts index 6641d3d8..c3db41ce 100644 --- a/apps/desktop/src/main/ipc-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers.ts @@ -31,6 +31,11 @@ import { stashAndCheckout, stashPop, } from "./git-service" +import { + localWorkspaceChangesSummary, + localWorkspaceFilePatch, + type LocalGitChangeScope, +} from "./git-workspace-changes" import { getResolvedChromeTier, resolveTitleBarOverlay } from "./liquid-glass" import { createProjectAgentsMd, listRuleFiles } from "./rules-files" import { createLogger } from "./logger" @@ -46,6 +51,7 @@ import { } from "./onboarding" import { openUserMcpConfigFile } from "./mcp-config" import { getOpenInTargets, openInTarget, setPreferredTarget } from "./open-in-targets" +import { isDesktopOAuthProviderId, loginDesktopOAuth, credentialSetParamsFromDesktopOAuth } from "./provider-oauth" import { MCP_CONFIG_OPEN_PATH } from "../shared/mcp-config" import { ensureServer, @@ -59,6 +65,10 @@ import { stopServer, subscribeNative, } from "./devo-manager" +import { + isSessionNotFoundError, + nativeIpcErrorEnvelope, +} from "../shared/native-ipc-error" import { getOpaqueWindows, getSettings, onSettingsChanged, updateSettings } from "./settings-store" import { desktopTerminalManager } from "./terminal-manager" import { @@ -70,6 +80,55 @@ import { } from "./updater" const log = createLogger("ipc") +type OAuthAttempt = { + controller: AbortController + phase: "authorizing" | "saving" + finished: Promise + finish: () => void +} +const oauthAttempts = new Map() +const pendingOauthLogins = new Map() +// Credential writes to one provider are ordered across renderer windows while their +// RPCs are pending. If transport fails before the server write finishes, ordering +// requires server-side support; do not blindly delete an unknown credential. +const oauthCredentialWrites = new Map>() + +function assertCurrentOAuth(senderId: number, attempt: OAuthAttempt): void { + if (attempt.controller.signal.aborted || oauthAttempts.get(senderId) !== attempt) { + throw new Error("Login cancelled") + } +} + +async function persistDesktopOAuthCredential( + senderId: number, + attempt: OAuthAttempt, + providerId: string, + credential: Awaited>, + onSaving: () => void, +): Promise { + assertCurrentOAuth(senderId, attempt) + const previous = oauthCredentialWrites.get(providerId) + let release!: () => void + const write = new Promise((resolve) => { + release = resolve + }) + oauthCredentialWrites.set(providerId, write) + try { + if (previous) await previous + assertCurrentOAuth(senderId, attempt) + // No await between entering the non-cancellable phase and dispatching the RPC. + // Cancel before here is accepted and cannot submit credential/set. + attempt.phase = "saving" + onSaving() + await requestNative( + "credential/set", + credentialSetParamsFromDesktopOAuth(providerId, credential), + ) + } finally { + release() + if (oauthCredentialWrites.get(providerId) === write) oauthCredentialWrites.delete(providerId) + } +} /** Read the opaque windows preference for use at window creation time. */ export { getOpaqueWindows as getOpaqueWindowsPref } from "./settings-store" @@ -178,7 +237,7 @@ function withLogging( * the Bun + Hono server on port 3100. Now they run in-process in Electron's * main process, communicating via IPC instead of HTTP. */ -export function registerIpcHandlers(): void { +export function registerIpcHandlers({ recreateWindows }: { recreateWindows?: () => Promise } = {}): void { // --- App info --- ipcMain.handle("app:info", () => ({ @@ -209,8 +268,23 @@ export function registerIpcHandlers(): void { "native:request", withLogging( "native:request", - async (_, request: { method: string; params?: unknown; directory?: string }) => - await requestNative(request.method, request.params, request.directory), + async (_, request: { method: string; params?: unknown; directory?: string }) => { + try { + return await requestNative(request.method, request.params, request.directory) + } catch (err) { + // SessionNotFound is an expected race (stale route / deleted session). + // Returning an envelope avoids Electron treating the handler rejection + // as an unhandled promise rejection; the renderer transport rethrows. + if (isSessionNotFoundError(err)) { + log.debug("native:request session not found", { + method: request.method, + message: err instanceof Error ? err.message : String(err), + }) + return nativeIpcErrorEnvelope(err) + } + throw err + } + }, ), ) @@ -236,6 +310,82 @@ export function registerIpcHandlers(): void { ipcMain.handle("native-traffic-log:state", () => getNativeTrafficLogState()) + ipcMain.handle( + "provider-oauth:login", + withLogging( + "provider-oauth:login", + async (event, request: { providerId: string; enterpriseUrl?: string }) => { + if (!isDesktopOAuthProviderId(request.providerId)) { + throw new Error(`Unsupported OAuth provider: ${request.providerId}`) + } + const senderId = event.sender.id + const controller = new AbortController() + pendingOauthLogins.get(senderId)?.abort() + pendingOauthLogins.set(senderId, controller) + try { + const prior = oauthAttempts.get(senderId) + if (prior?.phase === "saving") await prior.finished + if (controller.signal.aborted || pendingOauthLogins.get(senderId) !== controller) { + throw new Error("Login cancelled") + } + pendingOauthLogins.delete(senderId) + // A new login may supersede an authorization, but never an in-flight save. + oauthAttempts.get(senderId)?.controller.abort() + let finish!: () => void + const finished = new Promise((resolve) => { + finish = resolve + }) + const attempt: OAuthAttempt = { controller, phase: "authorizing", finished, finish } + oauthAttempts.set(senderId, attempt) + try { + const credential = await loginDesktopOAuth(request.providerId, { + signal: controller.signal, + enterpriseUrl: request.enterpriseUrl, + onUpdate: (update) => { + if (!controller.signal.aborted && oauthAttempts.get(senderId) === attempt) { + event.sender.send("provider-oauth:update", update) + } + }, + }) + await persistDesktopOAuthCredential( + senderId, + attempt, + request.providerId, + credential, + () => { + try { + event.sender.send("provider-oauth:update", { + phase: "saving", + instructions: "Saving credential...", + }) + } catch (error) { + log.warn("Could not send OAuth saving update", { senderId, error }) + } + }, + ) + } finally { + controller.abort() + if (oauthAttempts.get(senderId) === attempt) oauthAttempts.delete(senderId) + finish() + } + } finally { + if (pendingOauthLogins.get(senderId) === controller) pendingOauthLogins.delete(senderId) + } + }, + ), + ) + + ipcMain.handle("provider-oauth:cancel", (event): boolean => { + const senderId = event.sender.id + pendingOauthLogins.get(senderId)?.abort() + pendingOauthLogins.delete(senderId) + const attempt = oauthAttempts.get(senderId) + if (attempt?.phase === "saving") return false + attempt?.controller.abort() + if (attempt) oauthAttempts.delete(senderId) + return true + }) + subscribeNative((event) => { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send("native:event", event) @@ -389,6 +539,39 @@ export function registerIpcHandlers(): void { withLogging("git:root", async (_, directory: string) => await getGitRoot(directory)), ) + ipcMain.handle( + "git:workspace-changes-summary", + withLogging( + "git:workspace-changes-summary", + async ( + _, + directory: string, + scope: LocalGitChangeScope, + options?: { baseBranch?: string | null; ignoreWhitespace?: boolean }, + ) => await localWorkspaceChangesSummary(directory, scope, options ?? {}), + ), + ) + + ipcMain.handle( + "git:workspace-file-patch", + withLogging( + "git:workspace-file-patch", + async ( + _, + directory: string, + scope: LocalGitChangeScope | "turn", + filePath: string, + options?: { + baseBranch?: string | null + ignoreWhitespace?: boolean + fileStatus?: string | null + checkpointId?: string | null + mergeBase?: string | null + }, + ) => await localWorkspaceFilePatch(directory, scope, filePath, options ?? {}), + ), + ) + ipcMain.handle( "git:remote-url", withLogging( @@ -494,7 +677,13 @@ export function registerIpcHandlers(): void { return { success: true } }) - ipcMain.handle("app:relaunch", () => { + ipcMain.handle("app:relaunch", async () => { + if (!app.isPackaged) { + // Keep electron-vite and its staged runtime alive during development. + if (!recreateWindows) throw new Error("Development window recreation is unavailable") + await recreateWindows() + return + } app.relaunch() app.exit(0) }) diff --git a/apps/desktop/src/main/liquid-glass.test.ts b/apps/desktop/src/main/liquid-glass.test.ts index ee9b9ac1..bdf37b9c 100644 --- a/apps/desktop/src/main/liquid-glass.test.ts +++ b/apps/desktop/src/main/liquid-glass.test.ts @@ -30,7 +30,7 @@ describe("resolveWindowChrome", () => { titleBarOverlay: { color: "#00000000", symbolColor: "#f4f4f5", - height: 40, + height: 32, }, }, }) @@ -53,7 +53,7 @@ describe("resolveWindowChrome", () => { titleBarOverlay: { color: "#00000000", symbolColor: "#f4f4f5", - height: 40, + height: 32, }, }, }) @@ -76,7 +76,7 @@ describe("resolveWindowChrome", () => { titleBarOverlay: { color: "#00000000", symbolColor: "#f4f4f5", - height: 40, + height: 32, }, }, }) @@ -102,7 +102,7 @@ describe("resolveWindowChrome", () => { expect(resolveTitleBarOverlay(false)).toEqual({ color: "#00000000", symbolColor: "#111111", - height: 40, + height: 32, }) }) diff --git a/apps/desktop/src/main/liquid-glass.ts b/apps/desktop/src/main/liquid-glass.ts index 302f295e..606dc850 100644 --- a/apps/desktop/src/main/liquid-glass.ts +++ b/apps/desktop/src/main/liquid-glass.ts @@ -33,7 +33,7 @@ export interface ResolveWindowChromeOptions { platform?: NodeJS.Platform } -const TITLE_BAR_OVERLAY_HEIGHT = 40 +const TITLE_BAR_OVERLAY_HEIGHT = 32 const TITLE_BAR_OVERLAY_COLOR = "#00000000" const TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR = "#111111" const TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR = "#f4f4f5" diff --git a/apps/desktop/src/main/native-stdio-client.test.ts b/apps/desktop/src/main/native-stdio-client.test.ts index 34e11765..e57c9456 100644 --- a/apps/desktop/src/main/native-stdio-client.test.ts +++ b/apps/desktop/src/main/native-stdio-client.test.ts @@ -1,5 +1,8 @@ import path from "node:path" -import { describe, expect, test } from "bun:test" +import * as childProcess from "node:child_process" +import { EventEmitter } from "node:events" +import { PassThrough } from "node:stream" +import { describe, expect, spyOn, test } from "bun:test" import { StdioNativeClient, buildServerProcessEnv, @@ -48,6 +51,83 @@ describe("routeNativeLine", () => { }) describe("StdioNativeClient", () => { + test("ignores delayed events from a stopped child after restart", async () => { + type SyntheticChild = childProcess.ChildProcessWithoutNullStreams & { + stdin: PassThrough + stdout: PassThrough + stderr: PassThrough + pid: number + } + const children: SyntheticChild[] = [] + const spawnMock = spyOn(childProcess, "spawn").mockImplementation((() => { + let killed = false + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: 100 + children.length, + get killed() { return killed }, + kill() { + killed = true + return true + }, + }) as unknown as SyntheticChild + children.push(child) + return child + }) as unknown as typeof childProcess.spawn) + const client = new StdioNativeClient({ requestTimeoutMs: 1_000 }) + const events: unknown[] = [] + const unsubscribe = client.subscribe((event) => events.push(event)) + try { + client.start() + const oldChild = children[0]! + client.stop() + const response = client.request("session/list") + void response.catch(() => {}) + const currentChild = children[1]! + expect(client.pid()).toBe(currentChild.pid) + + // Reproduce the old exit event arriving after start() resets stopped. + oldChild.emit("exit", null, "SIGTERM") + oldChild.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, result: "old" })}\n`) + oldChild.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method: "old/event" })}\n`) + oldChild.stderr.write("old stderr\n") + oldChild.stdin.emit("error", new Error("old stdin failed")) + oldChild.emit("error", new Error("old process failed")) + + expect(client.connected()).toBe(true) + expect(client.pid()).toBe(currentChild.pid) + expect(events).toEqual([{ type: "closed", error: "Devo Native stdio client stopped" }]) + expect((client as unknown as { pending: Map }).pending.size).toBe(1) + + currentChild.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, result: "new" })}\n`) + await expect(response).resolves.toBe("new") + } finally { + client.stop() + unsubscribe() + for (const child of children) { + child.stdin.destroy() + child.stdout.destroy() + child.stderr.destroy() + } + spawnMock.mockRestore() + } + }) + + test("unsubscribes event listeners from the shared transport emitter", () => { + const client = new StdioNativeClient() + const emitter = (client as unknown as { + events: { listenerCount: (event: string) => number } + }).events + const unsubscribe = client.subscribe(() => {}) + + expect(emitter.listenerCount("event")).toBe(1) + unsubscribe() + expect(emitter.listenerCount("event")).toBe(0) + unsubscribe() + expect(emitter.listenerCount("event")).toBe(0) + }) + test("builds server env with bin dir first while preserving caller env", () => { const env = buildServerProcessEnv({ baseEnv: { PATH: "/usr/bin", KEEP: "base" }, @@ -180,16 +260,30 @@ describe("StdioNativeClient", () => { test("gives MCP admin RPCs a longer timeout than ordinary requests", () => { expect({ initialize: requestTimeoutMsForMethod("initialize", 10_000), + sessionNew: requestTimeoutMsForMethod("session/new", 10_000), + sessionResume: requestTimeoutMsForMethod("session/resume", 10_000), sessionList: requestTimeoutMsForMethod("session/list", 10_000), mcpTools: requestTimeoutMsForMethod("mcp/tools", 10_000), mcpSetEnabled: requestTimeoutMsForMethod("mcp/set_enabled", 5), + workspaceChanges: requestTimeoutMsForMethod("workspace/changes/read", 10_000), providerValidate: requestTimeoutMsForMethod("provider/validate", 10_000), + worktreeRemove: requestTimeoutMsForMethod("workspace/worktree/remove", 10_000), + catalogRefresh: requestTimeoutMsForMethod("model/catalog/refresh", 10_000), + providerDiscover: requestTimeoutMsForMethod("provider/discover", 10_000), + customCatalogTimeout: requestTimeoutMsForMethod("model/catalog/refresh", 90_000), }).toEqual({ initialize: 60_000, + sessionNew: 60_000, + sessionResume: 60_000, sessionList: 10_000, mcpTools: 60_000, mcpSetEnabled: 60_000, + workspaceChanges: 60_000, providerValidate: undefined, + worktreeRemove: 90_000, + catalogRefresh: 60_000, + providerDiscover: 60_000, + customCatalogTimeout: 90_000, }) }) diff --git a/apps/desktop/src/main/native-stdio-client.ts b/apps/desktop/src/main/native-stdio-client.ts index a0e730db..18b9b74d 100644 --- a/apps/desktop/src/main/native-stdio-client.ts +++ b/apps/desktop/src/main/native-stdio-client.ts @@ -33,21 +33,32 @@ type PendingRequest = { } const REQUEST_TIMEOUT_MS = 10_000 -/** Cold-starting the managed `devo server` process can exceed the default RPC budget. */ +/** Cold startup and session restore can exceed the default RPC budget on Windows. */ export const INITIALIZE_REQUEST_TIMEOUT_MS = 60_000 /** MCP admin RPCs may start a lazy server before listing tools. */ export const MCP_ADMIN_REQUEST_TIMEOUT_MS = 60_000 +/** Git-backed workspace diffs can exceed the default 10s budget on large trees. */ +export const WORKSPACE_CHANGES_REQUEST_TIMEOUT_MS = 60_000 +/** Remote catalog refresh (30s HTTP budget) and discovery must finish before IPC expires. */ +export const MODEL_CATALOG_REQUEST_TIMEOUT_MS = 60_000 export function requestTimeoutMsForMethod(method: string, fallbackMs: number): number | undefined { - if (method === "initialize") { + if (method === "initialize" || method === "session/new" || method === "session/resume") { return Math.max(fallbackMs, INITIALIZE_REQUEST_TIMEOUT_MS) } if (method === "provider/validate") { return undefined } + if (method === "model/catalog/refresh" || method === "provider/discover") { + return Math.max(fallbackMs, MODEL_CATALOG_REQUEST_TIMEOUT_MS) + } if (method === "mcp/tools" || method === "mcp/set_enabled") { return Math.max(fallbackMs, MCP_ADMIN_REQUEST_TIMEOUT_MS) } + if (method === "workspace/changes/read") { + return Math.max(fallbackMs, WORKSPACE_CHANGES_REQUEST_TIMEOUT_MS) + } + if (method.startsWith("workspace/worktree/")) return Math.max(fallbackMs, 90_000) return fallbackMs } @@ -243,29 +254,34 @@ export class StdioNativeClient implements NativeTransport { this.stopped = false const stdout = createInterface({ input: child.stdout }) - stdout.on("line", (line) => this.handleLine(line)) + stdout.on("line", (line) => { + if (this.child === child) this.handleLine(line) + }) const stderr = createInterface({ input: child.stderr }) stderr.on("line", (line) => { + if (this.child !== child) return if (line.trim()) log.warn(`[stderr] ${line}`) }) child.stdin.on("error", (error) => { + if (this.child !== child) return const reason = toError(error) log.warn("Devo Native stdio stdin failed", reason) - this.close(reason) + this.close(reason, child) }) child.on("error", (error) => { + if (this.child !== child) return log.error("Devo Native stdio process failed", error) - this.close(error) + this.close(error, child) }) child.on("exit", (code, signal) => { - if (this.stopped) return + if (this.child !== child || this.stopped) return const reason = `Devo Native stdio process exited with code ${code ?? "null"} signal ${signal ?? "null"}` log.warn(reason) - this.close(new Error(reason)) + this.close(new Error(reason), child) }) } @@ -314,8 +330,16 @@ export class StdioNativeClient implements NativeTransport { this.pendingMethods.delete(id) reject(new Error(`${method} request ${id} timed out`)) }, timeoutMs) - this.pending.set(id, { resolve, reject, timer }) + this.pending.set(id, { + resolve, + reject, + timer, + }) }) + // Server may reply (and reject) before the caller awaits — attach a + // no-op handler so Node does not log UnhandledPromiseRejectionWarning. + // Real callers still observe the rejection via await / .catch. + void response.catch(() => {}) this.pendingMethods.set(id, method) try { await this.writeJson(child, payload) @@ -332,7 +356,7 @@ export class StdioNativeClient implements NativeTransport { if (pending?.timer !== undefined) clearTimeout(pending.timer) this.pending.delete(id) this.pendingMethods.delete(id) - this.close(reason) + this.close(reason, child) throw reason } return response @@ -353,16 +377,17 @@ export class StdioNativeClient implements NativeTransport { }) } catch (error) { const reason = toError(error) - this.close(reason) + this.close(reason, child) throw reason } } async respond(id: JsonRpcId, result: unknown): Promise { this.start() + const child = this.requireChild() const payload = { jsonrpc: "2.0", id, result } try { - await this.writeJson(this.requireChild(), payload) + await this.writeJson(child, payload) this.recordTraffic({ direction: "desktop-to-server", kind: "response", @@ -371,7 +396,7 @@ export class StdioNativeClient implements NativeTransport { }) } catch (error) { const reason = toError(error) - this.close(reason) + this.close(reason, child) throw reason } } @@ -414,9 +439,13 @@ export class StdioNativeClient implements NativeTransport { if (!pending) return this.pending.delete(routed.id) if (pending.timer !== undefined) clearTimeout(pending.timer) - const error = routed.message.error as { message?: string } | undefined + const error = routed.message.error as { message?: string; code?: string } | undefined if (error) { - pending.reject(new Error(error.message ?? "Devo Native request failed")) + const rejected = new Error(error.message ?? "Devo Native request failed") as Error & { + code?: string + } + if (typeof error.code === "string") rejected.code = error.code + pending.reject(rejected) } else { pending.resolve(routed.message.result) } @@ -489,7 +518,8 @@ export class StdioNativeClient implements NativeTransport { return this.child } - private close(error: Error): void { + private close(error: Error, child?: ChildProcessWithoutNullStreams): void { + if (child && this.child !== child) return this.child = null this.initializeResult = undefined this.initializeInFlight = null diff --git a/apps/desktop/src/main/notification-policy.test.ts b/apps/desktop/src/main/notification-policy.test.ts index 2468ec94..0fbfbbd1 100644 --- a/apps/desktop/src/main/notification-policy.test.ts +++ b/apps/desktop/src/main/notification-policy.test.ts @@ -93,8 +93,8 @@ describe("notification policy", () => { }) describe("applyWatcherEvent", () => { - function session(status: string, parentID?: string): SessionState { - return { status, title: "Fix the tray menu", directory: "/repo", parentID } + function session(status: string, parentId?: string): SessionState { + return { status, title: "Fix the tray menu", directory: "/repo", parentId } } test("does not toast completion while hydrating a busy-to-idle replay", () => { @@ -104,7 +104,7 @@ describe("applyWatcherEvent", () => { directory: "/repo", payload: { type: "session.status", - properties: { sessionID: "s1", status: { type: "idle" } }, + properties: { sessionId: "s1", status: { type: "idle" } }, }, }, { sessions, pendingCount: 0, hydrating: true }, @@ -121,7 +121,7 @@ describe("applyWatcherEvent", () => { directory: "/repo", payload: { type: "session.status", - properties: { sessionID: "s1", status: { type: "idle" } }, + properties: { sessionId: "s1", status: { type: "idle" } }, }, }, { sessions, pendingCount: 0, hydrating: false }, @@ -145,7 +145,7 @@ describe("applyWatcherEvent", () => { directory: "/repo", payload: { type: "permission.asked", - properties: { sessionID: "s1", id: "p1", permission: "Run npm test" }, + properties: { sessionId: "s1", id: "p1", permission: "Run npm test" }, }, }, { sessions, pendingCount: 0, hydrating: true }, diff --git a/apps/desktop/src/main/notification-policy.ts b/apps/desktop/src/main/notification-policy.ts index 42e5b05a..77cb4150 100644 --- a/apps/desktop/src/main/notification-policy.ts +++ b/apps/desktop/src/main/notification-policy.ts @@ -2,7 +2,7 @@ export interface SessionState { status: string title: string directory?: string - parentID?: string + parentId?: string } export function isWorkingSessionStatus(status: string | undefined): boolean { @@ -86,7 +86,7 @@ export function applyWatcherEvent( switch (event.type) { case "permission.asked": { - const sessionId = props.sessionID as string + const sessionId = props.sessionId as string const permission = (props as { permission?: string }).permission const rootId = getRootSession(state.sessions, sessionId) const rootTitle = state.sessions.get(rootId)?.title @@ -115,7 +115,7 @@ export function applyWatcherEvent( } case "question.asked": { - const sessionId = props.sessionID as string + const sessionId = props.sessionId as string const questions = props.questions as Array<{ header?: string }> | undefined const header = questions?.[0]?.header ?? "Question" const rootId = getRootSession(state.sessions, sessionId) @@ -146,7 +146,7 @@ export function applyWatcherEvent( } case "session.status": { - const sessionId = props.sessionID as string + const sessionId = props.sessionId as string const newStatusType = (props.status as { type: string })?.type if (!sessionId || !newStatusType) break @@ -156,7 +156,7 @@ export function applyWatcherEvent( status: newStatusType, title: prev?.title ?? "", directory: directory ?? prev?.directory, - parentID: prev?.parentID, + parentId: prev?.parentId, }) stateChanged = true @@ -181,7 +181,7 @@ export function applyWatcherEvent( } case "session.error": { - const sessionId = props.sessionID as string + const sessionId = props.sessionId as string const error = props.error as { name?: string } | undefined if (!sessionId) break if (!isSubAgent(state.sessions, sessionId) && shouldShowLiveNotification(state.hydrating)) { @@ -199,7 +199,7 @@ export function applyWatcherEvent( case "session.created": case "session.updated": { const info = (props.info ?? props.session) as - | { id?: string; title?: string; parentID?: string } + | { id?: string; title?: string; parentId?: string } | undefined if (info?.id) { const existing = state.sessions.get(info.id) @@ -207,7 +207,7 @@ export function applyWatcherEvent( status: existing?.status ?? "idle", title: info.title ?? existing?.title ?? "", directory: directory ?? existing?.directory, - parentID: info.parentID ?? existing?.parentID, + parentId: info.parentId ?? existing?.parentId, }) stateChanged = true } @@ -222,7 +222,7 @@ export function applyWatcherEvent( } function isSubAgent(sessions: ReadonlyMap, sessionId: string): boolean { - return !!sessions.get(sessionId)?.parentID + return !!sessions.get(sessionId)?.parentId } function getRootSession(sessions: ReadonlyMap, sessionId: string): string { @@ -231,9 +231,9 @@ function getRootSession(sessions: ReadonlyMap, sessionId: while (true) { if (seen.has(id)) break seen.add(id) - const parentID = sessions.get(id)?.parentID - if (!parentID) break - id = parentID + const parentId = sessions.get(id)?.parentId + if (!parentId) break + id = parentId } return id } diff --git a/apps/desktop/src/main/notification-watcher.test.ts b/apps/desktop/src/main/notification-watcher.test.ts new file mode 100644 index 00000000..6cd8348f --- /dev/null +++ b/apps/desktop/src/main/notification-watcher.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import type { DevoNativeTransport, DevoNativeTransportEvent } from "@devo-ai/sdk/v2/client" + +mock.module("./notifications", () => ({ + setPermissionResponder: () => {}, + showNotification: () => {}, + updateBadgeCount: () => {}, +})) + +mock.module("./devo-manager", () => ({ + recycleServerForProtocolMismatch: async () => false, +})) + +class FakeTransport implements DevoNativeTransport { + readonly listeners = new Set<(event: DevoNativeTransportEvent) => void>() + subscribeCalls = 0 + unsubscribeCalls = 0 + + async request(method: string): Promise { + if (method === "initialize") { + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + } + if (method === "session/list") { + return { data: [], nextCursor: null } + } + throw new Error(`unexpected request ${method}`) + } + + async respond(): Promise {} + + subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void { + this.subscribeCalls += 1 + this.listeners.add(listener) + return () => { + this.unsubscribeCalls += 1 + this.listeners.delete(listener) + } + } + + connected(): boolean { + return true + } +} + +async function tick(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +afterEach(async () => { + const watcher = await import("./notification-watcher") + watcher.stopNotificationWatcher() + await tick() +}) + +describe("notification watcher client cleanup", () => { + test("unsubscribes the replaced client and closes the stopped stream", async () => { + const watcher = await import("./notification-watcher") + const transport = new FakeTransport() + + watcher.startNotificationWatcher(transport) + await tick() + expect(transport.subscribeCalls).toBe(1) + expect(transport.listeners.size).toBe(1) + + watcher.startNotificationWatcher(transport) + await tick() + expect(transport.subscribeCalls).toBe(2) + expect(transport.unsubscribeCalls).toBe(1) + expect(transport.listeners.size).toBe(1) + + watcher.stopNotificationWatcher() + await tick() + expect(transport.unsubscribeCalls).toBe(2) + expect(transport.listeners.size).toBe(0) + }) +}) diff --git a/apps/desktop/src/main/notification-watcher.ts b/apps/desktop/src/main/notification-watcher.ts index a060540d..c03bba45 100644 --- a/apps/desktop/src/main/notification-watcher.ts +++ b/apps/desktop/src/main/notification-watcher.ts @@ -18,6 +18,7 @@ export type SessionState = WatcherSessionState // ============================================================ let abortController: AbortController | null = null +let watcherClient: ReturnType | null = null /** Minimal session state for transition detection. */ const sessions = new Map() @@ -51,21 +52,32 @@ export function startNotificationWatcher(transport: DevoNativeTransport): void { log.debug("Stopping existing watcher before restart") abortController.abort() } + watcherClient?.dispose() + watcherClient = null abortController = new AbortController() pendingCount = 0 const client = createDevoClient({ transport }) + watcherClient = client setPermissionResponder(async ({ sessionId, permissionId, response }) => { - await client.permission.respond({ - sessionID: sessionId, - permissionID: permissionId, - response, - }) + try { + await client.permission.respond({ + sessionId: sessionId, + permissionId: permissionId, + response, + }) + } catch (err) { + log.error("Permission reply failed", { sessionId, permissionId, response }, err) + } }) log.info("Starting notification watcher") - connectWithRetry(client, abortController.signal) + void connectWithRetry(client, abortController.signal).catch((err) => { + if (!abortController?.signal.aborted) { + log.error("Notification watcher stopped unexpectedly", {}, err) + } + }) } /** @@ -76,6 +88,8 @@ export function stopNotificationWatcher(): void { abortController.abort() abortController = null } + watcherClient?.dispose() + watcherClient = null sessions.clear() pendingCount = 0 beginHydration() diff --git a/apps/desktop/src/main/notifications.test.ts b/apps/desktop/src/main/notifications.test.ts new file mode 100644 index 00000000..f4716fdc --- /dev/null +++ b/apps/desktop/src/main/notifications.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, mock, test } from "bun:test" + +class MockBrowserWindow { + static windows: MockBrowserWindow[] = [] + static getAllWindows(): MockBrowserWindow[] { + return MockBrowserWindow.windows + } + + readonly calls: string[] = [] + readonly navigation: Array<{ sessionId: string; visible: boolean }> = [] + readonly webContents = { + send: (channel: string, payload: { sessionId: string }) => { + this.calls.push("send") + if (channel !== "notification:navigate") throw new Error(`Unexpected channel: ${channel}`) + this.navigation.push({ sessionId: payload.sessionId, visible: this.visible }) + }, + } + + constructor( + private visible: boolean, + private minimized: boolean, + private restoreMakesVisible = true, + ) {} + + isDestroyed(): boolean { + return false + } + isFocused(): boolean { + return false + } + isVisible(): boolean { + return this.visible + } + isMinimized(): boolean { + return this.minimized + } + restore(): void { + this.calls.push("restore") + this.minimized = false + if (this.restoreMakesVisible) this.visible = true + } + show(): void { + this.calls.push("show") + this.visible = true + } + focus(): void { + this.calls.push("focus") + } +} + +class MockNotification { + static latest: MockNotification | undefined + static getLatest(): MockNotification | undefined { + return MockNotification.latest + } + static isSupported(): boolean { + return true + } + + private readonly listeners = new Map void>() + constructor(_options: { title: string; body: string }) { + MockNotification.latest = this + } + on(event: string, listener: () => void): void { + this.listeners.set(event, listener) + } + show(): void {} + close(): void {} + click(): void { + this.listeners.get("click")?.() + } +} + +mock.module("electron", () => ({ + BrowserWindow: MockBrowserWindow, + Notification: MockNotification, + app: {}, +})) +mock.module("./settings-store", () => ({ + getNotificationSettings: () => ({ completionMode: "unfocused" }), +})) + +describe("notification click navigation", () => { + test.each([ + { + name: "hidden", + visible: false, + minimized: false, + restoreMakesVisible: true, + calls: ["show", "focus", "send"], + }, + { + name: "minimized", + visible: false, + minimized: true, + restoreMakesVisible: true, + calls: ["restore", "focus", "send"], + }, + { + name: "visible", + visible: true, + minimized: false, + restoreMakesVisible: true, + calls: ["focus", "send"], + }, + { + name: "still hidden after restore", + visible: false, + minimized: true, + restoreMakesVisible: false, + calls: ["restore", "show", "focus", "send"], + }, + ])( + "opens a $name window before sending navigation", + async ({ visible, minimized, restoreMakesVisible, calls }) => { + const window = new MockBrowserWindow(visible, minimized, restoreMakesVisible) + MockBrowserWindow.windows = [window] + MockNotification.latest = undefined + const { showNotification } = await import("./notifications") + const sessionId = `session-${minimized}-${visible}-${restoreMakesVisible}` + + showNotification({ type: "completed", sessionId, title: "Finished", body: "Task completed" }) + const notification = MockNotification.getLatest() + expect(notification).toBeDefined() + notification?.click() + + expect(window.calls).toEqual([...calls]) + expect(window.navigation).toEqual([{ sessionId, visible: true }]) + }, + ) +}) diff --git a/apps/desktop/src/main/notifications.ts b/apps/desktop/src/main/notifications.ts index 1e663d05..8ee4cb08 100644 --- a/apps/desktop/src/main/notifications.ts +++ b/apps/desktop/src/main/notifications.ts @@ -193,6 +193,12 @@ function fireNotification(request: NotificationRequest): void { permissionId, response, directory: request.directory, + }).catch((err) => { + log.error( + "Permission notification action failed", + { sessionId: request.sessionId, permissionId, response }, + err, + ) }) }) } @@ -214,6 +220,7 @@ function navigateToSession(sessionId: string): void { const win = BrowserWindow.getAllWindows()[0] if (win) { if (win.isMinimized()) win.restore() + if (!win.isVisible()) win.show() win.focus() win.webContents.send("notification:navigate", { sessionId }) } diff --git a/apps/desktop/src/main/opencode-provider-migration.test.ts b/apps/desktop/src/main/opencode-provider-migration.test.ts index f454ff96..afde16bc 100644 --- a/apps/desktop/src/main/opencode-provider-migration.test.ts +++ b/apps/desktop/src/main/opencode-provider-migration.test.ts @@ -23,6 +23,7 @@ function scanResultWithConfig( const exampleConfig = { model: "deepseek/deepseek-v4-pro", + small_model: "deepseek/deepseek-v4-flash", provider: { deepseek: { npm: "@ai-sdk/openai-compatible", @@ -78,7 +79,7 @@ describe("OpenCode provider migration", () => { expect(result.category?.files[1].content).not.toContain("tp-xxxxxx") }) - test("executes provider upserts for each imported model and sets explicit default only", async () => { + test("executes one canonical provider upsert per imported provider", async () => { const calls: Array<{ method: string; params: Record }> = [] const result = await executeOpenCodeProviderMigration( @@ -90,43 +91,30 @@ describe("OpenCode provider migration", () => { ) expect(result.errors).toEqual([]) - expect(result.filesWritten).toEqual([ - "provider/upsert:deepseek/deepseek-v4-flash-deepseek", - "provider/upsert:deepseek/deepseek-v4-pro-deepseek", - "provider/upsert:mimo/mimo-v2-5-mimo", - "provider/upsert:mimo/mimo-v2-5-pro-mimo", - ]) + expect(result.filesWritten).toEqual(["provider/upsert:deepseek", "provider/upsert:mimo"]) expect(calls.map((call) => call.method)).toEqual([ "provider/upsert", "provider/upsert", - "provider/upsert", - "provider/upsert", ]) expect(calls[1].params).toEqual({ - provider_vendor: { - name: "deepseek", - base_url: "https://api.deepseek.com/v1", - credential: null, - headers: null, - wire_apis: ["openai_chat_completions"], - enabled: true, - }, - model_binding: { - binding_id: "deepseek-v4-pro-deepseek", - model_slug: "deepseek-v4-pro", - provider: "deepseek", - request_model: "deepseek-v4-pro", - display_name: "DeepSeek V4 Pro", - invocation_method: "openai_chat_completions", - default_reasoning_effort: null, + provider: { + id: "mimo", + name: "Xiaomi MiMo", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + wireApis: ["openai_chat_completions"], + models: { + "mimo-v2.5": { name: "Mimo v2.5" }, + "mimo-v2.5-pro": { name: "Mimo v2.5 Pro" }, + }, enabled: true, }, - default_model_binding: "deepseek-v4-pro-deepseek", - api_key: "sk-xxxx", + apiKey: "tp-xxxxxx", }) - expect(calls[0].params.default_model_binding).toBeUndefined() - expect(calls[2].params.default_model_binding).toBeUndefined() - expect(calls[2].params.api_key).toBe("tp-xxxxxx") + expect(calls[0].params.defaultModel).toBe("deepseek/deepseek-v4-pro") + expect(calls[0].params.smallModel).toBe("deepseek/deepseek-v4-flash") + expect(calls[0].params.apiKey).toBe("sk-xxxx") + expect(calls[1].params.defaultModel).toBeUndefined() + expect(calls[1].params.smallModel).toBeUndefined() }) test("does not guess a default model when OpenCode does not define one", async () => { @@ -152,7 +140,7 @@ describe("OpenCode provider migration", () => { ) expect(calls).toHaveLength(1) - expect(calls[0].default_model_binding).toBeUndefined() + expect(calls[0].defaultModel).toBeUndefined() }) test("reports missing config, parse errors, unsupported providers, and missing key/base URL without crashing", async () => { diff --git a/apps/desktop/src/main/opencode-provider-migration.ts b/apps/desktop/src/main/opencode-provider-migration.ts index cfe5a5b0..5693b494 100644 --- a/apps/desktop/src/main/opencode-provider-migration.ts +++ b/apps/desktop/src/main/opencode-provider-migration.ts @@ -7,6 +7,7 @@ import type { OpenCodeProviderSettings, OpenCodeScanResult, } from "@devo/configconv" +import type { CanonicalProviderUpsertParams } from "./canonical-provider-migration" interface MigrationFilePreview { path: string @@ -84,15 +85,13 @@ export async function executeOpenCodeProviderMigration( const errors: string[] = [...diagnostics.errors] for (const params of buildProviderUpsertParams(settings)) { - const providerName = params.model_binding.provider - const requestModel = params.model_binding.request_model - const bindingId = params.model_binding.binding_id + const providerName = params.provider.id try { await requestProviderUpsert("provider/upsert", params) - filesWritten.push(`provider/upsert:${providerName}/${bindingId}`) + filesWritten.push(`provider/upsert:${providerName}`) } catch (error) { errors.push( - `OpenCode provider migration failed for ${providerName}/${requestModel}: ${error instanceof Error ? error.message : String(error)}`, + `OpenCode provider migration failed for ${providerName}: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -105,59 +104,28 @@ export async function executeOpenCodeProviderMigration( } } -function buildProviderUpsertParams(settings: OpenCodeProviderSettings): Array<{ - provider_vendor: { - name: string - base_url: string | null - credential: null - headers: null - wire_apis: string[] - enabled: true - } - model_binding: { - binding_id: string - model_slug: string - provider: string - request_model: string - display_name: string - invocation_method: string - default_reasoning_effort: null - enabled: true - } - default_model_binding?: string - api_key?: string -}> { - const params = [] - - for (const provider of settings.providers) { - for (const model of provider.models) { - const bindingId = `${slugComponent(model.modelId)}-${slugComponent(provider.providerId)}` - params.push({ - provider_vendor: { - name: provider.providerId, - base_url: provider.baseUrl ?? null, - credential: null, - headers: null, - wire_apis: [provider.wireApi], - enabled: true as const, - }, - model_binding: { - binding_id: bindingId, - model_slug: model.modelId, - provider: provider.providerId, - request_model: model.modelId, - display_name: model.displayName, - invocation_method: provider.wireApi, - default_reasoning_effort: null, - enabled: true as const, - }, - default_model_binding: model.isDefault ? bindingId : undefined, - api_key: provider.apiKey, - }) +function buildProviderUpsertParams( + settings: OpenCodeProviderSettings, +): CanonicalProviderUpsertParams[] { + return settings.providers.map((provider) => { + const defaultModel = provider.models.find((model) => model.isDefault)?.modelId + const smallModel = provider.models.find((model) => model.isSmall)?.modelId + return { + provider: { + id: provider.providerId, + name: provider.displayName, + ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}), + wireApis: [provider.wireApi], + models: Object.fromEntries( + provider.models.map((model) => [model.modelId, { name: model.displayName }]), + ), + enabled: true, + }, + ...(defaultModel ? { defaultModel: `${provider.providerId}/${defaultModel}` } : {}), + ...(smallModel ? { smallModel: `${provider.providerId}/${smallModel}` } : {}), + ...(provider.apiKey ? { apiKey: provider.apiKey } : {}), } - } - - return params + }) } function diagnosticsFor(settings: OpenCodeProviderSettings): { @@ -186,7 +154,7 @@ function diagnosticsFor(settings: OpenCodeProviderSettings): { if (settings.providers.reduce((sum, provider) => sum + provider.models.length, 0) === 0) { warnings.push( - "OpenCode settings did not include any importable OpenAI-compatible provider models; no provider model bindings were imported.", + "OpenCode settings did not include any importable OpenAI-compatible provider models; no provider Connections were imported.", ) } @@ -205,7 +173,7 @@ function pushProviderDiagnostics( } if (provider.models.length === 0) { warnings.push( - `OpenCode provider ${provider.providerId} did not include model definitions; no model bindings were imported for this provider.`, + `OpenCode provider ${provider.providerId} did not include model definitions; no models were imported for this provider.`, ) } if (!provider.apiKey) { @@ -234,19 +202,6 @@ function readOpenCodeScanResult(scanResult: unknown): OpenCodeScanResult | undef } } -function slugComponent(value: string): string { - let out = "" - for (const ch of value) { - if (/[a-zA-Z0-9]/.test(ch)) { - out += ch.toLowerCase() - } else if (!out.endsWith("-")) { - out += "-" - } - } - const slug = out.replace(/^-+|-+$/g, "") - return slug || "model" -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } diff --git a/apps/desktop/src/main/provider-oauth-credential.test.ts b/apps/desktop/src/main/provider-oauth-credential.test.ts new file mode 100644 index 00000000..73d0df68 --- /dev/null +++ b/apps/desktop/src/main/provider-oauth-credential.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { credentialSetParamsFromDesktopOAuth } from "./provider-oauth-credential" + +describe("credentialSetParamsFromDesktopOAuth", () => { + /** + * Trace: L2-DES-AUTH-001 + * Verifies: Desktop OAuth login persists via Native credential/set oauth params. + */ + test("maps OAuth credential into credential/set params", () => { + expect( + credentialSetParamsFromDesktopOAuth("openai-codex", { + access: "access-token", + refresh: "refresh-token", + expiresAt: 1_700_000_000_000, + accountId: "acct_1", + }), + ).toEqual({ + provider: "openai-codex", + kind: "oauth", + access: "access-token", + refresh: "refresh-token", + expiresAt: 1_700_000_000_000, + accountId: "acct_1", + }) + }) +}) diff --git a/apps/desktop/src/main/provider-oauth-credential.ts b/apps/desktop/src/main/provider-oauth-credential.ts new file mode 100644 index 00000000..52f5786c --- /dev/null +++ b/apps/desktop/src/main/provider-oauth-credential.ts @@ -0,0 +1,31 @@ +export interface DesktopOAuthCredential { + access: string + refresh?: string + expiresAt?: number + accountId?: string + enterpriseUrl?: string +} + +/** Native `credential/set` params after a successful Desktop OAuth login. */ +export function credentialSetParamsFromDesktopOAuth( + providerId: string, + credential: DesktopOAuthCredential, +): { + provider: string + kind: "oauth" + access: string + refresh?: string + expiresAt?: number + accountId?: string + enterpriseUrl?: string +} { + return { + provider: providerId, + kind: "oauth", + access: credential.access, + ...(credential.refresh ? { refresh: credential.refresh } : {}), + ...(credential.expiresAt != null ? { expiresAt: credential.expiresAt } : {}), + ...(credential.accountId ? { accountId: credential.accountId } : {}), + ...(credential.enterpriseUrl ? { enterpriseUrl: credential.enterpriseUrl } : {}), + } +} diff --git a/apps/desktop/src/main/provider-oauth.test.ts b/apps/desktop/src/main/provider-oauth.test.ts new file mode 100644 index 00000000..1edac6d5 --- /dev/null +++ b/apps/desktop/src/main/provider-oauth.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, mock, test } from "bun:test" +import { createServer } from "node:net" + +const openExternal = mock(async (_url: string) => {}) +mock.module("electron", () => ({ shell: { openExternal } })) + +const { loginDesktopOAuth } = await import("./provider-oauth") + +function expectPortAvailable(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + server.once("error", reject) + server.listen(port, "127.0.0.1", () => server.close((error) => error ? reject(error) : resolve())) + }) +} + +describe("desktop OAuth callback cancellation", () => { + for (const providerId of ["openai-codex", "anthropic"] as const) { + test(`${providerId} does not listen or open a browser when already cancelled`, async () => { + openExternal.mockClear() + const controller = new AbortController() + controller.abort() + + await expect( + loginDesktopOAuth(providerId, { + signal: controller.signal, + onUpdate: () => {}, + }), + ).rejects.toThrow("Login cancelled") + expect(openExternal).not.toHaveBeenCalled() + }) + + test(`${providerId} releases callback port when browser launch fails and on retry`, async () => { + openExternal.mockClear() + const port = providerId === "openai-codex" ? 1455 : 53692 + openExternal.mockImplementationOnce(async () => { + throw new Error("Browser launch failed") + }) + await expect(loginDesktopOAuth(providerId, { onUpdate: () => {} })).rejects.toThrow( + "Browser launch failed", + ) + await expectPortAvailable(port) + + const controller = new AbortController() + openExternal.mockImplementationOnce(async () => controller.abort()) + await expect( + loginDesktopOAuth(providerId, { + signal: controller.signal, + onUpdate: () => {}, + }), + ).rejects.toThrow("Login cancelled") + await expectPortAvailable(port) + expect(openExternal).toHaveBeenCalledTimes(2) + }) + + test(`${providerId} skips the browser when cancelled on callback setup`, async () => { + openExternal.mockClear() + const controller = new AbortController() + + await expect( + loginDesktopOAuth(providerId, { + signal: controller.signal, + onUpdate: () => controller.abort(), + }), + ).rejects.toThrow("Login cancelled") + expect(openExternal).not.toHaveBeenCalled() + }) + } +}) diff --git a/apps/desktop/src/main/provider-oauth.ts b/apps/desktop/src/main/provider-oauth.ts new file mode 100644 index 00000000..25182e2b --- /dev/null +++ b/apps/desktop/src/main/provider-oauth.ts @@ -0,0 +1,454 @@ +import { randomBytes } from "node:crypto" +import { createServer } from "node:http" +import { shell } from "electron" +import { + credentialSetParamsFromDesktopOAuth, + type DesktopOAuthCredential, +} from "./provider-oauth-credential" + +export { credentialSetParamsFromDesktopOAuth, type DesktopOAuthCredential } + +export const DESKTOP_OAUTH_PROVIDER_IDS = [ + "openai-codex", + "anthropic", + "github-copilot", + "xai", +] as const + +export type DesktopOAuthProviderId = (typeof DESKTOP_OAUTH_PROVIDER_IDS)[number] + +export interface DesktopOAuthUpdate { + url?: string + instructions: string + userCode?: string +} + +export interface DesktopOAuthOptions { + signal?: AbortSignal + enterpriseUrl?: string + onUpdate: (update: DesktopOAuthUpdate) => void +} + +interface DeviceResponse { + device_code?: unknown + user_code?: unknown + verification_uri?: unknown + verification_uri_complete?: unknown + interval?: unknown + expires_in?: unknown + error?: unknown + error_description?: unknown +} + +const OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +const OPENAI_REDIRECT_URI = "http://localhost:1455/auth/callback" +const ANTHROPIC_CLIENT_ID = Buffer.from( + "OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl", + "base64", +).toString() +const ANTHROPIC_REDIRECT_URI = "http://localhost:53692/callback" +const ANTHROPIC_SCOPES = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" +const COPILOT_CLIENT_ID = Buffer.from("SXYxLmI1MDdhMDhjODdlY2ZlOTg=", "base64").toString() +const COPILOT_USER_AGENT = "GitHubCopilotChat/0.48.1" +const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access" + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`OAuth response missing ${field}`) + } + return value +} + +function requiredNumber(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`OAuth response missing ${field}`) + } + return value +} + +function throwIfCancelled(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error("Login cancelled") +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + throwIfCancelled(signal) + const timer = setTimeout(resolve, ms) + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer) + reject(new Error("Login cancelled")) + }, + { once: true }, + ) + }) +} + +async function generatePkce(): Promise<{ verifier: string; challenge: string }> { + const verifier = randomBytes(32).toString("base64url") + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) + return { verifier, challenge: Buffer.from(digest).toString("base64url") } +} + +function oauthHtml(title: string, message: string): string { + return `${title}

${title}

${message}

` +} + +async function waitForCallback( + port: number, + pathname: string, + expectedState: string, + signal?: AbortSignal, +): Promise { + throwIfCancelled(signal) + return new Promise((resolve, reject) => { + const server = createServer((request, response) => { + const url = new URL(request.url ?? "", "http://localhost") + if (url.pathname !== pathname) { + response.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }) + response.end(oauthHtml("Authentication failed", "Callback route not found.")) + return + } + const state = url.searchParams.get("state") + const code = url.searchParams.get("code") + if (state !== expectedState || !code) { + response.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + response.end(oauthHtml("Authentication failed", "Missing code or state mismatch.")) + return + } + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + response.end( + oauthHtml("Authentication successful", "Authentication completed. You can close this window."), + ) + cleanup() + resolve(code) + }) + const onAbort = () => { + cleanup() + reject(new Error("Login cancelled")) + } + const cleanup = () => { + signal?.removeEventListener("abort", onAbort) + server.close() + } + server.once("error", (error) => { + cleanup() + reject(error) + }) + signal?.addEventListener("abort", onAbort, { once: true }) + server.listen(port, "127.0.0.1") + }) +} + +/** Own the localhost callback until browser launch and callback have both settled. */ +async function openOAuthCallback( + port: number, + pathname: string, + state: string, + url: string, + update: DesktopOAuthUpdate, + options: DesktopOAuthOptions, +): Promise { + const controller = new AbortController() + const onAbort = () => controller.abort() + if (options.signal?.aborted) controller.abort() + options.signal?.addEventListener("abort", onAbort, { once: true }) + try { + const callback = waitForCallback(port, pathname, state, controller.signal) + // Caller cancellation may reject the callback while openExternal is pending. + void callback.catch(() => {}) + options.onUpdate(update) + throwIfCancelled(options.signal) + await shell.openExternal(url) + return await callback + } finally { + options.signal?.removeEventListener("abort", onAbort) + controller.abort() + } +} + +async function requestJson( + url: string, + init: RequestInit, + allowHttpError = false, +): Promise> { + const response = await fetch(url, init) + const text = await response.text() + if (!response.ok && !allowHttpError) { + throw new Error(`OAuth request failed (${response.status}): ${text || response.statusText}`) + } + try { + const value: unknown = JSON.parse(text) + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error() + return value as Record + } catch { + throw new Error("OAuth request returned invalid JSON") + } +} + +function expiresAt(expiresIn: unknown, skewMs = 0): number { + return Math.floor((Date.now() + requiredNumber(expiresIn, "expires_in") * 1000 - skewMs) / 1000) +} + +async function loginOpenAi(options: DesktopOAuthOptions): Promise { + const { verifier, challenge } = await generatePkce() + throwIfCancelled(options.signal) + const state = randomBytes(16).toString("hex") + const url = new URL("https://auth.openai.com/oauth/authorize") + url.search = new URLSearchParams({ + response_type: "code", + client_id: OPENAI_CLIENT_ID, + redirect_uri: OPENAI_REDIRECT_URI, + scope: "openid profile email offline_access", + code_challenge: challenge, + code_challenge_method: "S256", + state, + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: "pi", + }).toString() + const code = await openOAuthCallback( + 1455, + "/auth/callback", + state, + url.href, + { url: url.href, instructions: "Complete ChatGPT login in your browser." }, + options, + ) + const body = await requestJson("https://auth.openai.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: OPENAI_REDIRECT_URI, + }), + signal: options.signal, + }) + const access = requiredString(body.access_token, "access_token") + let accountId: string | undefined + try { + const payload = JSON.parse(Buffer.from(access.split(".")[1] ?? "", "base64url").toString()) + accountId = payload?.["https://api.openai.com/auth"]?.chatgpt_account_id + } catch { + // The server requires the account id for Codex; report a clear error below. + } + if (!accountId) throw new Error("Failed to extract ChatGPT account id") + return { + access, + refresh: requiredString(body.refresh_token, "refresh_token"), + expiresAt: expiresAt(body.expires_in), + accountId, + } +} + +async function loginAnthropic(options: DesktopOAuthOptions): Promise { + const { verifier, challenge } = await generatePkce() + throwIfCancelled(options.signal) + const url = new URL("https://claude.ai/oauth/authorize") + url.search = new URLSearchParams({ + code: "true", + client_id: ANTHROPIC_CLIENT_ID, + response_type: "code", + redirect_uri: ANTHROPIC_REDIRECT_URI, + scope: ANTHROPIC_SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + }).toString() + const code = await openOAuthCallback( + 53692, + "/callback", + verifier, + url.href, + { url: url.href, instructions: "Complete Anthropic login in your browser." }, + options, + ) + const body = await requestJson("https://platform.claude.com/v1/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: ANTHROPIC_CLIENT_ID, + code, + state: verifier, + redirect_uri: ANTHROPIC_REDIRECT_URI, + code_verifier: verifier, + }), + signal: options.signal, + }) + return { + access: requiredString(body.access_token, "access_token"), + refresh: requiredString(body.refresh_token, "refresh_token"), + expiresAt: expiresAt(body.expires_in, 5 * 60 * 1000), + } +} + +function normalizeEnterpriseDomain(input?: string): string | undefined { + const trimmed = input?.trim() + if (!trimmed) return undefined + try { + return new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`).hostname + } catch { + throw new Error("Invalid GitHub Enterprise URL/domain") + } +} + +async function loginCopilot(options: DesktopOAuthOptions): Promise { + const enterpriseUrl = normalizeEnterpriseDomain(options.enterpriseUrl) + const domain = enterpriseUrl ?? "github.com" + const device = (await requestJson(`https://${domain}/login/device/code`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": COPILOT_USER_AGENT, + }, + body: new URLSearchParams({ client_id: COPILOT_CLIENT_ID, scope: "read:user" }), + signal: options.signal, + })) as DeviceResponse + const userCode = requiredString(device.user_code, "user_code") + const url = requiredString(device.verification_uri, "verification_uri") + options.onUpdate({ url, userCode, instructions: `Enter code: ${userCode}` }) + await shell.openExternal(url) + const deadline = Date.now() + requiredNumber(device.expires_in, "expires_in") * 1000 + let interval = Math.max(1000, requiredNumber(device.interval, "interval") * 1000) + let githubToken: string | undefined + while (Date.now() < deadline) { + await wait(Math.min(interval, deadline - Date.now()), options.signal) + const token = (await requestJson( + `https://${domain}/login/oauth/access_token`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": COPILOT_USER_AGENT, + }, + body: new URLSearchParams({ + client_id: COPILOT_CLIENT_ID, + device_code: requiredString(device.device_code, "device_code"), + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + signal: options.signal, + }, + true, + )) as DeviceResponse & { access_token?: unknown } + if (typeof token.access_token === "string") { + githubToken = token.access_token + break + } + if (token.error === "authorization_pending") continue + if (token.error === "slow_down") { + interval += 5000 + continue + } + throw new Error(`GitHub device flow failed: ${String(token.error ?? "invalid response")}`) + } + if (!githubToken) throw new Error("GitHub device flow timed out") + const copilot = await requestJson(`https://api.${domain}/copilot_internal/v2/token`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${githubToken}`, + "User-Agent": COPILOT_USER_AGENT, + "Editor-Version": "vscode/1.136.1", + "Editor-Plugin-Version": "copilot-chat/0.48.1", + "Copilot-Integration-Id": "vscode-chat", + }, + signal: options.signal, + }) + return { + access: requiredString(copilot.token, "token"), + refresh: githubToken, + expiresAt: requiredNumber(copilot.expires_at, "expires_at") - 5 * 60, + ...(enterpriseUrl ? { enterpriseUrl } : {}), + } +} + +async function loginXai(options: DesktopOAuthOptions): Promise { + const device = (await requestJson("https://auth.x.ai/oauth2/device/code", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: XAI_CLIENT_ID, scope: XAI_SCOPE, referrer: "pi" }), + signal: options.signal, + redirect: "error", + })) as DeviceResponse + const userCode = requiredString(device.user_code, "user_code") + const url = new URL(requiredString(device.verification_uri, "verification_uri")) + if (url.protocol !== "https:" || url.username || url.password) { + throw new Error("Untrusted verification URI in xAI OAuth response") + } + options.onUpdate({ url: url.href, userCode, instructions: `Enter code: ${userCode}` }) + await shell.openExternal(url.href) + const deadline = Date.now() + requiredNumber(device.expires_in, "expires_in") * 1000 + let interval = Math.max( + 1000, + typeof device.interval === "number" ? device.interval * 1000 : 5000, + ) + while (Date.now() < deadline) { + await wait(Math.min(interval, deadline - Date.now()), options.signal) + const token = (await requestJson( + "https://auth.x.ai/oauth2/token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: XAI_CLIENT_ID, + device_code: requiredString(device.device_code, "device_code"), + }), + signal: options.signal, + redirect: "error", + }, + true, + )) as DeviceResponse & { + access_token?: unknown + refresh_token?: unknown + } + if (typeof token.access_token === "string") { + const lifetime = requiredNumber(token.expires_in ?? 3600, "expires_in") * 1000 + return { + access: token.access_token, + refresh: requiredString(token.refresh_token, "refresh_token"), + expiresAt: Math.floor( + (Date.now() + lifetime - Math.min(5 * 60 * 1000, lifetime / 2)) / 1000, + ), + } + } + if (token.error === "authorization_pending") continue + if (token.error === "slow_down") { + interval += 5000 + continue + } + throw new Error(`xAI device flow failed: ${String(token.error ?? "invalid response")}`) + } + throw new Error("xAI device code expired; sign in again") +} + +export function isDesktopOAuthProviderId(value: string): value is DesktopOAuthProviderId { + return DESKTOP_OAUTH_PROVIDER_IDS.some((providerId) => providerId === value) +} + +export async function loginDesktopOAuth( + providerId: DesktopOAuthProviderId, + options: DesktopOAuthOptions, +): Promise { + switch (providerId) { + case "openai-codex": + return loginOpenAi(options) + case "anthropic": + return loginAnthropic(options) + case "github-copilot": + return loginCopilot(options) + case "xai": + return loginXai(options) + } +} diff --git a/apps/desktop/src/main/quit-guard.test.ts b/apps/desktop/src/main/quit-guard.test.ts index 5be84960..6bcd661d 100644 --- a/apps/desktop/src/main/quit-guard.test.ts +++ b/apps/desktop/src/main/quit-guard.test.ts @@ -8,11 +8,11 @@ import { shouldPromptBeforeQuit, } from "./quit-guard" -function session(status: string, parentID?: string): SessionState { +function session(status: string, parentId?: string): SessionState { return { status, title: "Session", - parentID, + parentId, } } diff --git a/apps/desktop/src/main/quit-guard.ts b/apps/desktop/src/main/quit-guard.ts index 1e7bcdcc..6e4fa9da 100644 --- a/apps/desktop/src/main/quit-guard.ts +++ b/apps/desktop/src/main/quit-guard.ts @@ -13,9 +13,9 @@ export function countWorkingRootSessions(sessions: ReadonlyMap [session.id, session])) const runningSessions = Array.from(liveSessions.entries()) - .filter(([, state]) => !state.parentID && isWorkingSessionStatus(state.status)) + .filter(([, state]) => !state.parentId && isWorkingSessionStatus(state.status)) .map(([sessionId, state]) => { const discovered = discoveryById.get(sessionId) return { @@ -120,7 +120,7 @@ function buildRecentSection( ): MenuItemConstructorOptions[] { const runningSessionIds = new Set( Array.from(liveSessions.entries()) - .filter(([, state]) => !state.parentID && isWorkingSessionStatus(state.status)) + .filter(([, state]) => !state.parentId && isWorkingSessionStatus(state.status)) .map(([sessionId]) => sessionId), ) const recentSessions = discoverySessions @@ -181,7 +181,7 @@ function normalizeDiscoverySessions(discovery: DiscoveryCache | null): TraySessi title: titleForSession(session.title), directory: String(session.directory ?? ""), updatedAt: Number(session.time?.updated ?? session.time?.created ?? 0), - parentId: session.parentID ? String(session.parentID) : undefined, + parentId: session.parentId ? String(session.parentId) : undefined, })) } diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts index 59dc5810..6281d7e0 100644 --- a/apps/desktop/src/main/tray.test.ts +++ b/apps/desktop/src/main/tray.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events" import path from "node:path" import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" @@ -7,6 +8,9 @@ const templateImageFlags: boolean[] = [] const trayInstances: FakeElectronTray[] = [] const serverReadyListeners: Array<() => void> = [] const sessionListParams: unknown[] = [] +const nativeEventEmitter = new EventEmitter() +let nativeTransportSubscribeCount = 0 +let nativeTransportUnsubscribeCount = 0 let serverUrl: string | null = null let discoveredSessions: unknown[] = [] @@ -24,12 +28,22 @@ const fakeNativeTransport = { if (method === "session/list") { sessionListParams.push(params) if (sessionListGate) await sessionListGate - return { sessions: discoveredSessions } + return { data: discoveredSessions, nextCursor: null } } throw new Error(`unexpected request ${method}`) }, respond: async () => {}, - subscribe: () => () => {}, + subscribe: (listener: (...args: unknown[]) => void) => { + nativeTransportSubscribeCount += 1 + nativeEventEmitter.on("native-event", listener) + let subscribed = true + return () => { + if (!subscribed) return + subscribed = false + nativeEventEmitter.off("native-event", listener) + nativeTransportUnsubscribeCount += 1 + } + }, connected: () => true, } @@ -50,7 +64,7 @@ class FakeNativeImage { } } -class FakeElectronTray { +class FakeElectronTray extends EventEmitter { readonly icon: FakeNativeImage tooltip = "" title = "" @@ -58,6 +72,7 @@ class FakeElectronTray { destroyed = false constructor(icon: FakeNativeImage) { + super() this.icon = icon trayInstances.push(this) } @@ -137,6 +152,9 @@ beforeEach(() => { trayInstances.length = 0 serverReadyListeners.length = 0 sessionListParams.length = 0 + nativeEventEmitter.removeAllListeners("native-event") + nativeTransportSubscribeCount = 0 + nativeTransportUnsubscribeCount = 0 serverUrl = null discoveredSessions = [] sessionListGate = null @@ -229,10 +247,38 @@ describe("createTray", () => { const { createTray } = await import("./tray") discoveredSessions = [ { - sessionId: "ready-session", + id: "ready-session", + version: 1, title: "Ready from server", + titleState: "unset", cwd: "/repo", - updatedAt: "1970-01-01T00:00:02.000Z", + ephemeral: false, + createdAt: "1970-01-01T00:00:01.000Z", + lastActivityAt: "1970-01-01T00:00:02.000Z", + status: "idle", + flags: [], + archived: false, + activity: "idle", + queuedCount: 0, + model: { provider: "", model: "" }, + settings: { permissionProfile: "default" }, + preview: "", + usage: { + total: { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "1970-01-01T00:00:02.000Z", + }, }, ] @@ -247,6 +293,66 @@ describe("createTray", () => { ).toContain("Ready from server") }) + test("disposes each transient discovery client after its refresh", async () => { + serverUrl = "stdio://local" + const { createTray } = await import("./tray") + + createTray(() => undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(nativeTransportSubscribeCount).toBe(1) + expect(nativeTransportUnsubscribeCount).toBe(1) + + serverReadyListeners[0]?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(nativeTransportSubscribeCount).toBe(2) + expect(nativeTransportUnsubscribeCount).toBe(2) + }) + + test("does not accumulate Native listeners across scheduled discovery refreshes", async () => { + serverUrl = "stdio://local" + const { createTray, destroyTray } = await import("./tray") + const originalSetInterval = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + const intervalCallbacks: Array[0]> = [] + globalThis.setInterval = ((callback: Parameters[0]) => { + intervalCallbacks.push(callback) + return 1 as unknown as ReturnType + }) as typeof setInterval + globalThis.clearInterval = (() => {}) as typeof clearInterval + const warnings: Error[] = [] + const onWarning = (warning: Error) => { + if (warning.name === "MaxListenersExceededWarning") warnings.push(warning) + } + process.on("warning", onWarning) + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + + try { + expect(nativeEventEmitter.getMaxListeners()).toBe(10) + createTray(() => undefined) + await tick() + const refreshInterval = intervalCallbacks[0] + expect(typeof refreshInterval).toBe("function") + if (typeof refreshInterval !== "function") throw new Error("Tray refresh interval was not registered") + + for (let refresh = 0; refresh < 12; refresh++) { + refreshInterval() + await tick() + expect(nativeEventEmitter.listenerCount("native-event")).toBe(0) + } + await tick() + + expect(nativeTransportSubscribeCount).toBe(13) + expect(nativeTransportUnsubscribeCount).toBe(13) + expect(nativeEventEmitter.listenerCount("native-event")).toBe(0) + expect(warnings).toEqual([]) + } finally { + destroyTray() + process.off("warning", onWarning) + globalThis.setInterval = originalSetInterval + globalThis.clearInterval = originalClearInterval + } + }) + test("does not overlap server-ready tray discovery refreshes", async () => { const { createTray } = await import("./tray") let releaseSessionList: () => void = () => {} diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts index 18687ba0..439250aa 100644 --- a/apps/desktop/src/main/tray.ts +++ b/apps/desktop/src/main/tray.ts @@ -212,7 +212,7 @@ function updateTrayTitle( if (IS_MAC) { // Show counts next to the tray icon const busyCount = Array.from(liveSessions.values()).filter( - (s) => !s.parentID && (s.status === "busy" || s.status === "retry"), + (s) => !s.parentId && (s.status === "busy" || s.status === "retry"), ).length let title = "" @@ -226,9 +226,9 @@ function updateTrayTitle( } // Update tooltip with summary - const totalSessions = Array.from(liveSessions.values()).filter((s) => !s.parentID).length + const totalSessions = Array.from(liveSessions.values()).filter((s) => !s.parentId).length const busyCount = Array.from(liveSessions.values()).filter( - (s) => !s.parentID && (s.status === "busy" || s.status === "retry"), + (s) => !s.parentId && (s.status === "busy" || s.status === "retry"), ).length let tooltip = "Devo" @@ -260,8 +260,9 @@ async function refreshDiscovery(): Promise { } async function refreshDiscoveryForServer(): Promise { + let client: ReturnType | null = null try { - const client = createDevoClient({ transport: getNativeTransport() }) + client = createDevoClient({ transport: getNativeTransport() }) const [projectsResult, sessionsResult] = await Promise.all([ client.project.list(), client.session.list({ roots: true }), @@ -276,6 +277,8 @@ async function refreshDiscoveryForServer(): Promise { rebuildMenu() } catch (err) { log.warn("Failed to refresh discovery data for tray", err) + } finally { + client?.dispose() } } diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts new file mode 100644 index 00000000..6e04a5ab --- /dev/null +++ b/apps/desktop/src/main/updater.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, mock, test } from "bun:test" + +// The updater's Electron import must not start a real app in this test. +mock.module("electron", () => ({ + app: { isPackaged: false, getPath: () => "/Applications/Devo.app/Contents/MacOS/Devo" }, + BrowserWindow: { getAllWindows: () => [] }, + shell: { openExternal: async () => {} }, +})) + +const { detectCanAutoInstall } = await import("./updater") + +const executablePath = "/Applications/Devo app/Contents/MacOS/Devo" + +describe("macOS updater signing detection", () => { + test("allows a packaged macOS app when codesign verifies its executable", () => { + const runCodesign = mock(() => "") + expect( + detectCanAutoInstall({ + platform: "darwin", + packaged: true, + getExecutablePath: () => executablePath, + runCodesign, + }), + ).toBe(true) + expect(runCodesign).toHaveBeenCalledTimes(1) + expect(runCodesign).toHaveBeenCalledWith( + "codesign", + ["--verify", "--deep", "--strict", executablePath], + { stdio: "pipe" }, + ) + }) + + test("rejects an unsigned packaged macOS app (or unavailable codesign)", () => { + const runCodesign = mock(() => { + throw new Error("signature verification failed") + }) + expect(detectCanAutoInstall({ platform: "darwin", packaged: true, runCodesign })).toBe( + false, + ) + expect(runCodesign).toHaveBeenCalledTimes(1) + }) + + test("fails closed if the packaged macOS executable cannot be located", () => { + const runCodesign = mock(() => "") + expect( + detectCanAutoInstall({ + platform: "darwin", + packaged: true, + getExecutablePath: () => { + throw new Error("no executable") + }, + runCodesign, + }), + ).toBe(false) + expect(runCodesign).not.toHaveBeenCalled() + }) + + test("does not invoke codesign outside packaged macOS", () => { + for (const [platform, packaged] of [ + ["linux", true], + ["win32", true], + ["darwin", false], + ] as const) { + const getExecutablePath = mock(() => executablePath) + const runCodesign = mock(() => "") + expect( + detectCanAutoInstall({ platform, packaged, getExecutablePath, runCodesign }), + ).toBe(true) + expect(getExecutablePath).not.toHaveBeenCalled() + expect(runCodesign).not.toHaveBeenCalled() + } + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index b0142015..5197974a 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process" import { app, BrowserWindow, shell } from "electron" import type { AppUpdater, UpdateInfo } from "electron-updater" @@ -34,24 +35,31 @@ async function getAutoUpdater(): Promise { // ============================================================ /** - * Detect whether the running macOS .app bundle is properly code-signed - * (i.e. signed with a real Apple Developer ID, not ad-hoc or unsigned). + * Detect whether codesign verifies the running packaged macOS executable. * Returns true on non-macOS platforms since signing isn't required there. */ -function detectCanAutoInstall(): boolean { - if (process.platform !== "darwin") return true - if (!app.isPackaged) return true +export function detectCanAutoInstall({ + platform = process.platform, + packaged = app.isPackaged, + getExecutablePath = () => app.getPath("exe"), + runCodesign = execFileSync, +}: { + platform?: NodeJS.Platform + packaged?: boolean + getExecutablePath?: () => string + runCodesign?: (file: string, args: string[], options: { stdio: "pipe" }) => unknown +} = {}): boolean { + if (platform !== "darwin" || !packaged) return true try { - const { execSync } = require("node:child_process") - // codesign --verify exits 0 if valid signature, non-zero otherwise - execSync(`codesign --verify --deep --strict "${app.getPath("exe")}"`, { - encoding: "utf8", + // An ESM-safe import is required in the packaged main process. Pass the + // executable path as an argument rather than interpreting it in a shell. + runCodesign("codesign", ["--verify", "--deep", "--strict", getExecutablePath()], { stdio: "pipe", }) return true } catch { - // Unsigned or ad-hoc signed — Squirrel.Mac will reject the install + // Verification failure (including missing codesign) blocks Squirrel install. return false } } diff --git a/apps/desktop/src/preload/api.d.ts b/apps/desktop/src/preload/api.d.ts index 09895c2f..222926f7 100644 --- a/apps/desktop/src/preload/api.d.ts +++ b/apps/desktop/src/preload/api.d.ts @@ -17,6 +17,13 @@ export interface NativeTrafficLogState { path: string | null } +export interface ProviderOAuthUpdate { + url?: string + instructions: string + userCode?: string + phase?: "saving" +} + export interface ModelRef { providerID: string modelID: string @@ -476,6 +483,12 @@ export interface DevoAPI { nativeTraffic: { getState: () => Promise } + providerOAuth: { + login: (providerId: string, enterpriseUrl?: string) => Promise + /** False once the non-cancellable credential save has started. */ + cancel: () => Promise + onUpdate: (callback: (update: ProviderOAuthUpdate) => void) => () => void + } terminal: { create: (options: { cwd?: string; cols?: number; rows?: number }) => Promise write: (id: string, data: string) => void @@ -515,6 +528,42 @@ export interface DevoAPI { stashPop: (directory: string) => Promise getRoot: (directory: string) => Promise diffStat: (directory: string) => Promise + workspaceChangesSummary: ( + directory: string, + scope: "uncommitted" | "staged" | "unstaged" | "branch", + options?: { baseBranch?: string | null; ignoreWhitespace?: boolean }, + ) => Promise<{ + scope: string + status: string + workspace_root: string + coverage: string + attribution: string + change_set_status: string + files: Array<{ + path: string + status: string + additions: number | null + deletions: number | null + binary: boolean + diff_truncated: boolean + }> + stats: { files_changed: number; additions: number; deletions: number } + unified_diff: string | null + warnings: string[] + generated_at: string + }> + workspaceFilePatch: ( + directory: string, + scope: "uncommitted" | "staged" | "unstaged" | "branch" | "turn", + filePath: string, + options?: { + baseBranch?: string | null + ignoreWhitespace?: boolean + fileStatus?: string | null + checkpointId?: string | null + mergeBase?: string | null + }, + ) => Promise commitAll: (directory: string, message: string) => Promise push: (directory: string, remote?: string) => Promise createBranch: (directory: string, branchName: string) => Promise diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 4afff119..e1ec50ff 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -85,6 +85,24 @@ contextBridge.exposeInMainWorld("devo", { getState: () => ipcRenderer.invoke("native-traffic-log:state"), }, + providerOAuth: { + login: (providerId: string, enterpriseUrl?: string) => + ipcRenderer.invoke("provider-oauth:login", { providerId, enterpriseUrl }), + cancel: () => ipcRenderer.invoke("provider-oauth:cancel"), + onUpdate: ( + callback: (update: { url?: string; instructions: string; userCode?: string; phase?: "saving" }) => void, + ) => { + const listener = ( + _event: unknown, + update: { url?: string; instructions: string; userCode?: string; phase?: "saving" }, + ) => callback(update) + ipcRenderer.on("provider-oauth:update", listener) + return () => { + ipcRenderer.removeListener("provider-oauth:update", listener) + } + }, + }, + terminal: { create: (options: { cwd?: string; cols?: number; rows?: number }) => ipcRenderer.invoke("terminal:create", options), @@ -167,6 +185,23 @@ contextBridge.exposeInMainWorld("devo", { stashPop: (directory: string) => ipcRenderer.invoke("git:stash-pop", directory), getRoot: (directory: string) => ipcRenderer.invoke("git:root", directory), diffStat: (directory: string) => ipcRenderer.invoke("git:diff-stat", directory), + workspaceChangesSummary: ( + directory: string, + scope: "uncommitted" | "staged" | "unstaged" | "branch", + options?: { baseBranch?: string | null; ignoreWhitespace?: boolean }, + ) => ipcRenderer.invoke("git:workspace-changes-summary", directory, scope, options), + workspaceFilePatch: ( + directory: string, + scope: "uncommitted" | "staged" | "unstaged" | "branch" | "turn", + filePath: string, + options?: { + baseBranch?: string | null + ignoreWhitespace?: boolean + fileStatus?: string | null + checkpointId?: string | null + mergeBase?: string | null + }, + ) => ipcRenderer.invoke("git:workspace-file-patch", directory, scope, filePath, options), commitAll: (directory: string, message: string) => ipcRenderer.invoke("git:commit-all", directory, message), push: (directory: string, remote?: string) => ipcRenderer.invoke("git:push", directory, remote), diff --git a/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg b/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg new file mode 100644 index 00000000..56c20781 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/alibaba.svg @@ -0,0 +1 @@ +AlibabaCloud \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg b/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/kimi.svg b/apps/desktop/src/renderer/assets/provider-logos/kimi.svg new file mode 100644 index 00000000..83878fa2 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/minimax.svg b/apps/desktop/src/renderer/assets/provider-logos/minimax.svg new file mode 100644 index 00000000..beb7adb8 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg b/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg new file mode 100644 index 00000000..fb56ac10 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/moonshot.svg @@ -0,0 +1 @@ +MoonshotAI \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg b/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg new file mode 100644 index 00000000..3cdf7c86 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/moonshotai.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/src/renderer/assets/provider-logos/ollama.svg b/apps/desktop/src/renderer/assets/provider-logos/ollama.svg new file mode 100644 index 00000000..cc887e3d --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/openai.svg b/apps/desktop/src/renderer/assets/provider-logos/openai.svg new file mode 100644 index 00000000..78caf4fa --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/poolside.svg b/apps/desktop/src/renderer/assets/provider-logos/poolside.svg new file mode 100644 index 00000000..7969f594 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/poolside.svg @@ -0,0 +1 @@ +Poolside \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/qwen.svg b/apps/desktop/src/renderer/assets/provider-logos/qwen.svg new file mode 100644 index 00000000..f2d0ada1 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/tencent.svg b/apps/desktop/src/renderer/assets/provider-logos/tencent.svg new file mode 100644 index 00000000..42edd6cd --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/tencent.svg @@ -0,0 +1 @@ +Hunyuan \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg b/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg new file mode 100644 index 00000000..eb97d8e1 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/xiaomi.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/zai.svg b/apps/desktop/src/renderer/assets/provider-logos/zai.svg new file mode 100644 index 00000000..b03d6018 --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/zai.svg @@ -0,0 +1 @@ +ChatGLM \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg b/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg new file mode 100644 index 00000000..0c6e61ce --- /dev/null +++ b/apps/desktop/src/renderer/assets/provider-logos/zhipu.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index 1b6a472e..2749d776 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -1,11 +1,12 @@ +import { toast } from "sonner" import { createLogger } from "../../lib/logger" import { queryClient } from "../../lib/query-client" import type { Event } from "../../lib/types" +import type { ContextOccupancy } from "../../lib/context-occupancy" import { compactionStatusFamily } from "../compaction" import { serverConnectedAtom } from "../connection" import { discoveryAtom } from "../discovery" -import { removeMessageAtom, upsertMessageAtom } from "../messages" -import { applyPartDeltaAtom, removePartAtom, upsertPartAtom } from "../parts" +import { removeItemAtom, upsertItemAtom } from "../messages" import { addPermissionAtom, addQuestionAtom, @@ -20,7 +21,7 @@ import { import { setSessionActiveTurnAtom, setSessionQueueAtom } from "../queue" import { sessionNativeFamily } from "../session-native" import { appStore } from "../store" -import { isStreamingField, streamingVersionFamily } from "../streaming" +import { streamingVersionFamily } from "../streaming" import { todosFamily } from "../todos" import { setSessionDiffAtom } from "../ui" import { applyWorkspaceChangesUpdatedAtom } from "../workspace-changes" @@ -106,8 +107,8 @@ export function processEvent(event: Event): void { case "turn.provider_retry_status": { const properties = event.properties - const sessionId = properties.sessionID ?? properties.session_id - const turnId = properties.turnID ?? properties.turn_id + const sessionId = properties.sessionId + const turnId = properties.turnId if (sessionId && turnId) { const phase = String(properties.phase ?? "") set(setProviderRetryStatusAtom, { @@ -118,7 +119,7 @@ export function processEvent(event: Event): void { : { turnId, attempt: Number(properties.attempt ?? 0), - backoffMs: Number(properties.backoffMs ?? properties.backoff_ms ?? 0), + backoffMs: Number(properties.backoffMs ?? 0), provider: String(properties.provider ?? ""), model: String(properties.model ?? ""), phase, @@ -131,13 +132,13 @@ export function processEvent(event: Event): void { case "session.status": set(setSessionStatusAtom, { - sessionId: event.properties.sessionID, + sessionId: event.properties.sessionId, status: event.properties.status, }) // Clear error when session starts working again if (event.properties.status.type !== "idle") { set(setSessionErrorAtom, { - sessionId: event.properties.sessionID, + sessionId: event.properties.sessionId, error: undefined, }) } @@ -145,23 +146,24 @@ export function processEvent(event: Event): void { case "session.activeTurn": set(setSessionActiveTurnAtom, { - sessionId: event.properties.sessionID, - turnId: event.properties.turnID ?? null, + sessionId: event.properties.sessionId, + turnId: event.properties.turnId ?? null, }) break case "session.queue.updated": set(setSessionQueueAtom, { - sessionId: event.properties.sessionID, + sessionId: event.properties.sessionId, entries: event.properties.entries ?? [], }) break case "session.error": { - const { sessionID, error } = event.properties - if (sessionID && error) { + const { sessionId, turnId, error } = event.properties + if (sessionId && error) { set(setSessionErrorAtom, { - sessionId: sessionID, + sessionId, + turnId, error: { name: error.name, data: error.data }, }) } @@ -170,118 +172,90 @@ export function processEvent(event: Event): void { case "session.compaction.started": case "session/compaction/started": { - const sessionID = event.properties.sessionID ?? event.properties.session_id - if (sessionID) { - set(compactionStatusFamily(sessionID), "started") + const sessionId = event.properties.sessionId + if (sessionId) { + set(compactionStatusFamily(sessionId), "started") } break } case "session.compaction.completed": case "session/compaction/completed": { - const sessionID = event.properties.sessionID ?? event.properties.session_id - if (sessionID) { - set(compactionStatusFamily(sessionID), "completed") + const sessionId = event.properties.sessionId + if (sessionId) { + // Transcript markers carry the durable "completed" row; clear the + // live atom so a later compaction can show "started" again. + set(compactionStatusFamily(sessionId), null) } break } case "session.compaction.failed": case "session/compaction/failed": { - const sessionID = event.properties.sessionID ?? event.properties.session_id - if (sessionID) { - set(compactionStatusFamily(sessionID), null) + const sessionId = event.properties.sessionId + if (sessionId) { + set(compactionStatusFamily(sessionId), null) } break } case "permission.asked": set(addPermissionAtom, { - sessionId: event.properties.sessionID, + sessionId: event.properties.sessionId, permission: event.properties, }) break case "permission.replied": set(removePermissionAtom, { - sessionId: event.properties.sessionID, - permissionId: event.properties.requestID, + sessionId: event.properties.sessionId, + permissionId: event.properties.requestId, }) break case "question.asked": set(addQuestionAtom, { - sessionId: event.properties.sessionID, + sessionId: event.properties.sessionId, question: event.properties, }) break case "question.replied": set(removeQuestionAtom, { - sessionId: event.properties.sessionID, - requestId: event.properties.requestID, + sessionId: event.properties.sessionId, + requestId: event.properties.requestId, }) break case "question.rejected": set(removeQuestionAtom, { - sessionId: event.properties.sessionID, - requestId: event.properties.requestID, + sessionId: event.properties.sessionId, + requestId: event.properties.requestId, }) break - case "message.updated": - set(upsertMessageAtom, event.properties.info) + case "item.updated": + set(upsertItemAtom, event.properties.info) + set(streamingVersionFamily(event.properties.info.sessionId), (v) => v + 1) break - case "message.removed": - set(removeMessageAtom, { - sessionId: event.properties.sessionID, - messageId: event.properties.messageID, + case "item.removed": + set(removeItemAtom, { + sessionId: event.properties.sessionId, + itemId: event.properties.itemId, }) + set(streamingVersionFamily(event.properties.sessionId), (v) => v + 1) break - case "message.part.updated": { - const part = event.properties.part - set(upsertPartAtom, part) - // useSessionChat reads partsFamily imperatively through appStore.get, - // so every visible part update must bump the per-session version. - // Streaming text/reasoning parts may also be present in the streaming - // overlay, but the main-store write still needs to invalidate renders - // when it is the event that reaches the UI after a tool call. - set(streamingVersionFamily(part.sessionID), (v) => v + 1) - break - } - - case "message.part.delta": { - const { messageID, partID, field, delta, sessionID } = event.properties - set(applyPartDeltaAtom, { sessionId: sessionID, messageId: messageID, partId: partID, field, delta }) - // Non-streaming field deltas (e.g. tool input) bypass the streaming - // buffer and land directly in partsFamily. Bump the version so the - // UI re-renders to show the updated content. - if (!isStreamingField(field)) { - set(streamingVersionFamily(sessionID), (v) => v + 1) - } - break - } - - case "message.part.removed": { - const { messageID, partID, sessionID } = event.properties - set(removePartAtom, { sessionId: sessionID, messageId: messageID, partId: partID }) - // Part removal changes the visible part list, so notify the session. - set(streamingVersionFamily(sessionID), (v) => v + 1) - break - } - case "todo.updated": - set(todosFamily(event.properties.sessionID), event.properties.todos) + set(todosFamily(event.properties.sessionId), event.properties.todos) break case "session.commands.updated": { - const sessionID = event.properties.sessionID - if (!sessionID) break - const current = appStore.get(sessionNativeFamily(sessionID)) - set(sessionNativeFamily(sessionID), { + const sessionId = event.properties.sessionId + if (!sessionId) break + const current = appStore.get(sessionNativeFamily(sessionId)) + set(sessionNativeFamily(sessionId), { ...current, commands: event.properties.commands ?? [], }) @@ -289,10 +263,10 @@ export function processEvent(event: Event): void { } case "session.config.updated": { - const sessionID = event.properties.sessionID - if (!sessionID) break - const current = appStore.get(sessionNativeFamily(sessionID)) - set(sessionNativeFamily(sessionID), { + const sessionId = event.properties.sessionId + if (!sessionId) break + const current = appStore.get(sessionNativeFamily(sessionId)) + set(sessionNativeFamily(sessionId), { ...current, configOptions: event.properties.configOptions ?? [], }) @@ -300,10 +274,10 @@ export function processEvent(event: Event): void { } case "session.mode.updated": { - const sessionID = event.properties.sessionID - if (!sessionID) break - const current = appStore.get(sessionNativeFamily(sessionID)) - set(sessionNativeFamily(sessionID), { + const sessionId = event.properties.sessionId + if (!sessionId) break + const current = appStore.get(sessionNativeFamily(sessionId)) + set(sessionNativeFamily(sessionId), { ...current, modeID: event.properties.modeID, }) @@ -311,14 +285,27 @@ export function processEvent(event: Event): void { } case "session.usage.updated": { - const sessionID = event.properties.sessionID - if (!sessionID) break - const current = appStore.get(sessionNativeFamily(sessionID)) - set(sessionNativeFamily(sessionID), { + const sessionId = event.properties.sessionId + if (!sessionId) break + const current = appStore.get(sessionNativeFamily(sessionId)) + const nextUsed = Number(event.properties.used ?? 0) + const nextSize = Number(event.properties.size ?? 0) + const previousSize = Number(current.usage?.size ?? 0) + const occupancyWindow = Number(current.occupancy?.contextWindowTokens ?? 0) + // Server size is already the model effective window. Keep the + // denominator in sync with live turn updates (including increases + // after the user raises usable context). + const stableSize = nextSize > 0 ? nextSize : occupancyWindow > 0 ? occupancyWindow : previousSize + const nextOccupancy = + current.occupancy && stableSize > 0 && current.occupancy.contextWindowTokens !== stableSize + ? { ...current.occupancy, contextWindowTokens: stableSize } + : current.occupancy + set(sessionNativeFamily(sessionId), { ...current, + occupancy: nextOccupancy, usage: { - used: event.properties.used, - size: event.properties.size, + used: nextUsed, + size: stableSize, cost: event.properties.cost, }, }) @@ -326,23 +313,38 @@ export function processEvent(event: Event): void { } case "context.usage.updated": { - const sessionID = event.properties.sessionID - if (!sessionID) break - const current = appStore.get(sessionNativeFamily(sessionID)) - set(sessionNativeFamily(sessionID), { + const sessionId = event.properties.sessionId + if (!sessionId) break + // NativeClient normalizes protocol occupancy to this numeric renderer shape. + const occupancy = event.properties.occupancy as ContextOccupancy | undefined + const current = appStore.get(sessionNativeFamily(sessionId)) + const occupancyTotal = Number(occupancy?.totalTokens ?? 0) + const occupancyWindow = Number(occupancy?.contextWindowTokens ?? 0) + const previousUsed = Number(current.usage?.used ?? 0) + const previousOccupancyWindow = Number(current.occupancy?.contextWindowTokens ?? 0) + // Trust the server window (model effective). Shrinks and increases + // both apply immediately so the Context usage popover denominator + // stays current. + const nextWindow = occupancyWindow > 0 ? occupancyWindow : previousOccupancyWindow + set(sessionNativeFamily(sessionId), { ...current, - occupancy: event.properties.occupancy, + occupancy: occupancy, + usage: { + used: occupancyTotal > 0 ? occupancyTotal : previousUsed, + size: nextWindow > 0 ? nextWindow : Number(current.usage?.size ?? 0), + cost: current.usage?.cost, + }, }) break } case "session.diff": { - const { sessionID, diff } = event.properties as { - sessionID: string + const { sessionId, diff } = event.properties as { + sessionId: string diff: import("../../lib/types").FileDiff[] } - if (sessionID && diff) { - set(setSessionDiffAtom, { sessionId: sessionID, diffs: diff }) + if (sessionId && diff) { + set(setSessionDiffAtom, { sessionId, diffs: diff }) } break } @@ -351,6 +353,23 @@ export function processEvent(event: Event): void { set(applyWorkspaceChangesUpdatedAtom, event.properties) break + case "provider.authStale": { + const providerId = String( + event.properties?.providerId ?? event.properties?.provider_id ?? "", + ).trim() + const reason = + typeof event.properties?.reason === "string" && event.properties.reason.trim().length > 0 + ? event.properties.reason.trim() + : undefined + log.warn("Provider auth stale", { providerId, reason }) + queryClient.invalidateQueries({ queryKey: ["providers"] }) + const label = providerId.length > 0 ? providerId : "provider" + toast.warning(`Sign in again for ${label}`, { + description: reason ?? "Credentials expired or refresh failed. Open Settings → Connections.", + }) + break + } + // --- Worktree lifecycle events (from Devo experimental API) --- case "worktree.ready": diff --git a/apps/desktop/src/renderer/atoms/derived/agents.ts b/apps/desktop/src/renderer/atoms/derived/agents.ts index 0104fe96..aa9648da 100644 --- a/apps/desktop/src/renderer/atoms/derived/agents.ts +++ b/apps/desktop/src/renderer/atoms/derived/agents.ts @@ -405,7 +405,7 @@ export const agentFamily = atomFamily((sessionId: string) => { activities: [], permissions, questions, - parentId: session.parentID, + parentId: session.parentId, forkFromId: session.forkFromId, atTurnId: session.atTurnId, worktreePath: entry.worktreePath, @@ -500,7 +500,7 @@ export const projectSessionIdsFamily = atomFamily((directory: string) => { if (!entry) continue // Hide sub-agent sessions in the sidebar (they still exist in the store // for message/part lookups and direct navigation) - if (entry.session.parentID) continue + if (entry.session.parentId) continue // Match the project directory itself, or any of its sandbox directories if ( !directoriesMatch(entry.directory, directory) && @@ -581,7 +581,7 @@ export const projectListAtom = (() => { for (const id of sessionIds) { const entry = get(sessionFamily(id)) if (!entry) continue - if (entry.session.parentID) continue + if (entry.session.parentId) continue if (!entry.directory) continue const parentDir = sandboxToParent.get(entry.directory) diff --git a/apps/desktop/src/renderer/atoms/derived/session-chat-display.test.ts b/apps/desktop/src/renderer/atoms/derived/session-chat-display.test.ts new file mode 100644 index 00000000..ca431904 --- /dev/null +++ b/apps/desktop/src/renderer/atoms/derived/session-chat-display.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { itemDisplayText } from "./session-chat" + +const warning: NativeItemEnvelope = { + id: "warning-1", + sessionId: "session-1", + turnId: "turn-1", + seq: 1, + revision: 1, + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + state: "completed", + item: { + type: "warning", + code: "rlmKernelUnfenced", + message: "The sandbox is unavailable.", + retryable: false, + }, +} + +describe("Native warning text", () => { + test("shows the actual runtime message instead of the item discriminator", () => { + expect(itemDisplayText(warning)).toBe("The sandbox is unavailable.") + }) + test("uses readable fallback text for an empty message", () => { + expect(itemDisplayText({ ...warning, item: { ...warning.item, message: " " } })).toBe( + "Runtime warning.", + ) + }) +}) diff --git a/apps/desktop/src/renderer/atoms/derived/session-chat.ts b/apps/desktop/src/renderer/atoms/derived/session-chat.ts index 249ed557..7aa23ee0 100644 --- a/apps/desktop/src/renderer/atoms/derived/session-chat.ts +++ b/apps/desktop/src/renderer/atoms/derived/session-chat.ts @@ -1,113 +1,43 @@ -import type { Message, Part } from "../../lib/types" -import { getStreamingPartsForSession } from "../streaming" - -const DEVO_ITEM_KIND_META = "devo/itemKind" -const DEVO_RESEARCH_ARTIFACT_TITLE_META = "devo/researchArtifactTitle" - -// ============================================================ -// Types — wrappers around SDK Message + Part -// ============================================================ - -/** A message with its associated parts */ +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { + assistantOrReasoningText, + isUserMessageItem, + nativeItemType, + userMessageText, +} from "@devo-ai/sdk/v2/client" +import type { SessionItem } from "../messages" + +/** A transcript row — Native ItemEnvelope directly (no Part dual). */ export interface ChatMessageEntry { - info: Message - parts: Part[] + info: SessionItem } /** - * A "turn" groups a user message with its assistant responses. + * A "turn" groups a userMessage with following items that share turnId + * (or sequential assistants after the user when turnId is absent). */ export interface ChatTurn { id: string - /** Protocol turn id when available (`Message.turnID` / `devo/turnId`). */ turnId?: string userMessage: ChatMessageEntry assistantMessages: ChatMessageEntry[] } -function messageProtocolTurnId(entry: ChatMessageEntry): string | undefined { - const info = entry.info as { - turnID?: unknown - turnId?: unknown - metadata?: Record - } - const direct = info.turnID ?? info.turnId - if (typeof direct === "string" && direct.length > 0) return direct - const metaTurnId = info.metadata?.["devo/turnId"] - return typeof metaTurnId === "string" && metaTurnId.length > 0 ? metaTurnId : undefined -} - -function protocolTurnIdForTurn( - userMessage: ChatMessageEntry, - assistantMessages: ChatMessageEntry[], -): string | undefined { - return ( - messageProtocolTurnId(userMessage) ?? - assistantMessages.map(messageProtocolTurnId).find((turnId): turnId is string => Boolean(turnId)) - ) -} - -// ============================================================ -// Turn grouping with structural sharing -// ============================================================ - -function toolOutputFingerprint(part: { - state: { - status: string - output?: string - error?: string - metadata?: { output?: unknown } - raw?: unknown - } -}): number { - if (part.state.status === "completed") return part.state.output.length - if (part.state.status === "error") return part.state.error.length - if (part.state.status === "running") { - const output = part.state.metadata?.output - return typeof output === "string" ? output.length : 0 - } - if (part.state.status === "pending") { - return typeof part.state.raw === "string" ? part.state.raw.length : 0 - } - return 0 -} - -function messageFingerprint(entry: ChatMessageEntry): string { - const lastPart = entry.parts.at(-1) - const completed = entry.info.role === "assistant" ? (entry.info.time.completed ?? 0) : 0 - let textLen = 0 - const toolSegments: string[] = [] - const textMetadataSegments: string[] = [] - for (const part of entry.parts) { - if (part.type === "text" || part.type === "reasoning") { - textLen += part.text.length - if (part.type === "text") { - const metadata = (part as { metadata?: Record }).metadata - if (metadata?.[DEVO_ITEM_KIND_META] === "research_artifact") { - textMetadataSegments.push( - `${part.id}:${metadata[DEVO_ITEM_KIND_META]}:${metadata[DEVO_RESEARCH_ARTIFACT_TITLE_META] ?? ""}`, - ) - } - if ( - metadata?.[DEVO_ITEM_KIND_META] === "context_compaction" || - metadata?.[DEVO_ITEM_KIND_META] === "proposed_plan" || - metadata?.[DEVO_ITEM_KIND_META] === "plan" - ) { - textMetadataSegments.push( - `${part.id}:${metadata[DEVO_ITEM_KIND_META]}:${metadata["devo/compactionStatus"] ?? ""}:${part.text.length}`, - ) - } - } - } else if (part.type === "tool") { - toolSegments.push(`${part.id}:${part.state.status}:${toolOutputFingerprint(part)}`) - } - } - return `${entry.info.id}:${completed}:${entry.parts.length}:${lastPart?.id ?? ""}:${textLen}:${textMetadataSegments.join(",")}:${toolSegments.join(",")}` +function itemFingerprint(entry: ChatMessageEntry): string { + const info = entry.info + const type = nativeItemType(info) + const textLen = + type === "userMessage" + ? userMessageText(info).length + : type === "assistantMessage" || type === "reasoning" + ? assistantOrReasoningText(info).length + : JSON.stringify(info.item).length + return `${info.id}:${info.revision}:${info.state}:${type}:${textLen}` } function turnFingerprint(turn: ChatTurn): string { - const assistantFps = turn.assistantMessages.map(messageFingerprint).join("|") - return `${turn.turnId ?? ""}:${messageFingerprint(turn.userMessage)}>${assistantFps}` + const assistantFps = turn.assistantMessages.map(itemFingerprint).join("|") + return `${turn.turnId ?? ""}:${itemFingerprint(turn.userMessage)}>${assistantFps}` } export function groupIntoTurns(entries: ChatMessageEntry[], prevTurns: ChatTurn[]): ChatTurn[] { @@ -119,76 +49,76 @@ export function groupIntoTurns(entries: ChatMessageEntry[], prevTurns: ChatTurn[ const turns: ChatTurn[] = [] let currentUser: ChatMessageEntry | null = null let currentAssistantMessages: ChatMessageEntry[] = [] + let currentTurnId: string | undefined const flushTurn = () => { if (!currentUser) return const newTurn: ChatTurn = { id: currentUser.info.id, - turnId: protocolTurnIdForTurn(currentUser, currentAssistantMessages), + turnId: currentTurnId || currentUser.info.turnId || undefined, userMessage: currentUser, assistantMessages: currentAssistantMessages, } - const fp = turnFingerprint(newTurn) - const prevTurn = prevMap.get(fp) - turns.push(prevTurn ?? newTurn) + turns.push(prevMap.get(fp) ?? newTurn) } for (const entry of entries) { - if (entry.info.role === "user") { + if (isUserMessageItem(entry.info)) { flushTurn() currentUser = entry currentAssistantMessages = [] + currentTurnId = entry.info.turnId || undefined continue } - if ( - entry.info.role === "assistant" && - currentUser && - (!entry.info.parentID || entry.info.parentID === currentUser.info.id) - ) { - currentAssistantMessages.push(entry) + if (!currentUser) continue + const entryTurn = entry.info.turnId || undefined + if (currentTurnId && entryTurn && entryTurn !== currentTurnId) { + flushTurn() + currentUser = null + currentAssistantMessages = [] + currentTurnId = undefined + continue } + currentAssistantMessages.push(entry) } flushTurn() return turns } -/** - * Merges streaming buffer overlays with base store parts for a given session. - * This is used from useSessionChat to build ChatMessageEntry[] with streaming data merged. - * - * Now scoped by sessionId so only that session's streaming buffer is read, - * and uses the per-session streaming version for dependency tracking. - */ -export function mergeSessionParts( - sessionId: string, - messages: Message[], - getParts: (messageId: string) => Part[], - _streamingVersion: number, -): ChatMessageEntry[] { - // _streamingVersion is passed to establish dependency tracking in the calling useMemo - const streaming = getStreamingPartsForSession(sessionId) - - return messages.map((msg) => { - const baseParts = getParts(msg.id) - - const overrides = streaming[msg.id] - if (!overrides) { - return { info: msg, parts: baseParts } - } - - // Overlay streaming parts on top of the base parts - const overlaid = baseParts.map((part) => overrides[part.id] ?? part) - - // Include streaming parts that don't exist in the base store yet - const baseIds = new Set(baseParts.map((p) => p.id)) - for (const partId in overrides) { - if (!baseIds.has(partId)) { - overlaid.push(overrides[partId]) - } - } +/** Build ChatMessageEntry[] from Native session items (envelopes carry live text). */ +export function mergeSessionItems(items: SessionItem[]): ChatMessageEntry[] { + return items.map((info) => ({ info })) +} - return { info: msg, parts: overlaid } - }) +export function itemDisplayText(envelope: NativeItemEnvelope): string { + const type = nativeItemType(envelope) + if (type === "userMessage") return userMessageText(envelope) + if (type === "assistantMessage" || type === "reasoning") return assistantOrReasoningText(envelope) + if (type === "commandExecution") { + return String(envelope.item.command ?? envelope.item.output ?? "") + } + if (type === "toolCall" || type === "hostedToolCall") { + return String(envelope.item.toolName ?? envelope.item.callId ?? type) + } + if (type === "contextCompaction") { + return String(envelope.item.summary ?? "Context compaction") + } + if (type === "warning") { + return typeof envelope.item.message === "string" && envelope.item.message.trim() + ? envelope.item.message + : "Runtime warning." + } + if (type === "plan") { + const entries = Array.isArray(envelope.item.entries) ? envelope.item.entries : [] + return entries + .map((e) => { + const row = e && typeof e === "object" ? (e as Record) : {} + return String(row.step ?? row.content ?? "") + }) + .filter(Boolean) + .join("\n") + } + return type } diff --git a/apps/desktop/src/renderer/atoms/derived/session-metrics.ts b/apps/desktop/src/renderer/atoms/derived/session-metrics.ts index 5c885b12..6662b200 100644 --- a/apps/desktop/src/renderer/atoms/derived/session-metrics.ts +++ b/apps/desktop/src/renderer/atoms/derived/session-metrics.ts @@ -1,206 +1,114 @@ /** - * Derived Jotai atom for per-session metrics (work time, cost, tokens, - * model distribution, cache efficiency, tool breakdown, errors, averages). - * - * Uses `atomFamily` keyed by session ID so each session computes its metrics - * independently. Only re-evaluates when the session's message list or parts change. + * Native-item session metrics (rough cut). + * Cost/token fields stay 0 until Native envelopes carry usage. */ import { atom } from "jotai" import { atomFamily } from "jotai-family" -import { getToolCategory } from "../../components/chat/tool-category" +import { isUserMessageItem, nativeItemType } from "@devo-ai/sdk/v2/client" import { - computeSessionMetricsExtended, formatCost, formatPercentage, formatTokens, formatWorkDuration, - type ModelDistribution, type SessionMetricsExtended, - shortModelName, - type ToolBreakdown, } from "../../lib/session-metrics" -import type { Part } from "../../lib/types" -import { messagesFamily } from "../messages" -import { partsFamily, partStorageKey } from "../parts" -import { appStore } from "../store" -import { viewedSessionIdAtom } from "../ui" - -// ============================================================ -// Types -// ============================================================ +import { itemsFamily } from "../messages" export interface SessionMetricsValue { - /** Raw metrics (for computation) */ raw: SessionMetricsExtended - /** Formatted work duration string, e.g. "1m 34s" */ workTime: string - /** Formatted cost string, e.g. "$0.45" */ cost: string - /** Formatted total token string, e.g. "12.3k" */ tokens: string - /** Raw work time in ms (for live-ticking in the sidebar) */ workTimeMs: number - /** Work time from completed messages only (for live-ticking base) */ completedWorkTimeMs: number - /** Start time (epoch ms) of in-progress assistant message, or null if idle */ activeStartMs: number | null - /** Raw cost number (for comparisons) */ costRaw: number - /** Raw total token count (for comparisons) */ tokensRaw: number - /** Number of exchanges (user message + all assistant responses) */ exchangeCount: number - /** Number of user messages */ userMessageCount: number - /** Number of assistant messages (LLM invocations) */ assistantMessageCount: number - /** Model distribution: short model name -> count */ - modelDistribution: ModelDistribution - /** Model distribution with short names for display */ + modelDistribution: Record modelDistributionDisplay: Array<{ name: string; count: number }> - /** Cache efficiency percentage (0-100) */ cacheEfficiency: number - /** Formatted cache efficiency, e.g. "42%" */ cacheEfficiencyFormatted: string - /** Number of errors */ errorCount: number - /** Number of retries */ retryCount: number - /** Total tool calls */ toolCallCount: number - /** Tool calls by category */ - toolBreakdown: ToolBreakdown - /** Average cost per exchange, formatted */ + toolBreakdown: Record avgExchangeCost: string - /** Average work time per exchange, formatted */ avgExchangeTime: string } -// ============================================================ -// Structural equality -// ============================================================ - -function metricsEqual(prev: SessionMetricsValue | null, next: SessionMetricsValue): boolean { - if (!prev) return false - return ( - prev.workTime === next.workTime && - prev.cost === next.cost && - prev.tokens === next.tokens && - prev.workTimeMs === next.workTimeMs && - prev.completedWorkTimeMs === next.completedWorkTimeMs && - prev.activeStartMs === next.activeStartMs && - prev.costRaw === next.costRaw && - prev.tokensRaw === next.tokensRaw && - prev.exchangeCount === next.exchangeCount && - prev.userMessageCount === next.userMessageCount && - prev.assistantMessageCount === next.assistantMessageCount && - prev.cacheEfficiency === next.cacheEfficiency && - prev.errorCount === next.errorCount && - prev.retryCount === next.retryCount && - prev.toolCallCount === next.toolCallCount && - prev.avgExchangeCost === next.avgExchangeCost && - prev.avgExchangeTime === next.avgExchangeTime && - modelDistEqual(prev.modelDistribution, next.modelDistribution) && - toolBreakdownEqual(prev.toolBreakdown, next.toolBreakdown) - ) -} - -function modelDistEqual(a: ModelDistribution, b: ModelDistribution): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (a[key] !== b[key]) return false +function emptyRaw(partial: Partial = {}): SessionMetricsExtended { + return { + workTimeMs: 0, + completedWorkTimeMs: 0, + activeStartMs: null, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + exchangeCount: 0, + userMessageCount: 0, + assistantMessageCount: 0, + modelDistribution: {}, + cacheEfficiency: 0, + errorCount: 0, + avgExchangeCost: 0, + avgExchangeTimeMs: 0, + toolBreakdown: {}, + toolCallCount: 0, + retryCount: 0, + ...partial, } - return true } -function toolBreakdownEqual(a: ToolBreakdown, b: ToolBreakdown): boolean { - const aKeys = Object.keys(a) as Array - const bKeys = Object.keys(b) as Array - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (a[key] !== b[key]) return false - } - return true -} - -// ============================================================ -// Per-session metrics atom family -// ============================================================ - -/** - * Derives session metrics (work time, cost, tokens, model distribution, - * cache efficiency, tool breakdown, errors, averages) for a given session ID. - * - * **Optimization**: For the currently viewed session, subscribes reactively to - * each message's `partsFamily` atom so the metrics panel updates in real time. - * For background sessions (not currently viewed), reads parts via `appStore.get()` - * without creating Jotai subscriptions. This means background metrics only - * recompute when the message list changes (new message added), not on every - * individual part update. The metrics will catch up when the user navigates - * to that session (which triggers the reactive path). - * - * Uses structural equality to prevent downstream re-renders when the - * formatted values haven't actually changed (e.g., streaming updates - * that don't push the duration past the next second boundary). - */ export const sessionMetricsFamily = atomFamily((sessionId: string) => { - let prev: SessionMetricsValue | null = null return atom((get): SessionMetricsValue => { - const messages = get(messagesFamily(sessionId)) - const viewedSessionId = get(viewedSessionIdAtom) - const isViewed = viewedSessionId === sessionId - - // Collect all parts across all messages for tool breakdown / retry counting. - // For the viewed session, use reactive `get()` to subscribe to part changes. - // For background sessions, use non-reactive `appStore.get()` to avoid - // creating subscriptions that would trigger recomputation on every part update. - const allParts: Part[] = [] - for (const msg of messages) { - const key = partStorageKey(sessionId, msg.id) - const parts = isViewed ? get(partsFamily(key)) : appStore.get(partsFamily(key)) - if (parts.length > 0) { - for (const p of parts) { - allParts.push(p) - } + const items = get(itemsFamily(sessionId)) + let userMessageCount = 0 + let assistantMessageCount = 0 + let toolCallCount = 0 + for (const item of items) { + const type = nativeItemType(item) + if (isUserMessageItem(item)) userMessageCount += 1 + else if (type === "assistantMessage") assistantMessageCount += 1 + else if ( + type === "toolCall" || + type === "commandExecution" || + type === "fileChange" || + type === "hostedToolCall" + ) { + toolCallCount += 1 } } - - const raw = computeSessionMetricsExtended(messages, allParts, getToolCategory) - - // Build display-friendly model distribution with short names - const modelDistributionDisplay = Object.entries(raw.modelDistribution) - .map(([modelID, count]) => ({ name: shortModelName(modelID), count })) - .sort((a, b) => b.count - a.count) - - const next: SessionMetricsValue = { + const raw = emptyRaw({ + userMessageCount, + assistantMessageCount, + exchangeCount: userMessageCount, + toolCallCount, + }) + return { raw, - workTime: formatWorkDuration(raw.workTimeMs), - cost: formatCost(raw.cost), - tokens: formatTokens(raw.tokens.total), - workTimeMs: raw.workTimeMs, - completedWorkTimeMs: raw.completedWorkTimeMs, - activeStartMs: raw.activeStartMs, - costRaw: raw.cost, - tokensRaw: raw.tokens.total, - exchangeCount: raw.exchangeCount, - userMessageCount: raw.userMessageCount, - assistantMessageCount: raw.assistantMessageCount, - modelDistribution: raw.modelDistribution, - modelDistributionDisplay, - cacheEfficiency: raw.cacheEfficiency, - cacheEfficiencyFormatted: formatPercentage(raw.cacheEfficiency), - errorCount: raw.errorCount, - retryCount: raw.retryCount, - toolCallCount: raw.toolCallCount, - toolBreakdown: raw.toolBreakdown, - avgExchangeCost: formatCost(raw.avgExchangeCost), - avgExchangeTime: formatWorkDuration(raw.avgExchangeTimeMs), + workTime: formatWorkDuration(0), + cost: formatCost(0), + tokens: formatTokens(0), + workTimeMs: 0, + completedWorkTimeMs: 0, + activeStartMs: null, + costRaw: 0, + tokensRaw: 0, + exchangeCount: userMessageCount, + userMessageCount, + assistantMessageCount, + modelDistribution: {}, + modelDistributionDisplay: [], + cacheEfficiency: 0, + cacheEfficiencyFormatted: formatPercentage(0), + errorCount: 0, + retryCount: 0, + toolCallCount, + toolBreakdown: {}, + avgExchangeCost: formatCost(0), + avgExchangeTime: formatWorkDuration(0), } - - if (metricsEqual(prev, next)) return prev! - prev = next - return next }) }) diff --git a/apps/desktop/src/renderer/atoms/derived/session-requests.ts b/apps/desktop/src/renderer/atoms/derived/session-requests.ts index 1f437b16..60bb9d77 100644 --- a/apps/desktop/src/renderer/atoms/derived/session-requests.ts +++ b/apps/desktop/src/renderer/atoms/derived/session-requests.ts @@ -6,7 +6,7 @@ * session's UI so the user can respond without navigating away. * * Architecture: - * 1. `childrenMapAtom` — shared map of parentID→childIDs[], recomputed only + * 1. `childrenMapAtom` — shared map of parentId→childIDs[], recomputed only * when the set of session IDs changes (not on every status update). * 2. `effectivePermissionFamily(sessionId)` — first pending permission in * the session's subtree (self + all descendants). @@ -26,20 +26,20 @@ import { buildChildrenMap, findTreeRequest } from "../../lib/session-tree" // ============================================================ /** - * A map from parentID → list of childIDs for all currently-known sessions. + * A map from parentId → list of childIDs for all currently-known sessions. * * This atom subscribes to `sessionIdsAtom` and iterates `sessionFamily` for - * each ID to read the `parentID` field. It recomputes whenever sessions are + * each ID to read the `parentId` field. It recomputes whenever sessions are * added or removed (i.e. when `sessionIdsAtom` changes), but NOT when a * session's permissions or status change — those don't affect the tree shape. */ export const childrenMapAtom = atom((get) => { const ids = get(sessionIdsAtom) - const sessions = new Map() + const sessions = new Map() for (const id of ids) { const entry = get(sessionFamily(id)) if (!entry) continue - sessions.set(id, { parentID: entry.session.parentID }) + sessions.set(id, { parentId: entry.session.parentId }) } return buildChildrenMap(sessions) }) diff --git a/apps/desktop/src/renderer/atoms/messages.test.ts b/apps/desktop/src/renderer/atoms/messages.test.ts index 26384138..18ff2387 100644 --- a/apps/desktop/src/renderer/atoms/messages.test.ts +++ b/apps/desktop/src/renderer/atoms/messages.test.ts @@ -1,287 +1,161 @@ import { describe, expect, test } from "bun:test" import { createStore } from "jotai" -import { groupIntoTurns, mergeSessionParts } from "./derived/session-chat" -import { messagesFamily, setMessagesAtom, upsertMessageAtom } from "./messages" -import { partsFamily, partStorageKey } from "./parts" +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { groupIntoTurns, mergeSessionItems } from "./derived/session-chat" +import { itemsFamily, setItemsAtom, upsertItemAtom } from "./messages" -describe("message ordering", () => { - test("keeps assistant replies after optimistic user messages by creation time", () => { +function userItem(id: string, turnId: string, seq: number, createdAt: string): NativeItemEnvelope { + return { + id, + sessionId: "s1", + turnId, + seq, + revision: 1, + createdAt, + updatedAt: createdAt, + state: "completed", + item: { type: "userMessage", content: [{ type: "text", text: "hi" }], entry: "turnStart" }, + } +} + +function assistantItem(id: string, turnId: string, seq: number, createdAt: string): NativeItemEnvelope { + return { + id, + sessionId: "s1", + turnId, + seq, + revision: 1, + createdAt, + updatedAt: createdAt, + state: "completed", + item: { type: "assistantMessage", text: "hello" }, + } +} + +describe("Native item ordering", () => { + for (const canonicalFirst of [true, false]) { + test(`reconciles user messages when canonical arrives ${canonicalFirst ? "first" : "last"}`, () => { + const store = createStore() + const canonical = userItem("u1", "t1", 1, "2026-01-01T00:00:01.000Z") + const optimistic = userItem("optimistic-1", "t1", Number.MAX_SAFE_INTEGER, "2026-01-01T00:00:02.000Z") + for (const item of canonicalFirst ? [canonical, optimistic] : [optimistic, canonical]) { + store.set(upsertItemAtom, item) + } + expect(store.get(itemsFamily("s1"))).toEqual([canonical]) + }) + } + + test("keeps identical prompts in distinct turns", () => { const store = createStore() - const user = { - id: "optimistic-2000", - sessionID: "s1", - role: "user", - time: { created: 2_000 }, - } - const assistant = { - id: "019ef97e-0b70-7b20-88ee-d124de3aacde", - sessionID: "s1", - role: "assistant", - parentID: user.id, - time: { created: 2_100 }, - } + const canonical = userItem("u1", "t1", 1, "2026-01-01T00:00:01.000Z") + const optimistic = userItem("optimistic-2", "t2", Number.MAX_SAFE_INTEGER, "2026-01-01T00:00:02.000Z") + store.set(upsertItemAtom, canonical) + store.set(upsertItemAtom, optimistic) + expect(store.get(itemsFamily("s1"))).toEqual([canonical, optimistic]) + }) - store.set(upsertMessageAtom, user) - store.set(upsertMessageAtom, assistant) + test("keeps assistant replies after optimistic user messages by seq", () => { + const store = createStore() + const user = userItem("optimistic-2000", "", 1, "2026-01-01T00:00:02.000Z") + const assistant = assistantItem("a1", "turn-1", 2, "2026-01-01T00:00:02.100Z") + + store.set(upsertItemAtom, user) + store.set(upsertItemAtom, assistant) - const messages = store.get(messagesFamily("s1")) - const entries = mergeSessionParts("s1", messages, () => [], 0) + const items = store.get(itemsFamily("s1")) + const entries = mergeSessionItems(items) - expect(messages.map((message) => message.id)).toEqual([user.id, assistant.id]) + expect(items.map((item) => item.id)).toEqual([user.id, assistant.id]) expect(groupIntoTurns(entries, [])).toEqual([ { id: user.id, turnId: undefined, - userMessage: { info: user, parts: [] }, - assistantMessages: [{ info: assistant, parts: [] }], + userMessage: { info: user }, + assistantMessages: [{ info: assistant }], }, ]) }) test("propagates protocol turn ids onto chat turns", () => { - const user = { - id: "u1", - sessionID: "s1", - role: "user", - turnID: "protocol-turn-1", - time: { created: 1 }, - } - const assistant = { - id: "a1", - sessionID: "s1", - role: "assistant", - parentID: "u1", - turnID: "protocol-turn-1", - time: { created: 2 }, - } - const turns = groupIntoTurns( - [ - { info: user, parts: [] }, - { info: assistant, parts: [] }, - ], - [], - ) + const user = userItem("u1", "protocol-turn-1", 1, "2026-01-01T00:00:01.000Z") + const assistant = assistantItem("a1", "protocol-turn-1", 2, "2026-01-01T00:00:02.000Z") + const turns = groupIntoTurns([{ info: user }, { info: assistant }], []) expect(turns).toEqual([ { id: "u1", turnId: "protocol-turn-1", - userMessage: { info: user, parts: [] }, - assistantMessages: [{ info: assistant, parts: [] }], + userMessage: { info: user }, + assistantMessages: [{ info: assistant }], }, ]) }) - test("keeps parts scoped when message IDs repeat across sessions", () => { + test("hydrates session items without a Part dual", () => { const store = createStore() - const messageId = "repeat-message" - const firstMessage = { - id: messageId, - sessionID: "s1", - role: "user", - time: { created: 1 }, - } - const secondMessage = { - id: messageId, - sessionID: "s2", - role: "user", - time: { created: 1 }, - } - const firstPart = { - id: "text", - sessionID: "s1", - messageID: messageId, - type: "text", - text: "first session", - time: { start: 1 }, - } - const secondPart = { - id: "text", - sessionID: "s2", - messageID: messageId, - type: "text", - text: "second session", - time: { start: 1 }, - } + const first = userItem("m1", "t1", 1, "2026-01-01T00:00:01.000Z") + store.set(setItemsAtom, { sessionId: "s1", items: [first] }) + expect(store.get(itemsFamily("s1"))).toEqual([first]) + }) - store.set(setMessagesAtom, { - sessionId: "s1", - messages: [firstMessage], - parts: { [messageId]: [firstPart] }, - }) - store.set(setMessagesAtom, { - sessionId: "s2", - messages: [secondMessage], - parts: { [messageId]: [secondPart] }, + for (const coldReplay of [false, true]) { + test(`keeps steering messages after completion (${coldReplay ? "cold replay" : "live events"})`, () => { + const store = createStore() + const user = userItem("u1", "t1", 1, "2026-01-01T00:00:01.000Z") + const before = assistantItem("a1", "t1", 2, "2026-01-01T00:00:02.000Z") + const steer = { + ...userItem("steer-1", "t1", 3, "2026-01-01T00:00:03.000Z"), + item: { + type: "userMessage", + entry: "steer", + content: [{ type: "text", text: "Keep this steering message visible." }], + }, + } + const after = assistantItem("a2", "t1", 4, "2026-01-01T00:00:04.000Z") + const items = [user, before, steer, after] + if (coldReplay) { + store.set(setItemsAtom, { sessionId: "s1", items: [...items].reverse() }) + } else { + for (const item of items) store.set(upsertItemAtom, item) + } + const turns = groupIntoTurns(mergeSessionItems(store.get(itemsFamily("s1"))), []) + expect(turns).toEqual([ + { id: user.id, turnId: "t1", userMessage: { info: user }, assistantMessages: [{ info: before }] }, + { id: steer.id, turnId: "t1", userMessage: { info: steer }, assistantMessages: [{ info: after }] }, + ]) + expect(groupIntoTurns(mergeSessionItems(store.get(itemsFamily("s1"))), turns)).toEqual(turns) }) + } - const firstEntries = mergeSessionParts( - "s1", - store.get(messagesFamily("s1")), - (id) => store.get(partsFamily(partStorageKey("s1", id))), - 0, - ) - const secondEntries = mergeSessionParts( - "s2", - store.get(messagesFamily("s2")), - (id) => store.get(partsFamily(partStorageKey("s2", id))), - 0, + test("keeps explicitly loaded earlier history during live updates", () => { + const store = createStore() + const history = Array.from({ length: 240 }, (_, index) => + assistantItem(`item-${index}`, `turn-${index}`, index + 1, "2026-01-01T00:00:00.000Z"), ) + store.set(setItemsAtom, { sessionId: "s1", items: history }) + store.set(upsertItemAtom, { ...history[239], revision: 2 }) + store.set(upsertItemAtom, assistantItem("new-item", "new-turn", 241, "2026-01-01T00:01:00.000Z")) - expect(firstEntries).toEqual([{ info: firstMessage, parts: [firstPart] }]) - expect(secondEntries).toEqual([{ info: secondMessage, parts: [secondPart] }]) - }) - - test("groups turns once and skips orphan or mismatched assistant messages", () => { - const orphanAssistant = { - id: "orphan", - sessionID: "s1", - role: "assistant", - time: { created: 1 }, - } - const firstUser = { - id: "u1", - sessionID: "s1", - role: "user", - time: { created: 2 }, - } - const firstAssistant = { - id: "a1", - sessionID: "s1", - role: "assistant", - parentID: "u1", - time: { created: 3 }, - } - const mismatchedAssistant = { - id: "a-mismatch", - sessionID: "s1", - role: "assistant", - parentID: "u2", - time: { created: 4 }, - } - const secondUser = { - id: "u2", - sessionID: "s1", - role: "user", - time: { created: 5 }, - } - const secondAssistant = { - id: "a2", - sessionID: "s1", - role: "assistant", - time: { created: 6 }, - } - const entries = [ - orphanAssistant, - firstUser, - firstAssistant, - mismatchedAssistant, - secondUser, - secondAssistant, - ].map((info) => ({ info, parts: [] })) - - expect(groupIntoTurns(entries, [])).toEqual([ - { - id: "u1", - turnId: undefined, - userMessage: { info: firstUser, parts: [] }, - assistantMessages: [{ info: firstAssistant, parts: [] }], - }, - { - id: "u2", - turnId: undefined, - userMessage: { info: secondUser, parts: [] }, - assistantMessages: [{ info: secondAssistant, parts: [] }], - }, - ]) + const items = store.get(itemsFamily("s1")) + expect(items).toHaveLength(241) + expect(items[0]?.id).toBe("item-0") + expect(items.at(-1)?.id).toBe("new-item") }) - test("keeps tool messages in their parent turn", () => { - const firstUser = { - id: "u1", - sessionID: "s1", - role: "user", - time: { created: 1 }, - } - const firstTool = { - id: "tool-a", - sessionID: "s1", - role: "assistant", - parentID: "u1", - time: { created: 2 }, - } - const firstAssistant = { - id: "a1", - sessionID: "s1", - role: "assistant", - parentID: "u1", - time: { created: 3 }, - } - const secondUser = { - id: "u2", - sessionID: "s1", - role: "user", - time: { created: 4 }, - } - const secondTool = { - id: "tool-b", - sessionID: "s1", - role: "assistant", - parentID: "u2", - time: { created: 5 }, - } - const secondAssistant = { - id: "a2", - sessionID: "s1", - role: "assistant", - parentID: "u2", - time: { created: 6 }, - } - const firstToolPart = { - id: "tool-a-part", - sessionID: "s1", - messageID: "tool-a", - type: "tool", - callID: "call-a", - tool: "read", - state: { status: "completed", input: {}, output: "", title: "Read A", metadata: {} }, - } - const secondToolPart = { - id: "tool-b-part", - sessionID: "s1", - messageID: "tool-b", - type: "tool", - callID: "call-b", - tool: "read", - state: { status: "completed", input: {}, output: "", title: "Read B", metadata: {} }, - } - const entries = [ - { info: firstUser, parts: [] }, - { info: firstTool, parts: [firstToolPart] }, - { info: firstAssistant, parts: [] }, - { info: secondUser, parts: [] }, - { info: secondTool, parts: [secondToolPart] }, - { info: secondAssistant, parts: [] }, - ] - - expect(groupIntoTurns(entries, [])).toEqual([ + test("groups turns once and skips orphan assistant items before a user message", () => { + const orphan = assistantItem("orphan", "t0", 1, "2026-01-01T00:00:01.000Z") + const firstUser = userItem("u1", "t1", 2, "2026-01-01T00:00:02.000Z") + const firstAssistant = assistantItem("a1", "t1", 3, "2026-01-01T00:00:03.000Z") + const turns = groupIntoTurns( + [{ info: orphan }, { info: firstUser }, { info: firstAssistant }], + [], + ) + expect(turns).toEqual([ { id: "u1", - turnId: undefined, - userMessage: { info: firstUser, parts: [] }, - assistantMessages: [ - { info: firstTool, parts: [firstToolPart] }, - { info: firstAssistant, parts: [] }, - ], - }, - { - id: "u2", - turnId: undefined, - userMessage: { info: secondUser, parts: [] }, - assistantMessages: [ - { info: secondTool, parts: [secondToolPart] }, - { info: secondAssistant, parts: [] }, - ], + turnId: "t1", + userMessage: { info: firstUser }, + assistantMessages: [{ info: firstAssistant }], }, ]) }) diff --git a/apps/desktop/src/renderer/atoms/messages.ts b/apps/desktop/src/renderer/atoms/messages.ts index 0d027114..a633474f 100644 --- a/apps/desktop/src/renderer/atoms/messages.ts +++ b/apps/desktop/src/renderer/atoms/messages.ts @@ -1,150 +1,98 @@ import { atom } from "jotai" import { atomFamily } from "jotai-family" -import type { Message, Part } from "../lib/types" -import { partsFamily, partStorageKey } from "./parts" +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { + compareNativeItems, + isUserMessageItem, + sortedNativeItems, +} from "@devo-ai/sdk/v2/client" -// ============================================================ -// Helpers -// ============================================================ +const MAX_ITEMS_PER_SESSION = 200 -const MAX_MESSAGES_PER_SESSION = 200 +export type SessionItem = NativeItemEnvelope -function messageCreatedAt(message: Message): number { - const created = message.time?.created - return typeof created === "number" && Number.isFinite(created) ? created : 0 -} +/** Ordered Native ItemEnvelope list for a session (source of truth for transcript). */ +export const itemsFamily = atomFamily((_sessionId: string) => atom([])) -function compareMessages(left: Message, right: Message): number { - const byCreated = messageCreatedAt(left) - messageCreatedAt(right) - return byCreated === 0 ? left.id.localeCompare(right.id) : byCreated -} - -function sortedMessages(messages: Iterable): Message[] { - return [...messages].sort(compareMessages) -} - -// ============================================================ -// Per-session message list (sorted by creation time) -// ============================================================ - -export const messagesFamily = atomFamily((_sessionId: string) => atom([])) - -// ============================================================ -// Action atoms -// ============================================================ - -/** - * Set messages for a session (initial fetch + merge with existing Native event data). - */ -export const setMessagesAtom = atom( +export const setItemsAtom = atom( null, ( get, set, args: { sessionId: string - messages: Message[] - parts: Record + items: SessionItem[] }, ) => { - const existing = get(messagesFamily(args.sessionId)) - - // Fast path: no existing messages — just set everything + const existing = get(itemsFamily(args.sessionId)) if (!existing || existing.length === 0) { - set(messagesFamily(args.sessionId), sortedMessages(args.messages)) - for (const [messageId, msgParts] of Object.entries(args.parts)) { - set(partsFamily(partStorageKey(args.sessionId, messageId)), msgParts) - set(partsFamily(messageId), msgParts) - } + set(itemsFamily(args.sessionId), sortedNativeItems(args.items)) return } - - // Merge: fetched data fills gaps, while Native event versions win for matching IDs. - const byId = new Map(args.messages.map((message) => [message.id, message])) - for (const message of existing) { - byId.set(message.id, message) - } - const merged = sortedMessages(byId.values()) - - // Merge parts: fetched parts fill in gaps, Native event parts take priority - for (const [messageId, fetchedParts] of Object.entries(args.parts)) { - const scopedKey = partStorageKey(args.sessionId, messageId) - const existingScopedParts = get(partsFamily(scopedKey)) - if (!existingScopedParts || existingScopedParts.length === 0) { - // No Native event parts yet for this message — use fetched - set(partsFamily(scopedKey), fetchedParts) - } - const existingLegacyParts = get(partsFamily(messageId)) - if (!existingLegacyParts || existingLegacyParts.length === 0) { - set(partsFamily(messageId), fetchedParts) - } - // Otherwise keep the Native-event-accumulated parts (more recent) + const byId = new Map(args.items.map((item) => [item.id, item])) + for (const item of existing) { + byId.set(item.id, item) } - - set(messagesFamily(args.sessionId), merged) + set(itemsFamily(args.sessionId), sortedNativeItems(byId.values())) }, ) -/** - * Upsert a single message. - */ -export const upsertMessageAtom = atom(null, (get, set, message: Message) => { - const sessionId = message.sessionID - let existing = get(messagesFamily(sessionId)) +export const upsertItemAtom = atom(null, (get, set, item: SessionItem) => { + const sessionId = item.sessionId + let existing = get(itemsFamily(sessionId)) + + // The canonical user item can arrive before queue/push returns. In that + // order, the late optimistic bubble is already represented by its turn. + if ( + isUserMessageItem(item) && + item.id.startsWith("optimistic-") && + item.turnId && + existing.some( + (m) => isUserMessageItem(m) && !m.id.startsWith("optimistic-") && m.turnId === item.turnId, + ) + ) { + return + } - // When a real user message arrives, remove the oldest optimistic placeholder. - if (message.role === "user" && !message.id.startsWith("optimistic-")) { + if (isUserMessageItem(item) && !item.id.startsWith("optimistic-")) { const optimisticIndex = existing.findIndex( - (m) => m.id.startsWith("optimistic-") && m.role === "user", + (m) => + m.id.startsWith("optimistic-") && + isUserMessageItem(m) && + (!m.turnId || m.turnId === item.turnId), ) if (optimisticIndex !== -1) { - const optimisticId = existing[optimisticIndex].id - // Clean up parts for the optimistic message - set(partsFamily(partStorageKey(sessionId, optimisticId)), []) - set(partsFamily(optimisticId), []) - existing = existing.filter((_, i) => i !== optimisticIndex) + existing = existing.filter((_, index) => index !== optimisticIndex) } } - const existingIndex = existing.findIndex((item) => item.id === message.id) - if (existingIndex >= 0 && existing[existingIndex] === message) return - + const index = existing.findIndex((m) => m.id === item.id) const updated = - existingIndex >= 0 - ? existing.map((item, index) => (index === existingIndex ? message : item)) - : [...existing, message] - updated.sort(compareMessages) - - // Cap at MAX_MESSAGES_PER_SESSION - if (updated.length > MAX_MESSAGES_PER_SESSION) { - const removed = updated.shift()! - set(partsFamily(partStorageKey(sessionId, removed.id)), []) - set(partsFamily(removed.id), []) + index >= 0 + ? existing.map((m, i) => (i === index ? { ...m, ...item, item: { ...m.item, ...item.item } } : m)) + : [...existing, item] + + updated.sort(compareNativeItems) + + // Keep the live tail bounded until history has been expanded. Once a + // paginated history fetch hydrates more than the live cap, do not discard + // those requested rows each time a streaming update arrives. + if (existing.length <= MAX_ITEMS_PER_SESSION) { + while (updated.length > MAX_ITEMS_PER_SESSION) { + updated.shift() + } } - set(messagesFamily(sessionId), updated) + + set(itemsFamily(sessionId), updated) }) -/** - * Remove a message from a session. - */ -export const removeMessageAtom = atom( +export const removeItemAtom = atom( null, - ( - get, - set, - args: { - sessionId: string - messageId: string - }, - ) => { - const existing = get(messagesFamily(args.sessionId)) - if (!existing) return - const existingIndex = existing.findIndex((message) => message.id === args.messageId) - if (existingIndex < 0) return - const updated = [...existing] - updated.splice(existingIndex, 1) - set(partsFamily(partStorageKey(args.sessionId, args.messageId)), []) - set(partsFamily(args.messageId), []) - set(messagesFamily(args.sessionId), updated) + (get, set, args: { sessionId: string; itemId: string }) => { + const existing = get(itemsFamily(args.sessionId)) + set( + itemsFamily(args.sessionId), + existing.filter((item) => item.id !== args.itemId), + ) }, ) diff --git a/apps/desktop/src/renderer/atoms/parts.ts b/apps/desktop/src/renderer/atoms/parts.ts deleted file mode 100644 index 025d7dc8..00000000 --- a/apps/desktop/src/renderer/atoms/parts.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { atom } from "jotai" -import { atomFamily } from "jotai-family" -import type { Part } from "../lib/types" - -const PART_STORAGE_SEPARATOR = "\u001f" - -export function partStorageKey(sessionId: string, messageId: string): string { - return `${sessionId}${PART_STORAGE_SEPARATOR}${messageId}` -} - -function binarySearch( - arr: T[], - target: string, - key: (item: T) => string, -): { found: boolean; index: number } { - let lo = 0 - let hi = arr.length - while (lo < hi) { - const mid = (lo + hi) >>> 1 - const cmp = key(arr[mid]).localeCompare(target) - if (cmp < 0) lo = mid + 1 - else if (cmp > 0) hi = mid - else return { found: true, index: mid } - } - return { found: false, index: lo } -} - -// ============================================================ -// Per-message part list (sorted by id) -// ============================================================ - -export const partsFamily = atomFamily((_messageId: string) => atom([])) - -function upsertPart(existing: Part[] | undefined, part: Part): Part[] { - if (!existing || existing.length === 0) { - return [part] - } - - const result = binarySearch(existing, part.id, (p) => p.id) - if (result.found) { - if (existing[result.index] === part) return existing - const updated = existing.slice() - updated[result.index] = part - return updated - } - - const updated = existing.slice() - updated.splice(result.index, 0, part) - return updated -} - -function applyPartDelta( - existing: Part[] | undefined, - partId: string, - field: string, - delta: string, -): Part[] | undefined { - if (!existing || existing.length === 0) return undefined - const result = binarySearch(existing, partId, (p) => p.id) - if (!result.found) return undefined - const part = existing[result.index] - const record = part as Record - const current = record[field] - const updated = existing.slice() - updated[result.index] = { - ...part, - [field]: (typeof current === "string" ? current : "") + delta, - } - return updated -} - -function removePart(existing: Part[] | undefined, partId: string): Part[] | undefined { - if (!existing) return undefined - const result = binarySearch(existing, partId, (p) => p.id) - if (!result.found) return undefined - const updated = existing.slice() - updated.splice(result.index, 1) - return updated -} - -// ============================================================ -// Action atoms -// ============================================================ - -/** Upsert a single part */ -export const upsertPartAtom = atom(null, (get, set, part: Part) => { - const scopedKey = partStorageKey(part.sessionID, part.messageID) - set(partsFamily(scopedKey), upsertPart(get(partsFamily(scopedKey)), part)) - set(partsFamily(part.messageID), upsertPart(get(partsFamily(part.messageID)), part)) -}) - -/** Batch upsert multiple parts (used when flushing streaming buffer) */ -export const batchUpsertPartsAtom = atom(null, (get, set, parts: Part[]) => { - if (parts.length === 0) return - - // Group by messageId to minimize atom writes - const byMessage = new Map() - for (const part of parts) { - const group = byMessage.get(part.messageID) ?? [] - group.push(part) - byMessage.set(part.messageID, group) - } - - for (const [messageId, messageParts] of byMessage) { - let legacy = get(partsFamily(messageId)) - for (const part of messageParts) { - const scopedKey = partStorageKey(part.sessionID, part.messageID) - set(partsFamily(scopedKey), upsertPart(get(partsFamily(scopedKey)), part)) - legacy = upsertPart(legacy, part) - } - set(partsFamily(messageId), legacy) - } -}) - -/** Apply a string delta to a specific field of an existing part */ -export const applyPartDeltaAtom = atom( - null, - ( - get, - set, - args: { - sessionId: string - messageId: string - partId: string - field: string - delta: string - }, - ) => { - const scopedKey = partStorageKey(args.sessionId, args.messageId) - const scoped = applyPartDelta( - get(partsFamily(scopedKey)), - args.partId, - args.field, - args.delta, - ) - if (scoped) set(partsFamily(scopedKey), scoped) - - const legacy = applyPartDelta( - get(partsFamily(args.messageId)), - args.partId, - args.field, - args.delta, - ) - if (legacy) set(partsFamily(args.messageId), legacy) - }, -) - -/** Remove a part from a message */ -export const removePartAtom = atom( - null, - ( - get, - set, - args: { - sessionId: string - messageId: string - partId: string - }, - ) => { - const scopedKey = partStorageKey(args.sessionId, args.messageId) - const scoped = removePart(get(partsFamily(scopedKey)), args.partId) - if (scoped) set(partsFamily(scopedKey), scoped) - - const legacy = removePart(get(partsFamily(args.messageId)), args.partId) - if (legacy) set(partsFamily(args.messageId), legacy) - }, -) diff --git a/apps/desktop/src/renderer/atoms/preferences-drafts.test.ts b/apps/desktop/src/renderer/atoms/preferences-drafts.test.ts new file mode 100644 index 00000000..596be568 --- /dev/null +++ b/apps/desktop/src/renderer/atoms/preferences-drafts.test.ts @@ -0,0 +1,42 @@ +import { afterAll, expect, test } from "bun:test" +import { createStore } from "jotai" + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage") +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window") +const savedDrafts = { "qa-session": "An unsent draft survives reload." } +const values = new Map([["devo:drafts", JSON.stringify(savedDrafts)]]) + +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, +}) +Object.defineProperty(globalThis, "window", { + configurable: true, + value: { localStorage }, +}) + +afterAll(() => { + if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage) + else Reflect.deleteProperty(globalThis, "localStorage") + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow) + else Reflect.deleteProperty(globalThis, "window") +}) + +test("draft snapshots hydrate saved text before the composer subscribes", async () => { + const { draftsAtom, setDraftAtom, clearDraftAtom } = await import("./preferences") + const store = createStore() + expect(store.get(draftsAtom)).toEqual(savedDrafts) + store.set(setDraftAtom, { key: "qa-other-session", text: "Another draft" }) + expect(JSON.parse(values.get("devo:drafts")!)).toEqual({ + ...savedDrafts, + "qa-other-session": "Another draft", + }) + store.set(clearDraftAtom, "qa-session") + expect(JSON.parse(values.get("devo:drafts")!)).toEqual({ + "qa-other-session": "Another draft", + }) +}) diff --git a/apps/desktop/src/renderer/atoms/preferences.ts b/apps/desktop/src/renderer/atoms/preferences.ts index e3ebb8f4..c5a6e190 100644 --- a/apps/desktop/src/renderer/atoms/preferences.ts +++ b/apps/desktop/src/renderer/atoms/preferences.ts @@ -109,7 +109,13 @@ export const isTransparentAtom = atom((get) => { return !opaque && (tier === "liquid-glass" || tier === "vibrancy" || tier === "transparent") }) -export const draftsAtom = atomWithStorage>("devo:drafts", {}) +// The composer reads a snapshot without subscribing, so hydrate before its first read. +export const draftsAtom = atomWithStorage>( + "devo:drafts", + {}, + undefined, + { getOnInit: true }, +) export const projectModelsAtom = atomWithStorage>( "devo:projectModels", diff --git a/apps/desktop/src/renderer/atoms/session-composer.test.ts b/apps/desktop/src/renderer/atoms/session-composer.test.ts index 3e4f2ffb..79bec4ed 100644 --- a/apps/desktop/src/renderer/atoms/session-composer.test.ts +++ b/apps/desktop/src/renderer/atoms/session-composer.test.ts @@ -1,39 +1,15 @@ import { describe, expect, test } from "bun:test" import { - composerFromMessages, composerFromPersistedModel, composerFromSessionModel, EMPTY_COMPOSER_STATE, hydrateSessionComposerState, type SessionComposerState, } from "./session-composer" -import type { Message } from "../lib/types" describe("hydrateSessionComposerState", () => { - test("prefers last user message model over project default", () => { - const messages: Message[] = [ - { - id: "m1", - role: "user", - content: "hello", - model: { providerID: "openai", modelID: "gpt-4" }, - variant: "high", - } as Message, - ] - const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, messages, { - providerID: "anthropic", - modelID: "claude-3", - }) - expect(hydrated).toEqual({ - model: { providerID: "openai", modelID: "gpt-4" }, - variant: "high", - agent: null, - hasUserOverride: false, - }) - }) - test("falls back to project default for empty sessions", () => { - const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, [], { + const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, 0, { providerID: "anthropic", modelID: "claude-3", variant: "medium", @@ -54,22 +30,7 @@ describe("hydrateSessionComposerState", () => { agent: null, hasUserOverride: true, } - const messages: Message[] = [ - { - id: "m1", - role: "user", - content: "hello", - model: { providerID: "openai", modelID: "gpt-4" }, - } as Message, - ] - expect(hydrateSessionComposerState(current, messages, undefined)).toEqual(current) - }) -}) - -describe("composerFromMessages", () => { - test("returns null when no user messages carry composer metadata", () => { - const messages: Message[] = [{ id: "m1", role: "assistant", content: "hi" } as Message] - expect(composerFromMessages(messages)).toBeNull() + expect(hydrateSessionComposerState(current, 1, undefined)).toEqual(current) }) }) @@ -115,41 +76,18 @@ describe("composerFromSessionModel", () => { }) describe("hydrateSessionComposerState with persisted session seed", () => { - test("uses the session seed when history messages carry no model metadata", () => { - const messages: Message[] = [{ id: "m1", role: "user", content: "hello" } as Message] + test("uses the session seed when present", () => { const seed: SessionComposerState = { model: { providerID: "deepseek", modelID: "deepseek-v4-flash" }, variant: "high", agent: null, hasUserOverride: false, } - expect( - hydrateSessionComposerState(EMPTY_COMPOSER_STATE, messages, undefined, seed), - ).toEqual(seed) - }) - - test("current session settings win over older message metadata", () => { - const messages: Message[] = [ - { - id: "m1", - role: "user", - content: "hello", - model: { providerID: "openai", modelID: "gpt-4" }, - } as Message, - ] - const seed: SessionComposerState = { - model: { providerID: "deepseek", modelID: "deepseek-v4-flash" }, - variant: undefined, - agent: null, - hasUserOverride: false, - } - const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, messages, undefined, seed) - expect(hydrated).toEqual(seed) + expect(hydrateSessionComposerState(EMPTY_COMPOSER_STATE, 1, undefined, seed)).toEqual(seed) }) - test("keeps empty composer for non-empty sessions without metadata or seed", () => { - const messages: Message[] = [{ id: "m1", role: "user", content: "hello" } as Message] - const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, messages, { + test("keeps empty composer for non-empty sessions without seed", () => { + const hydrated = hydrateSessionComposerState(EMPTY_COMPOSER_STATE, 1, { providerID: "anthropic", modelID: "claude-3", }) diff --git a/apps/desktop/src/renderer/atoms/session-composer.ts b/apps/desktop/src/renderer/atoms/session-composer.ts index 02a53399..cd332fb2 100644 --- a/apps/desktop/src/renderer/atoms/session-composer.ts +++ b/apps/desktop/src/renderer/atoms/session-composer.ts @@ -1,7 +1,6 @@ import { atom } from "jotai" -import { atomFamily } from "jotai/utils" +import { atomFamily } from "jotai-family" import type { ModelRef } from "../hooks/use-devo-data" -import type { Message } from "../lib/types" import type { PersistedModelRef } from "./preferences" export interface SessionComposerState { @@ -67,51 +66,20 @@ export function composerFromSessionModel( } } -export function composerFromMessages(messages: Message[]): SessionComposerState | null { - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index] - if (message.role !== "user") continue - const dynamic = message as Message & Record - let model: ModelRef | null = null - if ("model" in message && message.model) { - const raw = message.model as { providerID: string; modelID: string } - if (raw.providerID && raw.modelID) { - model = { providerID: raw.providerID, modelID: raw.modelID } - } - } - const variant = - typeof dynamic.variant === "string" && dynamic.variant.length > 0 - ? dynamic.variant - : undefined - const agentName = - typeof dynamic.agent === "string" && dynamic.agent.length > 0 ? dynamic.agent : null - if (model || variant || agentName) { - return { - model, - variant, - agent: agentName, - hasUserOverride: false, - } - } - } - return null -} - +/** + * Hydrate composer from session seed / project default. + * Native transcripts do not carry per-message composer metadata. + */ export function hydrateSessionComposerState( current: SessionComposerState, - messages: Message[], + itemCount: number, projectDefault: PersistedModelRef | undefined, /** Persisted per-session turn settings from the wire session (server restores them). */ sessionSeed?: SessionComposerState | null, ): SessionComposerState { if (current.hasUserOverride) return current - // The session snapshot is the current "next turn" configuration. It is - // newer and authoritative over model metadata from an older history item; - // history remains the fallback for legacy sessions without a snapshot seed. if (sessionSeed) return sessionSeed - const fromMessages = composerFromMessages(messages) - if (fromMessages) return fromMessages - if (messages.length > 0) return current + if (itemCount > 0) return current return composerFromPersistedModel(projectDefault) } diff --git a/apps/desktop/src/renderer/atoms/session-native.test.ts b/apps/desktop/src/renderer/atoms/session-native.test.ts index 435ff2c3..36338b75 100644 --- a/apps/desktop/src/renderer/atoms/session-native.test.ts +++ b/apps/desktop/src/renderer/atoms/session-native.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" import { processEvent } from "./actions/event-processor" -import { partsFamily, partStorageKey } from "./parts" import { sessionNativeFamily } from "./session-native" import { sessionFamily, upsertSessionAtom } from "./sessions" import { appStore } from "./store" @@ -17,8 +16,7 @@ describe("Native session renderer state", () => { type: "permission.asked", properties: { id: "approval-1", - requestID: "approval-1", - sessionID, + sessionId: sessionID, permission: "Run command", }, } as const @@ -35,28 +33,28 @@ describe("Native session renderer state", () => { processEvent({ type: "session.commands.updated", properties: { - sessionID, + sessionId: sessionID, commands: [{ name: "compact", description: "Compact session" }], }, }) processEvent({ type: "session.config.updated", properties: { - sessionID, + sessionId: sessionID, configOptions: [{ id: "model", currentValue: "test-model" }], }, }) processEvent({ type: "session.mode.updated", properties: { - sessionID, + sessionId: sessionID, modeID: "plan", }, }) processEvent({ type: "session.usage.updated", properties: { - sessionID, + sessionId: sessionID, used: 42, size: 100, cost: { amount: 1, currency: "USD" }, @@ -65,7 +63,7 @@ describe("Native session renderer state", () => { processEvent({ type: "context.usage.updated", properties: { - sessionID, + sessionId: sessionID, occupancy: { totalTokens: 48_000, contextWindowTokens: 190_000, @@ -82,8 +80,8 @@ describe("Native session renderer state", () => { configOptions: [{ id: "model", currentValue: "test-model" }], modeID: "plan", usage: { - used: 42, - size: 100, + used: 48_000, + size: 190_000, cost: { amount: 1, currency: "USD" }, }, occupancy: { @@ -97,38 +95,118 @@ describe("Native session renderer state", () => { }) }) - test("notifies session chat renders when text parts update", () => { + test("live turn usage advances the fill amount and syncs the window", () => { + const sessionID = "session-native-usage-ahead" + processEvent({ + type: "context.usage.updated", + properties: { + sessionId: sessionID, + occupancy: { + totalTokens: 16_700, + contextWindowTokens: 190_000, + categories: [ + { id: "base", tokens: 10_000, shareBps: 5988 }, + { id: "conversation", tokens: 6_700, shareBps: 4012 }, + ], + }, + }, + }) + processEvent({ + type: "session.usage.updated", + properties: { + sessionId: sessionID, + used: 48_000, + size: 250_000, + }, + }) + + const native = appStore.get(sessionNativeFamily(sessionID)) + expect(native.usage?.used).toBe(48_000) + // Denominator follows the live effective window from the server. + expect(native.usage?.size).toBe(250_000) + expect(native.occupancy?.contextWindowTokens).toBe(250_000) + expect(native.occupancy?.totalTokens).toBe(16_700) + }) + + test("applies occupancy window increases immediately", () => { + const sessionID = "session-native-window-increase" + processEvent({ + type: "context.usage.updated", + properties: { + sessionId: sessionID, + occupancy: { + totalTokens: 50_000, + contextWindowTokens: 190_000, + categories: [], + }, + }, + }) + processEvent({ + type: "context.usage.updated", + properties: { + sessionId: sessionID, + occupancy: { + totalTokens: 52_000, + contextWindowTokens: 1_000_000, + categories: [], + }, + }, + }) + + const native = appStore.get(sessionNativeFamily(sessionID)) + expect(native.occupancy?.contextWindowTokens).toBe(1_000_000) + expect(native.usage?.used).toBe(52_000) + expect(native.usage?.size).toBe(1_000_000) + }) + + test("notifies session chat renders when Native items update", () => { const sessionID = "session-text-part-update" - const messageID = "message-text-part-update" const initialVersion = appStore.get(streamingVersionFamily(sessionID)) processEvent({ - type: "message.part.updated", + type: "item.updated", properties: { - part: { - id: "message-text-part-update-text", - sessionID, - messageID, - type: "text", - text: "streamed text", - time: { start: 1, end: 1 }, + info: { + id: "message-text-part-update", + sessionId: sessionID, + turnId: "turn-1", + seq: 1, + revision: 1, + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + state: "running", + item: { type: "assistantMessage", text: "streamed text" }, }, }, }) - expect(appStore.get(partsFamily(partStorageKey(sessionID, messageID)))).toEqual([ - { - id: "message-text-part-update-text", - sessionID, - messageID, - type: "text", - text: "streamed text", - time: { start: 1, end: 1 }, - }, - ]) expect(appStore.get(streamingVersionFamily(sessionID))).toBe(initialVersion + 1) }) + test("bumps version on every Native item upsert", () => { + const sessionID = "session-buffered-text-part" + const versionBefore = appStore.get(streamingVersionFamily(sessionID)) + + processEvent({ + type: "item.updated", + properties: { + info: { + id: "buffered-text", + sessionId: sessionID, + turnId: "turn-1", + seq: 1, + revision: 1, + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + state: "running", + item: { type: "assistantMessage", text: "hello world" }, + }, + }, + }) + + expect(appStore.get(streamingVersionFamily(sessionID))).toBe(versionBefore + 1) + }) + test("stores scheduled retries, clears resumed retries, and reports transient failures", () => { const sessionID = "session-provider-retry" appStore.set(upsertSessionAtom, { @@ -139,44 +217,57 @@ describe("Native session renderer state", () => { processEvent({ type: "turn.provider_retry_status", properties: { - sessionID, - turnID: "turn-1", + sessionId: sessionID, + turnId: "turn-1", attempt: 2, backoffMs: 1000, provider: "openai", model: "test-model", phase: "scheduled", - message: "Retrying provider request in 1.0s", + message: "Internal server error", }, }) - expect(appStore.get(sessionFamily(sessionID))?.retryStatus).toEqual({ + const scheduled = appStore.get(sessionFamily(sessionID)) + expect(scheduled?.retryStatus).toEqual({ turnId: "turn-1", attempt: 2, backoffMs: 1000, provider: "openai", model: "test-model", phase: "scheduled", - message: "Retrying provider request in 1.0s", + message: "Internal server error", }) + expect(scheduled?.providerErrors).toEqual([ + { + id: "retry-turn-1-2", + turnId: "turn-1", + message: "Internal server error", + phase: "scheduled", + attempt: 2, + backoffMs: 1000, + scheduledAtMs: scheduled?.providerErrors?.[0]?.scheduledAtMs, + }, + ]) + expect(typeof scheduled?.providerErrors?.[0]?.scheduledAtMs).toBe("number") processEvent({ type: "turn.provider_retry_status", properties: { - sessionID, - turnID: "turn-1", + sessionId: sessionID, + turnId: "turn-1", attempt: 2, backoffMs: 0, provider: "openai", model: "test-model", phase: "resumed", - message: "Retrying provider request now", + message: "Internal server error", }, }) processEvent({ type: "session.error", properties: { - sessionID, + sessionId: sessionID, error: { name: "PROVIDER_SERVER_ERROR", data: { message: "Internal server error" }, @@ -184,13 +275,32 @@ describe("Native session renderer state", () => { }, }) - expect(appStore.get(sessionFamily(sessionID))).toEqual({ + const failed = appStore.get(sessionFamily(sessionID)) + expect(failed).toEqual({ session: { id: sessionID, title: "Retry test" }, directory: "/repo", status: { type: "idle" }, permissions: [], questions: [], retryStatus: undefined, + providerErrors: [ + { + id: "retry-turn-1-2", + turnId: "turn-1", + message: "Internal server error", + phase: "scheduled", + attempt: 2, + backoffMs: 1000, + scheduledAtMs: failed?.providerErrors?.[0]?.scheduledAtMs, + }, + { + id: "failed-turn-1-PROVIDER_SERVER_ERROR-Internal server error", + turnId: "turn-1", + message: "Internal server error", + phase: "failed", + code: "PROVIDER_SERVER_ERROR", + }, + ], error: { name: "PROVIDER_SERVER_ERROR", data: { message: "Internal server error" }, @@ -198,3 +308,19 @@ describe("Native session renderer state", () => { }) }) }) + + +test("keeps replayed provider failures scoped to their original turns", () => { + const sessionId = "replayed-failures" + appStore.set(upsertSessionAtom, { session: { id: sessionId, title: "Failures" }, directory: "/repo" }) + for (const turnId of ["turn-first", "turn-last", "turn-last"]) { + processEvent({ + type: "session.error", + properties: { sessionId, turnId, error: { name: "PAYMENT_REQUIRED", data: { message: "Insufficient Balance" } } }, + }) + } + expect(appStore.get(sessionFamily(sessionId))?.providerErrors).toEqual([ + { id: "failed-turn-first-PAYMENT_REQUIRED-Insufficient Balance", turnId: "turn-first", message: "Insufficient Balance", phase: "failed", code: "PAYMENT_REQUIRED" }, + { id: "failed-turn-last-PAYMENT_REQUIRED-Insufficient Balance", turnId: "turn-last", message: "Insufficient Balance", phase: "failed", code: "PAYMENT_REQUIRED" }, + ]) +}) diff --git a/apps/desktop/src/renderer/atoms/sessions.test.ts b/apps/desktop/src/renderer/atoms/sessions.test.ts index 71199384..3cfceabe 100644 --- a/apps/desktop/src/renderer/atoms/sessions.test.ts +++ b/apps/desktop/src/renderer/atoms/sessions.test.ts @@ -33,6 +33,8 @@ describe("session unread completion state", () => { status: { type: "idle" }, permissions: [], questions: [], + retryStatus: undefined, + providerErrors: [], branch: undefined, worktreePath: undefined, worktreeBranch: undefined, @@ -57,6 +59,8 @@ describe("session unread completion state", () => { status: { type: "idle" }, permissions: [], questions: [], + retryStatus: undefined, + providerErrors: [], branch: undefined, worktreePath: undefined, worktreeBranch: undefined, diff --git a/apps/desktop/src/renderer/atoms/sessions.ts b/apps/desktop/src/renderer/atoms/sessions.ts index fef86584..15b3098c 100644 --- a/apps/desktop/src/renderer/atoms/sessions.ts +++ b/apps/desktop/src/renderer/atoms/sessions.ts @@ -1,8 +1,7 @@ import { atom } from "jotai" import { atomFamily } from "jotai-family" import type { PermissionRequest, QuestionRequest, Session, SessionStatus } from "../lib/types" -import { messagesFamily } from "./messages" -import { partsFamily, partStorageKey } from "./parts" +import { itemsFamily } from "./messages" import { viewedSessionIdAtom } from "./ui" // ============================================================ @@ -32,6 +31,20 @@ export type ProviderRetryStatus = { message: string } +/** Expandable provider/LLM failure rows for the chat process timeline. */ +export type ProviderErrorEntry = { + id: string + turnId: string + message: string + phase: "scheduled" | "failed" | string + attempt?: number + code?: string + /** Backoff budget when this retry was scheduled (ms). */ + backoffMs?: number + /** Client clock when the scheduled retry was observed. */ + scheduledAtMs?: number +} + /** Phases of worktree setup shown in the chat view's empty state */ export type SessionSetupPhase = "creating-worktree" | "starting-session" | null @@ -70,6 +83,8 @@ export interface SessionEntry { hasUnreadCompletion?: boolean /** Active provider retry status for the current turn. */ retryStatus?: ProviderRetryStatus + /** Accumulated provider retry / failure rows for expandable display. */ + providerErrors?: ProviderErrorEntry[] } // ============================================================ @@ -114,6 +129,7 @@ export const upsertSessionAtom = atom( setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, retryStatus: existing?.retryStatus, + providerErrors: existing?.providerErrors, }) // Add to index @@ -127,17 +143,8 @@ export const upsertSessionAtom = atom( ) export const removeSessionAtom = atom(null, (get, set, sessionId: string) => { - // Clean up message and part atoms to prevent memory leaks. - // messagesFamily/partsFamily create atoms on demand and never remove them, - // so we must explicitly clear and remove entries for deleted sessions. - const messages = get(messagesFamily(sessionId)) - if (messages && messages.length > 0) { - for (const msg of messages) { - partsFamily.remove(partStorageKey(sessionId, msg.id)) - partsFamily.remove(msg.id) - } - } - messagesFamily.remove(sessionId) + // Clean up item atoms to prevent memory leaks. + itemsFamily.remove(sessionId) sessionFamily.remove(sessionId) @@ -169,11 +176,15 @@ export const setSessionStatusAtom = atom( : completedTurn ? get(viewedSessionIdAtom) !== args.sessionId : entry.hasUnreadCompletion + // New turn: drop prior expandable error rows so they stay scoped to one attempt. + const startingFreshTurn = + isWorking && (entry.status.type === "idle" || entry.status.type === "error") set(sessionFamily(args.sessionId), { ...entry, status: args.status, hasUnreadCompletion, retryStatus: args.status.type === "idle" || args.status.type === "error" ? undefined : entry.retryStatus, + providerErrors: startingFreshTurn ? [] : entry.providerErrors, }) }, ) @@ -191,7 +202,37 @@ export const setProviderRetryStatusAtom = atom( ) => { const entry = get(sessionFamily(args.sessionId)) if (!entry) return - set(sessionFamily(args.sessionId), { ...entry, retryStatus: args.status }) + const status = args.status + if (!status) { + set(sessionFamily(args.sessionId), { ...entry, retryStatus: undefined }) + return + } + if (status.phase === "resumed") { + set(sessionFamily(args.sessionId), { ...entry, retryStatus: undefined }) + return + } + const message = status.message.trim() + const nextErrors = [...(entry.providerErrors ?? [])] + if (message || status.phase === "scheduled") { + const id = `retry-${status.turnId}-${status.attempt}` + const nextEntry: ProviderErrorEntry = { + id, + turnId: status.turnId, + message: message || "Provider request failed", + phase: status.phase || "scheduled", + attempt: status.attempt, + backoffMs: status.backoffMs, + scheduledAtMs: Date.now(), + } + const existingIndex = nextErrors.findIndex((item) => item.id === id) + if (existingIndex >= 0) nextErrors[existingIndex] = nextEntry + else nextErrors.push(nextEntry) + } + set(sessionFamily(args.sessionId), { + ...entry, + retryStatus: status, + providerErrors: nextErrors, + }) }, ) @@ -208,12 +249,41 @@ export const setSessionErrorAtom = atom( set, args: { sessionId: string + turnId?: string error: SessionError | undefined }, ) => { const entry = get(sessionFamily(args.sessionId)) if (!entry) return - set(sessionFamily(args.sessionId), { ...entry, error: args.error }) + if (!args.error) { + set(sessionFamily(args.sessionId), { ...entry, error: undefined }) + return + } + const message = + typeof args.error.data.message === "string" && args.error.data.message.trim() + ? args.error.data.message.trim() + : `${args.error.name}: ${JSON.stringify(args.error.data)}` + const turnId = + args.turnId || + entry.retryStatus?.turnId || + [...(entry.providerErrors ?? [])].reverse().find((item) => item.turnId)?.turnId || + "" + const id = `failed-${turnId || "session"}-${args.error.name}-${message.slice(0, 48)}` + const nextErrors = [...(entry.providerErrors ?? [])] + if (!nextErrors.some((item) => item.turnId === turnId && item.message === message && item.phase === "failed")) { + nextErrors.push({ + id, + turnId, + message, + phase: "failed", + code: args.error.name, + }) + } + set(sessionFamily(args.sessionId), { + ...entry, + error: args.error, + providerErrors: nextErrors, + }) }, ) @@ -395,7 +465,8 @@ export const setSessionsAtom = atom( error: existing?.error, setupPhase: existing?.setupPhase, hasUnreadCompletion: existing?.hasUnreadCompletion, - retryStatus: existing?.retryStatus, + retryStatus: existing?.retryStatus, + providerErrors: existing?.providerErrors, }) nextIds.add(session.id) } diff --git a/apps/desktop/src/renderer/atoms/streaming.ts b/apps/desktop/src/renderer/atoms/streaming.ts index 7eae036e..b58e0fb6 100644 --- a/apps/desktop/src/renderer/atoms/streaming.ts +++ b/apps/desktop/src/renderer/atoms/streaming.ts @@ -1,29 +1,9 @@ import { atom } from "jotai" import { atomFamily } from "jotai-family" -import type { Part } from "../lib/types" -import { partStorageKey } from "./parts" -import { appStore } from "./store" - -// ============================================================ -// Streaming Buffer — module-scoped (NOT atoms) -// Same pattern as the old streaming-store.ts but notifies -// via per-session Jotai version counters so only the affected -// session's components re-render. -// ============================================================ - -/** Throttle interval for React notifications — ~20 updates/sec */ -const FLUSH_THROTTLE_MS = 50 - -/** Parts keyed by sessionID + messageID -> Part object, only for actively-streaming parts */ -let buffer: Record> = {} - -// ============================================================ -// Per-session notification -// ============================================================ /** - * Per-session version counter. Only the session that is actively streaming - * will have its counter bumped, so components for other sessions stay idle. + * Per-session version counter bumped on Native `item.updated` / `item.removed`. + * Components subscribe so they re-render when that session's transcript changes. */ export const streamingVersionFamily = atomFamily((_sessionId: string) => atom(0)) @@ -32,207 +12,3 @@ export const streamingVersionFamily = atomFamily((_sessionId: string) => atom(0) * Kept temporarily so any transient consumers still compile. */ export const streamingVersionAtom = atom(0) - -/** Per-session throttle state */ -const sessionThrottle = new Map< - string, - { scheduled: ReturnType | undefined; lastFlush: number } ->() - -/** Set of session IDs that have been dirtied since the last per-session flush. */ -const dirtySessionIds = new Set() - -function getThrottle(sessionId: string) { - let t = sessionThrottle.get(sessionId) - if (!t) { - t = { scheduled: undefined, lastFlush: 0 } - sessionThrottle.set(sessionId, t) - } - return t -} - -function notifySession(sessionId: string): void { - const t = getThrottle(sessionId) - t.scheduled = undefined - t.lastFlush = performance.now() - dirtySessionIds.delete(sessionId) - appStore.set(streamingVersionFamily(sessionId), (v) => v + 1) -} - -function scheduleNotifySession(sessionId: string): void { - const t = getThrottle(sessionId) - if (t.scheduled) return - const elapsed = performance.now() - t.lastFlush - if (elapsed >= FLUSH_THROTTLE_MS) { - notifySession(sessionId) - } else { - t.scheduled = setTimeout(() => notifySession(sessionId), FLUSH_THROTTLE_MS - elapsed) - } -} - -// ============================================================ -// Session ID lookup — messageID -> sessionID -// ============================================================ - -/** - * Lightweight map from scoped message key to sessionID. Populated when streaming - * parts arrive (Part.sessionID is on every SDK Part variant). - */ -const messageSessionMap = new Map() -const bufferMessageIdMap = new Map() - -/** Look up which session a message belongs to. */ -export function getSessionForMessage(messageId: string): string | undefined { - return messageSessionMap.get(messageId) -} - -/** - * Get streaming parts scoped to a specific session. - * Only returns parts belonging to messages in that session. - */ -export function getStreamingPartsForSession( - sessionId: string, -): Record> { - const result: Record> = {} - for (const key in buffer) { - if (messageSessionMap.get(key) === sessionId) { - const messageId = bufferMessageIdMap.get(key) - if (messageId) { - result[messageId] = buffer[key] - } - } - } - return result -} - -// ============================================================ -// Public API — called from the event batcher (non-React code) -// ============================================================ - -/** - * Write a part to the streaming buffer. - * Called by connection-manager on every text/reasoning Native event. - */ -export function updateStreamingPart(part: Part): void { - const { messageID, sessionID } = part - // Register the messageID -> sessionID mapping - const key = partStorageKey(sessionID, messageID) - messageSessionMap.set(key, sessionID) - bufferMessageIdMap.set(key, messageID) - - if (!buffer[key]) buffer[key] = {} - buffer[key][part.id] = part - - dirtySessionIds.add(sessionID) - scheduleNotifySession(sessionID) -} - -/** - * Apply a string delta to a field of an existing part in the streaming buffer. - * Used for incremental text/reasoning updates (message.part.delta events). - * Returns true if the delta was applied, false if the part was not found. - */ -export function applyStreamingDelta( - messageId: string, - partId: string, - field: string, - delta: string, - sessionId: string, -): boolean { - const key = partStorageKey(sessionId, messageId) - messageSessionMap.set(key, sessionId) - bufferMessageIdMap.set(key, messageId) - - const msgParts = buffer[key] - if (!msgParts?.[partId]) return false - - const part = msgParts[partId] - // Part is a discriminated union; the server sends the field name as a plain string. - const record = part as Record - const existing = record[field] - // Create a shallow copy with the appended field value - msgParts[partId] = { ...part, [field]: (typeof existing === "string" ? existing : "") + delta } - - dirtySessionIds.add(sessionId) - scheduleNotifySession(sessionId) - return true -} - -/** - * Check if a part type should go through streaming buffer. - */ -export function isStreamingPartType(part: Part): boolean { - return part.type === "text" || part.type === "reasoning" -} - -/** - * Check if a field name corresponds to a streamable text field. - */ -export function isStreamingField(field: string): boolean { - return field === "content" || field === "text" || field === "reasoning" -} - -/** - * Get streaming parts for a specific message (used in derived atoms). - */ -export function getStreamingPartsForMessage(messageId: string): Record | undefined { - return buffer[messageId] -} - -/** - * Get all streaming parts. - */ -export function getAllStreamingParts(): Record> { - return buffer -} - -/** - * Check if there are any buffered streaming parts. - */ -export function hasStreamingParts(): boolean { - return Object.keys(buffer).length > 0 -} - -/** - * Get the current streaming part for a given message + part ID. - */ -export function getStreamingPart(messageId: string, partId: string): Part | undefined { - return buffer[messageId]?.[partId] -} - -/** - * Flush all streaming parts into the main Jotai atoms. - * Called when a session goes idle. - * Returns the flushed parts as a flat array for batch upsert. - */ -export function flushStreamingParts(): Part[] { - // Cancel all pending per-session timers - for (const [, t] of sessionThrottle) { - if (t.scheduled) { - clearTimeout(t.scheduled) - t.scheduled = undefined - } - } - - const allParts: Part[] = [] - const affectedSessions = new Set() - - for (const key in buffer) { - const sid = messageSessionMap.get(key) - if (sid) affectedSessions.add(sid) - for (const partId in buffer[key]) { - allParts.push(buffer[key][partId]) - } - } - - buffer = {} - bufferMessageIdMap.clear() - dirtySessionIds.clear() - - // Notify affected sessions that streaming data has been flushed - for (const sid of affectedSessions) { - appStore.set(streamingVersionFamily(sid), (v) => v + 1) - } - - return allParts -} diff --git a/apps/desktop/src/renderer/atoms/ui.ts b/apps/desktop/src/renderer/atoms/ui.ts index f27a9d2a..83ff832c 100644 --- a/apps/desktop/src/renderer/atoms/ui.ts +++ b/apps/desktop/src/renderer/atoms/ui.ts @@ -1,6 +1,8 @@ import { atom } from "jotai" -import { atomFamily, atomWithStorage } from "jotai/utils" -import type { FileDiff } from "../lib/types" +import { atomWithStorage } from "jotai/utils" +import { atomFamily } from "jotai-family" +import type { FileDiff, WorkspaceChangeScope } from "../lib/types" +import { REVIEW_PANEL_DEFAULT_WIDTH_PX } from "../lib/review-panel-width" export const commandPaletteOpenAtom = atom(false) @@ -89,6 +91,54 @@ export const sessionAtBottomFamily = atomFamily((sessionId: string) => /** Whether the review panel is open (resets to closed on app start) */ export const reviewPanelOpenAtom = atom(false) +/** Kind of view hosted in a right-panel browser tab. */ +export type RightPanelTabKind = "changes" | "terminal" + +export type RightPanelTab = { + id: string + kind: RightPanelTabKind + title: string +} + +export type RightPanelTabsState = { + tabs: RightPanelTab[] + activeId: string +} + +const DEFAULT_CHANGES_TAB: RightPanelTab = { + id: "changes-main", + kind: "changes", + title: "Changes", +} + +export const DEFAULT_RIGHT_PANEL_CHANGES_TAB = DEFAULT_CHANGES_TAB + +export const rightPanelTabsAtom = atom({ + tabs: [DEFAULT_CHANGES_TAB], + activeId: DEFAULT_CHANGES_TAB.id, +}) + +/** @deprecated Prefer rightPanelTabsAtom; kept for any leftover single-tab reads. */ +export type RightPanelTabId = RightPanelTabKind +export const rightPanelTabAtom = atom( + (get) => { + const state = get(rightPanelTabsAtom) + return state.tabs.find((tab) => tab.id === state.activeId)?.kind ?? "changes" + }, + (_get, set, kind: RightPanelTabKind) => { + set(rightPanelTabsAtom, (prev) => { + const existing = prev.tabs.find((tab) => tab.kind === kind) + if (existing) return { ...prev, activeId: existing.id } + const next: RightPanelTab = { + id: `${kind}-${Date.now().toString(36)}`, + kind, + title: kind === "changes" ? "Changes" : kind === "terminal" ? "Terminal" : kind, + } + return { tabs: [...prev.tabs, next], activeId: next.id } + }) + }, +) + /** Session-header context occupancy overlay (aligned to the conversation column). */ export const contextUsageOpenAtom = atom(false) @@ -119,13 +169,57 @@ export interface ReviewPanelSettings { diffStyle: DiffStyle /** Whether the review panel is expanded to full width */ expanded: boolean + /** User-resized panel width in pixels (ignored while expanded) */ + widthPx: number + /** Server-side `--ignore-all-space` for git-backed scopes */ + ignoreWhitespace: boolean + /** Wrap long diff lines instead of horizontal scrolling */ + wordWrap: boolean } export const reviewPanelSettingsAtom = atomWithStorage( "devo:review-panel-settings", - { diffStyle: "unified", expanded: false }, + { + diffStyle: "unified", + expanded: false, + widthPx: REVIEW_PANEL_DEFAULT_WIDTH_PX, + ignoreWhitespace: false, + wordWrap: false, + }, +) + +/** + * Per-workspace Changes UI (scope, expand toggles, find). Shared across + * sessions in the same project directory; survives panel hide / tab remounts. + */ +export type ReviewPanelWorkspaceUi = { + scope: WorkspaceChangeScope + baseBranch: string | null + /** Expanded/collapsed overrides keyed by scope, then file path. */ + userTogglesByScope: Partial>> + filterOpen: boolean + filterQuery: string +} + +const DEFAULT_REVIEW_PANEL_WORKSPACE_UI: ReviewPanelWorkspaceUi = { + // Default to working-tree scope; lists load via Electron local git (IPC). + // "turn" still uses the Devo server (checkpoint baseline). + scope: "uncommitted", + baseBranch: null, + userTogglesByScope: {}, + filterOpen: false, + filterQuery: "", +} + +export const reviewPanelWorkspaceUiFamily = atomFamily((_directory: string) => + atom(DEFAULT_REVIEW_PANEL_WORKSPACE_UI), ) +/** @deprecated Use reviewPanelWorkspaceUiFamily — kept for any leftover imports. */ +export type ReviewPanelSessionUi = ReviewPanelWorkspaceUi +/** @deprecated Use reviewPanelWorkspaceUiFamily */ +export const reviewPanelSessionUiFamily = reviewPanelWorkspaceUiFamily + /** Per-session diff data from the Devo API */ export const sessionDiffFamily = atomFamily((_sessionId: string) => atom([])) diff --git a/apps/desktop/src/renderer/atoms/workspace-changes.test.ts b/apps/desktop/src/renderer/atoms/workspace-changes.test.ts new file mode 100644 index 00000000..29b0fc00 --- /dev/null +++ b/apps/desktop/src/renderer/atoms/workspace-changes.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test" +import type { + WorkspaceChangeScope, + WorkspaceChangeView, + WorkspaceChangesUpdatedEventProperties, +} from "@devo-ai/sdk/v2/client" +import { createStore } from "jotai" +import { + applyWorkspaceChangesUpdatedAtom, + GIT_SCOPES, + latestWorkspaceTurnIdFamily, + registerSessionWorkspaceDirectoryAtom, + setWorkspaceChangesViewAtom, + workspaceChangesKey, + workspaceChangesStateFamily, +} from "./workspace-changes" +import type { WorkspaceChangesSummary } from "./workspace-changes" + +const SESSION = "ses_test" +const SESSION_B = "ses_other" +const TURN = "turn_1" +const CWD = "C:/tmp/repo" + +function turnEvent(scope: WorkspaceChangeScope): WorkspaceChangesUpdatedEventProperties { + return { + sessionId: SESSION, + turnId: TURN, + scope, + status: "ready", + coverage: "git_visible", + changeSetStatus: scope === "turn" ? "finalized" : "accumulating", + stats: { filesChanged: 2, additions: 10, deletions: 4 }, + version: 1, + generatedAt: "2026-09-07T00:00:00Z", + } +} + +function fakeView(scope: WorkspaceChangeScope): WorkspaceChangeView { + return { + scope, + status: "ready", + workspaceRoot: "C:/tmp/repo", + coverage: "git_visible", + attribution: "git_working_tree", + changeSetStatus: "accumulating", + files: [], + stats: { filesChanged: 0n, additions: 0n, deletions: 0n }, + warnings: [], + generatedAt: "2026-09-07T00:00:00Z", + } +} + +function seedView( + store: ReturnType, + scope: WorkspaceChangeScope, + ignoreWhitespace: boolean, +) { + store.set(setWorkspaceChangesViewAtom, { + key: workspaceChangesKey({ cwd: CWD, sessionId: SESSION, scope, ignoreWhitespace }), + view: fakeView(scope), + }) +} + +function summaryOf(scope: WorkspaceChangeScope): WorkspaceChangesSummary { + return { + sessionId: SESSION, + turnId: TURN, + scope, + status: "ready", + coverage: "git_visible", + changeSetStatus: "accumulating", + stats: { files_changed: 1, additions: 1, deletions: 0 }, + version: 1, + generatedAt: "2026-09-07T00:00:00Z", + } +} + +describe("workspaceChangesKey", () => { + test("distinguishes whitespace variants", () => { + const base = { cwd: CWD, scope: "uncommitted" as const } + expect(workspaceChangesKey({ ...base, ignoreWhitespace: false })).not.toBe( + workspaceChangesKey({ ...base, ignoreWhitespace: true }), + ) + expect(workspaceChangesKey(base)).toBe(workspaceChangesKey({ ...base, ignoreWhitespace: null })) + expect(workspaceChangesKey(base)).toBe(workspaceChangesKey({ ...base, ignoreWhitespace: false })) + }) + + test("git scopes share a cache across sessions in the same workspace", () => { + const a = workspaceChangesKey({ + cwd: CWD, + sessionId: SESSION, + scope: "unstaged", + }) + const b = workspaceChangesKey({ + cwd: CWD, + sessionId: SESSION_B, + scope: "unstaged", + }) + expect(a).toBe(b) + }) + + test("turn scope stays partitioned by session", () => { + const a = workspaceChangesKey({ + cwd: CWD, + sessionId: SESSION, + scope: "turn", + turnId: TURN, + }) + const b = workspaceChangesKey({ + cwd: CWD, + sessionId: SESSION_B, + scope: "turn", + turnId: TURN, + }) + expect(a).not.toBe(b) + }) +}) + +describe("applyWorkspaceChangesUpdatedAtom", () => { + test("records the latest turn id and stales the turn key", () => { + const store = createStore() + store.set(applyWorkspaceChangesUpdatedAtom, turnEvent("turn")) + + expect(store.get(latestWorkspaceTurnIdFamily(SESSION))).toBe(TURN) + + const state = store.get( + workspaceChangesStateFamily( + workspaceChangesKey({ sessionId: SESSION, scope: "turn", turnId: TURN }), + ), + ) + expect(state.summary?.stats.files_changed).toBe(2) + expect(state.stale).toBe(true) + expect(state.error).toBeNull() + }) + + test("turn events stale every git scope in both whitespace variants without dropping views", () => { + const store = createStore() + store.set(registerSessionWorkspaceDirectoryAtom, { sessionId: SESSION, directory: CWD }) + + for (const scope of GIT_SCOPES) { + for (const ignoreWhitespace of [false, true]) seedView(store, scope, ignoreWhitespace) + } + const unstagedWhitespaceKey = workspaceChangesKey({ + cwd: CWD, + scope: "unstaged", + ignoreWhitespace: true, + }) + store.set(workspaceChangesStateFamily(unstagedWhitespaceKey), { + summary: summaryOf("unstaged"), + view: fakeView("unstaged"), + loading: false, + stale: false, + error: null, + }) + const stagedWhitespaceKey = workspaceChangesKey({ + cwd: CWD, + scope: "staged", + ignoreWhitespace: true, + }) + store.set(workspaceChangesStateFamily(stagedWhitespaceKey), { + summary: summaryOf("staged"), + view: fakeView("staged"), + loading: false, + stale: false, + error: null, + }) + + store.set(applyWorkspaceChangesUpdatedAtom, turnEvent("turn")) + + for (const scope of GIT_SCOPES) { + for (const ignoreWhitespace of [false, true]) { + const state = store.get( + workspaceChangesStateFamily( + workspaceChangesKey({ cwd: CWD, scope, ignoreWhitespace }), + ), + ) + expect(state.stale).toBe(true) + expect(state.view).not.toBeNull() + } + } + expect(store.get(workspaceChangesStateFamily(stagedWhitespaceKey)).summary).toEqual( + summaryOf("staged"), + ) + }) + + test("non-turn events only stale their own key", () => { + const store = createStore() + store.set(registerSessionWorkspaceDirectoryAtom, { sessionId: SESSION, directory: CWD }) + store.set(applyWorkspaceChangesUpdatedAtom, turnEvent("uncommitted")) + + expect(store.get(latestWorkspaceTurnIdFamily(SESSION))).toBeNull() + expect( + store.get( + workspaceChangesStateFamily( + workspaceChangesKey({ cwd: CWD, scope: "uncommitted" }), + ), + ).stale, + ).toBe(true) + expect( + store.get( + workspaceChangesStateFamily( + workspaceChangesKey({ cwd: CWD, scope: "uncommitted", ignoreWhitespace: true }), + ), + ).summary, + ).toBeNull() + }) + + test("keeps only the two newest turn views per session", () => { + const store = createStore() + const key1 = workspaceChangesKey({ sessionId: SESSION, scope: "turn", turnId: "t1" }) + const key2 = workspaceChangesKey({ sessionId: SESSION, scope: "turn", turnId: "t2" }) + const key3 = workspaceChangesKey({ sessionId: SESSION, scope: "turn", turnId: "t3" }) + store.set(setWorkspaceChangesViewAtom, { key: key1, view: fakeView("turn") }) + store.set(setWorkspaceChangesViewAtom, { key: key2, view: fakeView("turn") }) + store.set(setWorkspaceChangesViewAtom, { key: key3, view: fakeView("turn") }) + + // Oldest turn payload is cleared (not atomFamily.remove — that breaks subscribers). + expect(store.get(workspaceChangesStateFamily(key1)).view).toBeNull() + expect(store.get(workspaceChangesStateFamily(key2)).view).not.toBeNull() + expect(store.get(workspaceChangesStateFamily(key3)).view).not.toBeNull() + }) +}) diff --git a/apps/desktop/src/renderer/atoms/workspace-changes.ts b/apps/desktop/src/renderer/atoms/workspace-changes.ts index 15c805a8..13e48b84 100644 --- a/apps/desktop/src/renderer/atoms/workspace-changes.ts +++ b/apps/desktop/src/renderer/atoms/workspace-changes.ts @@ -7,13 +7,23 @@ import type { WorkspaceChangesUpdatedEventProperties, } from "@devo-ai/sdk/v2/client" import { atom } from "jotai" -import { atomFamily } from "jotai/utils" +import { atomFamily } from "jotai-family" + +/** Scopes backed by live git state — partitioned by workspace cwd, not session. */ +export const GIT_SCOPES: WorkspaceChangeScope[] = ["staged", "unstaged", "uncommitted", "branch"] export type WorkspaceChangesCacheKeyInput = { - sessionId: string + /** + * Workspace root. Required partition for git scopes so sessions in the same + * project share one Changes cache. + */ + cwd?: string | null + /** Session id — only partitions the turn scope (and legacy fallbacks). */ + sessionId?: string | null scope: WorkspaceChangeScope turnId?: string | null baseBranch?: string | null + ignoreWhitespace?: boolean | null } export type WorkspaceChangesSummary = { @@ -40,12 +50,31 @@ export type WorkspaceChangesState = { error: string | null } +function isGitScope(scope: WorkspaceChangeScope): boolean { + return (GIT_SCOPES as string[]).includes(scope) +} + +/** + * Cache key for a Changes view. + * - Git scopes: `cwd` + scope + baseBranch + whitespace (shared across sessions). + * - Turn scope: `sessionId` + turnId (per-session artifact). + */ export function workspaceChangesKey(input: WorkspaceChangesCacheKeyInput): string { + if (input.scope === "turn" || !isGitScope(input.scope)) { + return [ + input.sessionId ?? "", + input.scope, + input.turnId ?? "", + "", + input.ignoreWhitespace ? "w" : "", + ].join("\u001f") + } return [ - input.sessionId, + input.cwd || input.sessionId || "", input.scope, - input.turnId ?? "", + "", input.baseBranch ?? "", + input.ignoreWhitespace ? "w" : "", ].join("\u001f") } @@ -61,8 +90,8 @@ function emptyWorkspaceChangesState(): WorkspaceChangesState { function eventSummary(event: WorkspaceChangesUpdatedEventProperties): WorkspaceChangesSummary { return { - sessionId: event.sessionID, - turnId: event.turnID, + sessionId: event.sessionId, + turnId: event.turnId, scope: event.scope, status: event.status, coverage: event.coverage, @@ -81,6 +110,19 @@ export const latestWorkspaceTurnIdFamily = atomFamily((_sessionId: string) => atom(null), ) +/** Maps a session to its workspace directory for git-scope invalidation. */ +export const sessionWorkspaceDirectoryFamily = atomFamily((_sessionId: string) => + atom(null), +) + +export const registerSessionWorkspaceDirectoryAtom = atom( + null, + (_get, set, args: { sessionId: string; directory: string }) => { + if (!args.sessionId || !args.directory) return + set(sessionWorkspaceDirectoryFamily(args.sessionId), args.directory) + }, +) + export const workspaceChangesStateFamily = atomFamily((_key: string) => atom(emptyWorkspaceChangesState()), ) @@ -97,6 +139,12 @@ export const markWorkspaceChangesLoadingAtom = atom( }, ) +/** Keep at most this many turn-scoped Changes views per session (LRU by write). */ +const MAX_CACHED_TURN_VIEWS = 2 + +/** sessionId → most-recently-written turn cache keys (newest first). */ +const turnViewKeysBySession = new Map() + export const setWorkspaceChangesViewAtom = atom( null, (_get, set, args: { key: string; view: WorkspaceChangeView }) => { @@ -107,6 +155,24 @@ export const setWorkspaceChangesViewAtom = atom( stale: false, error: null, }) + const parts = args.key.split("\u001f") + if (parts[1] !== "turn") return + const sessionId = parts[0] + if (!sessionId) return + const prev = turnViewKeysBySession.get(sessionId) ?? [] + const next = [args.key, ...prev.filter((item) => item !== args.key)].slice( + 0, + MAX_CACHED_TURN_VIEWS, + ) + // Drop heavy payloads from older turns. Do NOT atomFamily.remove() here — + // removing atoms that still have React subscribers (or mid-write) triggers + // "Should have a queue / Hooks conditionally". + for (const stale of prev) { + if (!next.includes(stale)) { + set(workspaceChangesStateFamily(stale), emptyWorkspaceChangesState()) + } + } + turnViewKeysBySession.set(sessionId, next) }, ) @@ -127,12 +193,14 @@ export const applyWorkspaceChangesUpdatedAtom = atom( (get, set, event: WorkspaceChangesUpdatedEventProperties) => { const summary = eventSummary(event) if (event.scope === "turn") { - set(latestWorkspaceTurnIdFamily(event.sessionID), event.turnID) + set(latestWorkspaceTurnIdFamily(event.sessionId), event.turnId) } + const cwd = get(sessionWorkspaceDirectoryFamily(event.sessionId)) const key = workspaceChangesKey({ - sessionId: event.sessionID, + cwd, + sessionId: event.sessionId, scope: event.scope, - turnId: event.scope === "turn" ? event.turnID : undefined, + turnId: event.scope === "turn" ? event.turnId : undefined, }) const current = get(workspaceChangesStateFamily(key)) set(workspaceChangesStateFamily(key), { @@ -141,5 +209,24 @@ export const applyWorkspaceChangesUpdatedAtom = atom( stale: true, error: null, }) + if (event.scope === "turn") { + // A finished turn changes the working tree, so every git-backed + // scope cache for this workspace is potentially stale. + const workspace = cwd || event.sessionId + for (const scope of GIT_SCOPES) { + for (const ignoreWhitespace of [false, true]) { + const scopeKey = workspaceChangesKey({ + cwd: workspace, + sessionId: event.sessionId, + scope, + ignoreWhitespace, + }) + const scopeState = get(workspaceChangesStateFamily(scopeKey)) + if (scopeState.view || scopeState.summary) { + set(workspaceChangesStateFamily(scopeKey), { ...scopeState, stale: true }) + } + } + } + } }, ) diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index 1dfcafc3..299b2ce0 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -46,7 +46,7 @@ import { import { ChatView } from "./chat" import { ContextUsageButton } from "./context-usage-button" import { BottomPanelIcon, RightPanelIcon } from "./panel-icons" -import { ReviewPanel } from "./review/review-panel" +import { ReviewSideRail } from "./review/review-side-rail" import { WorktreeActions } from "./worktree-actions" function useTurnWorkspaceChangeStats(sessionId: string): { @@ -131,8 +131,6 @@ interface AgentDetailProps { onForkFromTurn?: (turnId?: string) => Promise /** Edit and resend the latest user message */ onEditUserMessage?: (messageId: string, text: string) => Promise - /** Delete a specific part from a message (for error recovery) */ - onDeletePart?: (sessionId: string, messageId: string, partId: string) => Promise } export function AgentDetail({ @@ -163,7 +161,6 @@ export function AgentDetail({ isReverted, onForkFromTurn, onEditUserMessage, - onDeletePart, }: AgentDetailProps) { const navigate = useNavigate() const { projectSlug } = useParams({ strict: false }) as { projectSlug?: string } @@ -175,6 +172,7 @@ export function AgentDetail({ // Review panel state const [reviewPanelOpen, setReviewPanelOpen] = useAtom(reviewPanelOpenAtom) const [reviewSettings, setReviewSettings] = useAtom(reviewPanelSettingsAtom) + const reviewExpanded = reviewPanelOpen && Boolean(reviewSettings.expanded) // Keyboard shortcut: Cmd/Ctrl+Shift+D to toggle review panel useEffect(() => { @@ -299,7 +297,6 @@ export function AgentDetail({ isReverted={isReverted} onForkFromTurn={onForkFromTurn} onEditUserMessage={onEditUserMessage} - onDeletePart={onDeletePart} parentSessionName={parentSessionName} reviewPanelOpen={reviewPanelOpen} /> @@ -308,20 +305,23 @@ export function AgentDetail({ ) return ( -
- {/* Chat panel -- takes remaining space */} -
{chatContent}
- - {/* Review panel -- slides in/out from right */} +
+ {/* Chat panel -- collapses when Changes is expanded full-width */}
- {/* Keep ReviewPanel mounted so it retains state, just hidden at 0 width */} -
- -
+ {chatContent}
+ +
) } diff --git a/apps/desktop/src/renderer/components/app-bar.tsx b/apps/desktop/src/renderer/components/app-bar.tsx index 8e401bc8..4a65c077 100644 --- a/apps/desktop/src/renderer/components/app-bar.tsx +++ b/apps/desktop/src/renderer/components/app-bar.tsx @@ -1,7 +1,7 @@ import { useAppBarContent } from "./app-bar-context" // Height of the app bar in pixels — used as CSS variable -export const APP_BAR_HEIGHT = 40 +export const APP_BAR_HEIGHT = 32 /** * Detect whether we're running inside Electron (preload injects `window.devo`). diff --git a/apps/desktop/src/renderer/components/chat/assistant-item-error.test.ts b/apps/desktop/src/renderer/components/chat/assistant-item-error.test.ts new file mode 100644 index 00000000..bb54d391 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/assistant-item-error.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" +import { isAssistantItemError } from "./assistant-item-error" + +function envelope( + type: string, + state: string, + extra: Record = {}, +): NativeItemEnvelope { + return { + id: "item-1", + sessionId: "session-1", + turnId: "turn-1", + seq: 1, + revision: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + state, + item: { type, ...extra }, + } +} + +describe("Native assistant error detection", () => { + test("detects failed state and explicit assistant error metadata", () => { + expect(isAssistantItemError(envelope("assistantMessage", "failed"))).toBe(true) + expect(isAssistantItemError(envelope("assistantMessage", "completed", { error: "provider failed" }))).toBe(true) + }) + + test("does not treat successful assistants or failed tool rows as assistant errors", () => { + expect(isAssistantItemError(envelope("assistantMessage", "completed"))).toBe(false) + expect(isAssistantItemError(envelope("toolResult", "failed", { error: "tool failed" }))).toBe(false) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/assistant-item-error.ts b/apps/desktop/src/renderer/components/chat/assistant-item-error.ts new file mode 100644 index 00000000..da0d5c34 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/assistant-item-error.ts @@ -0,0 +1,9 @@ +import type { NativeItemEnvelope } from "@devo-ai/sdk/v2/client" + +/** Native assistant failures are carried by the envelope state or item error field. */ +export function isAssistantItemError(info: NativeItemEnvelope): boolean { + return ( + info.item.type === "assistantMessage" && + (info.state === "failed" || info.item.error != null) + ) +} diff --git a/apps/desktop/src/renderer/components/chat/chat-composer-persist.test.ts b/apps/desktop/src/renderer/components/chat/chat-composer-persist.test.ts index 3b0cf720..c9aa3b7b 100644 --- a/apps/desktop/src/renderer/components/chat/chat-composer-persist.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-composer-persist.test.ts @@ -133,3 +133,11 @@ describe("composer persist-on-selection", () => { expect(newChatSource.includes("DEFAULT_COMPOSER_PERMISSION_PROFILE")).toBe(true) }) }) + + +test("new-chat defaults come from Native rather than an older local model override", () => { + expect({ restoresLocalModel: newChatSource.includes("setSelectedModel(stored)"), + restoresLocalEffort: newChatSource.includes("setSelectedVariant(stored.variant)"), + restoresAgent: newChatSource.includes("setSelectedAgent(stored?.agent ?? null)") }) + .toEqual({ restoresLocalModel: false, restoresLocalEffort: false, restoresAgent: true }) +}) diff --git a/apps/desktop/src/renderer/components/chat/chat-input-layout.test.ts b/apps/desktop/src/renderer/components/chat/chat-input-layout.test.ts new file mode 100644 index 00000000..8250e94a --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chat-input-layout.test.ts @@ -0,0 +1,10 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const chatViewSource = readFileSync(new URL("./chat-view.tsx", import.meta.url), "utf8") + +describe("narrow composer layout", () => { + test("allows composer controls to wrap before they collide", () => { + expect(chatViewSource).toContain('') + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/chat-permission-keyboard.test.tsx b/apps/desktop/src/renderer/components/chat/chat-permission-keyboard.test.tsx new file mode 100644 index 00000000..5fa50436 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chat-permission-keyboard.test.tsx @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { act } from "react" +import type { Root } from "react-dom/client" +import type { Agent, PermissionRequest, PermissionResponse } from "../../lib/types" + +const dom = new Window({ url: "http://localhost" }) +Object.assign(globalThis, { + window: dom, + document: dom.document, + navigator: dom.navigator, + Node: dom.Node, + Element: dom.Element, + HTMLElement: dom.HTMLElement, + HTMLInputElement: dom.HTMLInputElement, + KeyboardEvent: dom.KeyboardEvent, + requestAnimationFrame: dom.requestAnimationFrame.bind(dom), + cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), + IS_REACT_ACT_ENVIRONMENT: true, +}) +const { createRoot } = await import("react-dom/client") +const { ChatPermissionFlow } = await import("./chat-permission") +const { handleSessionNavigationKeyDown } = await import("../root-layout-keyboard") +const agent = { id: "session-1" } as Agent +const permission: PermissionRequest = { + id: "permission-1", + requestId: "permission-1", + sessionId: "session-1", + permission: "read file", + metadata: { availableScopes: ["once"] }, +} +let root: Root | undefined +let mount: HTMLElement | undefined +let approvals: (PermissionResponse | undefined)[] = [] +let denials: (string | undefined)[] = [] + +async function renderCard() { + mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + await act(async () => { + root!.render( + { approvals.push(scope) }} + onDeny={async (_agent, _session, _permission, note) => { denials.push(note) }} + />, + ) + }) + return mount.querySelector('section[aria-label="Tool permission request"]')! +} + +async function key(target: EventTarget, name: string) { + const event = new dom.KeyboardEvent("keydown", { key: name, bubbles: true, cancelable: true }) + await act(async () => { target.dispatchEvent(event as unknown as Event) }) + return event +} + +afterEach(async () => { + if (root) await act(async () => { root!.unmount() }) + root = undefined + mount?.remove() + mount = undefined + document.body.replaceChildren() + approvals = [] + denials = [] +}) + +describe("permission card keyboard", () => { + test("Enter elsewhere cannot approve Allow once; arrows and Escape elsewhere remain free", async () => { + const card = await renderCard() + const outside = document.createElement("button") + document.body.append(outside) + outside.focus() + expect((await key(outside, "Enter")).defaultPrevented).toBe(false) + expect((await key(outside, "ArrowDown")).defaultPrevented).toBe(false) + expect((await key(outside, "Escape")).defaultPrevented).toBe(false) + expect(approvals).toEqual([]) + expect(denials).toEqual([]) + expect(card.querySelector('[aria-selected="true"]')?.textContent).toContain("Allow once") + }) + + test("card arrows/Enter approve, Escape selects denial and note Enter denies", async () => { + const card = await renderCard() + card.focus() + expect((await key(card, "Enter")).defaultPrevented).toBe(true) + expect(approvals).toEqual(["once"]) + expect((await key(card, "Escape")).defaultPrevented).toBe(true) + const note = card.querySelector("#permission-deny-note")! + note.focus() + await act(async () => { + Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(note, "not now") + note.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event) + }) + // The selected deny input stays inside the permission card. + expect((await key(note, "Enter")).defaultPrevented).toBe(true) + expect(denials).toEqual(["not now"]) + const outside = document.createElement("input") + document.body.append(outside) + outside.focus() + expect((await key(outside, "Enter")).defaultPrevented).toBe(false) + expect(denials).toEqual(["not now"]) + }) + + test("card Escape wins over document capture session navigation", async () => { + const card = await renderCard() + const navigations: string[] = [] + const onCapture = (event: KeyboardEvent) => { + handleSessionNavigationKeyDown(event, { + agents: [{ id: "session-1", projectSlug: "project" }], + sessionId: "session-1", + navigate: (target) => navigations.push(target.to), + }) + } + document.addEventListener("keydown", onCapture, { capture: true }) + try { + card.focus() + expect((await key(card, "Escape")).defaultPrevented).toBe(true) + expect(card.querySelector("#permission-deny-note")).not.toBeNull() + expect(navigations).toEqual([]) + } finally { + document.removeEventListener("keydown", onCapture, { capture: true }) + } + }) + + test("arrow navigation within the card selects denial without approving", async () => { + const card = await renderCard() + card.focus() + expect((await key(card, "ArrowDown")).defaultPrevented).toBe(true) + expect(card.querySelector("#permission-deny-note")).not.toBeNull() + expect(approvals).toEqual([]) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/chat-permission-options.test.ts b/apps/desktop/src/renderer/components/chat/chat-permission-options.test.ts index 7b81f8f7..bed68236 100644 --- a/apps/desktop/src/renderer/components/chat/chat-permission-options.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-permission-options.test.ts @@ -8,8 +8,8 @@ describe("chat permission options", () => { test("matches TUI once + deny for minimal scopes", () => { const choices = buildApprovalChoices({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "write: src/main.rs", metadata: { availableScopes: ["once"] }, }) @@ -19,8 +19,8 @@ describe("chat permission options", () => { test("includes contextual session and prefix-persist options", () => { const choices = buildApprovalChoices({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "run git add file.txt", metadata: { availableScopes: ["once", "session", "commandPrefixPersist"], @@ -40,8 +40,8 @@ describe("chat permission options", () => { test("does not surface turn or tool scopes in the picker", () => { const choices = buildApprovalChoices({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "write: hello.txt", metadata: { availableScopes: ["once", "turn", "tool", "session"], @@ -59,11 +59,11 @@ describe("chat permission options", () => { test("session scope label uses exact filepath for file tools", () => { const choices = buildApprovalChoices({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "write: C:\\Users\\hello.txt", metadata: { - availableScopes: ["once", "session", "path_prefix"], + availableScopes: ["once", "session", "pathPrefix"], path: "C:\\Users\\lenovo\\Desktop\\hello.txt", }, }) @@ -78,11 +78,11 @@ describe("chat permission options", () => { test("uses parent directory for file path prefix labels", () => { const choices = buildApprovalChoices({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "write: C:\\Users\\hello.txt", metadata: { - availableScopes: ["once", "path_prefix"], + availableScopes: ["once", "pathPrefix"], path: "C:\\Users\\lenovo\\Desktop\\hello.txt", }, }) @@ -93,30 +93,26 @@ describe("chat permission options", () => { ]) }) - test("normalizes legacy snake_case scopes", () => { + test("ignores non-canonical snake_case scopes", () => { const choices = buildApprovalChoices({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "write: hello.txt", metadata: { availableScopes: ["once", "path_prefix"], path: "C:\\Users\\hello.txt", }, }) - expect(choices.map((choice) => choice.label)).toEqual([ - "Allow once", - "Allow files under `C:\\Users`", - "Deny", - ]) + expect(choices.map((choice) => choice.label)).toEqual(["Allow once", "Deny"]) }) test("shows only action summary and agent reason", () => { expect( permissionSummaryLines({ id: "approval-1", - requestID: "approval-1", - sessionID: "session-1", + requestId: "approval-1", + sessionId: "session-1", permission: "write: C:\\Users\\hello.txt", metadata: { tool: "FileWrite", diff --git a/apps/desktop/src/renderer/components/chat/chat-permission-options.ts b/apps/desktop/src/renderer/components/chat/chat-permission-options.ts index 839322a5..1498f4bd 100644 --- a/apps/desktop/src/renderer/components/chat/chat-permission-options.ts +++ b/apps/desktop/src/renderer/components/chat/chat-permission-options.ts @@ -13,28 +13,19 @@ export type ApprovalChoice = label: string } -const SCOPE_ALIASES: Record = { - once: "once", - turn: "turn", - session: "session", - tool: "tool", - pathprefix: "pathPrefix", - path_prefix: "pathPrefix", - host: "host", - commandprefix: "commandPrefix", - command_prefix: "commandPrefix", - commandprefixpersist: "commandPrefixPersist", - command_prefix_persist: "commandPrefixPersist", -} - -function normalizeScopeToken(scope: unknown): PermissionResponse | null { - if (typeof scope !== "string") return null - const camel = scope.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) - for (const candidate of [scope, camel]) { - const key = candidate.toLowerCase() - if (key in SCOPE_ALIASES) return SCOPE_ALIASES[key] - } - return null +const KNOWN_SCOPES: PermissionResponse[] = [ + "once", + "turn", + "session", + "pathPrefix", + "host", + "tool", + "commandPrefix", + "commandPrefixPersist", +] + +function isPermissionResponse(scope: unknown): scope is PermissionResponse { + return typeof scope === "string" && (KNOWN_SCOPES as string[]).includes(scope) } function hasScope(scopes: PermissionResponse[], target: PermissionResponse): boolean { @@ -67,10 +58,7 @@ function availableScopesFor(permission: PermissionRequest): PermissionResponse[] const raw = Array.isArray(permission.metadata?.availableScopes) ? permission.metadata.availableScopes : ["once"] - const normalized = raw - .map(normalizeScopeToken) - .filter((scope): scope is PermissionResponse => scope !== null) - return Array.from(new Set(normalized)) + return Array.from(new Set(raw.filter(isPermissionResponse))) } /** diff --git a/apps/desktop/src/renderer/components/chat/chat-permission.tsx b/apps/desktop/src/renderer/components/chat/chat-permission.tsx index 988fa2a9..35e53f86 100644 --- a/apps/desktop/src/renderer/components/chat/chat-permission.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-permission.tsx @@ -1,7 +1,16 @@ import { Button } from "@devo/ui/components/button" import { cn } from "@devo/ui/lib/utils" import { ArrowUpIcon, CheckIcon, Loader2Icon } from "lucide-react" -import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react" +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type RefObject, +} from "react" import type { Agent, PermissionRequest, PermissionResponse } from "../../lib/types" import { buildApprovalChoices, @@ -69,9 +78,9 @@ export const ChatPermissionFlow = memo(function ChatPermissionFlow({ setResponding(true) try { if (selectedChoice.kind === "deny") { - await onDeny?.(agent, permission.sessionID, permission.id, denyNote.trim() || undefined) + await onDeny?.(agent, permission.sessionId, permission.id, denyNote.trim() || undefined) } else { - await onApprove?.(agent, permission.sessionID, permission.id, selectedChoice.scope) + await onApprove?.(agent, permission.sessionId, permission.id, selectedChoice.scope) } } finally { setResponding(false) @@ -84,7 +93,7 @@ export const ChatPermissionFlow = memo(function ChatPermissionFlow({ onApprove, onDeny, permission.id, - permission.sessionID, + permission.sessionId, responding, selectedChoice, ]) @@ -99,43 +108,27 @@ export const ChatPermissionFlow = memo(function ChatPermissionFlow({ [choices.length], ) - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.target instanceof HTMLInputElement && event.target.id === "permission-deny-note") { - if (event.key === "ArrowDown") { - event.preventDefault() - moveSelection(1) - return - } - if (event.key === "ArrowUp") { - event.preventDefault() - moveSelection(-1) - return - } - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - void handleSubmit() - } - return - } + // Only keyboard events from this card can answer the pending permission. + // In particular, Enter in another part of the app must never approve it. + function handleKeyDown(event: KeyboardEvent) { + if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing) return - if (event.key === "ArrowDown") { - event.preventDefault() - moveSelection(1) - } else if (event.key === "ArrowUp") { - event.preventDefault() - moveSelection(-1) - } else if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - void handleSubmit() - } else if (event.key === "Escape") { - event.preventDefault() - setSelectedIndex(choices.findIndex((choice) => choice.kind === "deny")) - } + if (event.key === "ArrowDown") { + event.preventDefault() + moveSelection(1) + } else if (event.key === "ArrowUp") { + event.preventDefault() + moveSelection(-1) + } else if (event.key === "Enter" && !event.shiftKey) { + // Let focused buttons handle Enter as a native click, not two submissions. + if (event.target instanceof Element && event.target.closest("button")) return + event.preventDefault() + void handleSubmit() + } else if (event.key === "Escape") { + event.preventDefault() + setSelectedIndex(choices.findIndex((choice) => choice.kind === "deny")) } - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [choices, handleSubmit, moveSelection]) + } useEffect(() => { const timer = requestAnimationFrame(() => cardRef.current?.focus()) @@ -145,6 +138,7 @@ export const ChatPermissionFlow = memo(function ChatPermissionFlow({ return (
{ - test("treats generic tool parts with questions as question tools", () => { - expect({ - byName: isQuestionToolInput("request_user_input"), - byAlias: isQuestionToolInput("question"), - byInput: isQuestionToolInput("tool", questionInput), - generic: isQuestionToolInput("tool", { command: "ls" }), - }).toEqual({ - byName: true, - byAlias: true, - byInput: true, - generic: false, - }) - }) - - test("summarizes one or many questions without dumping JSON", () => { - expect( - questionToolSubtitle({ - tool: "tool", - state: { status: "running", input: { questions: [questionInput.questions[0]] } }, - }), - ).toBe("Where should this run?") - expect( - questionToolSubtitle({ - tool: "request_user_input", - state: { status: "completed", input: questionInput }, - }), - ).toBe("2 questions") - }) - - test("pairs answers from the tool result onto each question", () => { - expect( - parseQuestionToolEntries({ - tool: "request_user_input", - state: { - input: questionInput, - output: JSON.stringify({ - answers: { - environment: { answers: ["Local"] }, - style: { answers: ["Compact"] }, - }, - }), - }, - }), - ).toEqual([ - { - id: "environment", - header: "Environment", - question: "Where should this run?", - isSecret: false, - answer: "Local", - }, - { - id: "style", - header: "Style", - question: "Which look do you prefer?", - isSecret: false, - answer: "Compact", - }, - ]) - }) - - test("keeps the expanded body quiet and 13px", () => { - expect({ - thirteenPx: source.includes("text-[13px]"), - waitingCopy: source.includes("Waiting for a reply"), - noPrimaryTint: !source.includes("bg-primary") && !source.includes("text-primary"), - checkIcon: source.includes("CheckIcon"), - }).toEqual({ - thirteenPx: true, - waitingCopy: true, - noPrimaryTint: true, - checkIcon: true, - }) - }) -}) diff --git a/apps/desktop/src/renderer/components/chat/chat-question-tool.tsx b/apps/desktop/src/renderer/components/chat/chat-question-tool.tsx deleted file mode 100644 index 76693ada..00000000 --- a/apps/desktop/src/renderer/components/chat/chat-question-tool.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { cn } from "@devo/ui/lib/utils" -import { CheckIcon } from "lucide-react" -import type { ToolPart } from "../../lib/types" - -export type QuestionToolEntry = { - id: string - header: string - question: string - isSecret: boolean - answer: string | null -} - -export function isQuestionToolName(tool: string): boolean { - return tool === "question" || tool === "request_user_input" -} - -export function isQuestionToolInput( - tool: string, - input?: Record | null, -): boolean { - if (isQuestionToolName(tool)) return true - return Array.isArray(input?.questions) -} - -export function parseQuestionToolEntries(part: { - tool: string - state: { - input?: Record - output?: string - status?: string - } -}): QuestionToolEntry[] { - const questions = part.state.input?.questions - if (!Array.isArray(questions)) return [] - const answers = parseQuestionAnswerMap(part.state.output) - return questions.map((raw, index) => { - const record = raw && typeof raw === "object" ? (raw as Record) : {} - const id = typeof record.id === "string" && record.id ? record.id : `question-${index}` - const header = typeof record.header === "string" ? record.header : "" - const question = typeof record.question === "string" ? record.question : "" - const isSecret = record.isSecret === true || record.is_secret === true - const values = answers[id] ?? [] - const first = values.map((value) => value.trim()).find(Boolean) ?? null - return { - id, - header, - question, - isSecret, - answer: first ? (isSecret ? "••••••" : first) : null, - } - }) -} - -export function questionToolSubtitle(part: { - tool: string - state: { - status?: string - input?: Record - output?: string - raw?: string - } -}): string | undefined { - const entries = parseQuestionToolEntries(part) - if (entries.length === 1) { - return entries[0]?.question || entries[0]?.header || undefined - } - if (entries.length > 1) { - return `${entries.length} questions` - } - if (part.state.status === "pending" || part.state.status === "running") { - return "Asking a question…" - } - return undefined -} - -function parseQuestionAnswerMap(output: string | undefined): Record { - if (!output) return {} - let value: unknown = output - try { - value = JSON.parse(output) as unknown - } catch { - return {} - } - if (!value || typeof value !== "object" || Array.isArray(value)) return {} - const record = value as Record - const nested = - record.answers && typeof record.answers === "object" && !Array.isArray(record.answers) - ? (record.answers as Record) - : record - const mapped: Record = {} - for (const [id, entry] of Object.entries(nested)) { - if (typeof entry === "string") { - mapped[id] = [entry] - continue - } - if (Array.isArray(entry)) { - mapped[id] = entry.map(String) - continue - } - if (entry && typeof entry === "object" && Array.isArray((entry as { answers?: unknown }).answers)) { - mapped[id] = ((entry as { answers: unknown[] }).answers).map(String) - } - } - return mapped -} - -export function QuestionToolContent({ part }: { part: ToolPart }) { - const entries = parseQuestionToolEntries(part) - if (entries.length === 0) return null - const waiting = part.state.status === "pending" || part.state.status === "running" - - return ( -
- {entries.map((entry) => ( -
- {entry.header ? ( -
{entry.header}
- ) : null} - {entry.question ? ( -
- {entry.question} -
- ) : null} -
- {entry.answer ? ( -
-
- ))} -
- ) -} diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts deleted file mode 100644 index 0a10f32b..00000000 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { readFileSync } from "node:fs" -import { describe, expect, test } from "bun:test" -import { buildBashTerminalOutput, getToolInfo, getToolSubtitle, parseReadOutput, stripShellEnvelope } from "./chat-tool-call" - -const elapsedHookSource = readFileSync(new URL("../../hooks/use-elapsed-time.ts", import.meta.url), "utf8") -const chatToolCallSource = readFileSync(new URL("./chat-tool-call.tsx", import.meta.url), "utf8") -const pierreDiffMountSource = readFileSync( - new URL("../../../../packages/ui/src/components/ai-elements/pierre-diff-mount.tsx", import.meta.url), - "utf8", -) -const rendererCssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8") - -describe("buildBashTerminalOutput", () => { - test("joins command and output into a single terminal block", () => { - expect({ - plain: buildBashTerminalOutput("bun test", "ok\nDone", undefined), - echoed: buildBashTerminalOutput("bun test", "$ bun test\nok", undefined), - errorPreferred: buildBashTerminalOutput("bun test", "partial", "boom"), - pending: buildBashTerminalOutput("bun test", undefined, undefined), - noCommand: buildBashTerminalOutput(undefined, "plain output", undefined), - }).toEqual({ - plain: "$ bun test\nok\nDone", - echoed: "$ bun test\nok", - errorPreferred: "$ bun test\nboom", - pending: "$ bun test", - noCommand: "plain output", - }) - }) - - test("truncates very long output", () => { - const truncated = buildBashTerminalOutput(undefined, "x".repeat(6000), undefined) - expect({ - endsWithMarker: truncated.endsWith("... (truncated)"), - length: truncated.length, - }).toEqual({ - endsWithMarker: true, - length: 5000 + "\n... (truncated)".length, - }) - }) -}) - -describe("stripShellEnvelope", () => { - test("strips the shell result envelope from tool output", () => { - const envelope = JSON.stringify({ - output: "", - command: "ls", - exit: 0, - description: "List files", - cwd: "/repo", - yield_time_ms: 1000, - }) - expect({ - envelopeOnly: stripShellEnvelope(envelope), - stdoutPlusEnvelope: stripShellEnvelope(`hello\nworld\n${envelope}`), - plainOutput: stripShellEnvelope("just text"), - otherJson: stripShellEnvelope('{"foo": 1}'), - envelopeWithOutput: stripShellEnvelope( - JSON.stringify({ output: "files", command: "ls", exit: 0 }), - ), - cmdEnvelope: stripShellEnvelope(JSON.stringify({ output: "ok", cmd: "ls", exit: 0 })), - }).toEqual({ - envelopeOnly: "", - stdoutPlusEnvelope: "hello\nworld", - plainOutput: "just text", - otherJson: '{"foo": 1}', - envelopeWithOutput: "files", - cmdEnvelope: "ok", - }) - }) -}) - -describe("getToolSubtitle", () => { - test("shows read paths relative to the project root", () => { - expect( - getToolSubtitle( - { - callID: "call-1", - id: "tool-1", - tool: "read", - type: "tool", - state: { - input: { filePath: "C:\\Users\\lenovo\\Desktop\\devo\\apps\\desktop\\src\\main.ts" }, - status: "completed", - time: { end: 1, start: 0 }, - output: "", - }, - } as any, - { projectRoot: "C:\\Users\\lenovo\\Desktop\\devo" }, - ), - ).toBe("apps/desktop/src/main.ts") - }) - - test("shows write paths relative to the project root", () => { - expect( - getToolSubtitle( - { - callID: "call-1", - id: "tool-1", - tool: "write", - type: "tool", - state: { - input: { path: "C:\\Users\\lenovo\\Desktop\\devo\\README.md" }, - status: "completed", - time: { end: 1, start: 0 }, - output: "", - }, - } as any, - { projectRoot: "C:\\Users\\lenovo\\Desktop\\devo" }, - ), - ).toBe("README.md") - }) - - test("shows apply_patch paths from patch input", () => { - expect( - getToolSubtitle( - { - callID: "call-1", - id: "tool-1", - tool: "apply_patch", - type: "tool", - state: { - input: { - patch: `*** Begin Patch -*** Update File: C:\\Users\\lenovo\\Desktop\\devo\\apps\\desktop\\src\\main.ts -@@ -*** End Patch`, - }, - status: "completed", - time: { end: 1, start: 0 }, - output: "", - }, - } as any, - { projectRoot: "C:\\Users\\lenovo\\Desktop\\devo" }, - ), - ).toBe("apps/desktop/src/main.ts") - }) -}) - -describe("read tool output density source", () => { - test("overrides CodeBlock internal text sizing for read output", () => { - expect({ - readClass: chatToolCallSource.includes("devo-read-output"), - preRule: rendererCssSource.includes(".devo-read-output pre"), - codeRule: rendererCssSource.includes(".devo-read-output code"), - lineHeight: rendererCssSource.includes("line-height: 1.35"), - preservesWhitespace: rendererCssSource.includes("white-space: pre"), - }).toEqual({ - readClass: true, - preRule: true, - codeRule: true, - lineHeight: true, - preservesWhitespace: true, - }) - }) -}) - -describe("useToolElapsedTime source", () => { - test("uses tool state time without renderer first-seen timestamps", () => { - expect({ - usesStateStart: elapsedHookSource.includes("part.state.time"), - usesFirstSeen: elapsedHookSource.includes("getPartFirstSeenAt"), - }).toEqual({ - usesStateStart: true, - usesFirstSeen: false, - }) - }) -}) - - -describe("parseReadOutput", () => { - test("unwraps stringified Mixed metadata and restores real newlines", () => { - const body = "hello.py\n\n1: def main():\n2: print(1)\n" - const stringified = JSON.stringify({ - output: body, - preview: "def main", - truncated: false, - }) - const parsed = parseReadOutput(stringified) - expect({ - hasRealNewline: parsed.includes("\n"), - noLiteralEscape: !parsed.includes("\\n"), - line1: parsed.includes("1: def main():"), - line2: parsed.includes("2: print(1)"), - }).toEqual({ - hasRealNewline: true, - noLiteralEscape: true, - line1: true, - line2: true, - }) - }) - - test("unescapes literal \\n when they dominate the string", () => { - const parsed = parseReadOutput( - '\\n1: def main():\\n2: name = input(\\"What is your name? \\")', - ) - expect(parsed.split("\n")).toEqual([ - "", - "1: def main():", - '2: name = input("What is your name? ")', - ]) - }) -}) - -describe("getToolInfo", () => { - test("labels shell tools as Running while active and Ran when finished", () => { - expect({ - running: getToolInfo("bash", { running: true }).title, - ran: getToolInfo("bash", { running: false }).title, - shellCommand: getToolInfo("shell_command", { running: true }).title, - execCommand: getToolInfo("exec_command").title, - }).toEqual({ - running: "Running", - ran: "Ran", - shellCommand: "Running", - execCommand: "Ran", - }) - }) - - test("labels write and edit with Writing/Added and Editing/Edited", () => { - expect({ - writing: getToolInfo("write", { running: true }).title, - added: getToolInfo("write", { running: false }).title, - editing: getToolInfo("edit", { running: true }).title, - edited: getToolInfo("edit", { running: false }).title, - patchEditing: getToolInfo("apply_patch", { running: true }).title, - patchEdited: getToolInfo("apply_patch", { running: false }).title, - }).toEqual({ - writing: "Writing", - added: "Added", - editing: "Editing", - edited: "Edited", - patchEditing: "Editing", - patchEdited: "Edited", - }) - }) - - test("does not use legacy Write/Edit/Patch titles for file-change tools", () => { - const titles = [ - getToolInfo("write").title, - getToolInfo("edit").title, - getToolInfo("apply_patch").title, - ] - expect(titles).toEqual(["Added", "Edited", "Edited"]) - expect(titles).not.toContain("Write") - expect(titles).not.toContain("Edit") - expect(titles).not.toContain("Patch") - }) - - test("shows the shell command after Running instead of a generic title", () => { - const running = { - callID: "call-1", - id: "tool-1", - tool: "bash", - type: "tool", - state: { - input: { command: "git status", description: "Check git status" }, - status: "running", - time: { start: 0 }, - title: "Command", - }, - } as any - expect({ - title: getToolInfo("bash", { running: true }).title, - subtitle: getToolSubtitle(running), - arrayCommand: getToolSubtitle({ - ...running, - tool: "shell_command", - state: { - ...running.state, - input: { command: ["git", "status", "--short"] }, - }, - } as any), - fromRaw: getToolSubtitle({ - ...running, - state: { - input: {}, - raw: '{"command":"bun test"}', - status: "pending", - time: { start: 0 }, - title: "Command", - }, - } as any), - }).toEqual({ - title: "Running", - subtitle: "git status", - arrayCommand: "git status --short", - fromRaw: "bun test", - }) - }) - - test("labels question tools even when the SDK fell back to generic tool", () => { - const input = { - questions: [{ id: "environment", header: "Environment", question: "Where should this run?" }], - } - expect({ - named: getToolInfo("request_user_input").title, - alias: getToolInfo("question").title, - generic: getToolInfo("tool", { input }).title, - subtitle: getToolSubtitle( - { - callID: "call-1", - id: "tool-1", - tool: "tool", - type: "tool", - state: { input, status: "running", time: { start: 0 } }, - } as any, - ), - }).toEqual({ - named: "Question", - alias: "Question", - generic: "Question", - subtitle: "Where should this run?", - }) - }) - - test("keeps file-change path typography aligned with Read", () => { - expect({ - noMonoOnFileChangePath: !chatToolCallSource.includes( - 'font-mono text-[12px] text-muted-foreground/50', - ), - fileChangePathUsesReadMutedClass: /fileChangeRow[\s\S]*?text-muted-foreground\/60/.test( - chatToolCallSource, - ), - }).toEqual({ - noMonoOnFileChangePath: true, - fileChangePathUsesReadMutedClass: true, - }) - }) -}) - -describe("ChatToolCall memo comparison", () => { - test("re-renders when the controlled open state changes so rows can expand", () => { - expect({ - comparesOpen: chatToolCallSource.includes("prev.open !== next.open"), - comparesTurnError: chatToolCallSource.includes("prev.turnHasError !== next.turnHasError"), - comparesTurnWorking: chatToolCallSource.includes("prev.turnWorking !== next.turnWorking"), - hidesSpinnerWhenTurnIdle: chatToolCallSource.includes( - "turnWorking && (status === \"running\" || status === \"pending\")", - ), - gatesNonFileChangeWhileControlledClosed: chatToolCallSource.includes("open === false ? null"), - defersFileChangeDiffMount: chatToolCallSource.includes( - "{getToolContent(part)}", - ), - remountsPierreAfterPanelLayout: !pierreDiffMountSource.includes("setInstanceKey((key) => key + 1)"), - mountsOnceAfterWarmup: pierreDiffMountSource.includes("isHighlighterLoaded"), - }).toEqual({ - comparesOpen: true, - comparesTurnError: true, - comparesTurnWorking: true, - hidesSpinnerWhenTurnIdle: true, - gatesNonFileChangeWhileControlledClosed: true, - defersFileChangeDiffMount: true, - remountsPierreAfterPanelLayout: true, - mountsOnceAfterWarmup: true, - }) - }) - - test("labels MCP tools with the server prefix instead of a generic wrench", () => { - expect({ - mcpPrefix: chatToolCallSource.includes('tool.startsWith("mcp__")'), - mcpTitle: chatToolCallSource.includes("`MCP · ${label}`"), - }).toEqual({ - mcpPrefix: true, - mcpTitle: true, - }) - }) -}) diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx deleted file mode 100644 index 4059a87b..00000000 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ /dev/null @@ -1,1238 +0,0 @@ -import { - CodeBlock, - CodeBlockActions, - CodeBlockContent, - CodeBlockCopyButton, - CodeBlockHeader, - CodeBlockTitle, -} from "@devo/ui/components/ai-elements/code-block" -import { Terminal, TerminalContent } from "@devo/ui/components/ai-elements/terminal" -import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@devo/ui/components/dialog" -import { cn } from "@devo/ui/lib/utils" - -import { - AlertTriangleIcon, - CodeIcon, - EditIcon, - EyeIcon, - FileCodeIcon, - FileIcon, - GlobeIcon, - Loader2Icon, - MessageCircleQuestionIcon, - PlugIcon, - SearchIcon, - SquareCheckIcon, - TerminalIcon, - WrenchIcon, - XIcon, - ZapIcon, -} from "lucide-react" -import type { ReactNode } from "react" -import { memo, useCallback, useMemo } from "react" -import type { BundledLanguage } from "shiki" -import { detectContentLanguage, detectLanguage, prettyPrintJson } from "../../lib/language" -import type { FilePart, ToolPart, ToolStateCompleted } from "../../lib/types" -import { FileChangeContent, FileChangeStatsBadge } from "./file-change-content" -import { - fileChangeStats, - fileChangeVerb, - hasFileChangeExpandableContent, - isFileChangeTool, -} from "./file-change-presentation" -import { - isQuestionToolInput, - parseQuestionToolEntries, - QuestionToolContent, - questionToolSubtitle, -} from "./chat-question-tool" -import { SubAgentCard } from "./sub-agent-card" -import type { ToolCategory } from "./tool-category" -import { - MountWhenVisible, - TranscriptDisclosure, - TranscriptDisclosureContent, - TranscriptDisclosureTrigger, -} from "./transcript-disclosure" -import { - formatToolPathForDisplay, - getFirstApplyPatchPath, - shortenPathForDisplay, - type ToolPathDisplayOptions, -} from "./tool-paths" - -// ============================================================ -// Constants -// ============================================================ - -/** Max characters to display in tool output before truncating */ -const MAX_OUTPUT_LENGTH = 5000 - -/** Truncate output for display, preserving useful content */ -function truncateOutput(output: string, max = MAX_OUTPUT_LENGTH): string { - if (output.length <= max) return output - return `${output.slice(0, max)}\n... (truncated)` -} - -/** Shell-family tools that share the command/terminal display. */ -const SHELL_TOOLS: ReadonlySet = new Set(["bash", "shell_command", "exec_command"]) - -/** - * The shell tool's result text is `\n`, where the - * envelope carries display metadata ({output, command, exit, description, - * cwd, yield_time_ms}). Strip it so the terminal shows only real command - * output; the command and description already appear in the tool row itself. - */ -export function stripShellEnvelope(output: string): string { - const parseEnvelope = (text: string): { body?: string } | undefined => { - try { - const parsed = JSON.parse(text) - if ( - parsed && - typeof parsed === "object" && - typeof parsed.exit === "number" && - (typeof parsed.command === "string" || typeof parsed.cmd === "string") - ) { - return { body: typeof parsed.output === "string" ? parsed.output : undefined } - } - } catch { - // Not JSON — ordinary command output. - } - return undefined - } - - const trimmed = output.trim() - // The output IS the envelope (the command produced no stdout). - if (trimmed.startsWith("{")) { - const whole = parseEnvelope(trimmed) - if (whole) return whole.body ?? "" - } - // Stdout followed by the envelope JSON (they are joined with "\n"). - const separator = output.lastIndexOf("\n{") - if (separator !== -1) { - const tail = parseEnvelope(output.slice(separator + 1).trim()) - if (tail) return output.slice(0, separator).trimEnd() - } - return output -} - -// ============================================================ -// Read output parsing — strip wrapper tags, keep original line numbers -// ============================================================ - -/** - * Parses output from various read tools. - * Handles: - * 1. Claude Code's `cat -n` format: 00001| content - * 2. Devo's XML-wrapped format: ...1: content - * 3. Mixed-result JSON accidentally stringified (literal `\n` escapes) - * - * Strips the wrapper tags and trailing metadata lines, but keeps the - * content's own line-number prefixes — the read output already carries - * line numbers, so the UI must not add another gutter of its own. - */ -export function parseReadOutput(raw: string): string { - let text = unwrapPossiblyStringifiedReadOutput(raw) - - // 1. Strip Devo XML-style tags - text = text.replace(/[\s\S]*?<\/path>\s*\n?/g, "") - text = text.replace(/[\s\S]*?<\/type>\s*\n?/g, "") - - // Extract content from or if present - const contentMatch = text.match(/([\s\S]*?)<\/content>/) - const entriesMatch = text.match(/([\s\S]*?)<\/entries>/) - - if (contentMatch) { - text = contentMatch[1] - } else if (entriesMatch) { - text = entriesMatch[1] - } else { - // 2. Fallback: Strip / wrapper lines (Claude Code) - text = text.replace(/^\s*\s*\n?/, "") - text = text.replace(/\n?\s*<\/file>\s*$/, "") - } - - // 3. Clean up trailing metadata lines - text = text.replace(/\n?\s*\(End of file[^)]*\)\s*$/, "") - text = text.replace(/\n?\s*\(File has more lines[^)]*\)\s*$/, "") - text = text.replace(/\n?\s*\(Output truncated[^)]*\)\s*$/, "") - - return unescapeLiteralNewlines(text) -} - -/** If Mixed metadata was JSON.stringified, pull out the `output` string. */ -function unwrapPossiblyStringifiedReadOutput(raw: string): string { - const trimmed = raw.trim() - if (!trimmed.startsWith("{")) return raw - try { - const parsed = JSON.parse(trimmed) as unknown - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const record = parsed as Record - if (typeof record.output === "string") return record.output - if (typeof record.text === "string") return record.text - } - } catch { - // Not JSON — fall through. - } - return raw -} - -/** - * History/SDK bugs sometimes leave literal `\\n` sequences instead of real - * newlines. Only unescape when escaped newlines clearly dominate. - */ -function unescapeLiteralNewlines(text: string): string { - const realNewlines = (text.match(/\n/g) ?? []).length - const escapedNewlines = (text.match(/\\n/g) ?? []).length - if (escapedNewlines === 0 || escapedNewlines <= realNewlines) return text - return text - .replace(/\\r\\n/g, "\n") - .replace(/\\n/g, "\n") - .replace(/\\t/g, "\t") - .replace(/\\"/g, '"') -} - -// ============================================================ -// Tool info resolver -// ============================================================ - -export function getToolInfo( - tool: string, - options?: { running?: boolean; input?: Record }, -): { - icon: typeof WrenchIcon - title: string -} { - switch (tool) { - case "read": - return { icon: EyeIcon, title: "Read" } - case "glob": - return { icon: SearchIcon, title: "Glob" } - case "grep": - return { icon: SearchIcon, title: "Grep" } - case "list": - return { icon: SearchIcon, title: "List" } - case "webfetch": - return { icon: GlobeIcon, title: "Fetch" } - case "bash": - case "shell_command": - case "exec_command": - case "write_stdin": - return { icon: TerminalIcon, title: options?.running ? "Running" : "Ran" } - case "edit": - return { - icon: EditIcon, - title: fileChangeVerb("edit", { running: options?.running, input: options?.input }), - } - case "write": - return { - icon: FileCodeIcon, - title: fileChangeVerb("write", { running: options?.running, input: options?.input }), - } - case "apply_patch": - return { - icon: CodeIcon, - title: fileChangeVerb("apply_patch", { - running: options?.running, - input: options?.input, - }), - } - case "task": - return { icon: ZapIcon, title: "Agent" } - case "todowrite": - return { icon: SquareCheckIcon, title: "Todos" } - case "todoread": - return { icon: SquareCheckIcon, title: "Todos" } - case "question": - case "request_user_input": - return { icon: MessageCircleQuestionIcon, title: "Question" } - default: - if (isQuestionToolInput(tool, options?.input)) { - return { icon: MessageCircleQuestionIcon, title: "Question" } - } - if (tool.startsWith("mcp__")) { - const segments = tool.split("__") - const label = segments.slice(2).join("__") || segments[1] || tool - return { icon: PlugIcon, title: `MCP · ${label}` } - } - return { icon: WrenchIcon, title: tool } - } -} - -/** - * Try to extract a field value from partial JSON in `state.raw`. - * During the `pending` state, `input` may be `{}` while the server is still - * streaming the tool-call arguments. The `raw` field (when available) contains - * the accumulated partial JSON string, so we can attempt to pull out early - * fields like `command` or `description` even before the server has finished - * parsing the full input. - */ -function extractFromRaw(state: ToolPart["state"], ...fields: string[]): string | undefined { - if (!("raw" in state) || typeof state.raw !== "string" || !state.raw) return undefined - const raw = state.raw - for (const field of fields) { - // Match "field": "value" — handles escaped quotes in the value - const pattern = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`) - const match = raw.match(pattern) - if (match?.[1]) return match[1] - } - return undefined -} - -function shellCommandText( - input?: Record, - state?: ToolPart["state"], -): string | undefined { - const fromValue = (value: unknown): string | undefined => { - if (typeof value === "string") { - const trimmed = value.trim() - return trimmed || undefined - } - if (Array.isArray(value)) { - const joined = value - .map((item) => String(item).trim()) - .filter(Boolean) - .join(" ") - return joined || undefined - } - return undefined - } - return ( - fromValue(input?.command) ?? - fromValue(input?.cmd) ?? - (state ? extractFromRaw(state, "command", "cmd") : undefined) - ) -} - -function isGenericShellSubtitle(value: string): boolean { - switch (value.trim().toLowerCase()) { - case "bash": - case "command": - case "exec_command": - case "execute": - case "ran": - case "running": - case "shell": - case "shell_command": - return true - default: - return false - } -} - -function shellCommandSubtitle( - input: Record | undefined, - state: ToolPart["state"], - title?: string, -): string | undefined { - const command = shellCommandText(input, state) - if (command) return command - if (title && !isGenericShellSubtitle(title)) return title - const description = typeof input?.description === "string" ? input.description.trim() : "" - if (description && !isGenericShellSubtitle(description)) return description - return undefined -} - -/** - * Returns a "Preparing ..." fallback label for tools in the `pending` state - * when no meaningful subtitle could be resolved from input/raw yet. - * Mirrors the Devo TUI's `InlineTool` behaviour (e.g. "~ Preparing write ..."). - */ -function getPendingLabel(tool: string): string { - switch (tool) { - case "write": - return "Preparing write..." - case "edit": - return "Preparing edit..." - case "apply_patch": - return "Preparing patch..." - case "bash": - case "shell_command": - case "exec_command": - return "Preparing command..." - case "read": - return "Preparing read..." - case "task": - return "Preparing agent..." - case "webfetch": - return "Preparing fetch..." - case "question": - case "request_user_input": - return "Asking a question..." - default: - return `Preparing ${tool}...` - } -} - -/** - * Extracts a human-readable subtitle from tool state. - * Falls back to a "Preparing ..." label when the tool is in the `pending` state - * and no input fields have been parsed yet (the model is still streaming arguments). - */ -export function getToolSubtitle( - part: ToolPart, - options: ToolPathDisplayOptions = {}, -): string | undefined { - const state = part.state - const input = state.input - const rawTitle = "title" in state && typeof state.title === "string" ? state.title : undefined - const title = rawTitle && rawTitle !== part.tool && rawTitle !== part.callID ? rawTitle : undefined - - let subtitle: string | undefined - - switch (part.tool) { - case "read": - subtitle = - formatToolPathForDisplay( - (input.filePath as string | undefined) ?? (input.path as string | undefined), - options, - ) ?? - formatToolPathForDisplay(extractFromRaw(state, "filePath", "path"), options) - break - case "glob": - subtitle = - (input.pattern as string) ?? - (input.path as string) ?? - extractFromRaw(state, "pattern", "path") - break - case "grep": - subtitle = - (input.pattern as string) ?? - (input.path as string) ?? - extractFromRaw(state, "pattern", "path") - break - case "bash": - case "shell_command": - case "exec_command": - subtitle = shellCommandSubtitle(input, state, title) - break - case "edit": - subtitle = - formatToolPathForDisplay( - (input.filePath as string | undefined) ?? (input.path as string | undefined), - options, - ) ?? - formatToolPathForDisplay(extractFromRaw(state, "filePath", "path"), options) - break - case "write": - subtitle = - formatToolPathForDisplay( - (input.filePath as string | undefined) ?? (input.path as string | undefined), - options, - ) ?? - formatToolPathForDisplay(extractFromRaw(state, "filePath", "path"), options) - break - case "apply_patch": { - const rawPatch = extractFromRaw(state, "patch", "diff") - const patchPath = - (input.filePath as string | undefined) ?? - (input.path as string | undefined) ?? - getFirstApplyPatchPath(input.patch as string | undefined) ?? - getFirstApplyPatchPath(input.diff as string | undefined) ?? - getFirstApplyPatchPath(rawPatch?.replace(/\\r\\n|\\n/g, "\n")) ?? - extractFromRaw(state, "filePath", "path") - subtitle = formatToolPathForDisplay(patchPath, options) ?? title - break - } - case "webfetch": - subtitle = (input.url as string) ?? extractFromRaw(state, "url") - break - case "task": - subtitle = (input.description as string) ?? title ?? extractFromRaw(state, "description") - break - case "todowrite": - case "todoread": { - const todos = input?.todos as Array<{ status: string }> | undefined - if (todos && todos.length > 0) { - const completed = todos.filter((t) => t.status === "completed").length - subtitle = `${completed}/${todos.length} completed` - } else { - subtitle = title - } - break - } - case "question": - case "request_user_input": { - subtitle = questionToolSubtitle(part) ?? title - break - } - default: - // Unknown / MCP tools: always show compact input params like [key=value, key=value] - // Input params are more useful than the SDK-generated title for MCP tools - if (isQuestionToolInput(part.tool, input)) { - subtitle = questionToolSubtitle(part) ?? title - } else { - subtitle = formatInputParams(input) ?? title - } - break - } - - // When pending with no resolved subtitle, show a "Preparing ..." label so - // the user sees activity instead of a blank card (matches Devo TUI behaviour). - if (!subtitle && state.status === "pending") { - return getPendingLabel(part.tool) - } - - return subtitle -} - -/** - * Formats tool input as a compact bracket notation for unknown/MCP tools. - * e.g. { url: "https://...", format: "md" } → [url=https://..., format=md] - */ -function formatInputParams(input: Record): string | undefined { - const entries = Object.entries(input) - if (entries.length === 0) return undefined - - const parts: string[] = [] - for (const [key, value] of entries) { - if (value == null) continue - const strVal = typeof value === "string" ? value : JSON.stringify(value) - // Truncate long values - const truncated = strVal.length > 60 ? `${strVal.slice(0, 57)}...` : strVal - parts.push(`${key}=${truncated}`) - } - - if (parts.length === 0) return undefined - return `[${parts.join(", ")}]` -} - -// ============================================================ -// Tool-specific content renderers -// ============================================================ - -/** - * Builds the text of the single terminal block shown for a bash tool call: - * the command as its first line, followed by the (cleaned) output. - * - * The SDK output often echoes the command as the first line (e.g. - * "$ command\n..."). Since we prepend the command ourselves, strip the - * duplicate before joining. - */ -export function buildBashTerminalOutput( - command: string | undefined, - output: string | undefined, - error: string | undefined, -): string { - let body = stripShellEnvelope(error ?? output ?? "") - if (command) { - const prefix = `$ ${command}` - if (body.startsWith(prefix)) { - body = body.slice(prefix.length).replace(/^\r?\n/, "") - } - } - body = truncateOutput(body) - if (!command) return body - return body ? `$ ${command}\n${body}` : `$ ${command}` -} - -/** - * Bash tool: one terminal block — command on the first line, ANSI-colored - * output below. No separate command code block or "Output" header. - * - * During `running` state the server streams incremental output via - * `state.metadata.output` (accumulated string, updated on every stdout/stderr - * chunk). We read that field so the terminal updates in real-time, matching the - * behaviour of the Devo TUI and web UI. - */ -function BashContent({ part }: { part: ToolPart }) { - const command = shellCommandText(part.state.input, part.state) - - // During "running", live output arrives in state.metadata.output. - // After completion it moves to state.output. - const streamingOutput = - part.state.status === "running" - ? (part.state.metadata?.output as string | undefined) - : undefined - const output = part.state.status === "completed" ? part.state.output : streamingOutput - const error = part.state.status === "error" ? (part.state as { error: string }).error : undefined - const isStreaming = part.state.status === "running" - - const terminalOutput = useMemo( - () => buildBashTerminalOutput(command, output, error), - [command, output, error], - ) - - if (!terminalOutput && !isStreaming) return null - - return ( - - - - ) -} - -/** - * Edit / write / apply_patch: Cursor-style expandable unified diff - * (see file-change-content.tsx). - */ - -/** - * Read tool: shows syntax-highlighted file contents. - * Strips wrapper tags but keeps the read output's own line numbers — - * the code block renders no line-number gutter of its own. - */ -function ReadContent({ part }: { part: ToolPart }) { - const filePath = (part.state.input?.filePath as string) ?? (part.state.input?.path as string) - const output = part.state.status === "completed" ? part.state.output : undefined - const error = part.state.status === "error" ? (part.state as { error: string }).error : undefined - - if (error) return - if (!output) return null - - const language = (detectLanguage(filePath) ?? "text") as BundledLanguage - - // No inner header — the tool row header already shows the filename. - const displayContent = truncateOutput(parseReadOutput(output)) - - return ( - - - - ) -} - -/** Search tools (glob/grep/list): shows results; pattern stays in the row subtitle. */ -function SearchContent({ part }: { part: ToolPart }) { - const pattern = (part.state.input?.pattern as string) ?? undefined - const include = (part.state.input?.include as string) ?? (part.state.input?.glob as string) ?? undefined - const path = (part.state.input?.path as string) ?? undefined - const output = part.state.status === "completed" ? part.state.output : undefined - // Grep/Glob already put `pattern` in the tool-row subtitle ("Grep · …"). - const patternInSubtitle = part.tool === "grep" || part.tool === "glob" - const showPattern = Boolean(pattern) && !patternInSubtitle - const hasMeta = showPattern || Boolean(include) || Boolean(path) - - return ( -
- {hasMeta && ( -
- {showPattern && ( - - pattern: {pattern} - - )} - {include && ( - - include: {include} - - )} - {path && ( - - in: {shortenPathForDisplay(path)} - - )} -
- )} - {output && ( -
-					{truncateOutput(output)}
-				
- )} -
- ) -} - -/** WebFetch tool: shows URL + fetched content with optional markdown/json highlighting */ -function WebFetchContent({ part }: { part: ToolPart }) { - const url = part.state.input?.url as string | undefined - const format = part.state.input?.format as string | undefined - const output = part.state.status === "completed" ? part.state.output : undefined - - const language = useMemo(() => { - if (format === "html") return "html" - if (format === "json") return "json" - if (output) return detectContentLanguage(output) - return undefined - }, [format, output]) as BundledLanguage | undefined - - const displayOutput = useMemo(() => { - if (!output) return undefined - if (language === "json") return prettyPrintJson(output) - return output - }, [output, language]) - - return ( -
- {url && ( -
- {url} -
- )} - {displayOutput && language ? ( - - - - ) : output ? ( -
-					{truncateOutput(output)}
-				
- ) : null} -
- ) -} - -/** TodoWrite tool: shows checklist items */ -function TodoContent({ part }: { part: ToolPart }) { - const todos = - (part.state.input?.todos as Array<{ content: string; status: string }> | undefined) ?? [] - - if (todos.length === 0) return null - - return ( -
- {todos.map((todo, i) => ( -
- - {todo.status === "completed" ? ( - - ) : todo.status === "in_progress" ? ( - - ) : todo.status === "cancelled" ? ( - - ) : ( - - )} - - - {todo.content} - -
- ))} -
- ) -} - -/** Error content for any tool */ -function ErrorContent({ error }: { error: string }) { - return ( -
-
- ) -} - -/** - * Generic tool output: auto-detects JSON and other structured content - * for syntax highlighting, falls back to plain text. - */ -function GenericContent({ part }: { part: ToolPart }) { - const output = part.state.status === "completed" ? part.state.output : undefined - const error = part.state.status === "error" ? (part.state as { error: string }).error : undefined - - const language = useMemo(() => { - if (!output) return undefined - return detectContentLanguage(output) as BundledLanguage | undefined - }, [output]) - - const displayOutput = useMemo(() => { - if (!output) return undefined - if (language === "json") return prettyPrintJson(output) - return output - }, [output, language]) - - return ( -
- {displayOutput && language ? ( - - - - {language} - - - - - - - - ) : output ? ( -
-					{truncateOutput(output)}
-				
- ) : null} - {error && } -
- ) -} - -// ============================================================ -// Tool group summaries -// ============================================================ - -/** - * Verb for an explore tool group: specific when every tool in the group is - * the same kind (e.g. a run of greps reads "Searched", not "Read"). - */ -function exploreGroupVerb(tools: ToolPart[]): string { - const first = tools[0]?.tool - if (!first || !tools.every((t) => t.tool === first)) return "Explored" - switch (first) { - case "read": - return "Read" - case "grep": - case "glob": - return "Searched" - case "list": - return "Listed" - default: - return "Explored" - } -} - -export function describeToolGroup( - category: ToolCategory, - tools: ToolPart[], - projectRoot?: string | null, -): string { - const count = tools.length - - if (count <= 3) { - const details = tools - .map((t) => getToolSubtitle(t, { projectRoot })) - .filter(Boolean) - .map((s) => { - const parts = s!.split("/") - return parts.length > 1 ? parts[parts.length - 1] : s - }) - - if (details.length > 0) { - switch (category) { - case "explore": - return `${exploreGroupVerb(tools)} ${count === 1 ? details[0] : details.join(", ")}` - case "edit": - return count === 1 ? `Edited ${details[0]}` : `Edited ${details.join(", ")}` - case "run": - return count === 1 ? `Ran ${details[0]}` : `Ran ${count} commands` - case "delegate": - return count === 1 ? `Delegated: ${details[0]}` : `Delegated ${count} tasks` - case "fetch": - return count === 1 ? `Fetched ${details[0]}` : `Fetched ${count} URLs` - case "ask": - return "Asked a question" - case "plan": - return "Updated plan" - default: - return `Ran ${details.join(", ")}` - } - } - } - - switch (category) { - case "explore": { - const fileCount = tools.filter((t) => t.tool === "read" || t.tool === "list").length - const searchCount = tools.filter((t) => t.tool === "grep" || t.tool === "glob").length - if (fileCount > 0 && searchCount > 0) { - return `Explored ${fileCount} ${fileCount === 1 ? "file" : "files"}, ${searchCount} ${searchCount === 1 ? "search" : "searches"}` - } - const verb = exploreGroupVerb(tools) - if (verb === "Searched") { - return `Searched ${count} ${count === 1 ? "pattern" : "patterns"}` - } - return `${verb} ${count} ${count === 1 ? "file" : "files"}` - } - case "edit": - return `Edited ${count} files` - case "run": - return `Ran ${count} commands` - case "delegate": - return `Delegated ${count} tasks` - case "fetch": - return `Fetched ${count} URLs` - case "ask": - return `Asked ${count} questions` - case "plan": - return "Updated plan" - default: - return `Ran ${count} tools` - } -} - -export function isGroupRunning(tools: ToolPart[], turnWorking = true): boolean { - if (!turnWorking) return false - return tools.some((t) => t.state.status === "running" || t.state.status === "pending") -} - -export function isGroupError(tools: ToolPart[]): boolean { - return tools.some((t) => t.state.status === "error") -} - -/** - * Returns whether a tool has expandable content. - */ -function hasExpandableContent(part: ToolPart): boolean { - const { tool, state } = part - // Task uses SubAgentCard, not a tool row - if (tool === "task") return false - // Todowrite has expandable todo items - if (tool === "todowrite" || tool === "todoread") { - const todos = state.input?.todos as Array<{ content: string; status: string }> | undefined - return (todos?.length ?? 0) > 0 - } - if (isQuestionToolInput(tool, state.input as Record | undefined)) { - return parseQuestionToolEntries(part).length > 0 - } - if (isFileChangeTool(tool)) { - const output = state.status === "completed" ? state.output : undefined - if (hasFileChangeExpandableContent(tool, state.input as Record, output)) { - return true - } - } - // If there's output or error, there's content - if (state.status === "completed" && state.output) return true - if (state.status === "error") return true - // Shell tools always have content (the command at least) - if (SHELL_TOOLS.has(tool)) return true - return false -} - -/** - * Resolves the content renderer for a tool. - */ -function getToolContent(part: ToolPart): ReactNode { - const error = part.state.status === "error" ? (part.state as { error: string }).error : undefined - if ( - error && - !SHELL_TOOLS.has(part.tool) && - !isFileChangeTool(part.tool) && - part.tool !== "read" - ) { - return - } - - switch (part.tool) { - case "bash": - case "shell_command": - case "exec_command": - return - case "edit": - case "write": - case "apply_patch": - return - case "read": - return - case "glob": - case "grep": - case "list": - return - case "webfetch": - return - case "todowrite": - case "todoread": - return - case "question": - case "request_user_input": - return - default: - if (isQuestionToolInput(part.tool, part.state.input as Record | undefined)) { - return - } - return - } -} - -// ============================================================ -// ChatToolCall — main export -// ============================================================ - -interface ChatToolCallProps { - part: ToolPart - /** Whether the turn containing this tool has an error (enables delete action) */ - turnHasError?: boolean - /** When false, running/pending tools do not show a live spinner. */ - turnWorking?: boolean - /** Delete this tool part (for error recovery) */ - onDelete?: (part: ToolPart) => void - /** Project root used only for display-only path labels. */ - projectRoot?: string | null - /** Tighter row rhythm for nested items inside a tool group. */ - compact?: boolean - open?: boolean - defaultOpen?: boolean - onOpenChange?: (open: boolean) => void -} - -/** - * Compares two ToolPart objects for meaningful changes. - * Avoids re-renders when a new object reference has the same content. - */ -function areToolPartsEqual(a: ToolPart, b: ToolPart): boolean { - if (a === b) return true - if (a.id !== b.id) return false - if (a.tool !== b.tool) return false - if (a.state.status !== b.state.status) return false - // File-change rows depend on input for verb, path, +/- stats, and expandable diff. - if (isFileChangeTool(a.tool)) { - const aIn = a.state.input as Record | undefined - const bIn = b.state.input as Record | undefined - if ( - aIn?.path !== bIn?.path || - aIn?.filePath !== bIn?.filePath || - aIn?.unifiedDiff !== bIn?.unifiedDiff || - aIn?.content !== bIn?.content || - aIn?.oldString !== bIn?.oldString || - aIn?.newString !== bIn?.newString || - aIn?.changeType !== bIn?.changeType - ) { - return false - } - } - // During "pending", the server streams partial tool-call arguments into - // state.raw. Compare raw length so the subtitle updates as arguments arrive. - if (a.state.status === "pending" && b.state.status === "pending") { - if (a.state.raw.length !== b.state.raw.length) return false - } - // During "running", the server streams incremental output via - // state.metadata.output. Compare the accumulated output length so - // React re-renders the terminal on every new chunk. - if (a.state.status === "running" && b.state.status === "running") { - const aMeta = a.state.metadata?.output as string | undefined - const bMeta = b.state.metadata?.output as string | undefined - if ((aMeta?.length ?? 0) !== (bMeta?.length ?? 0)) return false - } - // Compare output/error lengths for completed/error states - if (a.state.status === "completed" && b.state.status === "completed") { - if (a.state.output.length !== b.state.output.length) return false - if (a.state.time.end !== b.state.time.end) return false - } - if (a.state.status === "error" && b.state.status === "error") { - if (a.state.error !== b.state.error) return false - } - return true -} - -/** - * Renders a single tool call as a unified disclosure row with tool-specific - * content, or as a SubAgentCard for sub-agent tasks. - */ -export const ChatToolCall = memo( - function ChatToolCall({ - part, - turnHasError = false, - turnWorking = true, - onDelete, - projectRoot, - compact = false, - open, - defaultOpen: defaultOpenProp, - onOpenChange, - }: ChatToolCallProps) { - const toolInput = part.state.input as Record | undefined - - // +/- stats sit next to the path for file-change tools (not in trailing). - const diffStats = useMemo( - () => (isFileChangeTool(part.tool) ? fileChangeStats(part.tool, toolInput) : undefined), - [part.tool, toolInput], - ) - - const status = part.state.status as "running" | "error" | "completed" | "pending" - const isRunning = turnWorking && (status === "running" || status === "pending") - - // Trailing: spinner only (file-change +/- live in the label). - const trailingElement = useMemo(() => { - if (!isRunning) return undefined - return ( - - ) - }, [isRunning]) - - // When the turn has an error, add a delete button so the user can - // surgically remove a problematic tool part and continue the conversation. - const handleDelete = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - onDelete?.(part) - }, - [onDelete, part], - ) - - const finalTrailing = useMemo(() => { - if (!turnHasError || !onDelete) return trailingElement - const deleteButton = ( - - ) - if (!trailingElement) return deleteButton - return ( - - {trailingElement} - {deleteButton} - - ) - }, [turnHasError, onDelete, trailingElement, handleDelete]) - - // Skip rendering todoread parts without output - if (part.tool === "todoread" && part.state.status !== "completed") return null - - // --- Task tool: Sub-agent card --- - if (part.tool === "task") { - return - } - - // --- All other tools (including todos): unified tool row --- - const { title } = getToolInfo(part.tool, { running: isRunning, input: toolInput }) - const subtitle = getToolSubtitle(part, { projectRoot }) - const hasContent = hasExpandableContent(part) - const defaultOpen = defaultOpenProp ?? false - const fileChangeRow = isFileChangeTool(part.tool) - - // Extract attachments - const attachments: FilePart[] = - part.state.status === "completed" - ? ((part.state as ToolStateCompleted).attachments ?? []) - : [] - - // Same path typography as Read (`text-muted-foreground/60`, inherit sans). - const label = fileChangeRow ? ( - <> - {title} - {subtitle ? ( - {subtitle} - ) : null} - {diffStats ? ( - <> - {" "} - - - ) : null} - - ) : subtitle ? ( - <> - {title} - · {subtitle} - - ) : ( - {title} - ) - - return ( -
0 && !compact ? "space-y-1.5" : undefined}> - - - {hasContent && ( - - {fileChangeRow ? ( - {getToolContent(part)} - ) : open === false ? null : ( - getToolContent(part) - )} - - )} - - - {attachments.length > 0 && } -
- ) - }, - (prev, next) => { - if (!areToolPartsEqual(prev.part, next.part)) return false - // open is controlled by the parent timeline (expandedRowIds); without this - // comparison the memo blocks the re-render and the row can never expand. - if (prev.open !== next.open) return false - if (prev.compact !== next.compact) return false - if (prev.turnHasError !== next.turnHasError) return false - if (prev.turnWorking !== next.turnWorking) return false - if (prev.projectRoot !== next.projectRoot) return false - // onDelete/onOpenChange are callback refs - skip reference comparison to avoid - // re-renders from parent creating new closures - return true - }, -) - -// ============================================================ -// ToolAttachments — inline thumbnails for tool output images -// ============================================================ - -function ToolAttachments({ attachments }: { attachments: FilePart[] }) { - const imageAttachments = attachments.filter((a) => a.mime.startsWith("image/")) - const otherAttachments = attachments.filter((a) => !a.mime.startsWith("image/")) - - if (imageAttachments.length === 0 && otherAttachments.length === 0) return null - - return ( -
- {imageAttachments.map((file) => ( - - - } - > - {file.filename - - - {file.filename ?? "Tool output preview"} - {file.filename - - - ))} - {otherAttachments.map((file) => ( -
-
- ))} -
- ) -} diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts index 954e47d2..79865a05 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-turn.test.ts @@ -1,482 +1,121 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" -const source = readFileSync( - new URL("./chat-turn.tsx", import.meta.url), - "utf8", -); -const thoughtRowSource = readFileSync( - new URL("./thought-row.tsx", import.meta.url), - "utf8", -); -const transcriptDisclosureSource = readFileSync( - new URL("./transcript-disclosure.tsx", import.meta.url), - "utf8", -); -const processTimelineViewSource = readFileSync( - new URL("./process-timeline-view.tsx", import.meta.url), - "utf8", -); -const chatViewSource = readFileSync( - new URL("./chat-view.tsx", import.meta.url), - "utf8", -); +const source = readFileSync(new URL("./chat-turn.tsx", import.meta.url), "utf8") +const activitySource = readFileSync(new URL("./turn-activity.tsx", import.meta.url), "utf8") +const activityModelSource = readFileSync(new URL("./turn-activity-model.ts", import.meta.url), "utf8") +const chatViewSource = readFileSync(new URL("./chat-view.tsx", import.meta.url), "utf8") const permissionOptionsSource = readFileSync( - new URL("./chat-permission-options.ts", import.meta.url), - "utf8", -); + new URL("./chat-permission-options.ts", import.meta.url), + "utf8", +) const eventProcessorSource = readFileSync( - new URL("../../atoms/actions/event-processor.ts", import.meta.url), - "utf8", -); -const chipSource = readFileSync( - new URL("./composer-mode-chip.tsx", import.meta.url), - "utf8", -); -const chatToolCallSource = readFileSync( - new URL("./chat-tool-call.tsx", import.meta.url), - "utf8", -); -const compactionDividerSource = readFileSync( - new URL("./compaction-status-divider.tsx", import.meta.url), - "utf8", -); -const eventProcessorSource = readFileSync( - new URL("../../atoms/actions/event-processor.ts", import.meta.url), - "utf8", -); -const clientSource = readFileSync( - new URL("../../../../packages/devo-ai-sdk/src/v2/client.ts", import.meta.url), - "utf8", -); -const sharedReasoningSource = readFileSync( - new URL( - "../../../../packages/ui/src/components/ai-elements/reasoning.tsx", - import.meta.url, - ), - "utf8", -); -const responseActionsProps = - source.match(/\{!working && responseText && \([\s\S]*?]*)>/)?.[1] ?? - ""; -const footerMetadataSource = - source.match( - /\{\/\* Per-turn metadata[\s\S]*?\{\/\* Turn-level message actions/, - )?.[0] ?? ""; -const processTimelineIndex = source.indexOf(" { - test("keeps completed process collapsed, suppresses zero-second footer, and shows actions", () => { - expect({ - collapsesCompletedProcess: - source.includes("const processSectionVisible =") && - source.includes("completedProcessExpanded"), - showsSubSecondDuration: source.includes( - 'if (workTimeMs <= 0) return ""', - ) && source.includes("return formatWorkDuration(workTimeMs)"), - usesActiveTurnDuration: source.includes( - "computeTurnWorkTime(turn, { active: working })", - ), - usesInterleavedTimeline: source.includes("buildProcessTimeline"), - omitsStepsToggle: !source.includes("const stepsToggle ="), - footerConditionOmitsDuration: footerMetadataSource.includes( - "turn.assistantMessages.length > 0 && (turnModel || turnCostStr)", - ), - footerDoesNotRenderDuration: !footerMetadataSource.includes( - "{duration && {duration}}", - ), - usesAlwaysVisibleActions: - responseActionsProps.includes('className="-ml-1"'), - usesHoverHiddenActions: - responseActionsProps.includes("opacity-0") || - responseActionsProps.includes("group-hover/turn:opacity-100"), - rendersTimelineDirectlyUnderWorkedFor: - processTimelineIndex !== -1 && - source.indexOf(" { - expect({ - chatViewUsesComposerPermissionFlow: - chatViewSource.includes(" { - const userMessageIndex = source.indexOf("{/* User message */}"); - const workingStripIndex = source.indexOf(" { - const disclosureIndex = source.indexOf(" { - expect({ - disclosureAlwaysRendersWhenMounted: !source.includes( - "if (!duration && !hasProcessDetails) return null", - ), - disclosureUsesWorkedFallback: source.includes( - '{duration ? "Worked for " : "Worked"}', - ), - renderConditionUsesWorkedForSummary: source.includes( - "!working && showWorkedForSummary", - ), - showsWorkedForOnAnyCompletedTurn: source.includes( - "return turn.assistantMessages.length > 0", - ), - }).toEqual({ - disclosureAlwaysRendersWhenMounted: true, - disclosureUsesWorkedFallback: true, - renderConditionUsesWorkedForSummary: true, - showsWorkedForOnAnyCompletedTurn: true, - }); - }); + new URL("../../atoms/actions/event-processor.ts", import.meta.url), + "utf8", +) +const chipSource = readFileSync(new URL("./composer-mode-chip.tsx", import.meta.url), "utf8") +const userMessageBlockSource = readFileSync( + new URL("./user-message-block.tsx", import.meta.url), + "utf8", +) - test("uses transcript disclosure rows for thoughts and tools", () => { - expect({ - definesThoughtRow: thoughtRowSource.includes("export const ThoughtRow"), - thoughtContentUsesRail: thoughtRowSource.includes( - "", - ), - usesTranscriptDisclosureTrigger: transcriptDisclosureSource.includes( - "export const TranscriptDisclosureTrigger", - ), - usesCollapsedThoughtChevron: - transcriptDisclosureSource.includes("ChevronRightIcon") && - transcriptDisclosureSource.includes("ChevronDownIcon"), - removesBareReasoningTrigger: !source.includes(""), - keepsSharedReasoningTriggerUnchanged: - sharedReasoningSource.includes("export const ReasoningTrigger") && - sharedReasoningSource.includes("Thought for a few seconds"), - dropsVisibleThoughtCopyDependency: !source.includes( - "Thought for a few seconds", - ), - keepsActiveThinkingCue: thoughtRowSource.includes("Thinking..."), - switchesToThoughtWhenComplete: - thoughtRowSource.includes('"Thought"') || - thoughtRowSource.includes("Thought for "), - showsThoughtDurationHelper: thoughtRowSource.includes("computeThoughtWorkTime"), - toolsUseTranscriptDisclosure: chatToolCallSource.includes( - "
+ ) + } + + return ( +
+ {code !== undefined ? ( + +
+ toast.success("Python code copied")} + onError={() => toast.error("Could not copy Python code")} + >Copy +
+ +
+ ) : ( +
{formatNativeToolDetails(row.entry.info.item)}
+ )} + {output !== undefined && ( +
+
Output
+
{output.trim() || "No output"}
+
+ )} +
+ ) +} + +/** Each thought or tool owns its disclosure; tool source and output stay together. */ +export const TurnActivity = memo(function TurnActivity({ entries, working }: { + entries: ChatMessageEntry[] + working: boolean +}) { + const rows = useMemo(() => buildActivityRows(entries), [entries]) + const summary = activitySummary(entries, working) + const failed = rows.some((row) => ["Failed", "Connection lost"].includes(activityRowStatus(row) ?? "")) + return ( + 0}> + {summary}} + aria-label={`${summary}: activity details`} + /> + {rows.length > 0 && ( + + {rows.map((row) => { + const title = activityRowTitle(row) + const status = activityRowStatus(row) + const rowFailed = status === "Failed" || status === "Connection lost" + return ( + + {status}} + aria-label={`${title}: activity details`} + /> + + + + + ) + })} + + )} + + ) +}) diff --git a/apps/desktop/src/renderer/components/chat/turn-recovery-panel.tsx b/apps/desktop/src/renderer/components/chat/turn-recovery-panel.tsx new file mode 100644 index 00000000..aa28a893 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/turn-recovery-panel.tsx @@ -0,0 +1,18 @@ +import type { useTurnRecovery } from "../../hooks/use-turn-recovery" + +export function TurnRecoveryPanel({ state }: { state: ReturnType }) { + if (!state.recovery) return null + return ( +
+

This turn stopped unexpectedly. Continue from the saved context?

+ {state.recovery.reason &&

{state.recovery.reason}

} + {state.error &&

{state.error}

} +
+ + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/components/command-palette.test.ts b/apps/desktop/src/renderer/components/command-palette.test.ts new file mode 100644 index 00000000..57444d64 --- /dev/null +++ b/apps/desktop/src/renderer/components/command-palette.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const source = readFileSync(new URL("./command-palette.tsx", import.meta.url), "utf8") + +describe("command palette session search", () => { + test("searches Native session summaries beyond loaded sidebar pages", () => { + expect({ + controlledQuery: source.includes("value={query} onValueChange={setQuery}"), + unscopedClient: source.includes("const client = getBaseClient()"), + boundedSearch: source.includes("client.session.list({ search, limit: 50 })"), + preservesPagination: source.includes('appStore.set(setSessionsAtom, { sessions, statuses: {}, directory: "" })'), + ignoresStaleQueries: source.includes("cancelled = true"), + loadingAndFailure: source.includes("Searching sessions…") && source.includes("Session search failed. Try again."), + }).toEqual({ + controlledQuery: true, + unscopedClient: true, + boundedSearch: true, + preservesPagination: true, + ignoresStaleQueries: true, + loadingAndFailure: true, + }) + }) + + test("uses distinct item values for sessions with identical titles", () => { + expect(source).toContain('value={`session:${agent.id}`}') + expect(source).toContain('value={`active-session:${agent.id}`}') + expect(source).toContain("keywords={[agent.name, agent.project]}") + }) +}) diff --git a/apps/desktop/src/renderer/components/command-palette.tsx b/apps/desktop/src/renderer/components/command-palette.tsx index b99eaa3d..ba23e253 100644 --- a/apps/desktop/src/renderer/components/command-palette.tsx +++ b/apps/desktop/src/renderer/components/command-palette.tsx @@ -34,6 +34,8 @@ import { } from "lucide-react" import { useCallback, useEffect, useMemo, useState } from "react" import { sessionMetricsFamily } from "../atoms/derived/session-metrics" +import { setSessionsAtom } from "../atoms/sessions" +import { appStore } from "../atoms/store" import { automationsEnabledAtom, toggleAutomationsAtom } from "../atoms/feature-flags" import { isMockModeAtom, toggleMockModeAtom } from "../atoms/mock-mode" import { lastProjectDirectoryAtom, opaqueWindowsAtom } from "../atoms/preferences" @@ -50,9 +52,10 @@ import { import { createLogger } from "../lib/logger" import { formatShortcut } from "../lib/shortcut-display" import { navigateToNewChat } from "../lib/project-selection" +import { shouldToggleCommandPalette } from "./root-layout-keyboard" import type { ColorScheme } from "../lib/themes" import type { Agent } from "../lib/types" -import { reloadConfig } from "../services/connection-manager" +import { getBaseClient, reloadConfig } from "../services/connection-manager" interface CommandPaletteProps { open: boolean @@ -98,6 +101,9 @@ export function CommandPalette({ open, onOpenChange, agents, onForkSession }: Co const automationsEnabled = useAtomValue(automationsEnabledAtom) const toggleAutomations = useSetAtom(toggleAutomationsAtom) const [reloading, setReloading] = useState(false) + const [query, setQuery] = useState("") + const [searching, setSearching] = useState(false) + const [searchFailed, setSearchFailed] = useState(false) const newSessionShortcut = formatShortcut(["mod", "N"]) const undoShortcut = formatShortcut(["mod", "Z"]) const redoShortcut = formatShortcut(["shift", "mod", "Z"]) @@ -136,7 +142,7 @@ export function CommandPalette({ open, onOpenChange, agents, onForkSession }: Co useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + if (shouldToggleCommandPalette(e, open)) { e.preventDefault() onOpenChange(!open) } @@ -145,6 +151,42 @@ export function CommandPalette({ open, onOpenChange, agents, onForkSession }: Co return () => document.removeEventListener("keydown", handleKeyDown) }, [open, onOpenChange]) + // Sidebar pagination must not limit the conversations searchable here. + useEffect(() => { + const search = query.trim() + if (!open || search.length < 2) { + setSearching(false) + setSearchFailed(false) + if (!open) setQuery("") + return + } + let cancelled = false + setSearching(true) + setSearchFailed(false) + const timeout = window.setTimeout(async () => { + try { + const client = getBaseClient() + if (!client) throw new Error("Not connected to the server") + const { data: sessions } = await client.session.list({ search, limit: 50 }) + if (!cancelled) { + // Preserve existing live statuses and sidebar pagination state. + appStore.set(setSessionsAtom, { sessions, statuses: {}, directory: "" }) + } + } catch (err) { + if (!cancelled) { + setSearchFailed(true) + log.error("Session search failed", {}, err) + } + } finally { + if (!cancelled) setSearching(false) + } + }, 250) + return () => { + cancelled = true + window.clearTimeout(timeout) + } + }, [open, query]) + const activeSessions = useMemo( () => (open ? agents.filter((a) => a.status === "running" || a.status === "waiting") : []), [agents, open], @@ -157,11 +199,13 @@ export function CommandPalette({ open, onOpenChange, agents, onForkSession }: Co - + - No results found. + + {searching ? "Searching sessions…" : searchFailed ? "Session search failed. Try again." : "No results found."} + ( { navigate({ to: "/project/$projectSlug/session/$sessionId", @@ -365,6 +411,8 @@ export function CommandPalette({ open, onOpenChange, agents, onForkSession }: Co {agents.map((agent) => ( { navigate({ to: "/project/$projectSlug/session/$sessionId", diff --git a/apps/desktop/src/renderer/components/context-usage-button.tsx b/apps/desktop/src/renderer/components/context-usage-button.tsx index 1452c7e6..af2cfca5 100644 --- a/apps/desktop/src/renderer/components/context-usage-button.tsx +++ b/apps/desktop/src/renderer/components/context-usage-button.tsx @@ -5,6 +5,12 @@ * conversation surface so it matches the transcript column width and sits * flush with the top of the chat area, using the same popover chrome as * the rest of the desktop UI. + * + * Fill amount matches TUI status-bar semantics: prefer live + * `session.usage.updated` (TurnUsageUpdated / last-query display total) so + * the ring moves mid-turn; occupancy.totalTokens is only a fallback when no + * usage has arrived yet. Occupancy still supplies the window size and the + * category breakdown. */ import { cn } from "@devo/ui/lib/utils" import { useAtom, useAtomValue, useSetAtom } from "jotai" @@ -39,8 +45,19 @@ export function ContextUsageButton({ sessionId }: ContextUsageButtonProps) { const [open, setOpen] = useAtom(contextUsageOpenAtom) const native = useAtomValue(sessionNativeFamily(sessionId)) const occupancy = native.occupancy - const used = occupancy?.totalTokens ?? Number(native.usage?.used ?? 0) - const windowTokens = occupancy?.contextWindowTokens ?? Number(native.usage?.size ?? 0) + const usageUsed = Number(native.usage?.used ?? 0) + const usageWindow = Number(native.usage?.size ?? 0) + // TUI: last_query_total_tokens > 0 wins over occupancy.total_tokens. + const used = usageUsed > 0 ? usageUsed : (occupancy?.totalTokens ?? 0) + // Prefer occupancy's effective window (same basis as finalize / TUI). Fall + // back to usage.size only when occupancy has not arrived yet. Both stay in + // sync via context.usage.updated / session.usage.updated. + const windowTokens = + occupancy?.contextWindowTokens && occupancy.contextWindowTokens > 0 + ? occupancy.contextWindowTokens + : usageWindow > 0 + ? usageWindow + : 0 const percent = windowFillPercent(used, windowTokens) const rows = useMemo(() => occupancyCategoryRows(occupancy), [occupancy]) diff --git a/apps/desktop/src/renderer/components/customize/customize-view.tsx b/apps/desktop/src/renderer/components/customize/customize-view.tsx index 447c0bca..f595c33b 100644 --- a/apps/desktop/src/renderer/components/customize/customize-view.tsx +++ b/apps/desktop/src/renderer/components/customize/customize-view.tsx @@ -71,22 +71,22 @@ export function CustomizeView() { return (
-
+
-
+
{TABS.map((item) => (
-
+ {/* Flat content — no nested panel framing around SettingsSection cards */} +
@@ -127,12 +128,12 @@ function CustomizePanel({ tab, searchQuery }: { tab: CustomizeTab; searchQuery: const copy = EMPTY_COPY[tab] return ( -
-

{copy.title}

-

{copy.description}

-
-
diff --git a/apps/desktop/src/renderer/components/desktop-terminal-panel.test.ts b/apps/desktop/src/renderer/components/desktop-terminal-panel.test.ts new file mode 100644 index 00000000..3424fecb --- /dev/null +++ b/apps/desktop/src/renderer/components/desktop-terminal-panel.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const terminalPanelSource = readFileSync(new URL("./desktop-terminal-panel.tsx", import.meta.url), "utf8") + +describe("DesktopTerminalPanel collapsed state", () => { + test("removes hidden terminal controls from keyboard focus", () => { + expect(terminalPanelSource).toContain("aria-hidden={!open}") + expect(terminalPanelSource).toContain("inert={!open}") + }) +}) diff --git a/apps/desktop/src/renderer/components/desktop-terminal-panel.tsx b/apps/desktop/src/renderer/components/desktop-terminal-panel.tsx index a0a57710..0e3512ad 100644 --- a/apps/desktop/src/renderer/components/desktop-terminal-panel.tsx +++ b/apps/desktop/src/renderer/components/desktop-terminal-panel.tsx @@ -14,9 +14,11 @@ import { terminalNewTabNonceAtom } from "../atoms/terminal"; import { isElectron } from "../services/backend"; interface DesktopTerminalPanelProps { - open: boolean; - directory: string | null | undefined; - onOpenChange: (open: boolean) => void; + open: boolean + directory: string | null | undefined + onOpenChange: (open: boolean) => void + /** Fill a parent pane (right rail) instead of the bottom dock. */ + embedded?: boolean } type TerminalStatus = "idle" | "starting" | "running" | "exited" | "error"; @@ -129,6 +131,7 @@ export function DesktopTerminalPanel({ open, directory, onOpenChange, + embedded = false, }: DesktopTerminalPanelProps) { const runtimesRef = useRef(new Map()); const tabsRef = useRef([]); @@ -404,22 +407,24 @@ export function DesktopTerminalPanel({ const newTabNonce = useAtomValue(terminalNewTabNonceAtom); const previousNewTabNonceRef = useRef(newTabNonce); useEffect(() => { - if (newTabNonce === previousNewTabNonceRef.current) return; - previousNewTabNonceRef.current = newTabNonce; - if (open) addTerminalTab(); - }, [addTerminalTab, newTabNonce, open]); + if (newTabNonce === previousNewTabNonceRef.current) return + previousNewTabNonceRef.current = newTabNonce + // Side-rail Terminal is a single session; extra sessions are new right-panel tabs. + if (open && !embedded) addTerminalTab() + }, [addTerminalTab, embedded, newTabNonce, open]) useEffect(() => { - if (!open || !activeTabId || height <= 0) return; + if (!open || !activeTabId) return + if (!embedded && height <= 0) return requestAnimationFrame(() => { - resizeAndStartTab(activeTabId); - const runtime = runtimesRef.current.get(activeTabId); + resizeAndStartTab(activeTabId) + const runtime = runtimesRef.current.get(activeTabId) if (runtime) { - refreshTerminal(runtime.terminal); - runtime.terminal.focus(); + refreshTerminal(runtime.terminal) + runtime.terminal.focus() } - }); - }, [activeTabId, height, open, resizeAndStartTab]); + }) + }, [activeTabId, embedded, height, open, resizeAndStartTab]) useEffect(() => { if (!open) return; @@ -465,114 +470,128 @@ export function DesktopTerminalPanel({ tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null; const activeStatusLabel = activeTab ? statusLabel(activeTab.status) : ""; - if (!getTerminalBridge()) return null; + if (!getTerminalBridge()) return null return (
-
-
-
-
- {tabs.map((tab) => { - const isActive = tab.id === activeTabId; - const terminalTitle = tab.cwd ?? directory ?? "Terminal"; - const folderName = folderNameLabel(terminalTitle); - return ( -
+
+
+
+
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId + const terminalTitle = tab.cwd ?? directory ?? "Terminal" + const folderName = folderNameLabel(terminalTitle) + return ( +
+ + +
+ ) + })} +
+ + + } > -
+
+ + - - {folderName} - - - -
- ); - })} + } + > +
- - - } - > - - New terminal - - {activeStatusLabel && ( - - {activeStatusLabel} - - )} -
-
- - - } - > - - Close - -
-
-
+ + )} +
{tabs.map((tab) => (
- ); + ) } diff --git a/apps/desktop/src/renderer/components/new-chat.tsx b/apps/desktop/src/renderer/components/new-chat.tsx index 3eb758d7..c47aaf81 100644 --- a/apps/desktop/src/renderer/components/new-chat.tsx +++ b/apps/desktop/src/renderer/components/new-chat.tsx @@ -59,6 +59,7 @@ import { ComposerPermissionPicker } from "./chat/composer-permission-picker" import { DEFAULT_COMPOSER_PERMISSION_PROFILE, type ComposerPermissionProfile, + PERMISSION_PICKER_ENABLED, stashComposerPermissionForSession, } from "./chat/composer-permission" import { goalPromptText, parseComposerSlash } from "./chat/composer-slash" @@ -254,6 +255,7 @@ export function NewChat() { const [activeTrigger, setActiveTrigger] = useState<"goal" | null>(null) const [permissionProfile, setPermissionProfile] = useState(DEFAULT_COMPOSER_PERMISSION_PROFILE) + const [modelPickerOpen, setModelPickerOpen] = useState(false) const [skillPickerOpen, setSkillPickerOpen] = useState(false) // Slash command and mention popover state @@ -267,20 +269,16 @@ export function NewChat() { const slashPopoverRef = useRef(null) const mentionPopoverRef = useRef(null) - // Project model preferences are a UI fallback for older local state. + // Native preferences own model/effort defaults, shared with Settings. + // Local project state only restores the agent preference. const projectModels = useAtomValue(projectModelsAtom) const prevDirectoryRef = useRef("") useEffect(() => { if (!selectedDirectory || selectedDirectory === prevDirectoryRef.current) return prevDirectoryRef.current = selectedDirectory const stored = projectModels[selectedDirectory] - if (stored?.providerID && stored?.modelID) { - setSelectedModel(stored) - setSelectedVariant(stored.variant) - } else { - setSelectedModel(null) - setSelectedVariant(undefined) - } + setSelectedModel(null) + setSelectedVariant(undefined) // Restore the per-project agent preference (null = use config default) setSelectedAgent(stored?.agent ?? null) }, [selectedDirectory, projectModels]) @@ -323,6 +321,7 @@ export function NewChat() { if (!model) return addRecentModel(model) if (!selectedDirectory) return + setError(null) void persistRuntimeModelSelection(selectedDirectory, model).catch((err) => { console.error("Failed to persist model selection:", err) setError("Failed to save model setting") @@ -335,6 +334,7 @@ export function NewChat() { (variant: string | undefined) => { setSelectedVariant(variant) if (!variant || !selectedDirectory) return + setError(null) void persistRuntimeModelConfigOption(selectedDirectory, "thought_level", variant).catch((err) => { console.error("Failed to persist reasoning effort selection:", err) setError("Failed to save reasoning effort setting") @@ -385,6 +385,10 @@ export function NewChat() { setActiveTrigger("goal") controllerRef.current?.setText("") return true + case "model": + setModelPickerOpen(true) + controllerRef.current?.setText("") + return true case "skills": setSkillPickerOpen(true) controllerRef.current?.setText("") @@ -556,7 +560,7 @@ export function NewChat() { if (!client?.session?.updateSettings) return try { await client.session.updateSettings({ - sessionID: sessionId, + sessionId: sessionId, permissionProfile, mode: collaborationMode, }) @@ -843,11 +847,13 @@ export function NewChat() { - + {PERMISSION_PICKER_ENABLED && ( + + )} {collaborationMode === "plan" && ( ({ + id, + name, + enabled: true, + wireApis: [], + models: { "remote-model": { name: "Remote model" } }, +}) +let data: ProviderCatalogData +let serverConnected = true +let catalogError: string | null = null +let offline = false +const refreshed: string[] = [] +const completed: number[] = [] +let skipped = 0 +let reloaded = 0 +let invalidated = 0 +let oauthProps: any +let templateProps: any +const refreshCatalog = (policy: string) => { + refreshed.push(policy) +} +const reload = () => { + reloaded++ +} +mock.module("../../../hooks/use-server", () => ({ + useServerConnection: () => ({ connected: serverConnected }), +})) +mock.module("../../../hooks/use-devo-data", () => ({ + useProviderCatalog: () => ({ + data, + loading: false, + error: catalogError, + reload, + }), +})) +mock.module("../../../hooks/use-model-directory", () => ({ + useModelDirectory: () => ({ + refreshCatalog, + refreshing: false, + error: null, + offline, + }), +})) +mock.module("../../../lib/invalidate-provider-queries", () => ({ + invalidateProviderDependentQueries: () => { + invalidated++ + }, +})) +mock.module("@devo/ui/components/input", () => ({ + Input: ({ onChange, ...props }: any) => ( + + ), +})) +mock.module("../../settings/provider-icon", () => ({ + ProviderIcon: () => null, +})) +mock.module("../../settings/connect-provider-dialog", () => ({ + ConnectProviderDialog: (props: any) => { + oauthProps = props + return
OAuth connection
+ }, +})) +mock.module("../../settings/template-connect-dialog", () => ({ + TemplateConnectDialog: (props: any) => { + templateProps = props + return
API connection
+ }, +})) +mock.module("../../settings/connection-detail-dialog", () => ({ + ConnectionDetailDialog: (props: any) => ( +
Manage {props.provider.name}
+ ), +})) +const { ProviderSetupStep } = await import("./provider-setup-step") +const { createRoot } = await import("react-dom/client") +let root: Root | undefined +let mount: HTMLElement +beforeEach(() => { + data = { + providers: [ + provider("groq", "Groq"), + provider("new-provider", "Remote provider"), + provider("openai-codex", "ChatGPT"), + provider("anthropic", "Anthropic"), + ], + templateIds: new Set(["groq", "new-provider", "openai-codex", "anthropic"]), + connectedIds: new Set(["openai-codex"]), + connectionModels: {}, + } + serverConnected = true + catalogError = null + offline = false + refreshed.length = 0 + completed.length = 0 + skipped = 0 + reloaded = 0 + invalidated = 0 + oauthProps = null + templateProps = null +}) +afterEach(async () => { + await act(async () => root?.unmount()) + mount?.remove() + root = undefined +}) +async function render() { + if (!root) { + mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + } + await act(async () => + root!.render( + completed.push(count)} + onSkip={() => { + skipped++ + }} + />, + ), + ) +} +async function click(text: string) { + const button = [...mount.querySelectorAll("button")].find((button) => + button.textContent?.includes(text), + ) + if (!button) throw new Error(`Missing button ${text}`) + await act(async () => button.click()) +} + +test("onboarding shows the complete server catalog and requests a stale remote refresh", async () => { + await render() + expect( + [...mount.querySelectorAll('[aria-label="Provider catalog"] button')].map( + (button) => button.textContent, + ), + ).toEqual([ + "ChatGPT1 model · Connected", + "Anthropic1 model", + "Groq1 model", + "Remote provider1 model", + ]) + expect(refreshed).toEqual(["ifStale"]) + data = { + ...data, + providers: [...data.providers, provider("remote-added", "Added remotely")], + } + await render() + expect({ + added: mount.textContent?.includes("Added remotely"), + refreshes: refreshed, + }).toEqual({ added: true, refreshes: ["ifStale"] }) +}) + +test("search finds remote model names, handles no results, and refresh uses the canonical path", async () => { + data.providers[1].models = { "new-remote-id": { name: "Nova remote" } } + await render() + const input = mount.querySelector("input")! + await act(async () => { + input.value = "Nova" + input.dispatchEvent( + new dom.Event("input", { bubbles: true }) as unknown as Event, + ) + }) + expect( + [...mount.querySelectorAll('[aria-label="Provider catalog"] button')].map( + (button) => button.textContent, + ), + ).toEqual(["Remote provider1 model"]) + await act(async () => { + input.value = "missing" + input.dispatchEvent( + new dom.Event("input", { bubbles: true }) as unknown as Event, + ) + }) + expect(mount.textContent).toContain( + "No providers or models match your search.", + ) + await click("Refresh catalog") + expect({ refreshes: refreshed, reloads: reloaded }).toEqual({ + refreshes: ["ifStale", "force"], + reloads: 1, + }) +}) + +test("onboarding reuses API and OAuth dialogs and catalog refetches preserve OAuth state", async () => { + await render() + await click("Groq") + expect(templateProps.provider).toEqual(data.providers[0]) + await act(async () => templateProps.onConnected()) + expect({ reloads: reloaded, invalidations: invalidated }).toEqual({ + reloads: 1, + invalidations: 1, + }) + await click("Anthropic") + const original = oauthProps.provider + data = { ...data, providers: data.providers.map((entry) => ({ ...entry })) } + await render() + expect(oauthProps.provider).toBe(original) + await act(async () => oauthProps.onClose()) + await click("ChatGPT") + expect(mount.textContent).toContain("Manage ChatGPT") +}) + +test("offline and failed refresh keep saved connections usable and allow continuing", async () => { + offline = true + catalogError = "Network unavailable" + await render() + expect({ + error: mount + .querySelector('[role="alert"]') + ?.textContent?.includes("Network unavailable"), + offline: mount.querySelector('[role="status"]')?.textContent, + }).toEqual({ + error: true, + offline: "Offline mode · showing the saved catalog.", + }) + await click("Continue") + expect(completed).toEqual([1]) + data = { ...data, connectedIds: new Set() } + await render() + await click("I'll do this later") + expect(skipped).toBe(1) +}) + +test("waiting for the server does not start a refresh and permits skipping", async () => { + serverConnected = false + await render() + await click("Skip for now") + expect({ refreshes: refreshed, skipped }).toEqual({ + refreshes: [], + skipped: 1, + }) +}) diff --git a/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx b/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx index bfb05245..874aa77d 100644 --- a/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx +++ b/apps/desktop/src/renderer/components/onboarding/steps/provider-setup-step.tsx @@ -1,274 +1,247 @@ -/** - * Onboarding: Provider Setup. - * - * Allows users to connect AI providers during onboarding. - * Devo Zen is featured prominently as the built-in provider with free models. - * Reuses ConnectProviderDialog for the actual auth flows. - */ - +/** Onboarding uses the same server-owned catalog and connection flows as Settings. */ import { Button } from "@devo/ui/components/button" +import { Input } from "@devo/ui/components/input" import { Spinner } from "@devo/ui/components/spinner" -import { useQueryClient } from "@tanstack/react-query" -import { CheckIcon, ExternalLinkIcon, LinkIcon, SparklesIcon, ZapIcon } from "lucide-react" -import { motion } from "motion/react" -import { useCallback, useMemo, useState } from "react" +import { CheckIcon, RefreshCwIcon } from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" import { - type CatalogProvider, - queryKeys, - useAllProviders, - useConnectedProviders, - useProviderAuthMethods, + type CatalogProviderInfo, + useProviderCatalog, } from "../../../hooks/use-devo-data" +import { useModelDirectory } from "../../../hooks/use-model-directory" import { useServerConnection } from "../../../hooks/use-server" -import { - compareConnectedFirst, - isZenFreeTier, - POPULAR_PROVIDER_IDS, - ZEN_PROVIDER_ID, - ZEN_SIGNUP_URL, -} from "../../../lib/providers" +import { invalidateProviderDependentQueries } from "../../../lib/invalidate-provider-queries" +import { compareConnectedFirst } from "../../../lib/providers" import { ConnectProviderDialog } from "../../settings/connect-provider-dialog" +import { ConnectionDetailDialog } from "../../settings/connection-detail-dialog" +import { isDesktopOAuthProvider } from "../../settings/desktop-oauth-providers" import { ProviderIcon } from "../../settings/provider-icon" - -// ============================================================ -// Component -// ============================================================ +import { TemplateConnectDialog } from "../../settings/template-connect-dialog" interface ProviderSetupStepProps { onComplete: (count: number) => void onSkip: () => void } -export function ProviderSetupStep({ onComplete, onSkip }: ProviderSetupStepProps) { +export function ProviderSetupStep({ + onComplete, + onSkip, +}: ProviderSetupStepProps) { const { connected: serverConnected } = useServerConnection() - const { data: allProviders, loading: catalogLoading, reload: reloadCatalog } = useAllProviders() - const { loading: connectedLoading, reload: reloadConnected } = useConnectedProviders() - const { data: authMethods } = useProviderAuthMethods() - const queryClient = useQueryClient() - - const [connectDialogProvider, setConnectDialogProvider] = useState(null) - - const loading = catalogLoading || connectedLoading - const connectedIds = useMemo( - () => new Set(allProviders?.connected ?? []), - [allProviders?.connected], + const catalog = useProviderCatalog() + const directory = useModelDirectory() + const [search, setSearch] = useState("") + const [connection, setConnection] = useState(null) + const [detailId, setDetailId] = useState(null) + // Keep the OAuth provider stable while catalog refreshes complete. + const oauthProvider = useMemo( + () => (connection ? { ...connection, env: [] } : null), + [connection], ) - // Separate Zen from the other popular providers - const zenProvider = useMemo( - () => allProviders?.all.find((p) => p.id === ZEN_PROVIDER_ID) ?? null, - [allProviders], + useEffect(() => { + if (serverConnected) directory.refreshCatalog("ifStale") + }, [serverConnected, directory.refreshCatalog]) + + const providers = useMemo(() => { + const data = catalog.data + if (!data) return [] + const terms = search.toLowerCase().trim().split(/\s+/).filter(Boolean) + return data.providers + .filter((provider) => { + const text = + `${provider.id} ${provider.name} ${provider.description ?? ""} ${Object.entries( + provider.models ?? {}, + ) + .map(([id, model]) => `${id} ${model.name ?? ""}`) + .join(" ")}`.toLowerCase() + return terms.every((term) => text.includes(term)) + }) + .sort((a, b) => compareConnectedFirst(data.connectedIds, a, b)) + }, [catalog.data, search]) + const connectedCount = catalog.data?.connectedIds.size ?? 0 + const detail = catalog.data?.providers.find( + (provider) => provider.id === detailId, ) - - const otherProviders = useMemo(() => { - if (!allProviders) return [] - const filtered = allProviders.all.filter( - (p) => - p.id !== ZEN_PROVIDER_ID && - POPULAR_PROVIDER_IDS.includes(p.id as (typeof POPULAR_PROVIDER_IDS)[number]), - ) - return [...filtered].sort((a, b) => compareConnectedFirst(connectedIds, a, b)) - }, [allProviders, connectedIds]) - - const zenIsConnected = connectedIds.has(ZEN_PROVIDER_ID) - const zenHasApiKey = zenIsConnected && zenProvider !== null && !isZenFreeTier(zenProvider.models) - - const reload = useCallback(() => { - reloadCatalog() - reloadConnected() - queryClient.invalidateQueries({ queryKey: queryKeys.allProviders }) - queryClient.invalidateQueries({ queryKey: queryKeys.connectedProviders }) - queryClient.invalidateQueries({ - predicate: (q) => q.queryKey[0] === "providers", - }) - }, [reloadCatalog, reloadConnected, queryClient]) - - const handleContinue = useCallback(() => { - onComplete(connectedIds.size) - }, [onComplete, connectedIds.size]) + const error = catalog.error ?? directory.error + const refresh = () => { + catalog.reload() + directory.refreshCatalog("force") + } + const changed = useCallback(() => { + invalidateProviderDependentQueries() + catalog.reload() + }, [catalog.reload]) + const connected = () => { + setConnection(null) + changed() + } if (!serverConnected) { return ( -
-
- -

Waiting for Devo server...

-

- Devo is connecting to the Devo background process. This should only take a moment. -

-
-
- -
+
+ +

+ Connecting to Devo… +

+
) } return ( -
-
- - - -

AI Providers

-

- Devo supports connecting your own model API keys for more model choices. +

+
+

+ Connect a provider +

+

+ Choose from the live catalog or search for a model you want to use.

- -
- {/* Zen featured card */} - {loading ? ( -
-
-
-
-
-
-
- ) : zenProvider ? ( - setConnectDialogProvider(zenProvider)} +
+
+ setSearch(event.target.value)} + className="h-9 flex-1" /> - ) : null} - - {/* Other providers grid */} -
- {loading - ? ["s1", "s2", "s3", "s4", "s5", "s6"].map((key) => ( -
+
+ {error && ( +

+ Could not refresh the catalog: {error}. You can retry or continue + with saved providers. +

+ )} + {directory.offline && ( +

+ Offline mode · showing the saved catalog. +

+ )} +

+ {providers.length} provider{providers.length === 1 ? "" : "s"} ·{" "} + {connectedCount} connected +

+
+ {catalog.loading && !catalog.data ? ( +
+ +
+ ) : ( + providers.map((provider) => { + const isConnected = + catalog.data?.connectedIds.has(provider.id) ?? false + const count = Object.values(provider.models ?? {}).filter( + (model) => model.enabled !== false, + ).length + return ( + - ) - })} -
- -
- - {connectedIds.size === 0 && !zenIsConnected && ( -

- You won't be able to chat until a provider is connected. +

+ {count} model{count === 1 ? "" : "s"} + {isConnected ? " · Connected" : ""} +
+
+ {isConnected && ( +
- - setConnectDialogProvider(null)} - onConnected={() => { - setConnectDialogProvider(null) - reload() - }} - /> -
- ) -} - -// ============================================================ -// Zen featured card -// ============================================================ - -function ZenFeaturedCard({ - provider, - hasApiKey, - onConnect, -}: { - provider: CatalogProvider - hasApiKey: boolean - onConnect: () => void -}) { - const freeModelCount = Object.values(provider.models).filter( - (m) => (m as { cost?: { input?: number } }).cost?.input === 0, - ).length - const totalModelCount = Object.keys(provider.models).length - - return ( - -
-
- -
-
-
- {provider.name} - - -
-

- {freeModelCount} free models ready to use.{" "} - {hasApiKey - ? `${totalModelCount} models available with API key.` - : `Upgrade for ${totalModelCount}+ premium models.`} +

+ + {connectedCount === 0 && ( +

+ You can connect a provider later in Settings.

-
- - {!hasApiKey && ( - - Get a key at devo.ai - - )} -
-
+ )}
- + {connection && + (isDesktopOAuthProvider(connection.id) ? ( + setConnection(null)} + onConnected={connected} + /> + ) : ( + { + if (!open) setConnection(null) + }} + onConnected={connected} + /> + ))} + {detail && ( + { + if (!open) setDetailId(null) + }} + onChanged={changed} + /> + )} +
) } diff --git a/apps/desktop/src/renderer/components/review/commit-push-dialog.tsx b/apps/desktop/src/renderer/components/review/commit-push-dialog.tsx new file mode 100644 index 00000000..546f93af --- /dev/null +++ b/apps/desktop/src/renderer/components/review/commit-push-dialog.tsx @@ -0,0 +1,149 @@ +/** + * CommitPushDialog -- lightweight "commit everything and push" dialog for the + * ReviewPanel. Unlike the worktree CommitDialog there is no branch selection + * or PR step: `git add -A` + commit + push on the session's directory, then a + * refresh callback so the panel re-reads the (now clean) working tree. + */ + +import { Button } from "@devo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@devo/ui/components/dialog" +import { Textarea } from "@devo/ui/components/textarea" +import { GitCommitHorizontalIcon, Loader2Icon } from "lucide-react" +import { useCallback, useEffect, useState } from "react" +import type { GitDiffStat } from "../../../preload/api" +import { fetchDiffStat, gitCommitAll, gitPush } from "../../services/backend" + +interface CommitPushDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + directory: string + /** Called after a successful commit (even if the push failed) */ + onCommitted: () => void +} + +export function CommitPushDialog({ + open, + onOpenChange, + directory, + onCommitted, +}: CommitPushDialogProps) { + const [diffStat, setDiffStat] = useState(null) + const [loadingDiff, setLoadingDiff] = useState(false) + const [commitMessage, setCommitMessage] = useState("") + const [executing, setExecuting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + // Load diff stat when dialog opens + useEffect(() => { + if (!open) return + setDiffStat(null) + setCommitMessage("") + setError(null) + setSuccess(null) + setLoadingDiff(true) + fetchDiffStat(directory) + .then(setDiffStat) + .catch(() => setDiffStat(null)) + .finally(() => setLoadingDiff(false)) + }, [open, directory]) + + const filesChanged = diffStat?.filesChanged ?? 0 + const nothingToCommit = !loadingDiff && !!diffStat && filesChanged === 0 + + const handleExecute = useCallback(async () => { + setExecuting(true) + setError(null) + try { + const msg = + commitMessage.trim() || + `Update ${filesChanged} file${filesChanged !== 1 ? "s" : ""}` + const commitResult = await gitCommitAll(directory, msg) + if (!commitResult.success) { + setError(`Commit failed: ${commitResult.error}`) + return + } + // The tree is clean now regardless of whether the push works. + onCommitted() + const pushResult = await gitPush(directory) + if (!pushResult.success) { + setError(`Committed, but push failed: ${pushResult.error}`) + return + } + setSuccess("Committed and pushed") + setTimeout(() => onOpenChange(false), 1200) + } catch (err) { + setError(err instanceof Error ? err.message : "Operation failed") + } finally { + setExecuting(false) + } + }, [commitMessage, directory, filesChanged, onCommitted, onOpenChange]) + + return ( + + + + + + Commit & push + + + Stage all changes, commit, and push the current branch. + + + +
+
+ {loadingDiff ? ( + + + Scanning changes... + + ) : nothingToCommit ? ( + No changes detected + ) : ( + + {filesChanged} file{filesChanged !== 1 ? "s" : ""} changed + + )} +
+ +