diff --git a/.github/scripts/generate_star_history.py b/.github/scripts/generate_star_history.py new file mode 100644 index 0000000000..2d6ac42643 --- /dev/null +++ b/.github/scripts/generate_star_history.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Generate a self-hosted cumulative GitHub stars chart for the README.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import math +import os +import urllib.request +from pathlib import Path + + +def fetch_stars(repository: str, token: str) -> list[dt.date]: + url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100" + dates: list[dt.date] = [] + while url: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github.star+json", + "Authorization": f"Bearer {token}", + "User-Agent": "jcode-star-history", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urllib.request.urlopen(request) as response: + for star in json.load(response): + dates.append(dt.datetime.fromisoformat(star["starred_at"].replace("Z", "+00:00")).date()) + links = response.headers.get("Link", "") + url = "" + for link in links.split(","): + if 'rel="next"' in link: + url = link[link.index("<") + 1 : link.index(">")] + break + return dates + + +def week_start(day: dt.date) -> dt.date: + """Return the Monday containing ``day``.""" + return day - dt.timedelta(days=day.weekday()) + + +def render_svg(repository: str, dates: list[dt.date], today: dt.date | None = None) -> str: + if not dates: + raise RuntimeError("GitHub returned no stargazers") + today = today or dt.date.today() + dates.sort() + current_week = week_start(today) + weeks = [current_week - dt.timedelta(weeks=week) for week in reversed(range(26))] + values = [sum(day <= min(week + dt.timedelta(days=6), today) for day in dates) for week in weeks] + weekly = [ + sum(week <= day <= min(week + dt.timedelta(days=6), today) for day in dates) + for week in weeks + ] + + width, height = 800, 420 + left, right, top, bottom = 68, 24, 78, 64 + plot_w, plot_h = width - left - right, height - top - bottom + latest = values[-1] + magnitude = 10 ** max(0, len(str(latest)) - 2) + step = max(magnitude, math.ceil(latest / 4 / magnitude) * magnitude) + grid_max = max(step * 4, 4) + + def x(index: int) -> float: + return left + index * plot_w / (len(weeks) - 1) + + def y(value: int) -> float: + return top + (1 - value / grid_max) * plot_h + + y_ticks = [] + for index in range(5): + value = step * index + yy = y(value) + label = f"{value / 1000:g}k" if value >= 1000 else str(value) + y_ticks.append(f'{label}') + + points = " ".join(f"{x(index):.1f},{y(value):.1f}" for index, value in enumerate(values)) + area = f"{left},{top + plot_h:.1f} {points} {width-right},{top + plot_h:.1f}" + x_ticks = [] + dots = [] + for index, (week, value, gain) in enumerate(zip(weeks, values, weekly)): + if index % 4 == 0 or index == len(weeks) - 1: + x_ticks.append(f'{week:%b %-d}') + current = " current" if week == current_week else "" + radius = 5 if current else 3 + dots.append( + f'' + f'{week:%b %-d}: {value:,} total stars (+{gain:,} that week)' + ) + + return f''' +{repository} cumulative GitHub stars over time +Cumulative GitHub stars sampled weekly over the last 26 weeks, ending at {latest:,} stars. + + +GitHub stars over time +Cumulative stars · weekly sampling · last 26 weeks +{latest:,} stars ++{weekly[-1]:,} this week so far +{''.join(y_ticks)} + + +{''.join(dots)} +{''.join(x_ticks)} + +''' + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", default="1jehuang/jcode") + parser.add_argument("--output", type=Path, default=Path("docs/images/star-history.svg")) + args = parser.parse_args() + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if not token: + raise SystemExit("GITHUB_TOKEN or GH_TOKEN is required") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_svg(args.repo, fetch_stars(args.repo, token))) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bd8c40fdd..a285b327b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +env: + JCODE_CI: "1" + on: push: branches: [main, master] @@ -39,6 +42,12 @@ jobs: key: quality-ubuntu cache-all-crates: "true" + - name: Check module declarations resolve + # A `mod x;` with no file makes rustfmt fail with "Error writing files: + # failed to resolve mod", which reads like a formatting problem and hides + # every gate behind it. Naming the real cause first (221159294). + run: python3 scripts/check_module_files.py + - name: Check formatting run: cargo fmt --all -- --check @@ -48,6 +57,15 @@ jobs: - name: Run clippy with warnings denied run: cargo clippy --all-targets --all-features -- -D warnings + - name: Enforce Cargo.lock is up to date + shell: bash + # Adding a dependency without regenerating Cargo.lock breaks every + # `--locked` build. Only the Windows jobs pass `--locked`, so such a + # commit passes 8 of 9 CI jobs and fails Windows at "Build release + # binary", skipping all of its validation steps. This catches it in + # seconds, on the job that already owns dependency hygiene. + run: cargo metadata --locked --format-version 1 > /dev/null + - name: Enforce warning budget shell: bash run: scripts/check_warning_budget.sh @@ -72,6 +90,10 @@ jobs: shell: bash run: python3 scripts/check_dependency_boundaries.py + - name: Enforce Rust and TypeScript SDK surface parity + shell: bash + run: cargo test -p jcode-sdk parity -- --nocapture + - name: Enforce wildcard re-export ratchet shell: bash run: python3 scripts/check_wildcard_reexport_budget.py @@ -82,10 +104,29 @@ jobs: cargo install cargo-machete --locked cargo machete + release-automation: + name: Release Automation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Test Discord release announcements + run: python3 -m unittest -v scripts/test_post_discord_release.py + + - name: Compile release automation scripts + run: python3 -m py_compile scripts/post_discord_release.py scripts/test_post_discord_release.py + build: name: Build & Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} - timeout-minutes: 35 + # 35 minutes covers a normal push (~12 min with a warm Swatinem cache) but + # not a *rerun*, where the cache is cold: three consecutive reruns of a + # known-good commit were cancelled at the cap, each getting further through + # the same green steps (#693). A rerun is exactly what you reach for after a + # concurrency cancellation, so the old cap made healthy commits look red. + # 75 still bounds a genuinely hung job well under the windows job's 150. + timeout-minutes: 75 # Some dependencies (e.g. convert_case 0.10.0 via derive_more/crossterm, and # proc-macro2/quote) accidentally ship a `rust-toolchain.toml` *inside their # published crate*. When cargo builds such a crate its CWD is that crate dir, @@ -169,6 +210,133 @@ jobs: python3 .github/scripts/run_with_timeout.py 900 \ "$(rustup which cargo)" test --target ${{ matrix.target }} --lib --bins --no-run + - name: Run deterministic retention-readiness cohort + shell: bash + run: | + # A cold jcode-app-core test harness compiles heavy optional provider + # dependencies and can exceed three minutes on hosted runners. The + # cohort itself runs in under a second once the lib test is built. + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} \ + -p jcode-app-core --lib retention_readiness -- --nocapture + + - name: Run secret-input pty cohort (all platforms with a pty) + if: runner.os != 'Windows' + shell: bash + # `jcode-base --lib` is never executed on Linux CI: the only jcode-base + # test invocation anywhere in this workflow is a Windows-only + # `power_inhibit::tests::windows_` filter. So the masking fix for #660 had + # a test that was compiled and never run, which is the same + # looks-green-but-never-ran shape as the warning budget and the stdin + # detector (#651). These tests fork a pty, so they cannot run on the + # Windows runner; everywhere else they take about a second. + # + # 600s, not 300s: the budget is dominated by cold-compiling the + # jcode-base lib test harness, which exceeded 300s on a hosted macOS + # runner (run 30591536707) while the cohort itself runs in a second. + run: | + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} \ + -p jcode-base --lib secret_input -- --nocapture + + - name: Run embedding numeric-stability cohort (Linux only) + if: runner.os == 'Linux' + shell: bash + # `minilm_embedding_is_numerically_stable_across_inference_engines` pins + # the model's actual output, which is the only thing that can catch an + # inference-engine upgrade silently changing embeddings: persisted + # memories are keyed by model_id, which does not change across a tract + # bump, so the stale-embedding filter cannot notice (#657). + # + # That test skips itself when the model is absent, and jcode-embedding + # tests are not otherwise run here at all, so without this step it would + # never execute. Fetch the model first (~87MB) so the cohort is real + # rather than a silent skip. + run: | + set -euo pipefail + model_dir="$HOME/.jcode/models/all-MiniLM-L6-v2" + mkdir -p "$model_dir" + base="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main" + curl -sSfL --retry 3 -o "$model_dir/model.onnx" "$base/onnx/model.onnx" + curl -sSfL --retry 3 -o "$model_dir/tokenizer.json" "$base/tokenizer.json" + # Fail loudly if the fetch produced something unusable, so this cannot + # degrade back into a silent skip. + test -s "$model_dir/model.onnx" + test -s "$model_dir/tokenizer.json" + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} \ + -p jcode-embedding --lib -- --nocapture \ + | tee /tmp/embedding-cohort.log + # The test is present and the harness ran it (rather than the binary + # failing to build). + grep -q "minilm_embedding_is_numerically_stable_across_inference_engines ... ok" \ + /tmp/embedding-cohort.log + # This is the check that matters. A skipped test still reports "ok", + # so the reported result alone cannot distinguish "verified" from + # "silently did nothing"; only the skip message can. + if grep -q "skip: MiniLM model not present" /tmp/embedding-cohort.log; then + echo "::error::embedding cohort skipped despite fetching the model" + exit 1 + fi + + - name: Run stdin-forwarding cohort (all platforms) + shell: bash + # These tests exercise the per-platform stdin detector, whose + # implementations are entirely separate (`/proc/PID/syscall` on Linux, + # `proc_pidinfo` + thread state on macOS). They were never gated: the + # only `jcode-app-core --lib` run above is filtered to + # `retention_readiness`, and the broader suite is Linux-only, so the + # macOS detector had no coverage at all. `TH_STATE_WAITING` was defined + # as 2 (`TH_STATE_STOPPED`) rather than 3, which silently disabled + # macOS stdin forwarding entirely until #651. + # + # Deliberately not Linux-gated: running this on both Unix platforms is + # the whole point. Windows is a separate job and is not covered here, + # because these tests drive `head -n1`, which stock Windows does not + # provide; the Windows detector still has no test coverage. + run: | + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} \ + -p jcode-app-core --lib tool::bash::tests::test_stdin_forwarding -- --nocapture + + - name: Run TUI library tests (Linux only) + if: runner.os == 'Linux' + shell: bash + # These were previously only compiled (`--no-run` above), so test-only + # breakage reached master with CI green: three `ServerEvent::MessageEnd` + # call sites stayed unit variants after the enum gained a field, and the + # whole target failed to compile unnoticed (see #592). + # + # Run serially. Many of these tests share process-global state (model + # catalog, ambient cache, render state) and fail on ordering under + # parallelism, while passing reliably one-at-a-time: 1977 pass serially + # versus 2-4 varying failures in parallel. Scoping that state is tracked + # in #592; until then serial execution is the honest signal, and costs + # ~30s once the target is already built above. + # + # Two render tests depend on terminal color support and are still + # skipped. The copy-badge cohort no longer is: every one of those tests + # now installs the in-process `CapturedClipboard` sink and asserts the + # copied text through it, so they never touch the OS clipboard and pass + # on a headless runner (verified with DISPLAY/WAYLAND_DISPLAY unset). + # They were the tests most worth running, since they cover the copy path + # end to end. Tracked in #592. + # + # COLORTERM: the runner's headless shell advertises no color support, + # so capability detection falls back to 256-color and every RGB cell + # quantizes to `Indexed`. The palette-topology measurement identifies + # roles by their rendered RGB, and quantization error pushes some roles + # outside the matcher's family radius ("got 4 edges", run 30617961697). + # Declaring truecolor tests the code path users overwhelmingly run. + env: + COLORTERM: truecolor + run: | + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} \ + -p jcode-tui --lib -- --test-threads=1 \ + --skip test_prompt_entry_shimmer_color_moves_across_positions \ + --skip right_fact_stack_uses_neutral_gray_except_for_context_usage + - name: Compile integration test binaries shell: bash # Build the integration-test binaries up front (not counted against the @@ -201,8 +369,10 @@ jobs: run: | ./scripts/check_powershell_syntax.ps1 - - name: Enforce warning budget (Linux) - if: runner.os == 'Linux' + # Linux and macOS. Windows still has its own cfg-gated surface and is + # not yet warning-clean, so it stays out of the gate (#1177). + - name: Enforce warning budget (Linux, macOS) + if: runner.os == 'Linux' || runner.os == 'macOS' shell: bash run: | scripts/check_warning_budget.sh @@ -283,8 +453,14 @@ jobs: 'build_shell_command_uses_cmd_and_executes_command', 'pipe_name_is_stable_and_normalizes_case_and_separators', 'pipe_name_falls_back_when_stem_is_empty', + 'busy_pipe_is_reported_as_a_live_socket_path', 'stream_pair_round_trips_bytes', - 'split_stream_supports_concurrent_read_and_write' + 'split_stream_supports_concurrent_read_and_write', + 'test_cancel_command_idle_reports_nothing_to_cancel', + 'test_menu_number_rejected_as_api_key', + 'test_command_palette_suppressed_while_api_key_prompt_pending', + 'test_ctrl_c_with_active_copy_selection_copies_instead_of_quitting', + 'test_ctrl_c_in_copy_mode_without_selection_still_falls_through' ) foreach ($testName in $tests) { @@ -387,9 +563,63 @@ jobs: with: components: rustfmt + - name: Check module declarations resolve + # A `mod x;` with no file makes rustfmt fail with "Error writing files: + # failed to resolve mod", which reads like a formatting problem and hides + # every gate behind it. Naming the real cause first (221159294). + run: python3 scripts/check_module_files.py + - name: Check formatting run: cargo fmt --all -- --check + typescript-sdk: + name: TypeScript SDK + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + + # The SDK mirrors crates/jcode-harness-api by hand. Without this job the + # only guard that runs on a change is the Rust-side variant check, and a + # typo in the TypeScript itself (or a broken client) reaches consumers. + - name: Install + run: npm ci --no-audit --no-fund + working-directory: sdk/typescript + + - name: Typecheck, build, and test + run: npm run check + working-directory: sdk/typescript + + # `npm run check` proves the source compiles; it says nothing about what + # a consumer actually receives. The published tarball is a separate + # artifact (`files`, `exports`, `prepack`), and getting it wrong ships a + # package that installs but cannot be imported. Install it as a real + # dependency and import it the way a consumer would. + - name: Published tarball installs and imports + run: bash scripts/test_sdk_package.sh + + setup-friction: + name: Setup Friction Eval (Linux installer) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install probe shells + run: sudo apt-get update && sudo apt-get install -y fish zsh + + - name: Installer conversion telemetry tests + run: bash scripts/test_install_conversion.sh + + - name: Setup friction scorecard + run: bash scripts/setup_friction_eval.sh + powershell-syntax: name: PowerShell Syntax runs-on: windows-latest diff --git a/.github/workflows/discord-release.yml b/.github/workflows/discord-release.yml index cd6ea16041..e46141e801 100644 --- a/.github/workflows/discord-release.yml +++ b/.github/workflows/discord-release.yml @@ -1,50 +1,39 @@ name: Announce release on Discord +env: + JCODE_CI: "1" + on: + # Covers releases published outside the tag-driven release workflow. Events + # created by GITHUB_TOKEN are suppressed, so release.yml also dispatches this + # workflow explicitly after it publishes a release. release: types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing public release tag to announce + required: true + type: string + +concurrency: + group: discord-release-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false permissions: - contents: read + contents: write jobs: announce: runs-on: ubuntu-latest steps: - - name: Post release notes to Discord - env: - WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} - TAG: ${{ github.event.release.tag_name }} - NAME: ${{ github.event.release.name }} - BODY: ${{ github.event.release.body }} - URL: ${{ github.event.release.html_url }} - run: | - python3 - <<'EOF' - import json, os, re, urllib.request - - tag = os.environ["TAG"] - name = os.environ.get("NAME") or tag - body = os.environ.get("BODY") or "" - url = os.environ["URL"] + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} - # Strip platform-availability boilerplate and collapse blank lines - body = re.sub(r".*", "", body, flags=re.S) - body = re.sub(r"\n{3,}", "\n\n", body).strip() - - title = f"## {tag}" + (f" \u2014 {name}" if name != tag else "") - msg = f"{title}\n{body}" - suffix = f"\n\u2026 (full notes: <{url}>)" - if len(msg) > 1995: - msg = msg[:1995 - len(suffix)] + suffix - - req = urllib.request.Request( - os.environ["WEBHOOK_URL"], - data=json.dumps({"content": msg}).encode(), - headers={ - "Content-Type": "application/json", - "User-Agent": "jcode-release-bot/1.0", - }, - ) - urllib.request.urlopen(req) - print(f"Posted {tag} to Discord") - EOF + - name: Post release notes to Discord if not already announced + env: + GH_TOKEN: ${{ github.token }} + DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: python3 scripts/post_discord_release.py --tag "$TAG" diff --git a/.github/workflows/freebsd-smoke.yml b/.github/workflows/freebsd-smoke.yml index dab88dbb7c..e3b3ef4ffa 100644 --- a/.github/workflows/freebsd-smoke.yml +++ b/.github/workflows/freebsd-smoke.yml @@ -1,5 +1,8 @@ name: FreeBSD Smoke +env: + JCODE_CI: "1" + # Builds and smoke-tests jcode inside a real FreeBSD VM. # # GitHub does not offer native FreeBSD runners, so we boot a FreeBSD guest diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 749d7bfd6e..3a15377236 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -1,5 +1,8 @@ name: iOS TestFlight +env: + JCODE_CI: "1" + on: workflow_dispatch: push: @@ -84,21 +87,21 @@ jobs: printf '%s' "$APPSTORE_API_KEY_P8" > ~/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8 chmod 600 ~/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8 - - name: Archive (cloud-managed signing) + - name: Archive (unsigned; signing happens at export) working-directory: ios run: | set -o pipefail + # The team has no registered devices, so a development profile can + # never be created at archive time. Archive unsigned; the export + # step signs with cloud-managed Apple Distribution via the ASC key. xcodebuild archive \ -project JCodeMobile.xcodeproj \ -scheme "$SCHEME" \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath build/JCodeMobile.xcarchive \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$HOME/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8" \ - -authenticationKeyID "${{ secrets.APPSTORE_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.APPSTORE_ISSUER_ID }}" \ - CODE_SIGN_STYLE=Automatic \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ DEVELOPMENT_TEAM="$TEAM_ID" \ CURRENT_PROJECT_VERSION="${{ github.run_number }}" \ | tail -100 @@ -117,6 +120,8 @@ jobs: upload teamID TAS6ARKDN7 + signingStyle + automatic uploadSymbols manageAppVersionAndBuildNumber diff --git a/.github/workflows/publish-typescript-sdk.yml b/.github/workflows/publish-typescript-sdk.yml new file mode 100644 index 0000000000..60e363ee3e --- /dev/null +++ b/.github/workflows/publish-typescript-sdk.yml @@ -0,0 +1,79 @@ +name: Publish TypeScript SDK + +env: + JCODE_CI: "1" + +on: + workflow_dispatch: + inputs: + jcode_release_tag: + description: Released jcode tag whose binaries should be bundled + required: true + +permissions: + contents: read + +jobs: + publish: + name: Publish @1jehuang/jcode-sdk + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: sdk/typescript + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Enforce Rust and TypeScript SDK surface parity + working-directory: ${{ github.workspace }} + run: cargo test -p jcode-sdk parity -- --nocapture + + - name: Install dependencies + run: npm ci + + - name: Validate SDK + run: npm run check + + - name: Download released jcode runtimes + working-directory: ${{ github.workspace }} + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p sdk-runtime-assets + gh release download "${{ inputs.jcode_release_tag }}" --dir sdk-runtime-assets \ + --pattern 'jcode-linux-x86_64.tar.gz' \ + --pattern 'jcode-linux-aarch64.tar.gz' \ + --pattern 'jcode-macos-x86_64.tar.gz' \ + --pattern 'jcode-macos-aarch64.tar.gz' \ + --pattern 'jcode-windows-x86_64.tar.gz' \ + --pattern 'jcode-windows-aarch64.tar.gz' + + - name: Prepare platform packages + working-directory: ${{ github.workspace }} + run: bash scripts/prepare_sdk_runtime_packages.sh sdk-runtime-assets + + # These must exist before the main package is published. npm selects only + # the package matching the consumer's os/cpu from optionalDependencies. + - name: Publish platform runtime packages + working-directory: ${{ github.workspace }} + run: | + for package in sdk/npm/*; do + npm publish "$package" --access public --provenance + done + + - name: Publish with npm provenance + run: npm publish --access public --provenance diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 574207b690..fb4de7f383 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,11 @@ name: Release +env: + JCODE_CI: "1" + CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + CARGO_INCREMENTAL: "0" + on: push: tags: @@ -12,11 +18,6 @@ concurrency: permissions: contents: write -env: - CARGO_TERM_COLOR: always - SCCACHE_GHA_ENABLED: "true" - CARGO_INCREMENTAL: "0" - jobs: create-release: name: Create release @@ -99,6 +100,19 @@ jobs: with: targets: ${{ matrix.target }} + # Termux (Android) kernels implement ELF TLS Variant 1 while glibc + # expects Variant 2, so native __thread variables are zero-initialized at + # runtime and Tokio's runtime detection false-positives and panics. + # Building the aarch64 glibc binary with emulated TLS + # (pthread_getspecific) sidesteps kernel TLS entirely. That needs + # nightly (-Z tls-model, -Z build-std) with rust-src. + - name: Install nightly for emulated TLS (aarch64 only) + if: matrix.target == 'aarch64-unknown-linux-gnu' + uses: dtolnay/rust-toolchain@nightly + with: + targets: aarch64-unknown-linux-gnu + components: rust-src + - name: Setup sccache uses: mozilla-actions/sccache-action@v0.0.7 continue-on-error: true @@ -125,14 +139,24 @@ jobs: [target.x86_64-unknown-linux-gnu] linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=mold"] + [target.aarch64-unknown-linux-gnu] + linker = "clang" + rustflags = ["-C", "link-arg=-fuse-ld=mold", "-Z", "tls-model=emulated"] EOF fi if command -v sccache &>/dev/null && sccache --start-server 2>/dev/null; then export RUSTC_WRAPPER=sccache fi - cargo build --release --target ${{ matrix.target }} + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + # Rebuild std with emulated TLS too; see the nightly install step + # above for why Termux needs this. + cargo +nightly build -Z build-std=std,panic_abort --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi env: JCODE_RELEASE_BUILD: "1" + JCODE_CI_BUILD: "1" JCODE_BUILD_SEMVER: ${{ github.ref_name }} - name: Build portable Linux x86_64 release binary @@ -141,6 +165,7 @@ jobs: run: scripts/build_linux_compat.sh dist env: JCODE_RELEASE_BUILD: "1" + JCODE_CI_BUILD: "1" JCODE_BUILD_SEMVER: ${{ github.ref_name }} JCODE_COMPAT_ARTIFACT: ${{ matrix.artifact }} @@ -242,6 +267,7 @@ jobs: & cargo @cargoArgs env: JCODE_RELEASE_BUILD: "1" + JCODE_CI_BUILD: "1" JCODE_BUILD_SEMVER: ${{ github.ref_name }} - name: Verify built Windows binary launches @@ -407,6 +433,7 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | gh release upload "${env:GITHUB_REF_NAME}" dist/jcode-windows-x86_64.exe dist/jcode-windows-x86_64.tar.gz dist/jcode-windows-aarch64.exe dist/jcode-windows-aarch64.tar.gz --clobber @@ -450,6 +477,7 @@ jobs: # CARGO_BUILD_JOBS keeps memory in check; aws-lc-sys is memory hungry. export CARGO_BUILD_JOBS=3 export JCODE_RELEASE_BUILD=1 + export JCODE_CI_BUILD=1 export JCODE_BUILD_SEMVER="${{ github.ref_name }}" echo "::group::cargo build (jcode binary)" @@ -486,6 +514,10 @@ jobs: if: ${{ always() && needs.create-release.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + actions: write + contents: write + issues: write steps: - uses: actions/checkout@v4 with: @@ -497,15 +529,31 @@ jobs: path: artifacts pattern: jcode-* - - name: Validate at least one completed platform asset + - name: Validate complete platform asset set shell: bash run: | set -euo pipefail - mapfile -d '' assets < <( - find artifacts -type f \( -name '*.tar.gz' -o -name '*.exe' \) -print0 2>/dev/null + expected=( + artifacts/jcode-linux-x86_64/jcode-linux-x86_64.tar.gz + artifacts/jcode-linux-aarch64/jcode-linux-aarch64.tar.gz + artifacts/jcode-macos-aarch64/jcode-macos-aarch64.tar.gz + artifacts/jcode-macos-x86_64/jcode-macos-x86_64.tar.gz + artifacts/jcode-windows-x86_64/jcode-windows-x86_64.exe + artifacts/jcode-windows-x86_64/jcode-windows-x86_64.tar.gz + artifacts/jcode-windows-aarch64/jcode-windows-aarch64.exe + artifacts/jcode-windows-aarch64/jcode-windows-aarch64.tar.gz + artifacts/jcode-freebsd-x86_64/jcode-freebsd-x86_64.tar.gz ) - if [ "${#assets[@]}" -eq 0 ]; then - echo "No platform produced a releasable asset; keeping the release as a draft" >&2 + + missing=() + for asset in "${expected[@]}"; do + if [ ! -f "$asset" ]; then + missing+=("$asset") + fi + done + if [ "${#missing[@]}" -ne 0 ]; then + printf 'Missing required release asset: %s\n' "${missing[@]}" >&2 + echo "Keeping the release as a draft until every platform succeeds" >&2 exit 1 fi @@ -659,6 +707,17 @@ jobs: echo "Release ${GITHUB_REF_NAME} is already public; leaving it public." fi + # Releases created with GITHUB_TOKEN do not trigger `release: published`, + # but workflow_dispatch is allowed. Queue the dedicated, per-tag + # announcement workflow without letting Discord block package publishing. + - name: Queue Discord release announcement + id: discord_announcement + continue-on-error: true + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + run: gh workflow run discord-release.yml --ref "$DEFAULT_BRANCH" -f "tag=${GITHUB_REF_NAME}" + - name: Update Homebrew formula env: HOMEBREW_DEPLOY_KEY: ${{ secrets.HOMEBREW_DEPLOY_KEY }} @@ -716,7 +775,7 @@ jobs: libexec.install Dir["libssl.so*"], Dir["libcrypto.so*"] unless Dir["libssl.so*", "libcrypto.so*"].empty? (bin/"jcode").write <<~SH #!/bin/sh - exec "#{libexec}/jcode-linux-x86_64" "$@" + exec "#{libexec}/jcode-linux-x86_64" "\$@" SH end end @@ -857,3 +916,9 @@ jobs: --reason completed gh issue edit "$issue" --remove-label "$label" || true done + + - name: Report Discord announcement queue failure + if: always() && steps.discord_announcement.outcome == 'failure' + run: | + echo "The release was published, but its Discord announcement could not be queued." >&2 + exit 1 diff --git a/.github/workflows/require-issue.yml b/.github/workflows/require-issue.yml index e2a02fd0ce..c3fb0035cd 100644 --- a/.github/workflows/require-issue.yml +++ b/.github/workflows/require-issue.yml @@ -1,5 +1,8 @@ name: Require Linked Issue +env: + JCODE_CI: "1" + # Ensure every pull request is linked to a REAL GitHub issue in this repo. # A PR passes when it links an issue via any of: # * GitHub's "Development" sidebar (closing issue reference), or diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml new file mode 100644 index 0000000000..7f77e73e9e --- /dev/null +++ b/.github/workflows/update-star-history.yml @@ -0,0 +1,37 @@ +name: Update weekly stars chart + +env: + JCODE_CI: "1" + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-star-history + cancel-in-progress: true + +jobs: + update: + if: github.repository == '1jehuang/jcode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Generate chart using repository-authorized star data + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/scripts/generate_star_history.py + - name: Commit updated chart + run: | + if git diff --quiet -- docs/images/star-history.svg; then + exit 0 + fi + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add docs/images/star-history.svg + git commit -m "docs: update weekly stars chart" + git push diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml index 1ae8c60442..c8da370dee 100644 --- a/.github/workflows/windows-smoke.yml +++ b/.github/workflows/windows-smoke.yml @@ -1,5 +1,8 @@ name: Windows Smoke +env: + JCODE_CI: "1" + on: workflow_dispatch: inputs: @@ -82,6 +85,7 @@ jobs: 'build_shell_command_uses_cmd_and_executes_command', 'pipe_name_is_stable_and_normalizes_case_and_separators', 'pipe_name_falls_back_when_stem_is_empty', + 'busy_pipe_is_reported_as_a_live_socket_path', 'stream_pair_round_trips_bytes', 'split_stream_supports_concurrent_read_and_write', 'auto_provider_noninteractive_skips_untrusted_external_auth_instead_of_blocking' @@ -145,15 +149,8 @@ jobs: - name: Verify installer using local artifact shell: pwsh run: | - $cargoVersion = Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1 - if (-not $cargoVersion) { - throw 'Could not determine Cargo.toml version' - } - - $version = 'v' + $cargoVersion.Matches[0].Groups[1].Value & ./.github/scripts/verify_windows_install.ps1 ` - -ArtifactExePath 'target/x86_64-pc-windows-msvc/release/jcode.exe' ` - -Version $version + -ArtifactExePath 'target/x86_64-pc-windows-msvc/release/jcode.exe' smoke-arm64: name: Windows Smoke (ARM64) @@ -204,12 +201,5 @@ jobs: - name: Verify installer using local artifact shell: pwsh run: | - $cargoVersion = Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1 - if (-not $cargoVersion) { - throw 'Could not determine Cargo.toml version' - } - - $version = 'v' + $cargoVersion.Matches[0].Groups[1].Value & ./.github/scripts/verify_windows_install.ps1 ` - -ArtifactExePath 'target/aarch64-pc-windows-msvc/release/jcode.exe' ` - -Version $version + -ArtifactExePath 'target/aarch64-pc-windows-msvc/release/jcode.exe' diff --git a/.gitignore b/.gitignore index c71a3e3e07..f3692dba2b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ ios_simulator_screenshot.png target-ttest/ target-ttest*/ +# Local capture artefacts are not committed baselines. +captures/ + # Stray experiment/debug artifacts at the repo root. Real assets live under # assets/, docs/, ios/, and tests/ and are committed explicitly. /*.log diff --git a/.jcode/semantic-todo-migration-spec.md b/.jcode/semantic-todo-migration-spec.md new file mode 100644 index 0000000000..85673adb68 --- /dev/null +++ b/.jcode/semantic-todo-migration-spec.md @@ -0,0 +1,137 @@ +# Semantic Todo Assessment Migration Spec (worker handoff) + +Goal: remove all 0-100 quality scores from the todo system. Replace with semantic +string enums. Add difficulty, autonomy, delivery_state. Legacy numeric sessions +must still load (numbers map to enums on deserialize). Every todo write must +always persist (never reject a write; gates only emit continuations). Difficulty +and autonomy are NEVER gated. Completion gates evaluate confidence (evidence) and +delivery_state, with difficulty only calibrating how strict the delivery bar is. + +## Enums (all: snake_case string serde, Ord by declaration order, in jcode-task-types) + +```rust +pub enum IntentUnderstanding { Uncertain, Partial, Clear, Complete } +pub enum FeedbackLoopState { Absent, Weak, Usable, Strong, Closed } +pub enum ConfidenceState { Speculative, Plausible, Validated, Verified } +pub enum Difficulty { Trivial, Routine, Involved, Complex, Hard, Expert, Research, OpenEnded } +pub enum Autonomy { RequestedOnly, NecessaryFollowthrough, Proactive, Stewardship } +pub enum DeliveryState { ChangeMade, Integrated, WorkflowValidated, OutcomeDelivered } +``` + +Each enum gets: +- `#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ...)]`, `#[serde(rename_all = "snake_case")]` +- `pub fn as_str(&self) -> &'static str` +- `pub fn parse(&str) -> Option` (trim, ascii-lowercase) +- `pub fn from_legacy_score(u8) -> Self` per the tables below +- Deserialization must accept BOTH a string variant AND a legacy integer + (use a custom `Deserialize` impl or `#[serde(deserialize_with)]` helper via + untagged number-or-string). Serialization is always the string. + +## Legacy numeric -> enum mapping + +- IntentUnderstanding: 0-59 Uncertain, 60-95 Partial, 96-99 Clear, 100 Complete +- FeedbackLoopState: 0-19 Absent, 20-49 Weak, 50-79 Usable, 80-95 Strong, 96-100 Closed +- ConfidenceState: 0-59 Speculative, 60-95 Plausible, 96-99 Validated, 100 Verified +- DeliveryState (from legacy end_to_end_ownership): 0-49 ChangeMade, 50-79 Integrated, + 80-95 WorkflowValidated, 96-100 OutcomeDelivered +- Difficulty / Autonomy: no legacy field; default None. + +## Field changes + +TodoItem: +- `confidence: Option` (was Option) +- `completion_confidence: Option` +- `confidence_history: Vec` (legacy Vec entries convert on load) +- NEW `difficulty: Option` (skip_serializing_if none) + +TodoPlan: +- `understands_user_intent: Option` (keep aliases alignment_score / user_intention_alignment) +- `understands_user_intent_history: Vec` + +TodoGoal: +- `closed_feedback_loop: Option` (keep hill_climbability alias) + history +- `end_to_end_ownership` RENAMED to `delivery_state: Option` + with `#[serde(alias = "end_to_end_ownership")]`; history field + `delivery_state_history` with alias `end_to_end_ownership_history` +- NEW `difficulty: Option` +- NEW `autonomy: Option` +- `feedback_loop: Option` unchanged + +GateObservation.score becomes the relevant enum? Keep it simple: +change `score: Option` to a semantic snapshot; simplest is +`state: Option` holding as_str, with `#[serde(alias = "score")]` +tolerating old numeric via number-or-string deserializer. Workers may instead +keep two optional fields; prefer minimal churn. + +## Gate semantics (jcode-base todo.rs, app-core, tui) + +Never reject a write. All existing "deferred observation + turn-end digest + +continuation" plumbing stays, only comparisons change: + +- Intent gate: passing when `understands_user_intent >= Clear`. + Severe first-write nudge when `== Uncertain` (replaces SEVERE_INTENT_MISUNDERSTANDING). +- Feedback loop gate: passing when `closed_feedback_loop >= Closed` (i.e. == Closed). +- Completion confidence gate: completed todo passes when + `completion_confidence >= Validated`. +- Confidence spike: a completed todo is spike-finished when its final history + step jumps 2 or more levels (e.g. Speculative -> Validated), or with no + history when completion is >= 2 levels above planning confidence. +- Delivery gate (replaces ownership gate): a completed group passes when its + goal's `delivery_state` meets the required bar: + - difficulty None | Trivial | Routine -> WorkflowValidated or better + - Involved and above -> OutcomeDelivered + Difficulty itself is never a gate: absent difficulty just uses the lenient bar. +- Autonomy: never gated anywhere. Display/telemetry only. + +Delete or replace numeric constants (QUALITY_GATE_THRESHOLD, LOW_*, +SEVERE_INTENT_MISUNDERSTANDING, TODO_CONFIDENCE_SPIKE) with enum-based +predicates exported from jcode-base::todo, e.g.: +`intent_understanding_passes`, `feedback_loop_passes`, +`completion_confidence_passes`, `delivery_state_passes(goal)`, +`required_delivery_state(difficulty)`. +TUI code referencing the old constants must use these predicates. + +## Tool schema (app-core todo.rs) + +- Replace integer 0-100 properties with string enums: + - todo item: `confidence`, `completion_confidence` -> enum of + speculative|plausible|validated|verified; NEW optional `difficulty` enum. + - plan: `understands_user_intent` -> uncertain|partial|clear|complete. + - goal: `closed_feedback_loop` -> absent|weak|usable|strong|closed; + `end_to_end_ownership` REPLACED by `delivery_state` -> + change_made|integrated|workflow_validated|outcome_delivered; + NEW optional `difficulty` and `autonomy` enums. +- normalize_todo_input: keep string coercion; numeric values (int, float, + numeric string) for any of these fields must be converted to the mapped + enum string so legacy transcripts/providers still parse. Empty string -> null. +- Histories remain tool-maintained; record_score_observation generalizes over + the enums (push when last != new). +- Telemetry: TelemetryScoreSummary stays u8-based; map each enum to a + representative score via `legacy_score()` on each enum: + - IntentUnderstanding: 40/80/96/100 + - FeedbackLoopState: 10/35/65/88/98 + - ConfidenceState: 40/80/96/100 + - DeliveryState: 25/65/88/98 + This keeps jcode-telemetry-core, usage-types, and the worker schema untouched. + +## Always-save requirement + +Verify no code path returns Err/rejects before `save_todos/save_goals/save_plan` +based on assessment values. `newly_completed_groups_have_sufficient_ownership` +(write-time variant) should be removed or kept only for turn-finish; nothing may +block persisting. + +## Testing + +- task-types: round-trip each enum; legacy numeric JSON deserializes (e.g. + `{"understands_user_intent": 97}` -> Clear); serializes as string. +- base: update gate tests to enum values; legacy alias fields still load. +- app-core: schema test (no digits/0-100 in model-visible schema besides none), + normalize coercion of numeric legacy input, merge histories, always-save. +- tui: update todos_view/ui_messages/info_widget tests to render state words. +- Commands: `cargo test -p jcode-task-types -p jcode-base`, + `cargo test -p jcode-app-core todo`, `cargo test -p jcode-tui todo`, + `cargo check -p jcode-tui -p jcode-telemetry-core`. + +Do NOT touch unrelated dirty files. Commit nothing; +the coordinator commits. diff --git a/AGENTS.md b/AGENTS.md index efd53c54b6..0db8e9e061 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,19 +2,15 @@ ## Development Workflow -- **Commit as you go** - Make small, focused commits after completing each feature or fix -- If the git state is not clean, or there are other agents working in the codebase in parallel, do your best to still commit your work. -- **Push when done** - Push all commits to remote when finishing a task or session -- **Use fast iteration by default** - Prefer `cargo check`, targeted tests, and dev builds while iterating -- **Rebuild when done** - When you are done making changes, build the source. -- **Bump version for releases** - Update version in `Cargo.toml` when making releases. When cutting a new release, look at all the changes that happened since the last release and determine what the version bump should be ie patch or minor, etc. -- **Remote builds available** - Use `scripts/remote_build.sh` to offload heavy cargo work to another machine. If your build is terminated, likely is because there are not enough resources on this machine to build. use remote build in that case. Try checking the resource avaliablity on the machine before you run a build. - -## Logs -- Logs are written to `~/.jcode/logs/` (daily files like `jcode-YYYY-MM-DD.log`). - -## Debug Socket -- Use the debug socket for runtime level debugging +- **Welcome pull requests from everyone** - Review contributions on their merits, + regardless of whether the author is a maintainer, an existing contributor, a + first-time contributor, or an agent. Good PRs can be merged directly after review + and validation. Do not require a maintainer-authored rewrite merely because of + who submitted the change. See `CONTRIBUTING.md` for the contribution policy. +- **Keep work scoped** - Work on your own branch and preserve unrelated work. When + the user asks you to review or integrate a PR or branch, you may inspect, test, + and integrate that contribution regardless of author status. Do not pull in + unrelated branches or merge a PR without user authorization. ## Install Notes - `~/.local/bin/jcode` is the launcher symlink used from `PATH`. @@ -25,3 +21,28 @@ - On Windows, the equivalents are `%LOCALAPPDATA%\\jcode\\bin\\jcode.exe` for the launcher, `%LOCALAPPDATA%\\jcode\\builds\\stable\\jcode.exe` for stable, and `%LOCALAPPDATA%\\jcode\\builds\\versions\\\\jcode.exe` for immutable installs; `scripts/install.ps1` currently installs the stable channel. - Ensure `~/.local/bin` is **before** `~/.cargo/bin` in `PATH`. +## Verifying a change at runtime + +`cargo build` alone proves nothing about behavior. `jcode run` and interactive +sessions are served by the long-lived daemon at +`~/.jcode/builds/shared-server/jcode`, which is a symlink into +`~/.jcode/builds/versions//`. Until that symlink is repointed and the +daemon restarted (`jcode self-dev --build`), a freshly built binary is inert and +every runtime check silently measures the old code. + +To test a change without disturbing the shared daemon or the caller's session, +run your build against its own socket: + +```bash +cargo build --profile selfdev +./target/selfdev/jcode run --no-update --socket /run/user/1000/jcode-mytest.sock '' +``` + +Two things that waste time otherwise: + +- `crate::logging::info` writes to a log file, not stderr, so instrumenting a + code path with it produces no visible output under `--trace`. Use `eprintln!` + for throwaway diagnostics and delete it before committing. +- Confirm which binary you are actually inspecting. `strings` on + `builds/shared-server/jcode` reads a 70-byte symlink, not a program; resolve it + with `readlink -f` first. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..cf7bd05a56 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# Repository Instructions + +@AGENTS.md + +Follow `AGENTS.md` for the development workflow and `CONTRIBUTING.md` for the +contribution policy. Pull requests from everyone are welcome for direct review +and merging, regardless of contributor or maintainer status. Preserve unrelated +work and obtain user authorization before merging. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49f1aa329d..d420074442 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,21 +4,23 @@ Thanks for contributing. ## Issues vs pull requests -If the problem is easy for me to reproduce, please prefer opening a GitHub issue. A clear issue with reproduction steps, expected behavior, actual behavior, logs, screenshots, or traces is usually the fastest path to a fix. +Both issues and pull requests are welcome. Open an issue to report a bug or discuss an idea, or send a focused PR if you have a fix or improvement ready. For large changes, discuss the approach in an issue first to avoid spending time on work that may not fit the project. -Pull requests are more useful when the problem depends on an environment I may not have, such as macOS-specific behavior, Windows-specific behavior, unusual shells, terminal emulators, filesystems, GPU/display setups, provider accounts, or other local configuration. In those cases, a PR can be a useful reference because it captures the behavior in the environment where the problem actually occurs. +A clear issue or PR includes reproduction steps, expected behavior, actual behavior, and relevant logs, screenshots, or traces. Environment-specific fixes are especially helpful when they cover systems the maintainers may not have, such as macOS, Windows, unusual shells, terminal emulators, filesystems, GPU/display setups, or provider accounts. + +Every PR must link to an existing GitHub issue in this repository. If there is no issue yet, open one and reference it in the PR description, for example with `Closes #123`. ## Pull request policy -Pull requests are welcome and encouraged. +Pull requests from everyone are welcome and encouraged, including first-time contributors and people who are not maintainers or existing contributors. -That said, most PRs should be treated as proposals or references, not as changes that are likely to be merged directly. This project is developed with heavy use of code generation, and generated code can be deceptively plausible: it may fix the visible problem while introducing subtle correctness, lifecycle, architecture, or maintenance issues. +PRs are reviewed as changes that can be merged directly, not merely as proposals or references for a maintainer-authored rewrite. Review is based on correctness, tests, security, architecture, maintainability, and fit with the project, not the author's contributor status. -Because of that, I will often use PRs to understand the bug, feature request, test case, design direction, or proposed implementation, then write my own version of the change. The submitted code may still be extremely valuable as a reference, reproduction, or proof of concept, even if the final committed code is different. +AI-assisted and generated contributions are welcome and held to the same standards as handwritten code. Understand the changes you submit, explain their assumptions and tradeoffs, and validate them. This applies equally to maintainer and community contributions. -This is not a judgment that maintainer-generated code is inherently better than contributor-generated code. It is a practical ownership rule: if I am going to maintain the resulting code, I need to understand its assumptions, tradeoffs, and failure modes. +Maintainers may request revisions, help refine an implementation, or decline a change that does not fit the project. A rewrite is not required just because a PR comes from an outside contributor. -The best PRs therefore include: +The best PRs include: - a clear description of the problem being solved - a minimal reproduction or failing test when possible @@ -26,6 +28,4 @@ The best PRs therefore include: - focused changes that are easy to review independently - any relevant logs, screenshots, traces, or benchmarks -Large, generated, or highly invasive PRs may be closed even when the underlying idea is good. In those cases, the issue or PR may still be used as a reference for a maintainer-authored change. - -Handwritten by author: My clanker slop may or may not be better than your clanker slop. I know how to work with my clanker slop though. +Keep changes focused and split large changes into independently reviewable pieces when possible. Passing checks does not guarantee a merge, but author status or use of code generation is not, by itself, a reason to reject a contribution. diff --git a/Cargo.lock b/Cargo.lock index e7c041a661..0b18f7be59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,34 +3,60 @@ version = 4 [[package]] -name = "ab_glyph" -version = "0.2.32" +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", + "pom", ] [[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" +name = "aes" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] [[package]] -name = "adler2" -version = "2.0.1" +name = "agent-client-protocol" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more", + "futures", + "log", + "serde", + "serde_json", +] [[package]] -name = "adobe-cmap-parser" -version = "0.4.1" +name = "agent-client-protocol-schema" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2" dependencies = [ - "pom", + "anyhow", + "derive_more", + "schemars", + "serde", + "serde_json", + "strum 0.28.0", ] [[package]] @@ -81,33 +107,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-activity" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" -dependencies = [ - "android-properties", - "bitflags 2.10.0", - "cc", - "cesu8", - "jni 0.21.1", - "jni-sys 0.3.1", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum", - "thiserror 1.0.69", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -173,27 +172,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "anymap2" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" - [[package]] name = "anymap3" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" -[[package]] -name = "ar_archive_writer" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object", -] - [[package]] name = "arboard" version = "3.6.1" @@ -203,7 +187,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", @@ -211,10 +195,18 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", - "wl-clipboard-rs", "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -228,18 +220,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "ash" -version = "0.37.3+1.3.251" +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "libloading 0.7.4", + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] @@ -321,9 +310,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-config" -version = "1.8.16" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +checksum = "47712fde1909402600ccfbb26e47d482d2e58bb9e9e603d9f17e67cc435a6319" dependencies = [ "aws-credential-types", "aws-runtime", @@ -335,6 +324,7 @@ dependencies = [ "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -351,9 +341,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -385,9 +375,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.7.3" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -411,10 +401,11 @@ dependencies = [ [[package]] name = "aws-sdk-bedrock" -version = "1.141.0" +version = "1.148.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a16484ce62b16cadf941e1c408d9b73afb9e6fc456573e5a9681e38d45fb00a" +checksum = "3450c366c278d75c4d00b51ad6801b2f85681b7aa3c115a189106d816703e055" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -423,6 +414,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -435,10 +427,11 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.130.0" +version = "1.136.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2f7bca252e3c5c8f0ed12c5501bf8b0fbadb937cd9fdd71a0ebd9d7526540f" +checksum = "cf9484190cd923402dcc480aa92a582e3c370312d35d5d9564bbcdbbb5b69f8f" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-sigv4", @@ -449,6 +442,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -462,10 +456,11 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.98.0" +version = "1.103.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +checksum = "0469f435f645ad2162cfb463b15bde37115966ee3acf2d87fb4871ee309b8401" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -474,6 +469,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -486,10 +482,11 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.100.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +checksum = "085faefb253f770655e162b9304321e62a1e71adf7f019ee1f4454228a377b3a" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -498,6 +495,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -510,10 +508,11 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.103.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +checksum = "3c72b08911d8128dd360fe1b22a9fec0fa8b552dde8ec828dcf20ef5ec974e9f" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -523,6 +522,7 @@ dependencies = [ "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", @@ -535,9 +535,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.4.3" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -545,7 +545,7 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "crypto-bigint 0.5.5", + "crypto-bigint", "form_urlencoded", "hex", "hmac 0.13.0", @@ -553,7 +553,6 @@ dependencies = [ "http 1.4.0", "p256", "percent-encoding", - "ring", "sha2 0.11.0", "subtle", "time", @@ -563,9 +562,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -574,9 +573,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.20" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944" dependencies = [ "aws-smithy-types", "bytes", @@ -585,9 +584,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.6" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -607,57 +606,53 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.12" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", + "h2", "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.8.1", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.37", - "rustls-native-certs 0.8.3", + "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.62.5" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.15" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +checksum = "dd22a6ba36e3f113cb8d5b3d1fe0ed31c76ee608ef63322d753bb8d2c9479e77" dependencies = [ "aws-smithy-types", "urlencoding", @@ -665,15 +660,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" dependencies = [ "aws-smithy-async", "aws-smithy-http", "aws-smithy-http-client", "aws-smithy-observability", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "bytes", "fastrand", @@ -690,9 +686,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -708,20 +704,31 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + [[package]] name = "aws-smithy-types" -version = "1.4.7" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", "bytes", @@ -745,22 +752,26 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.15" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.15" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "rustc_version", "tracing", @@ -820,9 +831,9 @@ dependencies = [ [[package]] name = "base16ct" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" @@ -867,7 +878,25 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -876,6 +905,21 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -903,12 +947,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -928,32 +966,28 @@ dependencies = [ ] [[package]] -name = "block-sys" -version = "0.2.1" +name = "block-padding" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "objc-sys", + "generic-array", ] [[package]] name = "block2" -version = "0.3.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "block-sys", - "objc2 0.4.1", + "objc2", ] [[package]] -name = "block2" -version = "0.6.2" +name = "borrow-or-share" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2 0.6.3", -] +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "bstr" @@ -983,11 +1017,17 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] @@ -1032,38 +1072,21 @@ dependencies = [ ] [[package]] -name = "calloop" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" -dependencies = [ - "bitflags 2.10.0", - "log", - "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.2.0" +name = "castaway" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ - "calloop", - "rustix 0.38.44", - "wayland-backend", - "wayland-client", + "rustversion", ] [[package]] -name = "castaway" -version = "0.2.4" +name = "cbc" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "rustversion", + "cipher", ] [[package]] @@ -1078,17 +1101,11 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cff-parser" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" +checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087" [[package]] name = "cfg-if" @@ -1096,12 +1113,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1134,13 +1145,13 @@ dependencies = [ ] [[package]] -name = "chumsky" -version = "0.9.3" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "hashbrown 0.14.5", - "stacker", + "crypto-common 0.1.7", + "inout", ] [[package]] @@ -1207,16 +1218,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width 0.1.14", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -1229,37 +1230,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "com" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" -dependencies = [ - "com_macros", -] - -[[package]] -name = "com_macros" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" -dependencies = [ - "com_macros_support", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "com_macros_support" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "combine" version = "4.6.7" @@ -1367,7 +1337,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1391,27 +1361,6 @@ dependencies = [ "libm", ] -[[package]] -name = "cosmic-text" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75acbfb314aeb4f5210d379af45ed1ec2c98c7f1790bf57b8a4c562ac0c51b71" -dependencies = [ - "fontdb 0.15.0", - "libm", - "log", - "rangemap", - "rustc-hash 1.1.0", - "rustybuzz 0.11.0", - "self_cell", - "swash", - "sys-locale", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1507,26 +1456,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-bigint" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ + "generic-array", "rand_core 0.6.4", "subtle", + "zeroize", ] [[package]] @@ -1567,23 +1506,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - -[[package]] -name = "d3d12" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" -dependencies = [ - "bitflags 2.10.0", - "libloading 0.8.9", - "winapi", -] - [[package]] name = "darling" version = "0.20.11" @@ -1682,11 +1604,12 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "der" -version = "0.6.1" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468", "zeroize", ] @@ -1702,13 +1625,13 @@ dependencies = [ [[package]] name = "derive-new" -version = "0.5.9" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -1762,6 +1685,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1771,6 +1695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1808,12 +1733,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" @@ -1821,7 +1740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -1835,21 +1754,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading 0.8.9", -] - -[[package]] -name = "doc-comment" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" - [[package]] name = "document-features" version = "0.2.12" @@ -1861,9 +1765,9 @@ dependencies = [ [[package]] name = "downcast-rs" -version = "1.2.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" [[package]] name = "dunce" @@ -1877,22 +1781,39 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-eq" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388" + [[package]] name = "dyn-hash" -version = "0.2.2" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e" + +[[package]] +name = "ecb" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] [[package]] name = "ecdsa" -version = "0.14.8" +version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", + "spki", ] [[package]] @@ -1903,17 +1824,17 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "elliptic-curve" -version = "0.12.3" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", - "crypto-bigint 0.4.9", - "der", + "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -1936,6 +1857,9 @@ name = "email_address" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -1952,6 +1876,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1974,16 +1909,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" -[[package]] -name = "etagere" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" -dependencies = [ - "euclid 0.22.13", - "svg_fmt", -] - [[package]] name = "euclid" version = "0.20.14" @@ -1995,9 +1920,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -2023,16 +1948,39 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -2076,9 +2024,9 @@ dependencies = [ [[package]] name = "ff" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ "rand_core 0.6.4", "subtle", @@ -2124,12 +2072,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.8" @@ -2146,6 +2088,23 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2164,15 +2123,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-types" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" -dependencies = [ - "bytemuck", -] - [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -2184,30 +2134,25 @@ dependencies = [ [[package]] name = "fontdb" -version = "0.15.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020e203f177c0fb250fb19455a252e838d2bbbce1f80f25ecc42402aafa8cd38" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", - "memmap2 0.8.0", + "memmap2", "slotmap", "tinyvec", - "ttf-parser 0.19.2", + "ttf-parser", ] [[package]] -name = "fontdb" -version = "0.23.0" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "fontconfig-parser", - "log", - "memmap2 0.9.9", - "slotmap", - "tinyvec", - "ttf-parser 0.25.1", + "foreign-types-shared 0.1.1", ] [[package]] @@ -2217,7 +2162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2231,6 +2176,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2246,6 +2197,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2355,6 +2316,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2373,7 +2335,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -2410,11 +2372,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2427,17 +2391,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - [[package]] name = "glob" version = "0.3.3" @@ -2452,10 +2405,10 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "once_cell", - "thiserror 2.0.17", + "thiserror 2.0.19", "windows-sys 0.59.0", "x11rb", "xkeysym", @@ -2474,121 +2427,17 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "glow" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "glyphon" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a62d0338e4056db6a73221c2fb2e30619452f6ea9651bac4110f51b0f7a7581" -dependencies = [ - "cosmic-text", - "etagere", - "lru 0.12.5", - "wgpu", -] - -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.10.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "gpu-allocator" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "winapi", - "windows 0.52.0", -] - -[[package]] -name = "gpu-descriptor" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" -dependencies = [ - "bitflags 2.10.0", - "gpu-descriptor-types", - "hashbrown 0.14.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "group" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "rand_core 0.6.4", "subtle", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.13" @@ -2627,7 +2476,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] [[package]] @@ -2636,8 +2484,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2650,21 +2496,28 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] -name = "hassle-rs" -version = "0.11.0" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "bitflags 2.10.0", - "com", - "libc", - "libloading 0.8.9", - "thiserror 1.0.69", - "widestring", - "winapi", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", ] [[package]] @@ -2679,24 +2532,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - [[package]] name = "hmac" version = "0.12.1" @@ -2791,30 +2632,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -2825,7 +2642,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -2839,33 +2656,18 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper 1.8.1", + "hyper", "hyper-util", - "rustls 0.23.37", - "rustls-native-certs 0.8.3", + "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", "webpki-roots", ] @@ -2883,12 +2685,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper 1.8.1", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2920,17 +2722,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icrate" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" -dependencies = [ - "block2 0.3.0", - "dispatch", - "objc2 0.4.1", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -3019,7 +2810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" dependencies = [ "quantette", - "thiserror 2.0.17", + "thiserror 2.0.19", ] [[package]] @@ -3081,7 +2872,7 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png 0.18.0", + "png 0.18.1", "tiff", "zune-core 0.5.1", "zune-jpeg 0.5.11", @@ -3114,10 +2905,10 @@ dependencies = [ "chrono", "imap-proto", "lazy_static", + "native-tls", "nom 7.1.3", "ouroboros", "regex", - "rustls-connector", ] [[package]] @@ -3150,6 +2941,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.11" @@ -3163,6 +2964,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -3204,33 +3014,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[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" @@ -3248,12 +3031,12 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.50.0" +version = "0.84.0" dependencies = [ "anyhow", "async-stream", "async-trait", - "block2 0.6.2", + "block2", "chrono", "clap", "crossterm", @@ -3262,26 +3045,32 @@ dependencies = [ "global-hotkey", "hex", "jcode-build-meta", + "jcode-harness-api", + "jcode-harness-api-server", "jcode-provider-anthropic-runtime", "jcode-provider-antigravity-runtime", "jcode-provider-claude-cli-runtime", "jcode-provider-copilot-runtime", + "jcode-provider-core", "jcode-provider-cursor-runtime", "jcode-provider-doctor", "jcode-provider-gemini-runtime", + "jcode-provider-grok-build-runtime", "jcode-provider-openai-runtime", "jcode-provider-openrouter-runtime", "jcode-selfdev-types", "jcode-tui", "jcode-tui-session-picker", + "jcode-tui-style", "libc", - "objc2 0.6.3", + "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-user-notifications", "open", "ratatui", "reqwest 0.12.28", - "rustls 0.23.37", + "rustls", "serde", "serde_json", "sha2 0.10.9", @@ -3332,6 +3121,7 @@ dependencies = [ "jcode-base", "jcode-build-meta", "jcode-build-support", + "jcode-command-risk", "jcode-core", "jcode-import-core", "jcode-message-types", @@ -3340,6 +3130,7 @@ dependencies = [ "jcode-pdf", "jcode-plan", "jcode-provider-core", + "jcode-schema-dialect", "jcode-selfdev-types", "jcode-session-types", "jcode-setup-hints", @@ -3364,7 +3155,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "unicode-width 0.2.2", + "unicode-width", "url", "urlencoding", "uuid", @@ -3445,6 +3236,7 @@ dependencies = [ "jcode-terminal-launch", "jcode-tool-core", "jcode-tool-types", + "jcode-transport", "jcode-usage-types", "libc", "open", @@ -3453,6 +3245,7 @@ dependencies = [ "rand 0.9.3", "regex", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -3495,6 +3288,13 @@ dependencies = [ "tempfile", ] +[[package]] +name = "jcode-command-risk" +version = "0.1.0" +dependencies = [ + "tempfile", +] + [[package]] name = "jcode-compaction-core" version = "0.1.0" @@ -3518,32 +3318,11 @@ dependencies = [ "chrono", "libc", "rand 0.9.3", + "unicode-properties", + "unicode-segmentation", "windows-sys 0.59.0", ] -[[package]] -name = "jcode-desktop" -version = "0.1.0" -dependencies = [ - "ab_glyph", - "anyhow", - "arboard", - "base64 0.22.1", - "bytemuck", - "glyphon", - "image", - "jcode-fuzzy", - "jcode-tui-messages", - "libc", - "pollster", - "pulldown-cmark", - "serde", - "serde_json", - "wgpu", - "whoami", - "winit", -] - [[package]] name = "jcode-embedding" version = "0.1.0" @@ -3566,6 +3345,31 @@ dependencies = [ "serde", ] +[[package]] +name = "jcode-harness-api" +version = "0.1.0" +dependencies = [ + "jcode-usage-types", + "serde", + "serde_json", +] + +[[package]] +name = "jcode-harness-api-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "jcode-background-types", + "jcode-base", + "jcode-harness-api", + "jcode-transport", + "libc", + "rusqlite", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "jcode-import-core" version = "0.1.0" @@ -3617,6 +3421,7 @@ dependencies = [ "imap", "lettre", "mail-parser", + "native-tls", "pulldown-cmark", "urlencoding", ] @@ -3686,6 +3491,7 @@ dependencies = [ "jcode-logging", "jcode-message-types", "jcode-provider-core", + "jcode-schema-dialect", "serde", "serde_json", ] @@ -3717,6 +3523,7 @@ version = "0.1.0" dependencies = [ "chrono", "jcode-provider-gemini", + "jcode-schema-dialect", "serde", "serde_json", ] @@ -3733,6 +3540,7 @@ dependencies = [ "jcode-provider-antigravity", "jcode-provider-core", "jcode-provider-gemini", + "jcode-schema-dialect", "reqwest 0.12.28", "serde_json", "tempfile", @@ -3791,6 +3599,7 @@ name = "jcode-provider-copilot" version = "0.1.0" dependencies = [ "jcode-message-types", + "jcode-schema-dialect", "serde", "serde_json", ] @@ -3808,6 +3617,7 @@ dependencies = [ "jcode-message-types", "jcode-provider-copilot", "jcode-provider-core", + "jcode-provider-openai", "reqwest 0.12.28", "serde_json", "tempfile", @@ -3826,6 +3636,8 @@ dependencies = [ "httpdate", "jcode-logging", "jcode-message-types", + "jcode-schema-dialect", + "jcode-usage-types", "rand 0.9.3", "reqwest 0.12.28", "serde", @@ -3843,7 +3655,7 @@ dependencies = [ "bytes", "chrono", "flate2", - "h2 0.4.13", + "h2", "http 1.4.0", "jcode-base", "jcode-message-types", @@ -3853,7 +3665,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "uuid", "webpki-roots", @@ -3898,6 +3710,7 @@ version = "0.1.0" dependencies = [ "anyhow", "jcode-message-types", + "jcode-schema-dialect", "serde", "serde_json", ] @@ -3911,8 +3724,13 @@ dependencies = [ "chrono", "jcode-base", "jcode-message-types", + "jcode-provider-anthropic", + "jcode-provider-antigravity", "jcode-provider-core", "jcode-provider-gemini", + "jcode-provider-openai", + "jcode-provider-openrouter", + "jcode-schema-dialect", "reqwest 0.12.28", "serde", "serde_json", @@ -3922,6 +3740,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "jcode-provider-grok-build-runtime" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-trait", + "futures", + "jcode-message-types", + "jcode-provider-core", + "serde_json", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", +] + [[package]] name = "jcode-provider-metadata" version = "0.1.0" @@ -3960,7 +3795,9 @@ dependencies = [ "jcode-message-types", "jcode-provider-core", "jcode-provider-openai", + "jcode-schema-dialect", "reqwest 0.12.28", + "rustls", "serde_json", "tempfile", "tokio", @@ -3979,6 +3816,7 @@ dependencies = [ "jcode-core", "jcode-logging", "jcode-message-types", + "jcode-schema-dialect", "reqwest 0.12.28", "serde", "serde_json", @@ -4004,6 +3842,7 @@ dependencies = [ "tokio", "tokio-stream", "toml", + "uuid", ] [[package]] @@ -4012,7 +3851,34 @@ version = "0.1.0" dependencies = [ "pulldown-cmark", "serde", - "unicode-width 0.2.2", + "unicode-width", +] + +[[package]] +name = "jcode-schema-dialect" +version = "0.1.0" +dependencies = [ + "dirs", + "jcode-schema-dialect", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "jcode-sdk" +version = "0.1.0" +dependencies = [ + "jcode-harness-api", + "jcode-provider-metadata", + "jcode-transport", + "jsonschema", + "libc", + "serde", + "serde_json", + "tempfile", + "url", + "uuid", ] [[package]] @@ -4043,6 +3909,7 @@ dependencies = [ "global-hotkey", "jcode-build-meta", "jcode-config-types", + "jcode-core", "jcode-logging", "jcode-storage", "jcode-terminal-launch", @@ -4088,6 +3955,7 @@ version = "0.1.0" dependencies = [ "chrono", "serde", + "serde_json", ] [[package]] @@ -4146,6 +4014,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jcode-transport" +version = "0.1.0" +dependencies = [ + "hex", + "sha2 0.10.9", + "tokio", + "windows-sys 0.59.0", +] + [[package]] name = "jcode-tui" version = "0.1.0" @@ -4198,7 +4076,7 @@ dependencies = [ "tempfile", "terminal-colorsaurus", "tokio", - "unicode-width 0.2.2", + "unicode-width", "url", "urlencoding", ] @@ -4236,13 +4114,14 @@ dependencies = [ "jcode-render-core", "jcode-tui-mermaid", "jcode-tui-workspace", + "mdwright-latex", "pulldown-cmark", "ratatui", "serde", "serde_json", "syntect", "tempfile", - "unicode-width 0.2.2", + "unicode-width", "wait-timeout", ] @@ -4299,7 +4178,7 @@ dependencies = [ "chrono", "jcode-tui-style", "ratatui", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -4316,6 +4195,7 @@ dependencies = [ name = "jcode-tui-style" version = "0.1.0" dependencies = [ + "jcode-logging", "ratatui", ] @@ -4323,7 +4203,7 @@ dependencies = [ name = "jcode-tui-tool-display" version = "0.1.0" dependencies = [ - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -4375,22 +4255,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -4400,10 +4264,10 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys 0.4.1", + "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.17", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -4421,15 +4285,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - [[package]] name = "jni-sys" version = "0.4.1" @@ -4482,62 +4337,88 @@ dependencies = [ ] [[package]] -name = "kasuari" -version = "0.4.12" +name = "jsonschema" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "508004a5500f2e1f68af048f70feea2de86d35ab115d85716530860822aef397" dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.17", + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", ] [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "jsonschema-regex" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "5a8b30cafa78358ae6cd1494a7d6410b89530e28bf567f862c869c667e900d9f" dependencies = [ - "bitflags 2.10.0", - "serde", - "unicode-segmentation", + "regex-syntax", ] [[package]] -name = "khronos-egl" -version = "6.0.0" +name = "jsonschema-value" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +checksum = "5526bd381d230af94908d07e6835a33fd82a465e12f5f1e9c81f5c2aa23b3c21" dependencies = [ - "libc", - "libloading 0.8.9", - "pkg-config", + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", ] [[package]] -name = "khronos_api" -version = "3.1.0" +name = "kasuari" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] [[package]] -name = "kstring" -version = "2.0.2" +name = "keyboard-types" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ + "bitflags 2.10.0", "serde", - "static_assertions", + "unicode-segmentation", ] [[package]] name = "kurbo" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", - "euclid 0.22.13", + "euclid 0.22.14", + "polycool", "smallvec", ] @@ -4561,13 +4442,12 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.19" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", "base64 0.22.1", - "chumsky", "email-encoding", "email_address", "fastrand", @@ -4579,10 +4459,10 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls 0.23.37", - "socket2 0.6.1", + "rustls", + "socket2", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "url", "webpki-roots", ] @@ -4593,26 +4473,6 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libm" version = "0.2.15" @@ -4630,6 +4490,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -4652,116 +4523,91 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] -name = "liquid" -version = "0.26.8" +name = "litemap" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9338405fdbc0bce9b01695b2a2ef6b20eca5363f385d47bce48ddf8323cc25" -dependencies = [ - "doc-comment", - "liquid-core", - "liquid-derive", - "liquid-lib", - "serde", -] +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] -name = "liquid-core" -version = "0.26.8" +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb8fed70857010ed9016ed2ce5a7f34e7cc51d5d7255c9c9dc2e3243e490b42" -dependencies = [ - "anymap2", - "itertools 0.13.0", - "kstring", - "liquid-derive", - "num-traits", - "pest", - "pest_derive", - "regex", - "serde", - "time", -] +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] -name = "liquid-derive" -version = "0.26.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b51f1d220e3fa869e24cfd75915efe3164bd09bb11b3165db3f37f57bf673e3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "liquid-lib" -version = "0.26.8" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee1794b5605e9f8864a8a4f41aa97976b42512cc81093f8c885d29fb94c6c556" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "itertools 0.13.0", - "liquid-core", - "once_cell", - "percent-encoding", - "regex", - "time", - "unicode-segmentation", + "scopeguard", ] [[package]] -name = "litemap" -version = "0.8.1" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "litrs" -version = "1.0.0" +name = "logos" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "logos-codegen" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" dependencies = [ - "scopeguard", + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.117", ] [[package]] -name = "log" -version = "0.4.29" +name = "logos-derive" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] [[package]] name = "lopdf" -version = "0.34.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" dependencies = [ + "aes", + "bitflags 2.10.0", + "cbc", + "ecb", "encoding_rs", "flate2", + "getrandom 0.4.1", "indexmap", "itoa", "log", "md-5", - "nom 7.1.3", + "nom 8.0.0", + "rand 0.10.1", "rangemap", - "time", + "sha2 0.10.9", + "stringprep", + "thiserror 2.0.19", + "ttf-parser", "weezl", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.16.3" @@ -4812,15 +4658,6 @@ dependencies = [ "encoding_rs", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "maplit" version = "1.0.2" @@ -4848,25 +4685,27 @@ dependencies = [ ] [[package]] -name = "memchr" -version = "2.7.6" +name = "mdwright-latex" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "8f0cbb1d26a0d205c963a29220d71e23133836ead2ac344896a86d95c204cfba" +dependencies = [ + "logos", + "unicode-normalization", + "unicode-width", +] [[package]] -name = "memmap2" -version = "0.8.0" +name = "memchr" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" -dependencies = [ - "libc", -] +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -4877,6 +4716,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "memoffset" version = "0.9.1" @@ -4893,32 +4738,23 @@ source = "git+https://github.com/1jehuang/mermaid-rs-renderer.git?tag=v0.3.1#2f9 dependencies = [ "anyhow", "clap", - "fontdb 0.23.0", + "fontdb", "json5", "once_cell", "regex", "resvg 0.47.0", "serde", "serde_json", - "thiserror 2.0.17", - "ttf-parser 0.25.1", + "thiserror 2.0.19", + "ttf-parser", "usvg 0.47.0", ] [[package]] -name = "metal" -version = "0.27.0" +name = "micromap" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" -dependencies = [ - "bitflags 2.10.0", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", -] +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" [[package]] name = "mime" @@ -4926,6 +4762,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minijinja" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" +dependencies = [ + "memo-map", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4987,30 +4833,27 @@ dependencies = [ ] [[package]] -name = "naga" -version = "0.19.2" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ - "bit-set", - "bitflags 2.10.0", - "codespan-reporting", - "hexf-parse", - "indexmap", + "libc", "log", - "num-traits", - "rustc-hash 1.1.0", - "spirv", - "termcolor", - "thiserror 1.0.69", - "unicode-xid", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] name = "ndarray" -version = "0.16.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", @@ -5021,36 +4864,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.10.0", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys 0.3.1", -] - [[package]] name = "nix" version = "0.29.0" @@ -5059,7 +4872,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.10.0", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "memoffset", ] @@ -5083,6 +4896,45 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -5119,35 +4971,34 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "num-iter" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", - "libm", + "num-integer", + "num-traits", ] [[package]] -name = "num_enum" -version = "0.7.6" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num_enum_derive", - "rustversion", + "num-bigint", + "num-integer", + "num-traits", ] [[package]] -name = "num_enum_derive" -version = "0.7.6" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", + "autocfg", + "libm", ] [[package]] @@ -5159,39 +5010,13 @@ dependencies = [ "libc", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", - "objc_exception", -] - -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - [[package]] name = "objc2" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" -dependencies = [ - "objc-sys", - "objc2-encode 3.0.0", -] - -[[package]] -name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "objc2-encode 4.1.0", + "objc2-encode", ] [[package]] @@ -5201,9 +5026,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", + "block2", "libc", - "objc2 0.6.3", + "objc2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-foundation", @@ -5222,7 +5047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-foundation", ] @@ -5233,7 +5058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-foundation", ] @@ -5245,7 +5070,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2 0.6.3", + "objc2", ] [[package]] @@ -5256,7 +5081,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-io-surface", ] @@ -5267,7 +5092,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.3", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -5278,7 +5113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-core-graphics", ] @@ -5290,18 +5125,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", ] -[[package]] -name = "objc2-encode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" - [[package]] name = "objc2-encode" version = "4.1.0" @@ -5315,9 +5144,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "block2 0.6.2", + "block2", "libc", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -5328,7 +5157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-core-foundation", ] @@ -5339,26 +5168,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.10.0", - "objc2 0.6.3", + "objc2", "objc2-foundation", ] [[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", -] - -[[package]] -name = "object" -version = "0.37.3" +name = "objc2-user-notifications" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "memchr", + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", ] [[package]] @@ -5407,10 +5231,29 @@ dependencies = [ ] [[package]] -name = "openssl-probe" -version = "0.1.6" +name = "openssl" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "openssl-probe" @@ -5419,21 +5262,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "option-ext" -version = "0.2.0" +name = "openssl-src" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] [[package]] -name = "orbclient" -version = "0.3.53" +name = "openssl-sys" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c6933ddbbd16539a7672e697bb8d41ac3a4e99ac43eeb40c07236bd7fcb2dd" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ + "cc", "libc", - "libredox", + "openssl-src", + "pkg-config", + "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -5452,16 +5307,6 @@ dependencies = [ "num-traits", ] -[[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 = "ouroboros" version = "0.18.5" @@ -5492,23 +5337,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser 0.25.1", -] - [[package]] name = "p256" -version = "0.11.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", "elliptic-curve", + "primeorder", "sha2 0.10.9", ] @@ -5571,6 +5408,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathdiff" version = "0.2.3" @@ -5579,20 +5422,30 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-extract" -version = "0.8.2" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87aa267a18864f2f75471f6d316ea430f13e78f0b5a882ce261ebbdfd389a76a" +checksum = "417e8fdc940f1d5bc62c5f89864c3a2255f74f69aa353c98509213d67df61e73" dependencies = [ "adobe-cmap-parser", "cff-parser", "encoding_rs", "euclid 0.20.14", + "log", "lopdf", "postscript", "type1-encoding-parser", "unicode-normalization", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5642,17 +5495,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset 0.5.7", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "phf" version = "0.11.3" @@ -5745,9 +5587,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkcs8" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", "spki", @@ -5774,9 +5616,9 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags 2.10.0", "crc32fast", @@ -5786,25 +5628,14 @@ dependencies = [ ] [[package]] -name = "polling" -version = "3.11.0" +name = "polycool" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.3", - "windows-sys 0.61.2", + "arrayvec", ] -[[package]] -name = "pollster" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" - [[package]] name = "pom" version = "1.1.0" @@ -5856,12 +5687,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - [[package]] name = "prettyplease" version = "0.2.37" @@ -5882,12 +5707,12 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "elliptic-curve", ] [[package]] @@ -5923,17 +5748,11 @@ dependencies = [ "winapi", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" - [[package]] name = "prost" -version = "0.11.9" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -5941,25 +5760,15 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.11.9" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools", "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "psm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" -dependencies = [ - "ar_archive_writer", - "cc", + "syn 2.0.117", ] [[package]] @@ -6022,15 +5831,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -6038,17 +5838,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases 0.2.1", + "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", - "rustls 0.23.37", - "socket2 0.6.1", - "thiserror 2.0.17", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", "tokio", "tracing", - "web-time 1.1.0", + "web-time", ] [[package]] @@ -6063,14 +5863,14 @@ dependencies = [ "lru-slab", "rand 0.9.3", "ring", - "rustc-hash 2.1.2", - "rustls 0.23.37", + "rustc-hash", + "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.19", "tinyvec", "tracing", - "web-time 1.1.0", + "web-time", ] [[package]] @@ -6079,10 +5879,10 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -6192,12 +5992,12 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" -version = "0.4.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.10.1", ] [[package]] @@ -6209,12 +6009,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - [[package]] name = "rangemap" version = "1.7.1" @@ -6245,14 +6039,14 @@ dependencies = [ "compact_str", "hashbrown 0.16.1", "indoc", - "itertools 0.14.0", + "itertools", "kasuari", - "lru 0.16.3", - "strum", - "thiserror 2.0.17", + "lru", + "strum 0.27.2", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -6280,7 +6074,7 @@ dependencies = [ "ratatui", "rustix 0.38.44", "thiserror 1.0.69", - "windows 0.58.0", + "windows", ] [[package]] @@ -6313,21 +6107,15 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools 0.14.0", + "itertools", "line-clipping", "ratatui-core", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - [[package]] name = "rawpointer" version = "0.2.1" @@ -6350,38 +6138,19 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "read-fonts" -version = "0.22.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" -dependencies = [ - "bytemuck", - "font-types", + "either", + "itertools", + "rayon", ] [[package]] -name = "redox_syscall" -version = "0.3.5" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "bitflags 1.3.2", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -6433,6 +6202,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af3eb523cce0df0af3c30d624b829b2dabd233172b5bc2615fcd03ceae8f746" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.2" @@ -6447,9 +6233,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -6464,15 +6250,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -6486,12 +6266,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.13", + "h2", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", @@ -6499,15 +6279,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", - "rustls-native-certs 0.8.3", + "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -6533,20 +6313,20 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -6594,13 +6374,12 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "crypto-bigint 0.4.9", "hmac 0.12.1", - "zeroize", + "subtle", ] [[package]] @@ -6642,10 +6421,18 @@ dependencies = [ ] [[package]] -name = "rustc-hash" -version = "1.1.0" +name = "rusqlite" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] [[package]] name = "rustc-hash" @@ -6702,32 +6489,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - [[package]] name = "rustls" version = "0.23.37" @@ -6739,56 +6500,21 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-connector" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bd40675c79c896f46d0031bf64c448b35e583dd2bc949751ddd800351e453a" -dependencies = [ - "log", - "rustls 0.22.4", - "rustls-native-certs 0.7.3", - "rustls-pki-types", - "rustls-webpki 0.102.8", -] - -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe 0.1.6", - "rustls-pemfile", - "rustls-pki-types", - "schannel", - "security-framework 2.11.1", -] - [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.6.0", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", + "security-framework", ] [[package]] @@ -6797,7 +6523,7 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ - "web-time 1.1.0", + "web-time", "zeroize", ] @@ -6809,14 +6535,14 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.22.4", + "jni", "log", "once_cell", - "rustls 0.23.37", - "rustls-native-certs 0.8.3", + "rustls", + "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", - "security-framework 3.6.0", + "rustls-webpki", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -6828,27 +6554,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6867,23 +6572,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rustybuzz" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa" -dependencies = [ - "bitflags 1.3.2", - "bytemuck", - "libm", - "smallvec", - "ttf-parser 0.20.0", - "unicode-bidi-mirroring 0.1.0", - "unicode-ccc 0.1.2", - "unicode-properties", - "unicode-script", -] - [[package]] name = "rustybuzz" version = "0.20.1" @@ -6895,9 +6583,9 @@ dependencies = [ "core_maths", "log", "smallvec", - "ttf-parser 0.25.1", - "unicode-bidi-mirroring 0.4.0", - "unicode-ccc 0.4.0", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", "unicode-properties", "unicode-script", ] @@ -6917,6 +6605,19 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6945,45 +6646,41 @@ dependencies = [ ] [[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" +name = "schemars" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] [[package]] -name = "sct" -version = "0.7.1" +name = "schemars_derive" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ - "ring", - "untrusted", + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", ] [[package]] -name = "sctk-adwaita" -version = "0.8.3" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" -dependencies = [ - "ab_glyph", - "log", - "memmap2 0.9.9", - "smithay-client-toolkit", - "tiny-skia 0.11.4", -] +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sec1" -version = "0.3.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der", @@ -6993,19 +6690,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.6.0" @@ -7029,12 +6713,6 @@ dependencies = [ "libc", ] -[[package]] -name = "self_cell" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" - [[package]] name = "semver" version = "1.0.27" @@ -7071,6 +6749,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -7196,9 +6885,9 @@ dependencies = [ [[package]] name = "signature" -version = "1.6.4" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", "rand_core 0.6.4", @@ -7247,16 +6936,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "skrifa" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" -dependencies = [ - "bytemuck", - "read-fonts", -] - [[package]] name = "slab" version = "0.4.11" @@ -7278,50 +6957,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smithay-client-toolkit" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" -dependencies = [ - "bitflags 2.10.0", - "calloop", - "calloop-wayland-source", - "cursor-icon", - "libc", - "log", - "memmap2 0.9.9", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols 0.31.2", - "wayland-protocols-wlr 0.2.0", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.1" @@ -7332,20 +6967,11 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "spki" -version = "0.6.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", "der", @@ -7369,19 +6995,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.59.0", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -7405,15 +7018,25 @@ dependencies = [ [[package]] name = "string-interner" -version = "0.15.0" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07f9fdfdd31a0ff38b59deb401be81b73913d76c9cc5b1aed4e1330a223420b9" +checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15" dependencies = [ - "cfg-if", - "hashbrown 0.14.5", + "hashbrown 0.16.1", "serde", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7426,7 +7049,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -7442,16 +7074,22 @@ dependencies = [ ] [[package]] -name = "subtle" -version = "2.6.1" +name = "strum_macros" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "svg_fmt" -version = "0.4.5" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svgtypes" @@ -7464,21 +7102,21 @@ dependencies = [ ] [[package]] -name = "swash" -version = "0.1.19" +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "skrifa", - "yazi", - "zeno", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] name = "syn" -version = "1.0.109" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -7487,9 +7125,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -7530,19 +7168,10 @@ dependencies = [ "regex-syntax", "serde", "serde_derive", - "thiserror 2.0.17", + "thiserror 2.0.19", "walkdir", ] -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - [[package]] name = "system-configuration" version = "0.6.1" @@ -7594,15 +7223,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal-colorsaurus" version = "1.0.3" @@ -7659,10 +7279,10 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitflags 2.10.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", - "fixedbitset 0.4.2", + "fixedbitset", "hex", "lazy_static", "libc", @@ -7703,11 +7323,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.19", ] [[package]] @@ -7723,13 +7343,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -7836,7 +7456,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png 0.18.0", + "png 0.18.1", "tiny-skia-path 0.12.0", ] @@ -7900,7 +7520,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools 0.14.0", + "itertools", "log", "macro_rules_attribute", "monostate", @@ -7914,7 +7534,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.17", + "thiserror 2.0.19", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -7931,7 +7551,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -7947,23 +7567,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls", "tokio", ] @@ -7986,11 +7596,11 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", - "rustls 0.23.37", - "rustls-native-certs 0.8.3", + "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite", ] @@ -8002,6 +7612,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "pin-project-lite", @@ -8016,8 +7627,8 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_datetime", + "toml_edit", ] [[package]] @@ -8029,15 +7640,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.20.2" @@ -8047,29 +7649,8 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.2", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.2", + "toml_datetime", + "winnow", ] [[package]] @@ -8155,16 +7736,19 @@ dependencies = [ [[package]] name = "tract-core" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b5347639690871b124593a8c8903f1f369531498b8abaebd18eb5c58163971" +checksum = "608e176a669d5da02cccc92bbfe5ee4e57686ed8841022608a9eba014d3b7886" dependencies = [ "anyhow", "anymap3", - "bit-set", + "bit-set 0.10.0", "derive-new", "downcast-rs", "dyn-clone", + "dyn-eq", + "erased-serde", + "inventory", "lazy_static", "log", "maplit", @@ -8172,8 +7756,9 @@ dependencies = [ "num-complex", "num-integer", "num-traits", - "paste", + "pastey", "rustfft", + "serde", "smallvec", "tract-data", "tract-linalg", @@ -8181,20 +7766,24 @@ dependencies = [ [[package]] name = "tract-data" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a3f476a1804e05708e9bc5e2d29dcab82bad531e357d3d14d7da80fbba0b6d" +checksum = "870236dd45aaeb1381023cb709a67ff14ece608ee0b37f99aa166d166db9b0d0" dependencies = [ "anyhow", "downcast-rs", "dyn-clone", + "dyn-eq", "dyn-hash", "half", - "itertools 0.12.1", + "inventory", + "itertools", "lazy_static", + "libm", "maplit", "ndarray", - "nom 7.1.3", + "nom 8.0.0", + "nom-language", "num-integer", "num-traits", "parking_lot", @@ -8203,11 +7792,21 @@ dependencies = [ "string-interner", ] +[[package]] +name = "tract-extra" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "effcae1ebfce133e8bf6c79ad31298cbee0a9eceb3e4642fe1d14cb54cca78e6" +dependencies = [ + "tract-nnef", + "tract-pulse", +] + [[package]] name = "tract-hir" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dca047ba1151fe3446fb0194d4b6ddb9ae8f361337c47a267870c53605fbafb" +checksum = "28783b2bb583177685f65016a866b01eb3d383bce4fa7ae1b9692b325b64449d" dependencies = [ "derive-new", "log", @@ -8216,43 +7815,45 @@ dependencies = [ [[package]] name = "tract-linalg" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8e0703eb53ef1bbf77050ff261675818dd5f0d6c27044c6e48ede9b845f9e0" +checksum = "d3e01491f7360806ef061af4c016a2d0a801d586768896394a0b8a7d6872c2b0" dependencies = [ "byteorder", "cc", "derive-new", "downcast-rs", "dyn-clone", + "dyn-eq", "dyn-hash", "half", "lazy_static", - "liquid", - "liquid-core", - "liquid-derive", "log", + "minijinja", "num-traits", - "paste", - "rayon", + "pastey", "scan_fmt", - "smallvec", - "time", "tract-data", - "unicode-normalization", "walkdir", ] [[package]] name = "tract-nnef" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cb88a4367ec2c695610223cf886f01fc1deb5c9a82c7a74b1a5d32dc0b1466" +checksum = "00417fabf01aeea7bc56107367862e5bb8da18c9ad850c9061d3823700479ecc" dependencies = [ "byteorder", + "erased-serde", "flate2", "log", - "nom 7.1.3", + "minijinja", + "nom 8.0.0", + "nom-language", + "safetensors", + "serde", + "serde_json", + "simd-adler32", "tar", "tract-core", "walkdir", @@ -8260,74 +7861,95 @@ dependencies = [ [[package]] name = "tract-onnx" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5830aa672b2aa4dc98a97a36e5988eaf77b3ecee65e2601619588d2ca557008" +checksum = "a3215dd27bddd2a041a20fee750013b400135186d3485501c9274c755b19ceb0" dependencies = [ "bytes", "derive-new", + "dyn-eq", "log", - "memmap2 0.9.9", + "memmap2", "num-integer", "prost", "smallvec", + "tract-extra", "tract-hir", "tract-nnef", "tract-onnx-opl", + "tract-transformers", ] [[package]] name = "tract-onnx-opl" -version = "0.21.10" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121d3d224c806ba3d941f4bb50943ad33b59d1da5ae704d0e4e76d2808221f96" +checksum = "44f549ad3f245c1c00ce710c66cc0e086be0b637f35934d37b655ff175e1ab3e" dependencies = [ - "getrandom 0.2.16", + "dyn-eq", + "getrandom 0.4.1", "log", - "rand 0.8.5", + "rand 0.10.1", "rand_distr", "rustfft", + "tract-extra", "tract-nnef", ] [[package]] -name = "transpose" -version = "0.2.3" +name = "tract-pulse" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +checksum = "a164e22e96ab9b5c90458fa270700570d87963e3dec9ccaac1ab18d9fa763ce2" dependencies = [ - "num-integer", - "strength_reduce", + "downcast-rs", + "dyn-eq", + "erased-serde", + "lazy_static", + "log", + "serde", + "tract-pulse-opl", + "tract-transformers", ] [[package]] -name = "tree_magic_mini" -version = "3.2.2" +name = "tract-pulse-opl" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +checksum = "65418f9e93e0af0d567f4f2f1bf2309b53930fa2543c635a69fda10c87a04987" dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", + "downcast-rs", + "dyn-eq", + "lazy_static", + "tract-nnef", ] [[package]] -name = "try-lock" -version = "0.2.5" +name = "tract-transformers" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "8471ebf7f52d226552283d9130539595540c28ed41b8e032df3830507d3ccdd5" +dependencies = [ + "float-ord", + "rayon", + "tract-nnef", +] [[package]] -name = "ttf-parser" -version = "0.19.2" +name = "transpose" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] [[package]] -name = "ttf-parser" -version = "0.20.0" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttf-parser" @@ -8351,7 +7973,7 @@ dependencies = [ "httparse", "log", "rand 0.8.5", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "sha1", "thiserror 1.0.69", @@ -8360,13 +7982,19 @@ dependencies = [ [[package]] name = "type1-encoding-parser" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" dependencies = [ "pom", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.0" @@ -8442,12 +8070,6 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694" - [[package]] name = "unicode-bidi-mirroring" version = "0.4.0" @@ -8456,15 +8078,15 @@ checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" [[package]] name = "unicode-ccc" -version = "0.1.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] -name = "unicode-ccc" -version = "0.4.0" +name = "unicode-general-category" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" @@ -8472,12 +8094,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - [[package]] name = "unicode-normalization" version = "0.1.25" @@ -8520,9 +8136,9 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.14.0", + "itertools", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -8531,12 +8147,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" -[[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.2" @@ -8594,13 +8204,13 @@ dependencies = [ "base64 0.22.1", "data-url", "flate2", - "fontdb 0.23.0", + "fontdb", "imagesize", "kurbo", "log", "pico-args", "roxmltree 0.21.1", - "rustybuzz 0.20.1", + "rustybuzz", "simplecss", "siphasher", "strict-num", @@ -8621,19 +8231,19 @@ dependencies = [ "base64 0.22.1", "data-url", "flate2", - "fontdb 0.23.0", + "fontdb", "imagesize", "kurbo", "log", "pico-args", "roxmltree 0.21.1", - "rustybuzz 0.20.1", + "rustybuzz", "simplecss", "siphasher", "strict-num", "svgtypes", "tiny-skia-path 0.12.0", - "ttf-parser 0.25.1", + "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo", @@ -8671,6 +8281,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -8744,12 +8370,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -8802,201 +8422,67 @@ version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" 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", - "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.10.0", - "hashbrown 0.15.5", - "indexmap", - "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.3", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.10.0", - "rustix 1.1.3", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.10.0", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.3", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", + "unicode-ident", ] [[package]] -name = "wayland-protocols-plasma" -version = "0.2.0" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", - "wayland-scanner", + "leb128fmt", + "wasmparser", ] [[package]] -name = "wayland-protocols-wlr" -version = "0.2.0" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", - "wayland-scanner", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" +name = "wasm-streams" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ - "bitflags 2.10.0", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.32.12", - "wayland-scanner", + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "wayland-scanner" -version = "0.31.10" +name = "wasm-streams" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "proc-macro2", - "quick-xml", - "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 = [ - "dlib", - "log", - "once_cell", - "pkg-config", + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] @@ -9009,16 +8495,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "web-time" version = "1.1.0" @@ -9119,130 +8595,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" dependencies = [ "bitflags 1.3.2", - "euclid 0.22.13", + "euclid 0.22.14", "lazy_static", "serde", "wezterm-dynamic", ] -[[package]] -name = "wgpu" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" -dependencies = [ - "arrayvec", - "cfg-if", - "cfg_aliases 0.1.1", - "js-sys", - "log", - "naga", - "parking_lot", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" -dependencies = [ - "arrayvec", - "bit-vec", - "bitflags 2.10.0", - "cfg_aliases 0.1.1", - "codespan-reporting", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "web-sys", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-hal" -version = "0.19.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.10.0", - "block", - "cfg_aliases 0.1.1", - "core-graphics-types", - "d3d12", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", - "hassle-rs", - "js-sys", - "khronos-egl", - "libc", - "libloading 0.8.9", - "log", - "metal", - "naga", - "ndk-sys", - "objc", - "once_cell", - "parking_lot", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "winapi", -] - -[[package]] -name = "wgpu-types" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" -dependencies = [ - "bitflags 2.10.0", - "js-sys", - "web-sys", -] - -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", - "web-sys", -] - [[package]] name = "wide" version = "0.8.3" @@ -9253,12 +8611,6 @@ dependencies = [ "safe_arch", ] -[[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" @@ -9290,16 +8642,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.58.0" @@ -9310,15 +8652,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.58.0" @@ -9443,15 +8776,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -9497,21 +8821,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -9560,12 +8869,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -9584,12 +8887,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -9608,12 +8905,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -9644,12 +8935,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -9668,12 +8953,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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -9692,12 +8971,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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -9716,12 +8989,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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -9740,54 +9007,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winit" -version = "0.29.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.10.0", - "bytemuck", - "calloop", - "cfg_aliases 0.1.1", - "core-foundation 0.9.4", - "core-graphics", - "cursor-icon", - "icrate", - "js-sys", - "libc", - "log", - "memmap2 0.9.9", - "ndk", - "ndk-sys", - "objc2 0.4.1", - "once_cell", - "orbclient", - "percent-encoding", - "raw-window-handle", - "redox_syscall 0.3.5", - "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit", - "smol_str", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", - "wayland-protocols-plasma", - "web-sys", - "web-time 0.2.4", - "windows-sys 0.48.0", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - [[package]] name = "winnow" version = "0.5.40" @@ -9797,15 +9016,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" -dependencies = [ - "memchr", -] - [[package]] name = "wit-bindgen" version = "0.46.0" @@ -9900,24 +9110,6 @@ 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.3", - "thiserror 2.0.17", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.32.12", - "wayland-protocols-wlr 0.3.12", -] - [[package]] name = "writeable" version = "0.6.2" @@ -9933,28 +9125,13 @@ dependencies = [ "tap", ] -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - [[package]] name = "x11rb" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ - "as-raw-xcb-connection", "gethostname", - "libc", - "libloading 0.8.9", - "once_cell", "rustix 1.1.3", "x11rb-protocol", ] @@ -9975,37 +9152,12 @@ dependencies = [ "rustix 1.1.3", ] -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.10.0", - "dlib", - "log", - "once_cell", - "xkeysym", -] - [[package]] name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - [[package]] name = "xmlparser" version = "0.13.6" @@ -10030,12 +9182,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -[[package]] -name = "yazi" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" - [[package]] name = "yoke" version = "0.8.1" @@ -10059,12 +9205,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeno" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" - [[package]] name = "zerocopy" version = "0.8.33" diff --git a/Cargo.toml b/Cargo.toml index e482c94bf6..af5982279a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.50.0" +version = "0.84.0" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false @@ -25,8 +25,12 @@ members = [ "crates/jcode-build-support", "crates/jcode-compaction-core", "crates/jcode-config-types", + "crates/jcode-command-risk", "crates/jcode-core", "crates/jcode-fuzzy", + "crates/jcode-harness-api", + "crates/jcode-harness-api-server", + "crates/jcode-sdk", "crates/jcode-memory-types", "crates/jcode-message-types", "crates/jcode-overnight-core", @@ -44,6 +48,7 @@ members = [ "crates/jcode-provider-metadata", "crates/jcode-provider-env", "crates/jcode-provider-core", + "crates/jcode-schema-dialect", "crates/jcode-provider-bedrock", "crates/jcode-provider-anthropic", "crates/jcode-provider-antigravity", @@ -59,6 +64,7 @@ members = [ "crates/jcode-provider-openrouter-runtime", "crates/jcode-provider-anthropic-runtime", "crates/jcode-provider-openai-runtime", + "crates/jcode-provider-grok-build-runtime", "crates/jcode-provider-doctor", "crates/jcode-tui-markdown", "crates/jcode-tui-messages", @@ -66,6 +72,7 @@ members = [ "crates/jcode-tui-core", "crates/jcode-tui-mermaid", "crates/jcode-task-types", + "crates/jcode-transport", "crates/jcode-tool-core", "crates/jcode-tool-types", "crates/jcode-tui-account-picker", @@ -82,7 +89,6 @@ members = [ "crates/jcode-terminal-image", "crates/jcode-telemetry-core", "crates/jcode-tui-workspace", - "crates/jcode-desktop", "crates/jcode-render-core", ] @@ -158,6 +164,7 @@ chrono = { version = "0.4", features = ["serde"] } sha2 = "0.10" hex = "0.4" open = "5" # Open URLs in browser +jcode-tui-style = { path = "crates/jcode-tui-style" } jcode-tui-session-picker = { path = "crates/jcode-tui-session-picker", features = ["serde"] } # Streaming @@ -171,6 +178,7 @@ crossterm = { version = "0.29", features = ["event-stream"] } # PDF parsing (behind feature flag) jcode-build-meta = { path = "crates/jcode-build-meta" } +# Tiny leaf helpers (console/env/fs/id) used directly by the cli layer. # Presentation layer: the terminal UI (`tui`) + offline replay (`video_export`), # compiled as a separate rustc unit. It re-exports the application core # (`jcode-app-core`, which re-exports `jcode-base`), so the root crate (cli + @@ -192,6 +200,7 @@ jcode-provider-claude-cli-runtime = { path = "crates/jcode-provider-claude-cli-r jcode-provider-openrouter-runtime = { path = "crates/jcode-provider-openrouter-runtime" } jcode-provider-anthropic-runtime = { path = "crates/jcode-provider-anthropic-runtime" } jcode-provider-openai-runtime = { path = "crates/jcode-provider-openai-runtime" } +jcode-provider-grok-build-runtime = { path = "crates/jcode-provider-grok-build-runtime" } jcode-selfdev-types = { path = "crates/jcode-selfdev-types" } # Archive extraction (for auto-update) @@ -220,9 +229,20 @@ embeddings = ["jcode-tui/embeddings"] # Live AWS Bedrock support (the AWS SDK stack) lives in jcode-provider-bedrock; # forwards down through jcode-tui -> jcode-app-core -> jcode-base. bedrock = ["jcode-tui/bedrock"] +# Internal release feature: build a supported OpenSSL inside the CentOS 7 +# compatibility image while preserving the glibc 2.17 runtime baseline. +linux-compat-vendored-openssl = ["jcode-tui/linux-compat-vendored-openssl"] mmdr-size-api = ["jcode-tui/mmdr-size-api"] pdf = ["jcode-tui/pdf"] +# The harness API bridge ships inside the released binary as `jcode api-bridge`. +# SDK users must not need a Rust toolchain to reach the API: requiring +# `cargo run -p jcode-harness-api-server` made the TypeScript SDK unusable for +# anyone who installed jcode from a release artifact. Unix-only because the +# bridge listens on a Unix socket. +[target.'cfg(unix)'.dependencies] +jcode-harness-api-server = { path = "crates/jcode-harness-api-server" } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } @@ -261,6 +281,7 @@ objc2-app-kit = { version = "0.3", features = [ "NSMenuItem", "NSRunningApplication", ] } +objc2-user-notifications = "0.3" [profile.release] opt-level = 1 @@ -302,6 +323,103 @@ opt-level = 3 [profile.test.package."jcode-tui-anim"] opt-level = 3 +# The fuzzy matcher runs a char-level DP over every picker entry on every +# keystroke (e.g. /model search across hundreds of routes). Like the anim +# kernels above, it is small, pure, dependency-free code that rarely changes, +# so keeping it fully optimized in dev/selfdev builds costs one compile and +# removes visible input lag in unoptimized builds. +[profile.dev.package."jcode-fuzzy"] +opt-level = 3 + +[profile.selfdev.package."jcode-fuzzy"] +opt-level = 3 + +[profile.test.package."jcode-fuzzy"] +opt-level = 3 + +[profile.release.package."jcode-fuzzy"] +opt-level = 3 + +# Keep the terminal render stack optimized even in dev/selfdev/test builds. +# +# Every TUI frame walks ratatui buffers cell-by-cell and re-measures visible +# text with unicode-width/unicode-segmentation. At opt-level = 0 those inner +# loops dominated live-client profiles (perf showed str_width, grapheme +# bsearch, and Buffer::index_of_opt as the top symbols) and pushed a routine +# full frame to ~12ms p50 / 21ms p95, which reads as input-line lag while +# streaming saturates the redraw loop. These are stable third-party crates +# that almost never recompile, so pinning them is a one-time compile cost +# (same rationale as jcode-tui-anim above). +[profile.dev.package.ratatui] +opt-level = 3 +[profile.selfdev.package.ratatui] +opt-level = 3 +[profile.test.package.ratatui] +opt-level = 3 + +[profile.dev.package.ratatui-core] +opt-level = 3 +[profile.selfdev.package.ratatui-core] +opt-level = 3 +[profile.test.package.ratatui-core] +opt-level = 3 + +[profile.dev.package.ratatui-widgets] +opt-level = 3 +[profile.selfdev.package.ratatui-widgets] +opt-level = 3 +[profile.test.package.ratatui-widgets] +opt-level = 3 + +[profile.dev.package.ratatui-crossterm] +opt-level = 3 +[profile.selfdev.package.ratatui-crossterm] +opt-level = 3 +[profile.test.package.ratatui-crossterm] +opt-level = 3 + +[profile.dev.package.crossterm] +opt-level = 3 +[profile.selfdev.package.crossterm] +opt-level = 3 +[profile.test.package.crossterm] +opt-level = 3 + +[profile.dev.package.unicode-width] +opt-level = 3 +[profile.selfdev.package.unicode-width] +opt-level = 3 +[profile.test.package.unicode-width] +opt-level = 3 + +[profile.dev.package.unicode-segmentation] +opt-level = 3 +[profile.selfdev.package.unicode-segmentation] +opt-level = 3 +[profile.test.package.unicode-segmentation] +opt-level = 3 + +[profile.dev.package.unicode-truncate] +opt-level = 3 +[profile.selfdev.package.unicode-truncate] +opt-level = 3 +[profile.test.package.unicode-truncate] +opt-level = 3 + +[profile.dev.package.unicode-linebreak] +opt-level = 3 +[profile.selfdev.package.unicode-linebreak] +opt-level = 3 +[profile.test.package.unicode-linebreak] +opt-level = 3 + +[profile.dev.package.compact_str] +opt-level = 3 +[profile.selfdev.package.compact_str] +opt-level = 3 +[profile.test.package.compact_str] +opt-level = 3 + # Keep the text-shaping stack optimized even in dev/selfdev/test builds. # # cosmic-text + rustybuzz + ttf-parser + swash + yazi do all of the desktop @@ -481,6 +599,10 @@ codegen-units = 256 [dev-dependencies] async-stream = "0.3" +jcode-harness-api = { path = "crates/jcode-harness-api" } +# Used by tests/context_window_matrix.rs to assert the shared context-window +# resolution invariants directly, without going through a live provider. +jcode-provider-core = { path = "crates/jcode-provider-core" } # Enables the downstream test-support helpers (storage::lock_test_env, # auth::test_sandbox, bus::reset_models_updated_publish_state_for_tests, the # ExternalAuthReviewCandidate read accessors) for the root crate's own cli test diff --git a/OAUTH.md b/OAUTH.md index 3a31f03a06..e7275a157c 100644 --- a/OAUTH.md +++ b/OAUTH.md @@ -269,7 +269,7 @@ jcode --provider-profile my-api auth-test --no-tool-smoke This writes `[providers.my-api]` in `~/.jcode/config.toml` and stores the key in jcode's private app config dir, for example `~/.config/jcode/provider-my-api.env`. For localhost servers, use `--no-api-key`. -Two notable presets are: +Notable presets include: ### Fireworks - Login: `jcode login --provider fireworks` @@ -279,6 +279,16 @@ Two notable presets are: - Default model hint: `accounts/fireworks/routers/kimi-k2p5-turbo` - Docs: +### Novita AI +- Login: `jcode login --provider novita` or `/login novita` in the TUI +- Authentication: pay-as-you-go API key, not a subscription login or browser OAuth +- Stored env file: `~/.config/jcode/novita.env` +- API key env var: `NOVITA_API_KEY` +- Base URL: `https://api.novita.ai/openai` +- Default model hint: `zai-org/glm-5.3` +- Get a key: +- Docs: + ### MiniMax - Login: `jcode login --provider minimax` - Stored env file: `~/.config/jcode/minimax.env` diff --git a/README.md b/README.md index fc24a36269..99abe8fa61 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,18 @@ [![GitHub Stars](https://badgen.net/github/stars/1jehuang/jcode?icon=github)](https://github.com/1jehuang/jcode/stargazers) [![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/nBe9vGyK9a) -The next generation coding agent harness to raise the skill ceiling.
-Built for multi-session workflows, infinite customizability, and performance. +The most RAM efficient harness
+The most intelligent harness -
+1jehuang/jcode | Trendshift - - jcode memory demonstration + + jcode YC launch video
-[Website](https://jcode.sh) · [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Further Reading](#further-reading) · [Contributing](CONTRIBUTING.md) +[Website](https://jcode.sh) · [Docs](https://jcode.sh/docs) · [SDK](https://jcode.sh/sdk) · [Benchmarks](https://jcode.sh/bench) · [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Further Reading](#further-reading) · [Contributing](CONTRIBUTING.md) @@ -45,6 +45,23 @@ irm https://jcode.sh/install.ps1 | iex Need Homebrew, source builds, provider setup, or want an agent to set it up for you? [Jump to detailed installation](#detailed-installation). +### Updating + +Run `/update` in the TUI to download the latest stable release in the background +and reload with your session preserved. From a terminal, use `jcode update`, then +restart the client. Both commands use the same update policy, including for dev builds. + +Older or equal release versions are skipped. For a development build, Jcode also +compares the running binary's Git commit with the release tag. Builds ahead of, +identical to, or diverged from the release are preserved. If ancestry cannot be +verified locally or through GitHub, the update stops rather than risking a downgrade. +The displayed dev patch includes a commit-count offset, so it is not used as a +release version comparison. + +This is the default `features.update_channel = "stable"` behavior. An explicit +`"main"` channel still opts into source-branch updates. Use `/rebuild` or the +self-dev build workflow to rebuild your own checkout. + --- @@ -300,10 +317,12 @@ To show you important information without taking space away from the screen that Jcode can render at over a thousand fps. Your monitor will not have the refresh rate to show you, but this means you will not have silly flicker problems. -The custom scrollback implementation of jcode allows it to do much more than a native scrollback. However, it is a terminal-level limitation that I cannot have smooth, partial line scrolling with a custom scrollback. To fix this, I made my own terminal. Handterm https://github.com/1jehuang/handterm implements a native scroll api, and also happens to be very effiecent. This is a work in progress. Scrolling is still well implemented for normal terminals. +The custom scrollback implementation of jcode allows it to do much more than a native scrollback. However, it is a terminal-level limitation that I cannot have smooth, partial line scrolling with a custom scrollback. To fix this, I made my own terminal. Handterm https://github.com/1jehuang/handterm implements a native scroll api, and also happens to be very efficient. This is a work in progress. Scrolling is still well implemented for normal terminals. Jcode is left-aligned by default. You can switch to centered mode with the `Alt+C` hotkey, with the `/alignment` command, or in the config. +To disable emoji globally in TUI and CLI output, set `emoji = false` under `[display]` in `~/.jcode/config.toml`, or launch with `JCODE_NO_EMOJI=1`. Jcode replaces emoji with compact ASCII markers while preserving other Unicode text. + --- ## Swarm @@ -337,13 +356,19 @@ jcode works with subscription-backed OAuth flows and many provider integrations, - **Azure OpenAI** (`jcode login --provider azure`) - **Alibaba Cloud Coding Plan** (`jcode login --provider alibaba-coding-plan`) - **Fireworks** (`jcode login --provider fireworks`) +- **Novita AI** (`jcode login --provider novita`, API key) - **MiniMax** (`jcode login --provider minimax`) +- **Meta Model API / Muse** (`jcode login --provider meta-muse`) - **LM Studio** (`jcode login --provider lmstudio`) - **Ollama** (`jcode login --provider ollama`) - **Custom OpenAI-compatible endpoint** (`jcode login --provider openai-compatible`) For custom OpenAI-compatible endpoints, jcode now prompts for the API base and supports local localhost servers without requiring an API key. +The native OpenAI providers use Responses WebSocket v2 with opportunistic +background prewarming and HTTPS fallback. See [OpenAI WebSocket transport](docs/OPENAI_WEBSOCKET.md) +for behavior, controls, and verification. + ### Config-file setup for self-hosted endpoints and MCP If you prefer to configure things by editing files instead of using the login UI, jcode supports both a custom OpenAI-compatible endpoint config and MCP config files. @@ -360,18 +385,20 @@ There are two ways to set one up: jcode login --provider # for example: jcode login --provider openrouter + jcode login --provider orcarouter jcode login --provider deepseek jcode login --provider opencode # OpenCode Zen jcode login --provider moonshotai + jcode login --provider meta-muse # Meta Model API / Muse Spark ``` - Built-in OpenAI-compatible profile ids include: `openrouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list. + Built-in OpenAI-compatible profile ids include: `openrouter`, `orcarouter`, `deepseek`, `zai`, `kimi`, `moonshotai`, `meta-muse` (Meta Model API / Muse Spark), `opencode` (OpenCode Zen), `opencode-go`, `302ai`, `baseten`, `cortecs`, `huggingface`, `nebius`, `scaleway`, `stackit`, and `firmware`. Each profile only sets the endpoint and key variable; you still pick the model with `/model` (or `--model`). Run `jcode login` with no provider to see the interactive list. - **Any other endpoint** — point jcode at an arbitrary OpenAI-compatible API (hosted or local) with `jcode login --provider openai-compatible` or the scriptable `jcode provider add` command described below. Useful environment overrides for these endpoints: -- `JCODE_STREAM_IDLE_TIMEOUT_SECS` — raise the streaming idle timeout (default 180s) for slow reasoning models that think silently before emitting tokens. Also settable as `[provider] stream_idle_timeout_secs` in `config.toml`. +- `JCODE_STREAM_IDLE_TIMEOUT_SECS` — raise the base streaming idle timeout (default 180s) for slow reasoning models that think silently before emitting tokens. High reasoning efforts scale this automatically (high 2x, xhigh 3x, max 4x). Also settable as `[provider] stream_idle_timeout_secs` in `config.toml`. - Per-model `context_window` (alias `context_limit`) in a `[[providers..models]]` entry — set the context window when the endpoint has no usable `/v1/models` response, so jcode does not fall back to the generic 200k default. - `extra_body` — inject non-standard top-level fields into every chat/completions request body for backends that require them. See [Extra request-body fields](#extra-request-body-fields-extra_body) below. @@ -444,12 +471,47 @@ base_url = "https://llm.example.com/v1" api_key_env = "JCODE_PROVIDER_MY_API_API_KEY" env_file = "provider-my-api.env" default_model = "my-model-id" +# Optional: prevent model names such as `gpt-5-*` from automatically enabling +# `reasoning_effort` on gateways that reject it. +disable_reasoning_heuristics = true [[providers.my-api.models]] id = "my-model-id" context_window = 128000 +# Explicitly enable `/effort` and select this model's initial effort. Set +# `reasoning = false` on an individual model to disable it instead. +reasoning = true +reasoning_effort = "high" ``` +Anthropic Messages-compatible gateways use the same named-profile surface with +`type = "anthropic-compatible"`. The profile can select bearer, custom-header, +or no authentication and attach gateway-specific headers to every request: + +```toml +[provider] +default_provider = "corp-claude" +default_model = "claude-sonnet-4-6" + +[providers.corp-claude] +type = "anthropic-compatible" +base_url = "https://gateway.example.com/anthropic/v1" +auth = "bearer" +api_key_env = "CORP_CLAUDE_TOKEN" +default_model = "claude-sonnet-4-6" + +[providers.corp-claude.headers] +x-tenant-id = "tenant-42" + +[[providers.corp-claude.models]] +id = "claude-sonnet-4-6" +context_window = 200000 +``` + +For direct environment-based configuration, `ANTHROPIC_BASE_URL` overrides the +non-OAuth Messages endpoint and `ANTHROPIC_AUTH_TOKEN` is sent as a bearer token. +Claude OAuth traffic always continues to use Anthropic's official endpoints. + ##### Extra request-body fields (`extra_body`) Some OpenAI-compatible backends require non-standard top-level request fields. For example, NVIDIA NIM DeepSeek-V4 reasoning models (`deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro`) only enable thinking when the request includes `chat_template_kwargs`; without it they reply without reasoning (or, for some deployments, hang). jcode lets you inject arbitrary top-level fields two ways. @@ -512,6 +574,15 @@ Claude Code compatibility: - `.mcp.json` at the repo root (Claude Code's project config) - `.claude/mcp.json` (legacy fallback) +Claude Code config is read live on every load rather than copied into jcode's +global config. Additions, edits, and deletions therefore take effect without +leaving a stale snapshot (and inline environment values are not duplicated). +For migration from Codex CLI, jcode still performs a one-time import from +`~/.codex/config.toml` into `~/.jcode/mcp.json` when the latter does not exist. +That imported file is then jcode-owned; later Codex changes are not synced +automatically. Imported environment values are copied too and may contain +secrets. + Both the canonical `mcpServers` key and jcode's historical `servers` key are accepted. jcode currently supports stdio (command-based) servers only; HTTP/SSE entries (`"type": "http"`/`"sse"`) are recognized and skipped with a log line. Example MCP config: @@ -524,12 +595,16 @@ Example MCP config: "args": ["--root", "/workspace"], "env": {}, "shared": true + }, + "websearch": { + "command": "/path/to/slow-mcp-server", + "timeout_secs": 120 } } } ``` -On first run, jcode also tries to import MCP servers from `~/.claude.json` (falling back to the legacy `~/.claude/mcp.json`) and `~/.codex/config.toml` if `~/.jcode/mcp.json` does not exist yet. +Each request to an MCP server (`tools/call`, `tools/list`, `initialize`) times out after 30 seconds by default. Set `timeout_secs` on a server whose tools legitimately run longer. For headless or SSH sessions, OAuth-style providers support `jcode login --provider --no-browser` (alias: `--headless`) so jcode prints the auth URL/QR and falls back to manual code or callback paste instead of trying to launch a local browser. @@ -567,8 +642,8 @@ The above image is the first page of provider logins ### Supported provider - **Native / first-party style providers:** `claude`, `openai`, `copilot`, `gemini`, `azure`, `alibaba-coding-plan` -- **Aggregator / compatibility providers:** `openrouter`, `openai-compatible` -- **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google` +- **Aggregator / compatibility providers:** `openrouter`, `orcarouter`, `openai-compatible` +- **Additional provider integrations:** `opencode`, `opencode-go`, `zai` / `kimi`, `302ai`, `baseten`, `cortecs`, `deepseek`, `firmware`, `huggingface`, `moonshotai`, `nebius`, `scaleway`, `stackit`, `groq`, `mistral`, `perplexity`, `togetherai`, `deepinfra`, `fireworks`, `novita`, `minimax`, `xai`, `lmstudio`, `ollama`, `chutes`, `cerebras`, `cursor`, `antigravity`, `google` Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second. @@ -704,6 +779,10 @@ Notes: ## Further Reading +- [jcode.sh/docs](https://jcode.sh/docs) — install, providers, configuration, keybindings +- [jcode.sh/swarm](https://jcode.sh/swarm) — many coding agents in one repository +- [jcode.sh/sdk](https://jcode.sh/sdk) — TypeScript SDK: drive jcode sessions from your own program +- [jcode.sh/bench](https://jcode.sh/bench) — benchmark methodology and results - [Ambient Mode / OpenClaw](docs/AMBIENT_MODE.md) - [Browser Provider Protocol](docs/BROWSER_PROVIDER_PROTOCOL.md) - [Memory Architecture](docs/MEMORY_ARCHITECTURE.md) @@ -759,6 +838,7 @@ Set up jcode on this machine for me. - Azure OpenAI: `~/.config/jcode/azure-openai.env`, `AZURE_OPENAI_*`, or an existing `az login` - OpenRouter: `OPENROUTER_API_KEY` - Fireworks: `~/.config/jcode/fireworks.env`, `FIREWORKS_API_KEY` + - Novita AI: `~/.config/jcode/novita.env`, `NOVITA_API_KEY` - MiniMax: `~/.config/jcode/minimax.env`, `MINIMAX_API_KEY` - NVIDIA NIM: `~/.config/jcode/nvidia-nim.env`, `NVIDIA_API_KEY` - Alibaba Cloud Coding Plan: existing jcode config/env if present diff --git a/TELEMETRY.md b/TELEMETRY.md index 5e5055a9f4..85ad064337 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -1,8 +1,52 @@ # jcode Telemetry -jcode collects **anonymous, minimal usage statistics** to help understand how many people use jcode, what providers/models are popular, whether onboarding works, which feature families are used, how often sessions succeed, and whether performance/regressions are improving. This data helps prioritize development without collecting prompts or code. - -Recent telemetry additions also include: coarse onboarding steps, explicit thumbs-up / thumbs-down feedback, build-channel / dev-mode cleanup flags, session/workflow/tool-category summaries, coarse project language buckets, retention helpers like active days in the last 7 / 30 days, workflow cadence fields for session timing and multi-sessioning, privacy-safe per-turn timing/outcome metrics, and schema v5 agent-time / autonomy / pain-attribution metrics. +jcode collects **anonymous, minimal usage statistics** to help understand how many people use jcode, what providers/models are popular, whether onboarding works, which feature families are used, how often sessions succeed, and whether performance/regressions are improving. This data helps prioritize development. Ordinary telemetry does **not** contain prompts, source code, model responses, or conversation transcripts. + +Jcode also offers a separate, optional transcript-sharing program. It is off by +default and requires choosing **Share full transcripts** in the telemetry +settings. This consent is independent of ordinary usage telemetry and is +versioned so an older preference cannot silently opt a user into a newly +introduced content program. + +### Optional Full Transcript Event + +When transcript sharing is explicitly enabled, one upload is queued when a +non-empty session closes or crashes. The upload contains the complete structured +conversation: user prompts, model responses and reasoning retained by Jcode, +source code present in messages, tool names and inputs, and tool results. Images +remain represented by their transcript content-block metadata; Jcode does not +add local files that were not already present in the conversation. + +Before upload, Jcode recursively replaces likely credentials with +`[REDACTED_SECRET]`. This covers sensitive JSON fields (API keys, tokens, +passwords, authorization headers, cookies, private keys, and client secrets), +known provider-token formats, bearer tokens, JWTs, AWS access-key IDs, private +key blocks, and common environment-variable assignments. The receiving Worker +runs the same classes of checks again before writing to R2. Ordinary source code +is retained. Secret detection is defense in depth rather than a mathematical +guarantee, so users should still avoid intentionally pasting live credentials. + +| Field | Purpose | +|-------|---------| +| `upload_id` | Random identifier for this upload | +| `id` | Installation telemetry ID, used to honor deletion requests | +| `consent_version` | Version of the explicit content-sharing consent | +| `provider` / `model` / `end_reason` | Session metadata | +| `message_count` / `messages` | Complete structured conversation | + +Transcript uploads use the dedicated `/v1/transcript` endpoint and are stored +in a private R2 bucket, separate from Analytics Engine and ordinary D1 event +rows. D1 stores only upload metadata and the private object key. Uploads are +limited to 8 MiB and the R2 bucket must have a 30-day deletion lifecycle rule. +Access should be restricted to specifically authorized maintainers working on +quality evaluation. Transcript data must not be sold or shared with unrelated +third parties. + +Disable transcript sharing at any time from `/telemetry` by selecting **No +prompts or transcripts** or **Send nothing**. `JCODE_NO_TELEMETRY` and +`DO_NOT_TRACK` override the content setting and prevent uploads. + +Recent telemetry additions also include: coarse onboarding steps, explicit thumbs-up / thumbs-down feedback, build-channel / dev-mode cleanup flags, session/workflow/tool-category summaries, coarse project language buckets, retention helpers like active days in the last 7 / 30 days, workflow cadence fields for session timing and multi-sessioning, privacy-safe per-turn timing/outcome metrics, schema v5 agent-time / autonomy / pain-attribution metrics, and numeric-only todo progress aggregates. ## What We Collect @@ -51,10 +95,18 @@ Recent telemetry additions also include: coarse onboarding steps, explicit thumb | Field | Example | Purpose | |-------|---------|----------| | `event` | `"feedback"` | Event type | -| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` | +| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback submitted with `/feedback ...` or the `maintainer_feedback` agent tool | | `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present | | `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present | +The `maintainer_feedback` tool is available only as another explicit telemetry +path: it obeys the same telemetry opt-out as `/feedback` and sends no event when +telemetry is disabled. Its schema tells the agent to paraphrase, omit secrets and +private data, and label whether the report originated with the user, the agent, +or both. User-originated and mixed reports are rejected unless the user explicitly +approved sharing them; agent-only technical observations do not need per-report +approval. Jcode does not attach transcript content, repository files, or paths. + ### Sponsored Discovery Event One event is sent after each `discover_tools` attempt. A random per-request ID @@ -66,9 +118,9 @@ without exposing prompts or a persistent telemetry identifier to that service. |-------|---------|----------| | `event` | `"discovery"` | Event type | | `request_id` | `"9a23..."` | Random correlation ID scoped to one request | -| `phase` | `"browse"` / `"select"` / `"suggest"` / `"unknown"` | Discovery funnel stage; `suggest` records a missing catalog capability proposal | +| `phase` | `"browse"` / `"details"` / `"select"` / `"suggest"` / `"unknown"` | Discovery funnel stage; `details` records investigation without selection and `suggest` records a missing catalog capability proposal | | `category` | `"payments"` | Fixed discovery category, when valid | -| `selected_tool` | `"agentcard"` | Public catalog tool name in the select phase | +| `selected_tool` | `"agentcard"` | Public catalog tool name in the details or select phase | | `outcome` | `"success"` / `"failure"` | Attempt result | | `failure_reason` | `"timeout"` | Allowlisted coarse failure class only | | `http_status` | `200` | Discovery service response status, if received | @@ -88,6 +140,53 @@ The benchmark runner sets `JCODE_DISCOVERY_BENCHMARK=1`. Discovery requests then carry `x-jcode-discovery-benchmark: 1`, and the corresponding telemetry event has `benchmark_run: true`. +When telemetry is enabled, discovery API requests also carry +`x-jcode-session-correlation-id`. It is a fresh random UUID for the current +runtime session, is not derived from the persistent telemetry ID, and is never +reused across sessions. The same UUID appears on the numeric-only Todo Session +event below. When telemetry is disabled, this header is omitted. + +### Todo Session Event + +This does not replace the todo counters added in migration 0021. Those live on +`session_details` / `turn_details` and count how often todo gates fired +(`tool_cat_todo`, `feature_todo_used`, `todo_gate_*_count`). This event is the +complement: the lifecycle outcome of the list and the score values themselves, +plus the per-session join key. Read 0021 for "how often did gates fire" and this +event for "did the work finish, and how confident was the agent". + +One aggregate event is sent when an active session ends. Its `id` and +`correlation_id` fields are the same fresh per-session UUID. The persistent +telemetry ID, account ID, internal session ID, todo IDs, and all user/model text +are absent, so the event is joinable to discovery requests from that session but +not to an install, account, or another session. + +That join is not yet possible in practice. The discovery service stores its rows +in the `jcode-subscriptions` D1 while this event lands in `jcode-telemetry`, and +nothing on the receiving side reads +`x-jcode-session-correlation-id` yet, so the header is currently sent and +discarded. The correlation design is what makes the join possible later; it does +not by itself make the number available. + +| Field | Type | Purpose | +|-------|------|---------| +| `event` | `"todo_session"` | Event type | +| `id` / `correlation_id` | UUID strings | Same random, single-session join key; never the persistent telemetry ID | +| `session_end_reason` | enum string | Coarse lifecycle end reason | +| `todos_created` / `todos_completed` / `todos_abandoned` | non-negative integers | Todo lifecycle transitions and items ending non-complete | +| `todo_updates` | non-negative integer | Number of todo tool calls | +| `groups_completed` / `groups_total` | non-negative integers | Final coherent-goal completion summary | +| `max_todo_list_size` | non-negative integer | Todo list high-water mark | +| `confidence_min` / `confidence_mean` / `confidence_count` | number / number / integer | Distribution-safe current confidence summary | +| `completion_confidence_min` / `completion_confidence_mean` / `completion_confidence_count` | number / number / integer | Distribution-safe completion-confidence summary | +| `understands_user_intent_min` / `understands_user_intent_mean` / `understands_user_intent_count` | number / number / integer | Distribution-safe plan-level intent-understanding summary; count is 0 or 1 | +| `closed_feedback_loop_min` / `closed_feedback_loop_mean` / `closed_feedback_loop_count` | number / number / integer | Distribution-safe per-goal feedback-loop score summary | +| `end_to_end_ownership_min` / `end_to_end_ownership_mean` / `end_to_end_ownership_count` | number / number / integer | Distribution-safe per-goal ownership summary | +| `schema_version` / `version` / `os` / `arch` / build flags | numbers, booleans, and enum/identifier strings | Compatibility and coarse release filtering | + +Todo content, goal labels, feedback-loop text, user-intention text, task content, +file paths, code, prompts, item IDs, and per-item rows are **never sent**. + ### Session Start Event | Field | Example | Purpose | @@ -148,7 +247,7 @@ carry `x-jcode-discovery-benchmark: 1`, and the corresponding telemetry event ha | `total_tokens` | `23223` | Sum of input, output, cache-read, and cache-creation tokens | | `feature_*_used` | `true/false` | Whether a feature family was used (memory, swarm, web, email, MCP, side panel, goals, todos, selfdev, background, subagents) | | `tool_cat_*` | `0..N` | Coarse tool category counts (read/search, write, shell, web, memory, subagent, swarm, email, side-panel, goal, todo, MCP, other) | -| `todo_gate_*_count` | `0..N` | How often todo quality gates fired in-session (end-to-end ownership, hill-climbability, completion confidence, confidence spike) | +| `todo_gate_*_count` | `0..N` | How often todo quality gates fired in-session (end-to-end ownership, closed feedback loop, completion confidence, confidence spike) | | `command_*_used` | `true/false` | Whether a slash-command family was used in-session | | `workflow_*_used` | `true/false` | Whether the session looked like coding, research, testing, background, subagent, or swarm work | | `unique_mcp_servers` | `2` | Count of distinct MCP servers touched in-session | @@ -227,24 +326,62 @@ Most events also carry a few coarse quality / cleanup fields: | `event_id` | `"uuid"` | Deduplication | | `session_id` | `"uuid"` | Joins session-scoped events together | | `schema_version` | `3` | Forward-compatible parsing | -| `build_channel` | `"release"` / `"selfdev"` / `"local_build"` | Filter out dev/test usage | +| `build_channel` | `"release"` / `"ci_release"` / `"selfdev"` / `"local_build"` | Separate installed releases, CI/CD-built releases, and dev/test usage | | `is_git_checkout` | `true/false` | Distinguish source-tree usage from installed usage | | `is_ci` | `true/false` | Filter CI noise | | `ran_from_cargo` | `true/false` | Filter local dev launches | +CI/CD jobs should set `JCODE_CI=1` when running jcode. `JCODE_CI=0` explicitly +marks a run as non-CI and overrides inherited provider variables. When this +setting is absent, jcode falls back to common provider markers such as `CI`, +`GITHUB_ACTIONS`, `GITLAB_CI`, and `BUILDKITE`. Build provenance is independent: +official release workflows set `JCODE_CI_BUILD=1` while compiling, producing the +`ci_release` channel without classifying later end-user executions as CI. + ## What We Do NOT Collect +- **No conversation transcripts, prompts, code, or LLM responses**, except text you explicitly submit with `/feedback ...` - No file paths, project names, or directory structures -- No code, prompts, or LLM responses, except text explicitly submitted with `/feedback ...` - No tool inputs or tool outputs - No MCP server names or configurations -- No IP addresses (Cloudflare Workers don't log these by default) +- No IP addresses (the worker never reads or stores the client IP) +- No city, region, coordinates, postal code, or timezone - No personal information of any kind - No error messages or stack traces in telemetry (only coarse categories and end reasons) - No exact wall-clock timestamps beyond coarse hour-of-day / weekday buckets +### Coarse Geography (added by the receiving worker, not the client) + +The telemetry worker records a single **2-letter country code**, resolved by +Cloudflare at the edge from the connection (`request.cf.country`). jcode itself +never collects, computes, or sends location data, and the value cannot be set or +spoofed by the client. + +| Field | Example | Purpose | +|-------|---------|----------| +| `country` | `"DE"` | Aggregate "which countries do users come from" reporting | + +Only the country is kept: the IP address, city, region, coordinates, postal +code, and timezone are never read or stored. It is stored as a per-day +aggregate (`country_daily` counts) plus a `last_country` column on the daily +active-user rollup. Unknown (`XX`) and Tor (`T1`) codes are discarded. + The UUID is randomly generated on first run and stored at `~/.jcode/telemetry_id`. It is not derived from your machine, username, email, or any identifiable information. +## How We Use and Share Data + +Telemetry is used to operate, debug, secure, and improve jcode, and for product and +retention analytics. We may publish or share **aggregate** statistics (for example +install counts, OS/provider distribution, version adoption) and we share data with the +infrastructure providers needed to run the pipeline, currently Cloudflare. + +We do **not** sell event-level telemetry, and we do not collect conversation content to +sell or to train models. If that ever changes, it will be a separate, clearly disclosed, +**opt-in** program rather than a silent change to this document. + +We do not attempt to re-identify users from telemetry, and the client does not link +telemetry to account identity. + ## How It Works 1. On first launch, jcode generates a random UUID and sends an `install` event @@ -259,6 +396,12 @@ The UUID is randomly generated on first run and stored at `~/.jcode/telemetry_id The telemetry endpoint is a Cloudflare Worker that stores events in a D1 database. The source code for the worker is in [`telemetry-worker/`](./telemetry-worker/). +## Changes to This Policy + +The version of this document in the repository is the current policy. If we ever want to +collect conversation content, or to share or sell anything beyond aggregate statistics, +that will require a separate opt-in rather than a quiet edit here. + ### Schema v5 deployment note Agent-time, autonomy, and pain-attribution fields require the D1 migration in `telemetry-worker/migrations/0008_agent_time_and_churn.sql`. Until that migration is applied, schema v5 clients can still send the new JSON payloads, but the worker will drop unknown columns through dynamic column filtering and dashboard agent-time panels will remain empty or show optional-panel errors. After migration, run/redeploy the telemetry worker and query the dashboard's **Agent time / autonomy** panel. @@ -268,13 +411,23 @@ Agent-time, autonomy, and pain-attribution fields require the D1 migration in `t Any of these methods will disable telemetry completely: ```bash -# Option 1: Environment variable +# Option 1: Persistent CLI setting +jcode telemetry disable + +# Inspect the current setting without creating a telemetry ID +jcode telemetry status +jcode telemetry status --json + +# Re-enable telemetry +jcode telemetry enable + +# Option 2: Environment variable export JCODE_NO_TELEMETRY=1 -# Option 2: Standard DO_NOT_TRACK (https://consoledonottrack.com/) +# Option 3: Standard DO_NOT_TRACK (https://consoledonottrack.com/) export DO_NOT_TRACK=1 -# Option 3: File-based opt-out +# Option 4: File-based opt-out touch ~/.jcode/no_telemetry ``` @@ -282,10 +435,10 @@ When opted out, zero network requests are made. The telemetry module short-circu ## Verification -This is open source. The entire telemetry implementation is in [`src/telemetry.rs`](./src/telemetry.rs) - you can read exactly what gets sent. There are no other network calls related to telemetry anywhere in the codebase. +This is open source. The telemetry implementation is in [`crates/jcode-telemetry-core/src/`](./crates/jcode-telemetry-core/src/) - you can read exactly what gets sent. There are no other network calls related to telemetry anywhere in the codebase. ## Data Retention -Telemetry data is used in aggregate only (install count, active users, provider distribution, session success/crash rates, feature-level counts). Individual event records are retained for up to 12 months and then deleted. +Telemetry data is used in aggregate (install count, active users, provider distribution, session success/crash rates, feature-level counts). Individual event records are retained for up to 12 months and then deleted. High-volume raw events are pruned earlier on a nightly schedule, after their aggregate signal has been captured in a compact daily-activity rollup: per-turn and per-session-start records and onboarding-step records are kept for about 30 days, upgrade records for about 60 days, and auth-success records for about 180 days. Session summary records (the per-session aggregate counts described above) are kept for up to 12 months. diff --git a/changelog/index.json b/changelog/index.json index 3324c1feca..a90f095942 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,22 +1,304 @@ { "entries": [ - { "version": "0.50.0", "date": "2026-07-17" }, - { "version": "0.49.0", "date": "2026-07-16" }, - { "version": "0.48.0", "date": "2026-07-15" }, - { "version": "0.47.0", "date": "2026-07-14" }, - { "version": "0.46.0", "date": "2026-07-13" }, - { "version": "0.45.0", "date": "2026-07-13" }, - { "version": "0.44.0", "date": "2026-07-12" }, - { "version": "0.43.0", "date": "2026-07-11" }, - { "version": "0.42.0", "date": "2026-07-11" }, - { "version": "0.41.0", "date": "2026-07-10" }, - { "version": "0.40.0", "date": "2026-07-10" }, - { "version": "0.39.0", "date": "2026-07-09" }, - { "version": "0.38.0", "date": "2026-07-09" }, - { "version": "0.37.0", "date": "2026-07-07" }, - { "version": "0.36.0", "date": "2026-07-05" }, - { "version": "0.35.1", "date": "2026-07-04" }, - { "version": "0.35.0", "date": "2026-07-04" }, - { "version": "0.34.0", "date": "2026-07-02" } + { + "version": "0.84.0", + "date": "2026-09-06" + }, + { + "version": "0.83.0", + "date": "2026-09-06" + }, + { + "version": "0.82.0", + "date": "2026-09-06" + }, + { + "version": "0.81.7", + "date": "2026-09-04" + }, + { + "version": "0.81.6", + "date": "2026-09-03" + }, + { + "version": "0.81.5", + "date": "2026-09-03" + }, + { + "version": "0.81.4", + "date": "2026-08-30" + }, + { + "version": "0.81.3", + "date": "2026-08-29" + }, + { + "version": "0.81.2", + "date": "2026-08-28" + }, + { + "version": "0.81.1", + "date": "2026-08-25" + }, + { + "version": "0.81.0", + "date": "2026-08-25" + }, + { + "version": "0.80.1", + "date": "2026-08-25" + }, + { + "version": "0.80.0", + "date": "2026-08-24" + }, + { + "version": "0.79.1", + "date": "2026-08-21" + }, + { + "version": "0.79.0", + "date": "2026-08-21" + }, + { + "version": "0.78.1", + "date": "2026-08-19" + }, + { + "version": "0.78.0", + "date": "2026-08-18" + }, + { + "version": "0.77.2", + "date": "2026-08-18" + }, + { + "version": "0.77.1", + "date": "2026-08-17" + }, + { + "version": "0.77.0", + "date": "2026-08-17" + }, + { + "version": "0.76.0", + "date": "2026-08-14" + }, + { + "version": "0.75.5", + "date": "2026-08-12" + }, + { + "version": "0.75.4", + "date": "2026-08-12" + }, + { + "version": "0.75.3", + "date": "2026-08-11" + }, + { + "version": "0.75.2", + "date": "2026-08-11" + }, + { + "version": "0.75.1", + "date": "2026-08-11" + }, + { + "version": "0.75.0", + "date": "2026-08-10" + }, + { + "version": "0.74.0", + "date": "2026-08-10" + }, + { + "version": "0.73.0", + "date": "2026-08-09" + }, + { + "version": "0.71.1", + "date": "2026-08-08" + }, + { + "version": "0.71.0", + "date": "2026-08-06" + }, + { + "version": "0.70.1", + "date": "2026-08-06" + }, + { + "version": "0.70.0", + "date": "2026-08-06" + }, + { + "version": "0.69.0", + "date": "2026-08-06" + }, + { + "version": "0.68.0", + "date": "2026-08-05" + }, + { + "version": "0.67.1", + "date": "2026-08-03" + }, + { + "version": "0.67.0", + "date": "2026-08-03" + }, + { + "version": "0.66.0", + "date": "2026-08-03" + }, + { + "version": "0.64.2", + "date": "2026-07-30" + }, + { + "version": "0.64.1", + "date": "2026-07-30" + }, + { + "version": "0.64.0", + "date": "2026-07-30" + }, + { + "version": "0.61.1", + "date": "2026-07-28" + }, + { + "version": "0.61.0", + "date": "2026-07-27" + }, + { + "version": "0.60.0", + "date": "2026-07-26" + }, + { + "version": "0.59.0", + "date": "2026-07-25" + }, + { + "version": "0.58.0", + "date": "2026-07-25" + }, + { + "version": "0.57.0", + "date": "2026-07-24" + }, + { + "version": "0.56.0", + "date": "2026-07-24" + }, + { + "version": "0.55.0", + "date": "2026-07-22" + }, + { + "version": "0.54.4", + "date": "2026-07-20" + }, + { + "version": "0.54.3", + "date": "2026-07-20" + }, + { + "version": "0.54.2", + "date": "2026-07-20" + }, + { + "version": "0.54.1", + "date": "2026-07-20" + }, + { + "version": "0.54.0", + "date": "2026-07-20" + }, + { + "version": "0.53.0", + "date": "2026-07-19" + }, + { + "version": "0.52.0", + "date": "2026-07-19" + }, + { + "version": "0.51.1", + "date": "2026-07-18" + }, + { + "version": "0.50.0", + "date": "2026-07-17" + }, + { + "version": "0.49.0", + "date": "2026-07-16" + }, + { + "version": "0.48.0", + "date": "2026-07-15" + }, + { + "version": "0.47.0", + "date": "2026-07-14" + }, + { + "version": "0.46.0", + "date": "2026-07-13" + }, + { + "version": "0.45.0", + "date": "2026-07-13" + }, + { + "version": "0.44.0", + "date": "2026-07-12" + }, + { + "version": "0.43.0", + "date": "2026-07-11" + }, + { + "version": "0.42.0", + "date": "2026-07-11" + }, + { + "version": "0.41.0", + "date": "2026-07-10" + }, + { + "version": "0.40.0", + "date": "2026-07-10" + }, + { + "version": "0.39.0", + "date": "2026-07-09" + }, + { + "version": "0.38.0", + "date": "2026-07-09" + }, + { + "version": "0.37.0", + "date": "2026-07-07" + }, + { + "version": "0.36.0", + "date": "2026-07-05" + }, + { + "version": "0.35.1", + "date": "2026-07-04" + }, + { + "version": "0.35.0", + "date": "2026-07-04" + }, + { + "version": "0.34.0", + "date": "2026-07-02" + } ] } diff --git a/changelog/v0.51.1.json b/changelog/v0.51.1.json new file mode 100644 index 0000000000..90e2fcae6f --- /dev/null +++ b/changelog/v0.51.1.json @@ -0,0 +1,11 @@ +{ + "version": "0.51.1", + "date": "2026-07-18", + "title": "Safer live Claude Code takeover", + "fixes": [ + "Taking over a live Claude Code session now signals the exact verified process, so an unrelated process that reused the PID can never be stopped by mistake", + "Takeover refuses transcripts that belong to a different Claude session instead of importing the wrong conversation", + "If Claude Code does not exit in time during takeover, the prepared Jcode session is preserved and can still be resumed", + "The final transcript refresh after takeover is retried, so the last messages Claude flushes on exit are no longer silently dropped" + ] +} diff --git a/changelog/v0.52.0.json b/changelog/v0.52.0.json new file mode 100644 index 0000000000..da817c6340 --- /dev/null +++ b/changelog/v0.52.0.json @@ -0,0 +1,33 @@ +{ + "version": "0.52.0", + "date": "2026-07-19", + "title": "ChatGPT web models, faster /model search, and a calmer TUI", + "highlights": [ + "New browser-backed ChatGPT web route adds gpt-5.6-pro[web], letting Pro-tier web models run through your logged-in browser", + "/model fuzzy search is dramatically faster and no longer lags while typing", + "Opening the / slash-command palette no longer shifts the screen, and the menu renders as an overlay" + ], + "improvements": [ + "Platform-API-only GPT Pro models now appear in the /model picker", + "All providers now expose their complete reasoning effort ladders", + "macOS menu bar sessions are labeled by their active work and show only your root sessions", + "New display.overscroll_status config (off/on/overscroll) and overscroll now requires a gesture starting at the transcript bottom", + "New JCODE_PERF_TIER environment variable forces a performance tier for one invocation", + "Auto-poke stops after consecutive provider guardrail refusals instead of retrying forever", + "Tool calls show a short intent line by default, with technical detail behind display.tool_call_details", + "Long-idle sessions periodically release retained memory back to the OS" + ], + "fixes": [ + "Copy uses native Linux and macOS clipboards before falling back to OSC 52", + "Update checks authenticate to GitHub when possible, avoiding rate-limit failures on shared IPs", + "/fast set to off now persists instead of reverting to the default", + "Memory tool results are scoped to the session working directory instead of leaking across projects", + "PascalCase OAuth tool aliases now resolve inside batch subcalls", + "Windows server spawn no longer times out prematurely on slow machines", + "Desktop attach no longer blocks on a busy session", + "PowerShell install scripts no longer break from a UTF-8 BOM", + "Logging in no longer activates the same profile twice", + "Split-pane launches keep their elapsed-time display", + "Bash commands no longer spill onto a second line when an intent is shown" + ] +} diff --git a/changelog/v0.53.0.json b/changelog/v0.53.0.json new file mode 100644 index 0000000000..ec28f06cc6 --- /dev/null +++ b/changelog/v0.53.0.json @@ -0,0 +1,21 @@ +{ + "version": "0.53.0", + "date": "2026-07-19", + "title": "GitHub triage, copyable math, and sturdier reloads", + "highlights": [ + "New built-in /triage command helps review and organize GitHub issues from a jcode session", + "Rendered LaTeX equations can now be copied as their original math source instead of opaque image markers" + ], + "improvements": [ + "Bursty response streams reveal more smoothly while keeping the terminal responsive", + "Desktop resizing and hot reloads use fewer unnecessary redraws, react promptly to worker activity, and preserve the final window position and size" + ], + "fixes": [ + "Remote skill prompts are dispatched correctly instead of remaining stuck in the client", + "Noninteractive Google sign-in now completes OAuth callbacks reliably", + "Windows updates and server startup avoid launcher replacement and named-pipe probe stalls", + "Terminal images choose a reliable protocol for each terminal instead of reusing an incompatible global choice", + "Swarm clients drop cleared plans, omit long-finished members, bound durable plan graphs, and prune stale recovery state", + "Exited jcode client processes are reaped instead of accumulating in the background" + ] +} diff --git a/changelog/v0.54.0.json b/changelog/v0.54.0.json new file mode 100644 index 0000000000..32e9036947 --- /dev/null +++ b/changelog/v0.54.0.json @@ -0,0 +1,23 @@ +{ + "version": "0.54.0", + "date": "2026-07-20", + "title": "Subscription models, session facts, and faster Windows startup", + "highlights": [ + "Jcode subscription routes now include curated Bedrock coding models and appear reliably in model selection and provider diagnostics", + "Session facts now use available space beside the conversation while remaining grouped and readable", + "The Windows hotkey listener now prewarms the background server for faster first launches" + ], + "improvements": [ + "Terminals without inline image support now explain the fallback, preserve image interactions, and show Mermaid source when rendering is unavailable", + "Long sessions use less memory by releasing transcript copies, trimming inactive clients, and unloading the embedding model sooner", + "Windows startup and credential maintenance avoid repeated or blocking security work" + ], + "fixes": [ + "The input view follows new content to the bottom consistently", + "Swarm recursion is limited to deep-swarm roots, live agent counts remain bounded, and resumed sessions avoid a lock inversion", + "Mixed provider and subscription routes survive catalog hydration, stale catalogs refresh correctly, and direct Bedrock routes remain available", + "Bedrock uses configured AWS profiles while authentication and remote catalog inputs stay within their intended security scope", + "Onboarding credentials and deferred Windows ACL retries are handled more safely", + "Transport and PDF dependencies include current security fixes" + ] +} diff --git a/changelog/v0.54.1.json b/changelog/v0.54.1.json new file mode 100644 index 0000000000..f8fc753410 --- /dev/null +++ b/changelog/v0.54.1.json @@ -0,0 +1,15 @@ +{ + "version": "0.54.1", + "date": "2026-07-20", + "title": "Portable builds and native terminal math", + "highlights": [ + "LaTeX expressions render natively in Handterm-capable terminals" + ], + "improvements": [ + "Header rendering reuses authentication state to reduce repeated provider work" + ], + "fixes": [ + "Portable Linux downloads build with a current bundled OpenSSL while retaining compatibility with older glibc systems", + "FreeBSD release builds compile setup guidance correctly" + ] +} diff --git a/changelog/v0.54.2.json b/changelog/v0.54.2.json new file mode 100644 index 0000000000..99c632c61e --- /dev/null +++ b/changelog/v0.54.2.json @@ -0,0 +1,23 @@ +{ + "version": "0.54.2", + "date": "2026-07-20", + "title": "Subscription models, native terminal math, and portable builds", + "highlights": [ + "Jcode subscription routes include curated Bedrock coding models and appear reliably in model selection and provider diagnostics", + "LaTeX expressions render natively in Handterm-capable terminals", + "The Windows hotkey listener prewarms the background server for faster first launches" + ], + "improvements": [ + "Session facts use available space beside the conversation while remaining grouped and readable", + "Terminals without inline image support explain the fallback, preserve image interactions, and show Mermaid source when rendering is unavailable", + "Long sessions use less memory by releasing transcript copies, trimming inactive clients, and unloading the embedding model sooner" + ], + "fixes": [ + "Portable Linux downloads use a current bundled OpenSSL while retaining compatibility with older glibc systems", + "FreeBSD release builds compile setup guidance correctly", + "Remote skill prompts route without relying on a checked panic path", + "The input view follows new content to the bottom consistently", + "Swarm recursion stays limited to deep-swarm roots, live agent counts remain bounded, and resumed sessions avoid a lock inversion", + "Mixed provider and subscription routes survive catalog hydration while Bedrock authentication and catalog inputs remain within their intended security scope" + ] +} diff --git a/changelog/v0.54.3.json b/changelog/v0.54.3.json new file mode 100644 index 0000000000..13178f2684 --- /dev/null +++ b/changelog/v0.54.3.json @@ -0,0 +1,23 @@ +{ + "version": "0.54.3", + "date": "2026-07-20", + "title": "Subscription models, native terminal math, and portable builds", + "highlights": [ + "Jcode subscription routes include curated Bedrock coding models and appear reliably in model selection and provider diagnostics", + "LaTeX expressions render natively in Handterm-capable terminals", + "The Windows hotkey listener prewarms the background server for faster first launches" + ], + "improvements": [ + "Session facts use available space beside the conversation while remaining grouped and readable", + "Terminals without inline image support explain the fallback, preserve image interactions, and show Mermaid source when rendering is unavailable", + "Long sessions use less memory by releasing transcript copies, trimming inactive clients, and unloading the embedding model sooner" + ], + "fixes": [ + "Portable Linux downloads use a current bundled OpenSSL while retaining compatibility with older glibc systems", + "FreeBSD release builds compile setup guidance correctly", + "Remote skill prompts route without relying on a checked panic path", + "The input view follows new content to the bottom consistently", + "Swarm recursion stays limited to deep-swarm roots, live agent counts remain bounded, and resumed sessions avoid a lock inversion", + "Mixed provider and subscription routes survive catalog hydration while Bedrock authentication and catalog inputs remain within their intended security scope" + ] +} diff --git a/changelog/v0.54.4.json b/changelog/v0.54.4.json new file mode 100644 index 0000000000..f12df021d4 --- /dev/null +++ b/changelog/v0.54.4.json @@ -0,0 +1,10 @@ +{ + "version": "0.54.4", + "date": "2026-07-20", + "title": "Complete cross-platform release artifacts", + "fixes": [ + "Restores the portable Linux x86_64 download by including the complete runtime needed to build bundled OpenSSL", + "Windows x86_64 and ARM64 downloads publish reliably from the release pipeline", + "Release publication now remains gated until every Linux, macOS, Windows, and FreeBSD artifact is available" + ] +} diff --git a/changelog/v0.55.0.json b/changelog/v0.55.0.json new file mode 100644 index 0000000000..ae8adc5a9b --- /dev/null +++ b/changelog/v0.55.0.json @@ -0,0 +1,25 @@ +{ + "version": "0.55.0", + "date": "2026-07-22", + "title": "Terminal panes, expanded model catalog, and smarter task tracking", + "highlights": [ + "Terminal spawns inside tmux now open in split panes instead of new windows", + "Expanded Jcode Bedrock subscription model catalog", + "Todo planning now measures how well objectives and feedback loops match your request" + ], + "improvements": [ + "New /subscribe command with helpful nudges at rate-limit and long-task moments", + "New no-emoji output option keeps responses emoji-free when configured", + "Unreliable Llama tool-calling routes and the unstable Nemotron Super route are excluded from the model picker" + ], + "fixes": [ + "Interactive model switching across providers works again", + "OpenAI models in the model picker no longer show broken placeholder routes", + "Structured rate limit errors are formatted readably", + "Auto-retry stops promptly when an OpenAI usage limit is reached", + "Slash command palette no longer flickers from spinner redraws", + "Long-lived OpenAI websocket sessions keep their prompt cache alive", + "Native compaction correctly resets the cache baseline, avoiding wasted tokens", + "Todo completion follow-ups are delivered reliably" + ] +} diff --git a/changelog/v0.56.0.json b/changelog/v0.56.0.json new file mode 100644 index 0000000000..dc48072cb8 --- /dev/null +++ b/changelog/v0.56.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.56.0", + "date": "2026-07-24", + "title": "Redesigned header, faster pickers, and a harness API", + "highlights": [ + "Redesigned TUI header: left-aligned, shows git branch beside the working directory, vertical auth list, and capped MCP/skills lines", + "Much faster fuzzy filtering in pickers, with instant /model open for large remote catalogs", + "New versioned harness API socket lets external apps drive jcode sessions" + ], + "improvements": [ + "/thinking-display is now the primary command for thinking visibility", + "Unseen-updates box moved above the jcode line and renders inside startup padding", + "'/login to add provider:' heading lists unconfigured providers inline", + "Initial empty screen stays put so the first prompt no longer shifts content", + "Info widgets settle into stable placements instead of jumping around", + "Server/client header lines are dimmed for less visual noise" + ], + "fixes": [ + "MCP servers owned by a session now run in that session's working directory and stay out of the shared pool", + "Copilot Sonnet 5 requests send the correct reasoning effort" + ] +} diff --git a/changelog/v0.57.0.json b/changelog/v0.57.0.json new file mode 100644 index 0000000000..a99de18abb --- /dev/null +++ b/changelog/v0.57.0.json @@ -0,0 +1,26 @@ +{ + "version": "0.57.0", + "date": "2026-07-24", + "title": "Triage batch: model limits, streaming, and rendering fixes", + "highlights": [ + "Claude Opus 5 is now in the built-in Anthropic catalog and works without manual configuration", + "Anthropic requests derive their max output tokens per model instead of a flat 32K cap", + "OpenRouter streaming no longer drops content when responses arrive with CRLF or multi-line SSE events" + ], + "improvements": [ + "Todo plans ask for user intent once at the plan level instead of per item", + "Requests show a distinct 'sending request' phase instead of sitting on 'connecting'", + "Thinking/reasoning display is off by default for new users", + "The /login heading lists unconfigured providers as dim rows for a cleaner header", + "/model requests the route-expanded catalog and rebuilds an already-open picker" + ], + "fixes": [ + "Kimi K3 now resolves to its full 1M context window everywhere", + "The context meter reflects catalog context limits for Anthropic models", + "Failed LaTeX renders are cached so redraws stop respawning latex processes", + "PDF extraction failures are recovered instead of crashing the session", + "Swarm history truncation no longer panics on multi-byte characters", + "Anthropic OAuth sessions no longer advertise built-in tools that are not available", + "Desktop builds no longer break on Windows from Unix-only IPC" + ] +} diff --git a/changelog/v0.58.0.json b/changelog/v0.58.0.json new file mode 100644 index 0000000000..914b180812 --- /dev/null +++ b/changelog/v0.58.0.json @@ -0,0 +1,26 @@ +{ + "version": "0.58.0", + "date": "2026-07-25", + "title": "Max-effort reasoning streams, model context limits, and the /model picker", + "highlights": [ + "OpenAI max-effort reasoning turns no longer time out mid-thought: requests ask for reasoning summaries and the idle budget scales with reasoning effort", + "The /model picker shows pretty model names and matches queries like \"opus 4.8\" against them", + "New and unrecognized models no longer silently fall back to a 200K context window" + ], + "improvements": [ + "Streaming idle and websocket completion budgets scale with reasoning effort (high 2x, xhigh 3x, max 4x) so long silent thinks are not mistaken for dead connections", + "The client-side stall guard budgets against the largest effort-scaled server timeout, so the server's visible error always fires first", + "Update failure messages collapse to one short line instead of a wall of text", + "Update checks back off from GitHub API rate limits instead of retrying in a loop", + "Spawned swarm workers fail loudly when they cannot use their requested model" + ], + "fixes": [ + "Config-declared models survive a live catalog refresh", + "Named profiles that point elsewhere are treated as user-declared rather than built-in", + "Antigravity self-heals the missing thought_signature dead-end instead of getting stuck", + "A home-directory subscribe no longer clobbers the working directory of a project session", + "Kimi ids with an explicit -256k suffix resolve to 256K instead of the family default", + "JCODE_TOOL_CALL_DETAILS now invalidates the cached config when it changes", + "Fuzzy matching no longer rejects late matches because of the positional penalty" + ] +} diff --git a/changelog/v0.59.0.json b/changelog/v0.59.0.json new file mode 100644 index 0000000000..5eb4e5855e --- /dev/null +++ b/changelog/v0.59.0.json @@ -0,0 +1,25 @@ +{ + "version": "0.59.0", + "date": "2026-07-25", + "title": "Desktop preview and provider fixes", + "highlights": [ + "The desktop preview (jcode-desktop2) now starts the jcode runtime and harness bridge itself, so it connects on first launch instead of requiring two daemons to be started by hand", + "The desktop preview gained an animated, draggable hero donut on an empty session, a focus-aware caret and input border, and soft-wrapping multi-line composer; set JCODE_DESKTOP2_DONUT=0 to turn the motion off", + "New Celeris provider support" + ], + "improvements": [ + "Model names render structurally for AWS Bedrock, including bare and context-variant revisions", + "The auth list hides providers you have no credentials for", + "Webfetch extracts pages more cleanly: site chrome is stripped, output is capped, and attribute text no longer leaks into the result", + "Snapshot dates render compactly and acronyms are uppercased in the TUI", + "The desktop preview remembers window geometry, supports mouse and keyboard text selection, and centres the composer text in its well", + "Onboarding opens the bug-review action instantly, and gained a telemetry settings screen" + ], + "fixes": [ + "Bare OpenAI-compatible and named-profile model ids route to the right provider profile again", + "Only versioned GPT, Claude, and Gemini names are prettified in the model picker, so custom ids show as typed", + "Terminal escape remnants pasted at the composer boundary are stripped instead of corrupting input", + "Swarm ids and MCP discovery bind to the accepted working directory, fixing cross-directory mixups", + "Stranded agent continuations are recovered rather than silently stalling a turn" + ] +} diff --git a/changelog/v0.60.0.json b/changelog/v0.60.0.json new file mode 100644 index 0000000000..b661d808ee --- /dev/null +++ b/changelog/v0.60.0.json @@ -0,0 +1,18 @@ +{ + "version": "0.60.0", + "date": "2026-07-26", + "title": "Discovery repair and desktop preview polish", + "highlights": [ + "Tool discovery works again for users whose config was frozen while discovery shipped opt-in, so discover_tools is registered instead of staying silently unavailable", + "The desktop preview shows which provider and model is answering, and greets an empty session with an animated halftone donut and rotating composer hints" + ], + "improvements": [ + "Discovery gained a financial-data category for market, pricing, and company-data tools", + "Keybinding hints show ⌥ instead of Alt on macOS", + "The desktop preview lays its text out on a consistent vertical rhythm that holds up at any window size", + "A hand-written discovery opt-out is still respected, and saving your config no longer bakes today's defaults into it" + ], + "fixes": [ + "Idle animations no longer drag the terminal UI into unnecessary full redraws" + ] +} diff --git a/changelog/v0.61.0.json b/changelog/v0.61.0.json new file mode 100644 index 0000000000..0a0c5cd7c7 --- /dev/null +++ b/changelog/v0.61.0.json @@ -0,0 +1,33 @@ +{ + "version": "0.61.0", + "date": "2026-07-27", + "title": "Remote sessions, a destructive-command gate, and a faster desktop preview", + "highlights": [ + "/remote is a new front door for reaching a running session from another machine over the WebSocket gateway", + "Destructive shell commands like recursive deletes now pause for an explicit reflection step before running, closing several bypass routes found in review", + "The desktop preview gained selectable transcript text, an Alt spatial session overview, live reasoning and tool-intent streaming, and much smoother scrolling" + ], + "improvements": [ + "Quality-gate reminders about open todos now arrive once at the end of a turn instead of nagging after every write", + "Interactive model switching works across providers instead of failing when the target model lives on a different provider", + "Reasoning effort options are shown for OpenAI-compatible routes that support them, and token usage is reported for their chat streams", + "Failing OpenAI-compatible model-catalog refreshes back off instead of retrying hot, and each named provider keeps its own model cache", + "A stall watchdog logs long silent hangs during active work so they can be diagnosed", + "The slash-command registry was cleaned up: /keys no longer collides, missing aliases were restored, and help gaps were filled" + ], + "fixes": [ + "Streaming responses no longer lose data when server-sent events split across chunk boundaries", + "A turn that stops expecting a tool call but delivers none now recovers instead of stranding the session", + "Pressing Enter immediately after pasting no longer sends the message prematurely", + "/clear also clears the side panel", + "Copilot retries transient 5xx token-exchange failures and sends the Sonnet reasoning effort correctly", + "Configured context windows are honored instead of being overridden by a GPT fallback", + "MCP servers started for a session now run in that session's working directory", + "agentgrep respects the file field when scoping grep and find", + "Session restore no longer hard-pins the Claude/OpenAI OAuth route, and a failed OpenRouter switch preserves the previous profile", + "Anthropic requests that would end on an assistant turn get a continuation turn instead of erroring", + "A dead terminal no longer mislabels a live session as crashed, and terminal state is restored safely on exit", + "Rendered LaTeX math recovers after its image cache is evicted", + "Quoted shell arguments are preserved on Windows, and the desktop build no longer breaks on Windows over Unix-only IPC" + ] +} diff --git a/changelog/v0.61.1.json b/changelog/v0.61.1.json new file mode 100644 index 0000000000..f03a379cca --- /dev/null +++ b/changelog/v0.61.1.json @@ -0,0 +1,17 @@ +{ + "version": "0.61.1", + "date": "2026-07-28", + "title": "A responsive input line while turns stream", + "highlights": [ + "Typing in the input line stays smooth while a turn is streaming: buffered keystrokes are coalesced into one frame and user input now beats server events to the loop" + ], + "improvements": [ + "Remote-mode clients drain bursts of buffered terminal input (fast typing, key repeat, scroll wheels) into a single render, matching the local loop", + "Both interactive loops poll terminal input ahead of timers and server/bus chatter, so heavy streaming can no longer starve keystrokes", + "Header rebuilds no longer probe credential files on the render thread, removing periodic 40ms+ input stalls", + "The desktop2 preview keeps evolving: a niri-style session overview on Super, a live RAM readout, pinned live tool cards, display-paced animations, and lower per-window memory" + ], + "fixes": [ + "Separator spacing is preserved after LaTeX symbol commands in rendered markdown" + ] +} diff --git a/changelog/v0.64.0.json b/changelog/v0.64.0.json new file mode 100644 index 0000000000..6f0e74317e --- /dev/null +++ b/changelog/v0.64.0.json @@ -0,0 +1,13 @@ +{ + "version": "0.64.0", + "date": "2026-07-30", + "title": "View-only clear and cross-session prompt history", + "highlights": [ + "Cmd+K (or /cls) clears the chat view while the model keeps its full context, unlike /clear which starts a fresh session", + "Ctrl+R opens a reverse search over your prompt history across all sessions" + ], + "improvements": [ + "/cls and /clear-view are listed in /help and command suggestions, and Cmd+Shift+K stays reserved for line scrolling" + ], + "fixes": [] +} diff --git a/changelog/v0.64.1.json b/changelog/v0.64.1.json new file mode 100644 index 0000000000..9072bd4e76 --- /dev/null +++ b/changelog/v0.64.1.json @@ -0,0 +1,13 @@ +{ + "version": "0.64.1", + "date": "2026-07-30", + "title": "View clear moves to Ctrl+L", + "highlights": [ + "The view-only clear now lives on Ctrl+L (terminal-style) instead of Cmd+K, which collided with Cmd+J/K prompt navigation on macOS" + ], + "improvements": [ + "Ctrl+L and /cls keep queued messages as well as context; only the rendered transcript is wiped", + "While a diagram or diff pane is available, Ctrl+L still focuses the pane as before" + ], + "fixes": [] +} diff --git a/changelog/v0.64.2.json b/changelog/v0.64.2.json new file mode 100644 index 0000000000..bfc11259c1 --- /dev/null +++ b/changelog/v0.64.2.json @@ -0,0 +1,15 @@ +{ + "version": "0.64.2", + "date": "2026-07-30", + "title": "Idle animation off for everyone", + "highlights": [ + "The decorative idle animation is now turned off for all users, including existing configs, via a one-time migration; re-enable it anytime with display.idle_animation = true" + ], + "improvements": [ + "Ctrl+R reverse history search now behaves readline-style", + "H1/H2 markdown headings render visually larger via underline" + ], + "fixes": [ + "Desktop: messages typed mid-turn are queued instead of being dropped with 'already processing'" + ] +} diff --git a/changelog/v0.65.0.json b/changelog/v0.65.0.json new file mode 100644 index 0000000000..ed6c18c3c9 --- /dev/null +++ b/changelog/v0.65.0.json @@ -0,0 +1,34 @@ +{ + "version": "0.65.0", + "date": "2026-08-02", + "title": "Seamless self-update, calmer TUI", + "highlights": [ + "Self-update is now seamless: a live progress bar during download and a graceful in-place reload when it finishes", + "Todos you are working on stay pinned in a band at the top of the viewport while you scroll", + "Ctrl+L is a true terminal-style clear: the screen blanks, history stays above, and the prompt sits at the top" + ], + "improvements": [ + "New display.external_sessions setting hides other CLIs' sessions from the session picker", + "The swarm gallery and snapshots now show which provider and auth route each agent is using", + "Markdown tables honour column alignment in every renderer, and copying a table gives you clean text", + "Inline math stays inline in image mode and blends with the surrounding prose colour", + "The discover_tools browse card is now a single compact line, and the sponsored-discovery notice is gone", + "Tool descriptions and parameter docs are capped, leaving more of the context window for your work", + "Ollama reports the context window it is actually serving instead of the trained window", + "Crash-resume hints now point at the session picker" + ], + "fixes": [ + "Custom OpenAI-compatible profile models route to their own profile instead of Copilot", + "OpenAI tool catalogs no longer break on MCP tools with untyped or unsupported schema properties", + "Ctrl+D forward-deletes mid-edit instead of quitting", + "Copying on X11 uses xclip/xsel so the selection survives after jcode exits", + "One bad value in [display] no longer discards the rest of config.toml", + "Reasoning text with multi-byte characters no longer crashes the TUI", + "Cursor provider uses the correct regional agent host instead of hardcoding global", + "Anthropic reasoning effort chosen with Ctrl+O in /model now persists", + "launch_hotkeys enabled = false is honoured on macOS", + "The API key login prompt visibly responds and tells you when a key already exists", + "Desktop no longer shows a false 'update ready' banner on dev builds", + "Builds on musl and Termux/aarch64 targets are fixed" + ] +} diff --git a/changelog/v0.66.0.json b/changelog/v0.66.0.json new file mode 100644 index 0000000000..4db098feb2 --- /dev/null +++ b/changelog/v0.66.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.66.0", + "date": "2026-08-03", + "title": "Build on jcode", + "highlights": [ + "A production-ready TypeScript SDK and Rust SDK can now launch isolated jcode agents or connect to a running instance", + "SDK clients can stream turns, request validated structured output, inspect models and providers, manage session retention, search project files, and subscribe to events", + "The desktop app can resume stored sessions and paste clipboard images directly" + ], + "improvements": [ + "Desktop scrolling is faster and smoother across mouse wheels, trackpads, and keyboard controls", + "Desktop settings expose reasoning display and copy-on-select controls", + "Ctrl+L now behaves like a terminal clear while keeping earlier history available above the viewport", + "Tool output limits prevent oversized command results from overwhelming agent context" + ], + "fixes": [ + "Private SDK instances inherit logins safely, isolate sessions, and clean up their processes and state on close or crash", + "Desktop reconnects now recover their retry timing and clearly report successful reconnection", + "Authentication guidance no longer suggests stale static API models", + "Session cancellation remains correct after a session rename" + ] +} diff --git a/changelog/v0.67.0.json b/changelog/v0.67.0.json new file mode 100644 index 0000000000..67f31f2430 --- /dev/null +++ b/changelog/v0.67.0.json @@ -0,0 +1,21 @@ +{ + "version": "0.67.0", + "date": "2026-08-03", + "title": "Richer SDK and desktop workflows", + "highlights": [ + "The Rust SDK now supports owned launches, global lifecycle events, schema-validated structured runs, and runtime file management", + "The new desktop experience adds multi-session workspace navigation, native math typesetting, persistent plans, richer settings and model selection, and image previews", + "Headed terminal spawns now integrate with Herdr for reliable visible agent sessions" + ], + "improvements": [ + "Todo plans now use semantic quality assessments and continue iterating when evidence shows meaningful work remains", + "Rust and TypeScript SDK capabilities are kept in parity with clearer lifecycle behavior", + "Desktop self-development builds can relaunch registered desktop instances automatically" + ], + "fixes": [ + "Restored desktop edit cards preserve their file names", + "Desktop errors render as distinct red cards", + "Todo assessment changes and low-confidence completion notices render correctly in the terminal", + "Activating a Jcode account now refreshes available models automatically" + ] +} diff --git a/changelog/v0.67.1.json b/changelog/v0.67.1.json new file mode 100644 index 0000000000..d8283539f1 --- /dev/null +++ b/changelog/v0.67.1.json @@ -0,0 +1,13 @@ +{ + "version": "0.67.1", + "date": "2026-08-03", + "title": "Provider reliability fixes", + "improvements": [ + "Anthropic usage now shows model-specific weekly limits alongside account-wide windows" + ], + "fixes": [ + "Gemini tool schemas are sanitized for provider compatibility", + "MCP notifications no longer cause request-handling failures", + "Completed desktop todo items render without a duplicate marker glyph" + ] +} diff --git a/changelog/v0.68.0.json b/changelog/v0.68.0.json new file mode 100644 index 0000000000..17d999902e --- /dev/null +++ b/changelog/v0.68.0.json @@ -0,0 +1,26 @@ +{ + "version": "0.68.0", + "date": "2026-08-05", + "title": "Smarter provider compatibility and cloud onboarding", + "highlights": [ + "Tool schemas now adapt to each provider automatically and recover from newly reported schema incompatibilities", + "Managed cloud activation is now the default remote setup flow", + "The TypeScript SDK now bundles platform-specific jcode runtimes for easier installation" + ], + "improvements": [ + "Integration discovery can record intentional off-catalog selections", + "Client hooks can now be composed", + "Todo cards remain readable at narrow window widths", + "LaTeX rendering uses consistent high-contrast colors" + ], + "fixes": [ + "Claude MCP configuration now stays live, loads project settings from the working directory, and expands environment variables", + "Provider credentials are no longer inherited by MCP servers unless configured explicitly", + "ACP resumes subscribe before attaching so early events are not missed", + "Favorite-model cycling now visits every configured favorite", + "Linux power inhibition no longer loops on authorization failures", + "Desktop session previews no longer interfere with live turns, and overview scrolling stops cleanly", + "macOS notification clicks now route to the correct session", + "Anthropic quota failures reroute to an available fallback model" + ] +} diff --git a/changelog/v0.69.0.json b/changelog/v0.69.0.json new file mode 100644 index 0000000000..5b6f502083 --- /dev/null +++ b/changelog/v0.69.0.json @@ -0,0 +1,27 @@ +{ + "version": "0.69.0", + "date": "2026-08-06", + "title": "ACP controls and smoother hosted access", + "highlights": [ + "ACP clients can now select models and reasoning effort, receive token usage, and update session configuration", + "Hosted subscriptions now use metered billing with a simpler onboarding experience", + "macOS turn notifications now use a dedicated broker for more reliable delivery and session focus" + ], + "improvements": [ + "You can replace jcode's base system prompt with .jcode/system-prompt.md", + "Composer Up and Down navigation now follows visually wrapped rows", + "Keybinding changes take effect on the next keystroke without restarting", + "Config file edits now explain what changed and whether a restart is required", + "Client-scoped hooks can now run multiple commands", + "Ctrl+L now provides a true terminal-style clear while preserving scrollback" + ], + "fixes": [ + "Explicitly resumed TUI sessions now replay their history", + "Text-only provider requests no longer include unsupported image content", + "Duplicate tool results can no longer wedge a session", + "Gemini and Antigravity credentials no longer appear expired when refresh succeeds", + "Todo auto-poke cycles avoid repeated unchanged prompts and resume correctly", + "Model and provider pickers no longer show duplicate compatibility or discovery labels", + "OpenAI-compatible MCP tools accept untyped object properties without breaking the tool catalog" + ] +} diff --git a/changelog/v0.70.0.json b/changelog/v0.70.0.json new file mode 100644 index 0000000000..b7510e068f --- /dev/null +++ b/changelog/v0.70.0.json @@ -0,0 +1,18 @@ +{ + "version": "0.70.0", + "date": "2026-08-06", + "title": "Terminal-aware forks and an inline model picker", + "highlights": [ + "Forked sessions now follow your active terminal environment, opening Ghostty tabs, tmux or Zellij panes, GNU Screen windows, and appropriate platform terminals", + "The desktop model picker now opens as an animated inline transcript chooser with keyboard navigation" + ], + "improvements": [ + "Client-scoped hooks can run multiple commands while preserving the requesting terminal context", + "Tool-heavy turns receive clearer guidance to batch independent operations" + ], + "fixes": [ + "Ghostty forks fall back to a new window when macOS tab automation is unavailable instead of silently disappearing", + "Synthetic provider-recovery instructions no longer appear as user prompts in transcripts", + "Todo follow-up checks now use more targeted and neutral prompts" + ] +} diff --git a/changelog/v0.70.1.json b/changelog/v0.70.1.json new file mode 100644 index 0000000000..9cc216451f --- /dev/null +++ b/changelog/v0.70.1.json @@ -0,0 +1,8 @@ +{ + "version": "0.70.1", + "date": "2026-08-06", + "title": "Release validation follow-up", + "improvements": [ + "The inline desktop model picker release now passes repository formatting validation" + ] +} diff --git a/changelog/v0.71.0.json b/changelog/v0.71.0.json new file mode 100644 index 0000000000..ca9321535a --- /dev/null +++ b/changelog/v0.71.0.json @@ -0,0 +1,20 @@ +{ + "version": "0.71.0", + "date": "2026-08-06", + "title": "Subscription onboarding and broader model support", + "highlights": [ + "Onboarding now defaults to the Jcode subscription, with clearer hosted-model pricing and a direct path to subscription details", + "OpenAI users can choose the full GPT-5.6 family, including Terra, and Meta Model API users can configure Muse and DeepSeek passback models" + ], + "improvements": [ + "Todo quality feedback now distinguishes synthetic validation and tracks requirement-to-check traceability", + "Cargo commands report action durations to make slow build and test steps easier to identify" + ], + "fixes": [ + "Scheduled turns no longer leak into user prompt history", + "Empty transcript checkpoints are prevented so session history remains usable", + "Terminal launches fall back cleanly when spawn hooks reject a launch", + "The TypeScript SDK waits for daemon registration before closing launched sessions", + "Permanent memory sidecar failures are distinguished from transient failures and use the current Claude model" + ] +} diff --git a/changelog/v0.71.1.json b/changelog/v0.71.1.json new file mode 100644 index 0000000000..ba66a9e124 --- /dev/null +++ b/changelog/v0.71.1.json @@ -0,0 +1,16 @@ +{ + "version": "0.71.1", + "date": "2026-08-08", + "title": "TypeScript SDK reliability and runtime refresh", + "highlights": [ + "The TypeScript SDK now ships current jcode runtimes instead of the v0.67.1 runtime", + "Launched SDK sessions shut down reliably even when daemon registration is delayed" + ], + "improvements": [ + "Platform runtime packages are refreshed together at SDK version 1.2.0" + ], + "fixes": [ + "Linux ARM64 release builds use the platform-native C character type for terminal lookup", + "Anthropic OAuth sessions use a runtime that tolerates unavailable usage reporting while preserving inference" + ] +} diff --git a/changelog/v0.73.0.json b/changelog/v0.73.0.json new file mode 100644 index 0000000000..456b4eef8d --- /dev/null +++ b/changelog/v0.73.0.json @@ -0,0 +1,19 @@ +{ + "version": "0.73.0", + "date": "2026-08-09", + "title": "Focused desktop sessions and provider reliability", + "highlights": [ + "Desktop session panels can expand the focused session for more working space" + ], + "improvements": [ + "Switching models preserves the active catalog profile", + "Tab focus changes avoid unnecessary full terminal repaints", + "Missing swarm server errors now explain how to restore the connection" + ], + "fixes": [ + "OpenAI model responses no longer duplicate thinking text", + "GitHub Copilot tool schemas are normalized for reliable tool calls", + "Custom models default to text input unless they explicitly advertise other capabilities", + "Homebrew launchers preserve command-line arguments during startup" + ] +} diff --git a/changelog/v0.74.0.json b/changelog/v0.74.0.json new file mode 100644 index 0000000000..03ddb27122 --- /dev/null +++ b/changelog/v0.74.0.json @@ -0,0 +1,24 @@ +{ + "version": "0.74.0", + "date": "2026-08-10", + "title": "Better ACP controls and focused session workflows", + "highlights": [ + "ACP clients can switch models and reasoning effort, list available models, and use model control slash commands", + "Jcode can search version-matched documentation bundled with the installed build", + "The session picker can filter sessions to the current working directory" + ], + "improvements": [ + "Session todos are pinned by default without being duplicated in the transcript or info widgets", + "Config and swarm prompt editors now temporarily hand off terminal control and restore the TUI after exit", + "Independent root sessions in the same repository now keep separate swarm plans while spawned workers remain attached to their parent swarm" + ], + "fixes": [ + "Headed forks reliably submit their staged startup prompt after subscribing", + "ACP tool policies that enable MCP now include tools dynamically exposed by configured MCP servers", + "Desktop new-session transitions no longer stall while establishing a live connection", + "Explicit OpenRouter provider selections remain pinned", + "Nested shell commands and concrete external paths receive more accurate command-risk classification", + "Imitated Antigravity tool calls are rejected instead of being treated as genuine tool requests", + "Ollama cloud models use their reported context metadata" + ] +} diff --git a/changelog/v0.75.0.json b/changelog/v0.75.0.json new file mode 100644 index 0000000000..0b798309e3 --- /dev/null +++ b/changelog/v0.75.0.json @@ -0,0 +1,23 @@ +{ + "version": "0.75.0", + "date": "2026-08-10", + "title": "A more capable desktop workspace", + "highlights": [ + "The desktop app now includes a project file explorer and an in-app help overlay", + "Desktop sessions open as spatial panels with familiar new-tab shortcuts and animated navigation", + "The desktop window can hot-reload app code without being recreated" + ], + "improvements": [ + "Desktop reasoning now shows the full thought history by default", + "Desktop resume navigation supports Vim-style keyboard chords", + "Desktop activity appears immediately while a response is starting" + ], + "fixes": [ + "Desktop windows remain visible and reconnect cleanly across daemon and app reloads", + "Desktop session polling no longer interferes with live requests", + "Adjacent diff rows no longer show hairline gaps at fractional display scales", + "Explicit provider routes remain pinned when selected", + "Shifted punctuation and meta new-session shortcuts work consistently in the TUI", + "ACP clients enforce dynamic MCP tool policy" + ] +} diff --git a/changelog/v0.75.1.json b/changelog/v0.75.1.json new file mode 100644 index 0000000000..dcd281bba9 --- /dev/null +++ b/changelog/v0.75.1.json @@ -0,0 +1,18 @@ +{ + "version": "0.75.1", + "date": "2026-08-11", + "title": "More reliable autonomous runs", + "highlights": [ + "Todo completion synonyms such as done and finished no longer trigger false auto-poke loops", + "Grok Build is available as an ACP provider" + ], + "improvements": [ + "Todo status values are documented and constrained in the tool schema", + "Desktop and SDK clients expose provider request lifecycle status" + ], + "fixes": [ + "Todo statuses are normalized on write and compared case-insensitively during headless run completion checks", + "Codex quota windows no longer appear more than once", + "Pinned todo configuration tests no longer leak process-global configuration" + ] +} diff --git a/changelog/v0.75.2.json b/changelog/v0.75.2.json new file mode 100644 index 0000000000..d49ab7cd7d --- /dev/null +++ b/changelog/v0.75.2.json @@ -0,0 +1,14 @@ +{ + "version": "0.75.2", + "date": "2026-08-11", + "title": "Strict todo status validation", + "highlights": [ + "The todo tool now rejects unknown status values instead of storing them silently" + ], + "improvements": [ + "Invalid status errors list the accepted pending, in_progress, completed, and cancelled values" + ], + "fixes": [ + "Unknown model-written status strings can no longer leave todo completion behavior ambiguous" + ] +} diff --git a/changelog/v0.75.3.json b/changelog/v0.75.3.json new file mode 100644 index 0000000000..c51f859f40 --- /dev/null +++ b/changelog/v0.75.3.json @@ -0,0 +1,14 @@ +{ + "version": "0.75.3", + "date": "2026-08-11", + "title": "Reliable streams and desktop session overview", + "highlights": [ + "Desktop sessions now include a restored Super-key overview with clickable session navigation" + ], + "improvements": [ + "The desktop overview shortcut works safely across compositors" + ], + "fixes": [ + "Transient stream_read_error failures are now recognized as retryable transport errors" + ] +} diff --git a/changelog/v0.75.4.json b/changelog/v0.75.4.json new file mode 100644 index 0000000000..80b2d772f2 --- /dev/null +++ b/changelog/v0.75.4.json @@ -0,0 +1,19 @@ +{ + "version": "0.75.4", + "date": "2026-08-12", + "title": "More reliable remote sessions, providers, and automation", + "highlights": [ + "Remote sessions now preserve active skills and recover cleanly after clearing session state", + "OpenAI-compatible and OpenRouter streams handle transient failures and fallback tool calls more reliably" + ], + "improvements": [ + "ACP sessions tolerate host-provided MCP servers and report turn token usage", + "Shifted keyboard symbols report their alternate keys consistently", + "Development builds choose safer job counts based on available memory" + ], + "fixes": [ + "Background commands no longer inherit stdin unexpectedly", + "Telemetry respects opt-out before install events and isolates tests from user configuration", + "Fallback tool-call identifiers remain unique when providers omit IDs" + ] +} diff --git a/changelog/v0.75.5.json b/changelog/v0.75.5.json new file mode 100644 index 0000000000..4985d49946 --- /dev/null +++ b/changelog/v0.75.5.json @@ -0,0 +1,20 @@ +{ + "version": "0.75.5", + "date": "2026-08-12", + "title": "Reliable remote sessions, providers, and release builds", + "highlights": [ + "Remote sessions now preserve active skills and recover cleanly after clearing session state", + "OpenAI-compatible and OpenRouter streams handle transient failures and fallback tool calls more reliably" + ], + "improvements": [ + "ACP sessions tolerate host-provided MCP servers and report turn token usage", + "Shifted keyboard symbols report their alternate keys consistently", + "Development builds choose safer job counts based on available memory" + ], + "fixes": [ + "Background commands no longer inherit stdin unexpectedly", + "Telemetry respects opt-out before install events and isolates tests from user configuration", + "Fallback tool-call identifiers remain unique when providers omit IDs", + "ACP prompt requests now compile correctly in optimized cross-platform release builds" + ] +} diff --git a/changelog/v0.76.0.json b/changelog/v0.76.0.json new file mode 100644 index 0000000000..6eddfa64dd --- /dev/null +++ b/changelog/v0.76.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.76.0", + "date": "2026-08-14", + "title": "Transcript privacy and broader provider support", + "highlights": [ + "Opt-in transcript telemetry now supports privacy-preserving collection with automatic secret redaction", + "Anthropic-compatible provider profiles can now connect to additional compatible services", + "Grok Build login can now be completed directly inside the TUI" + ], + "improvements": [ + "Startup update checks can now be disabled in configuration", + "Transient provider retries are more resilient and configurable", + "Z.AI Coding Plan supports reasoning effort controls and safely handles text-only models" + ], + "fixes": [ + "OpenRouter responses preserve tool outputs even when the corresponding tool call is unavailable", + "Prompt files are deduplicated and oversized skill context is clipped", + "Repeated paste placeholders expand correctly", + "Self-development builds promote artifacts to the correct paths", + "Grok model lists refresh after login and use the current managed backend" + ] +} diff --git a/changelog/v0.77.0.json b/changelog/v0.77.0.json new file mode 100644 index 0000000000..dbeabd6ff7 --- /dev/null +++ b/changelog/v0.77.0.json @@ -0,0 +1,26 @@ +{ + "version": "0.77.0", + "date": "2026-08-17", + "title": "Background visibility and expanded authentication", + "highlights": [ + "Background tasks now appear in the pinned status band, report intermediate progress reliably, and wake stalled agents automatically", + "Cursor and Grok Build now support native authentication flows, with OrcaRouter available as a provider profile", + "Duplicate provider accounts now receive memorable animal names in the account picker" + ], + "improvements": [ + "Todo intent and understanding are shown inline with clearer status colors and expandable pinned details", + "Pinned todo, copy, and edit-expand controls can now be activated with the mouse", + "Session search finds late transcript content and preserves longer search prefixes", + "The SDK now exposes persisted session titles and reasoning-effort updates", + "Bash output is collapsed by default with a cleaner presentation", + "Users can submit privacy-conscious maintainer feedback after explicit consent" + ], + "fixes": [ + "Pasted content remains visible after sending", + "Hyphenated MCP tool names dispatch correctly", + "Repeated OpenRouter tool outputs are preserved", + "Provider selection honors an explicitly requested CLI provider", + "Setup hotkey uninstall actions are honored", + "macOS swarm Option shortcuts work correctly" + ] +} diff --git a/changelog/v0.77.1.json b/changelog/v0.77.1.json new file mode 100644 index 0000000000..6a6743ff96 --- /dev/null +++ b/changelog/v0.77.1.json @@ -0,0 +1,7 @@ +{ + "version": "0.77.1", + "date": "2026-08-17", + "fixes": [ + "The remote release command now detects the current repository and follows its own release conventions instead of assuming Jcode-specific tooling" + ] +} diff --git a/changelog/v0.77.2.json b/changelog/v0.77.2.json new file mode 100644 index 0000000000..cd51675abc --- /dev/null +++ b/changelog/v0.77.2.json @@ -0,0 +1,13 @@ +{ + "version": "0.77.2", + "date": "2026-08-18", + "improvements": [ + "Inline diff previews now show the affected file path for clearer review context" + ], + "fixes": [ + "One-shot sessions now close automatically after completing their response", + "Ambient launches now fall back gracefully when opening a visible terminal fails", + "Spawned agents keep their prompt when an explicitly blank initial message is supplied", + "MiniMax authentication now uses the correct API key variable while preserving existing credentials" + ] +} diff --git a/changelog/v0.78.0.json b/changelog/v0.78.0.json new file mode 100644 index 0000000000..05e7b5a9fa --- /dev/null +++ b/changelog/v0.78.0.json @@ -0,0 +1,13 @@ +{ + "version": "0.78.0", + "date": "2026-08-18", + "highlights": [ + "Harness API and SDK clients can now receive images embedded in transcript messages" + ], + "improvements": [ + "Todo quality checks now give shorter, clearer guidance and avoid repeatedly blocking final responses" + ], + "fixes": [ + "Completed hook observers are now cleaned up reliably instead of accumulating over time" + ] +} diff --git a/changelog/v0.78.1.json b/changelog/v0.78.1.json new file mode 100644 index 0000000000..a211052ba2 --- /dev/null +++ b/changelog/v0.78.1.json @@ -0,0 +1,16 @@ +{ + "version": "0.78.1", + "date": "2026-08-19", + "highlights": [ + "Git integrations can now be discovered through the tool catalog" + ], + "improvements": [ + "Session discovery is faster and bounded for dashboard clients" + ], + "fixes": [ + "Pinned background tasks now stay limited to the two most recently active tasks", + "Station sessions now use the correct model status routing", + "Anthropic catalog probes now send the required provider headers", + "Command safety checks no longer flag inert heredoc payloads" + ] +} diff --git a/changelog/v0.79.0.json b/changelog/v0.79.0.json new file mode 100644 index 0000000000..2de6db319d --- /dev/null +++ b/changelog/v0.79.0.json @@ -0,0 +1,16 @@ +{ + "version": "0.79.0", + "date": "2026-08-21", + "highlights": [ + "Large MCP tool catalogs can now be deferred automatically to keep agent context compact", + "Recent session lists now use a durable metadata index for faster dashboard loading" + ], + "improvements": [ + "Background commands promoted after a timeout now report live progress" + ], + "fixes": [ + "Remote TUI sessions now dispatch productivity results and handle Alt-key toggles correctly", + "Memory sidecars now preserve provider routing while omitting unnecessary metadata", + "Invalid CI telemetry overrides now fall back safely" + ] +} diff --git a/changelog/v0.79.1.json b/changelog/v0.79.1.json new file mode 100644 index 0000000000..456aed19e8 --- /dev/null +++ b/changelog/v0.79.1.json @@ -0,0 +1,16 @@ +{ + "version": "0.79.1", + "date": "2026-08-21", + "highlights": [ + "Large MCP tool catalogs can now be deferred automatically to keep agent context compact", + "Recent session lists now use a durable metadata index for faster dashboard loading" + ], + "improvements": [ + "Background commands promoted after a timeout now report live progress" + ], + "fixes": [ + "Remote TUI sessions now dispatch productivity results and handle Alt-key toggles correctly", + "Memory sidecars now preserve provider routing while omitting unnecessary metadata", + "Invalid CI telemetry overrides now fall back safely" + ] +} diff --git a/changelog/v0.80.0.json b/changelog/v0.80.0.json new file mode 100644 index 0000000000..878cbb3b0e --- /dev/null +++ b/changelog/v0.80.0.json @@ -0,0 +1,20 @@ +{ + "version": "0.80.0", + "date": "2026-08-24", + "highlights": [ + "Integration discovery can now investigate a candidate's capabilities, compatibility, setup, pricing, and limitations before selecting it", + "Subagent models can now be chosen from the full model picker and stay synchronized across remote sessions", + "Model thinking is now shown by default" + ], + "improvements": [ + "Onboarding offers clearer project choices and supports rotating choices from any key", + "Gmail replies now preserve thread context from message IDs and correctly encode non-ASCII subjects", + "Session context now reports the local date, time, and timezone", + "The todo side panel is more compact" + ], + "fixes": [ + "Subagent model selection is preserved when the model catalog refreshes", + "Configured default models are preserved after authentication", + "OpenAI reasoning replay no longer sends output-only status fields" + ] +} diff --git a/changelog/v0.80.1.json b/changelog/v0.80.1.json new file mode 100644 index 0000000000..78ce978b6c --- /dev/null +++ b/changelog/v0.80.1.json @@ -0,0 +1,11 @@ +{ + "version": "0.80.1", + "date": "2026-08-25", + "highlights": [], + "improvements": [ + "Custom OpenAI-compatible providers can configure reasoning capability and default effort per model" + ], + "fixes": [ + "Custom gateways can disable model-name reasoning heuristics that caused unsupported reasoning_effort parameters to be sent" + ] +} diff --git a/changelog/v0.81.0.json b/changelog/v0.81.0.json new file mode 100644 index 0000000000..54df08d870 --- /dev/null +++ b/changelog/v0.81.0.json @@ -0,0 +1,18 @@ +{ + "version": "0.81.0", + "date": "2026-08-25", + "title": "Embedder control", + "highlights": [ + "Headless embedders can use external wake mode to receive typed wake requests without the daemon starting invisible turns", + "Operators can pin one model and authentication route for every spawned swarm worker" + ], + "improvements": [ + "The Rust and TypeScript SDKs can launch isolated jcode runtimes on Windows and configure swarm models and wake behavior", + "The SDK now launches private API bridges through the supported jcode CLI entry point", + "Remote model pickers use the daemon-provided route catalog without unnecessary network refreshes" + ], + "fixes": [ + "Invalid configuration files are preserved when settings change instead of being overwritten", + "Mistral reasoning effort is normalized to the supported maximum" + ] +} diff --git a/changelog/v0.81.1.json b/changelog/v0.81.1.json new file mode 100644 index 0000000000..122e1525be --- /dev/null +++ b/changelog/v0.81.1.json @@ -0,0 +1,7 @@ +{ + "version": "0.81.1", + "date": "2026-08-25", + "fixes": [ + "Desktop-owned sessions are marked as crashed when their client disappears unexpectedly, while deliberate detaches remain clean" + ] +} diff --git a/changelog/v0.81.2.json b/changelog/v0.81.2.json new file mode 100644 index 0000000000..226be54405 --- /dev/null +++ b/changelog/v0.81.2.json @@ -0,0 +1,9 @@ +{ + "version": "0.81.2", + "date": "2026-08-28", + "fixes": [ + "Inline Mermaid diagrams and raster images now cycle only through visibly distinct sizes when clicked", + "Resized Mermaid diagrams invalidate cached message geometry and no longer leave blank placeholder space underneath", + "Clicking an inline image copies its pixels, while clicking a Mermaid diagram copies its editable source code" + ] +} diff --git a/changelog/v0.81.3.json b/changelog/v0.81.3.json new file mode 100644 index 0000000000..fdafc219a5 --- /dev/null +++ b/changelog/v0.81.3.json @@ -0,0 +1,12 @@ +{ + "version": "0.81.3", + "date": "2026-08-29", + "improvements": [ + "Active tool details are now emphasized in the running status line for faster progress scanning", + "Selected text remains clearly visible while copying from the terminal" + ], + "fixes": [ + "Session attachments are correlated to the correct connection when multiple clients connect concurrently", + "Closing a macOS terminal window no longer leaves its jcode session running unexpectedly" + ] +} diff --git a/changelog/v0.81.4.json b/changelog/v0.81.4.json new file mode 100644 index 0000000000..29dbdac819 --- /dev/null +++ b/changelog/v0.81.4.json @@ -0,0 +1,7 @@ +{ + "version": "0.81.4", + "date": "2026-08-30", + "improvements": [ + "Internal reliability and performance work" + ] +} diff --git a/changelog/v0.81.5.json b/changelog/v0.81.5.json new file mode 100644 index 0000000000..89b9f820e0 --- /dev/null +++ b/changelog/v0.81.5.json @@ -0,0 +1,19 @@ +{ + "version": "0.81.5", + "date": "2026-09-03", + "features": [ + "Add claude-fable-5-1 to the direct Anthropic catalog", + "Add agents.swarm_effort config pin and JCODE_SWARM_EFFORT override; show worker effort in swarm list" + ], + "fixes": [ + "Persist sessions created with a title before the first visible message", + "Hold the idle-agent reservation through the wake turn and terminal status fanout", + "Price OpenRouter @endpoint-pinned models from the pinned endpoint", + "Accept disabled failover aliases", + "Clear inline images when the session is cleared", + "Isolate sandboxed homes from the macOS Keychain", + "Preserve macOS Ctrl+5 prompt jump", + "Update Claude OAuth client version", + "Fail fast on hard usage-limit exhaustion in auth-test instead of retrying" + ] +} diff --git a/changelog/v0.81.6.json b/changelog/v0.81.6.json new file mode 100644 index 0000000000..ee9baa2fa3 --- /dev/null +++ b/changelog/v0.81.6.json @@ -0,0 +1,8 @@ +{ + "version": "0.81.6", + "date": "2026-09-03", + "features": [], + "fixes": [ + "Send the x-opencode-session header on OpenCode Go/Zen requests (required from 2026-09-05)" + ] +} diff --git a/changelog/v0.81.7.json b/changelog/v0.81.7.json new file mode 100644 index 0000000000..0514007049 --- /dev/null +++ b/changelog/v0.81.7.json @@ -0,0 +1,9 @@ +{ + "version": "0.81.7", + "date": "2026-09-04", + "features": [ + "Add GPT-6 Astra (gpt-6-astra) to the OpenAI model catalog and make it the default OpenAI model", + "Carry attached images into /fork and /btw prompts" + ], + "fixes": [] +} diff --git a/changelog/v0.82.0.json b/changelog/v0.82.0.json new file mode 100644 index 0000000000..e140dd088a --- /dev/null +++ b/changelog/v0.82.0.json @@ -0,0 +1,24 @@ +{ + "version": "0.82.0", + "date": "2026-09-06", + "title": "Faster starts and richer session controls", + "highlights": [ + "OpenAI Responses WebSocket connections now warm up while sessions are idle and before request preparation, reducing time to the first response", + "Click images in the side panel to open an enlarged preview", + "Rust and TypeScript SDKs can include image attachments in soft interrupts, and interrupts sent to idle sessions now start a response immediately" + ], + "improvements": [ + "Swarm workers can use an explicit model override or inherit the coordinator model", + "Configure MCP request timeouts separately for each server with timeout_secs", + "Headless and browser-suppressed login flows display a QR code for signing in on another device" + ], + "fixes": [ + "Reattaching to a session preserves its saved working directory", + "Fast service-tier selections remain visible after reconnecting or refreshing the model catalog", + "Mouse-wheel scrolling works on the hovered diagram without first moving keyboard focus", + "Idle session disconnects are no longer incorrectly marked as crashes", + "Repeated ownership completion checks no longer interrupt unchanged sessions", + "Remote turns report actual context compaction metrics", + "Reduce retained memory from session tool policies, batch tools, and MCP registries" + ] +} diff --git a/changelog/v0.83.0.json b/changelog/v0.83.0.json new file mode 100644 index 0000000000..9dd2c639b7 --- /dev/null +++ b/changelog/v0.83.0.json @@ -0,0 +1,23 @@ +{ + "version": "0.83.0", + "date": "2026-09-06", + "title": "Native SSH sessions and remote login", + "highlights": [ + "Run the local terminal UI against a remote Jcode server over SSH, keeping the workspace, tools, and agent execution on the remote host", + "Explicitly import a local Jcode-managed OpenAI or Claude login to a trusted SSH host after confirmation, without overwriting an existing remote credential store", + "Sign in to Novita with its built-in API-key login option" + ], + "improvements": [ + "Authenticate supported providers on an SSH host from the local TUI with private login prompts and per-attempt cancellation", + "Native SSH sessions opt in to continuing active turns after a connection drops", + "Rust SDK clients can connect to native harnesses through system SSH" + ], + "fixes": [ + "Attaching to unsaved live sessions uses the server's working directory, and brief client reloads preserve idle unsaved sessions", + "Forking an empty live session works and preserves the linked child session", + "Model catalogs retain reasoning effort and clear stale model or provider settings when sessions and providers change", + "SDK session event streams receive runtime details, route availability updates, and connection phases for the correct session", + "Session creation no longer waits for superseded telemetry work", + "Desktop session-launch shortcuts are forwarded correctly under niri" + ] +} diff --git a/changelog/v0.84.0.json b/changelog/v0.84.0.json new file mode 100644 index 0000000000..5bfe82dbec --- /dev/null +++ b/changelog/v0.84.0.json @@ -0,0 +1,16 @@ +{ + "version": "0.84.0", + "date": "2026-09-06", + "title": "Clearer remote login onboarding", + "highlights": [ + "Connecting to an SSH host now asks a plain yes-or-no question before offering to import a local OpenAI or Claude login" + ], + "improvements": [ + "The remote login picker lists the providers the host actually supports, with local imports shown as explicit choices", + "Remote login status is sanitized before display, so host credential details are never echoed back" + ], + "fixes": [ + "Pasted input while choosing a login stays in the active picker instead of leaking into the prompt", + "Remote hosts configured with an unrecognized provider are reported as unknown rather than signed in" + ] +} diff --git a/crates/jcode-agent-runtime/src/lib.rs b/crates/jcode-agent-runtime/src/lib.rs index be70183e94..991536d70d 100644 --- a/crates/jcode-agent-runtime/src/lib.rs +++ b/crates/jcode-agent-runtime/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct SoftInterruptMessage { pub content: String, + pub images: Vec<(String, String)>, /// If true, can skip remaining tools when injected at point C. pub urgent: bool, pub source: SoftInterruptSource, diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index 4dbe659440..d0bdf12b7d 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -61,6 +61,7 @@ jcode-agent-runtime = { path = "../jcode-agent-runtime" } jcode-ambient-types = { path = "../jcode-ambient-types" } jcode-notify-email = { path = "../jcode-notify-email" } jcode-provider-core = { path = "../jcode-provider-core" } +jcode-schema-dialect = { path = "../jcode-schema-dialect" } # NOTE: jcode-app-core does NOT depend on any jcode-tui-* crate. They were # unused dead dependency edges here (the TUI declares them itself). Removing # them stops a jcode-tui-* edit from cascading a recompile through app-core. @@ -86,6 +87,7 @@ jcode-build-meta = { path = "../jcode-build-meta" } # Re-exported via `pub use jcode_base::*` in lib.rs. default-features=false so # this crate controls jcode-base's optional features (see [features] below). jcode-base = { path = "../jcode-base", default-features = false } +jcode-command-risk = { path = "../jcode-command-risk" } jcode-core = { path = "../jcode-core" } jcode-message-types = { path = "../jcode-message-types" } jcode-overnight-core = { path = "../jcode-overnight-core" } @@ -113,6 +115,8 @@ jemalloc-prof = ["jcode-base/jemalloc-prof"] embeddings = ["jcode-base/embeddings"] # live AWS Bedrock support lives in jcode-base (provider/bedrock); forward there. bedrock = ["jcode-base/bedrock"] +# Used only by the CentOS 7 portable Linux release build. +linux-compat-vendored-openssl = ["jcode-notify-email/linux-compat-vendored-openssl"] # PDF text extraction lives in this crate (tool/read.rs). pdf = ["dep:jcode-pdf"] # Compiles this crate's test-only helpers/accessors (e.g. the diff --git a/crates/jcode-app-core/build.rs b/crates/jcode-app-core/build.rs new file mode 100644 index 0000000000..7b208a112d --- /dev/null +++ b/crates/jcode-app-core/build.rs @@ -0,0 +1,44 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo = manifest.join("../.."); + let docs_dir = repo.join("docs"); + println!( + "cargo:rerun-if-changed={}", + repo.join("README.md").display() + ); + println!("cargo:rerun-if-changed={}", docs_dir.display()); + + let mut files = vec![repo.join("README.md")]; + if let Ok(entries) = fs::read_dir(&docs_dir) { + files.extend(entries.flatten().map(|entry| entry.path()).filter(|path| { + path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("md") + })); + } + files.sort(); + + let mut generated = String::from("pub(crate) static JCODE_DOCS: &[(&str, &str)] = &[\n"); + for path in files { + let relative = path + .strip_prefix(&repo) + .expect("documentation is in repository"); + let relative = slash_path(relative); + generated.push_str(&format!( + " ({relative:?}, include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/../../{relative}\"))),\n" + )); + } + generated.push_str("];\n"); + + let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("jcode_docs.rs"); + fs::write(out, generated).expect("write generated Jcode documentation corpus"); +} + +fn slash_path(path: &Path) -> String { + path.components() + .map(|part| part.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 70eb9a1061..6da8bb1ddb 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -5,6 +5,8 @@ mod environment; mod inline_tail; mod interrupts; mod messages; +#[cfg(test)] +mod model_usage_tests; mod prompting; mod provider; mod response_recovery; @@ -186,6 +188,12 @@ pub struct Agent { active_skill: Option, allowed_tools: Option>, disabled_tools: HashSet, + /// Generation-scoped ownership of this Agent's global tool-policy entry. + _tool_policy_registration: crate::tool::SessionToolPolicyRegistration, + /// MCP top-level definition exposure policy captured when the session starts. + mcp_tools_mode: crate::config::McpToolsMode, + /// Auto-mode token estimate above which MCP definitions are deferred. + mcp_tools_token_threshold: usize, /// Provider-specific session ID for conversation resume (e.g., Claude Code CLI session) provider_session_id: Option, /// Last upstream provider (OpenRouter) observed for this session @@ -233,6 +241,9 @@ pub struct Agent { mcp_late_register_resolved: bool, /// Override system prompt (used by ambient mode to inject a custom prompt) system_prompt_override: Option, + /// AGENTS.md is session bootstrap input. Keep the captured text stable so + /// tool writes do not mutate the provider's cacheable prefix mid-session. + agents_md_snapshot: (Option, crate::prompt::ContextInfo), /// Whether memory features are enabled for this session memory_enabled: bool, /// One-step undo snapshot captured before the most recent rewind. @@ -249,9 +260,24 @@ pub struct Agent { /// Persists across turns so the coordinator's viewport never blanks at /// turn boundaries or freezes during long tool calls. inline_tail: inline_tail::InlineTailBuffer, + /// Prevent duplicate content uploads when shutdown/finalization is invoked + /// more than once for the same in-memory agent. + transcript_telemetry_sent: bool, + /// One logical runtime session, independent of the process-global legacy + /// telemetry slot and of any TUI clients viewing this agent. + concurrency_session: Option, } impl Agent { + fn refresh_agents_md_snapshot(&mut self) { + let working_dir = self + .session + .working_dir + .as_deref() + .map(std::path::Path::new); + self.agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir); + } + fn should_track_client_cache(&self) -> bool { match std::env::var("JCODE_TRACK_CLIENT_CACHE") { Ok(value) => { @@ -270,8 +296,16 @@ impl Agent { disabled_tools: HashSet, ) -> Self { let skills = SkillRegistry::shared_snapshot(); + let tool_config = &crate::config::config().tools; + let working_dir = session.working_dir.as_deref().map(std::path::Path::new); + let agents_md_snapshot = crate::prompt::load_agents_md_files_from_dir(working_dir); let initial_provider_model = provider.model(); - let agent = Self { + let tool_policy_registration = crate::tool::register_session_tool_policy( + &session.id, + allowed_tools.clone(), + disabled_tools.clone(), + ); + Self { provider, registry, skills, @@ -279,6 +313,9 @@ impl Agent { active_skill: None, allowed_tools, disabled_tools, + _tool_policy_registration: tool_policy_registration, + mcp_tools_mode: tool_config.mcp_tools, + mcp_tools_token_threshold: tool_config.mcp_tools_token_threshold, provider_session_id: None, last_upstream_provider: None, last_connection_type: None, @@ -296,19 +333,16 @@ impl Agent { locked_tools: None, mcp_late_register_resolved: false, system_prompt_override: None, + agents_md_snapshot, memory_enabled: crate::config::config().features.memory, rewind_undo_snapshot: None, stdin_request_tx: None, provider_runtime_state: ProviderRuntimeState::observed(initial_provider_model), inline_output_tap: false, inline_tail: inline_tail::InlineTailBuffer::default(), - }; - crate::tool::set_session_tool_policy( - &agent.session.id, - agent.allowed_tools.clone(), - agent.disabled_tools.clone(), - ); - agent + transcript_telemetry_sent: false, + concurrency_session: None, + } } fn current_skills_snapshot(&self) -> Arc { @@ -350,8 +384,38 @@ impl Agent { registry: Registry, working_dir: Option<&str>, ) -> Self { + Self::new_with_initial_ownership(provider, registry, working_dir, None, true) + } + + /// A connection may only be a viewer attaching to an existing Agent. + /// Do not count its provisional session before that choice is resolved. + pub(crate) fn new_provisional_with_initial_working_dir( + provider: Arc, + registry: Registry, + working_dir: Option<&str>, + ) -> Self { + Self::new_with_initial_ownership(provider, registry, working_dir, None, false) + } + + pub(crate) fn new_with_parent_and_initial_working_dir( + provider: Arc, + registry: Registry, + working_dir: Option<&str>, + parent_id: Option, + ) -> Self { + Self::new_with_initial_ownership(provider, registry, working_dir, parent_id, true) + } + + fn new_with_initial_ownership( + provider: Arc, + registry: Registry, + working_dir: Option<&str>, + parent_id: Option, + track_concurrency: bool, + ) -> Self { + let start = Instant::now(); let tool_selection = crate::config::config().tools.selection(); - let mut session = Session::create(None, None); + let mut session = Session::create(parent_id, None); if let Some(working_dir) = working_dir { session.working_dir = Some(working_dir.to_string()); } @@ -363,19 +427,30 @@ impl Agent { tool_selection.disabled_tools, ); agent.session.mark_active(); - agent.session.model = Some(agent.provider.model()); - agent.session.provider_key = - crate::session::derive_session_provider_key(agent.provider.name()); + agent.session.model = Some(agent.provider_model()); + agent.session.provider_key = agent.provider_key_for_new_session(); + agent.reconcile_explicit_provider_pin_route(); agent.session.ensure_initial_session_context_message(); agent.seed_compaction_from_session(); agent.log_env_snapshot("create"); agent.fire_session_lifecycle_hook("session_start", "create"); + if track_concurrency { + agent.activate_concurrency_tracking(); + } + let setup_ms = start.elapsed().as_millis(); + let telemetry_start = Instant::now(); crate::telemetry::begin_session_with_parent( agent.provider.name(), &agent.provider.model(), agent.session.parent_id.clone(), false, ); + logging::info(&format!( + "[TIMING] agent_new: setup={}ms, telemetry={}ms, total={}ms", + setup_ms, + telemetry_start.elapsed().as_millis(), + start.elapsed().as_millis(), + )); agent } @@ -402,8 +477,7 @@ impl Agent { ); agent.session.mark_active(); if agent.session.provider_key.is_none() { - agent.session.provider_key = - crate::session::derive_session_provider_key(agent.provider.name()); + agent.session.provider_key = agent.provider_key_for_new_session(); } if let Some(model) = agent.session.model.clone() { let model_request = @@ -420,9 +494,11 @@ impl Agent { "Failed to restore session model '{}' via '{}': {}", model, model_request, e )); + } else { + agent.reconcile_explicit_provider_pin_route(); } } else { - agent.session.model = Some(agent.provider.model()); + agent.session.model = Some(agent.provider_model()); } agent.restore_reasoning_effort_from_session(); agent.session.ensure_initial_session_context_message(); @@ -430,6 +506,7 @@ impl Agent { agent.seed_compaction_from_session(); agent.log_env_snapshot("attach"); agent.fire_session_lifecycle_hook("session_start", "attach"); + agent.begin_concurrency_tracking(); crate::telemetry::begin_session_with_parent( agent.provider.name(), &agent.provider.model(), @@ -569,6 +646,17 @@ impl Agent { self.rewind_undo_snapshot = None; } + /// Synchronize the remote client's selected skill, accepting only names + /// present in the daemon's own registry snapshot. + pub(super) fn set_remote_active_skill(&mut self, active_skill: Option) -> bool { + let skills = self.current_skills_snapshot(); + let recognized = active_skill + .as_ref() + .is_none_or(|name| skills.get(name).is_some()); + self.active_skill = active_skill.filter(|name| skills.get(name).is_some()); + recognized + } + fn sync_session_compaction_state_from_manager( &mut self, manager: &crate::compaction::CompactionManager, @@ -803,9 +891,20 @@ impl Agent { let mut missing_for_message = Vec::new(); for id in tool_uses { self.tool_call_ids.insert(id.clone()); - if !self.tool_result_ids.contains(&id) { - missing_for_message.push(id); + if self.tool_result_ids.contains(&id) { + continue; + } + // A tool that is still executing is not an interrupted tool: + // its real result is on the way, and synthesizing a + // placeholder now produces a duplicate tool_result that + // Anthropic rejects outright. See `tool::inflight`. + if crate::tool::inflight::is_tool_in_flight(&id) { + logging::info(&format!( + "Skipping missing tool-output repair for {id}: tool is still executing" + )); + continue; } + missing_for_message.push(id); } if !missing_for_message.is_empty() { missing_repairs.push((index, missing_for_message)); @@ -871,16 +970,18 @@ impl Agent { /// Mark this agent session as closed and persist it. pub fn mark_closed(&mut self) { - crate::telemetry::end_session_with_reason( - self.provider.name(), - &self.provider.model(), - crate::telemetry::SessionEndReason::NormalExit, - ); + self.finish_concurrency_tracking(); self.persist_soft_interrupt_snapshot(); self.session.mark_closed(); if !self.session.messages.is_empty() { self.persist_session_best_effort("session close state"); } + self.upload_transcript_telemetry(crate::telemetry::SessionEndReason::NormalExit); + crate::telemetry::end_session_with_reason( + self.provider.name(), + &self.provider.model(), + crate::telemetry::SessionEndReason::NormalExit, + ); self.fire_session_lifecycle_hook("session_end", "close"); } @@ -901,15 +1002,69 @@ impl Agent { } pub fn mark_crashed(&mut self, message: Option) { + self.finish_concurrency_tracking(); + self.persist_soft_interrupt_snapshot(); + self.session.mark_crashed(message); + if !self.session.messages.is_empty() { + self.persist_session_best_effort("session crash state"); + } + self.upload_transcript_telemetry(crate::telemetry::SessionEndReason::Unknown); crate::telemetry::record_crash( self.provider.name(), &self.provider.model(), crate::telemetry::SessionEndReason::Unknown, ); - self.persist_soft_interrupt_snapshot(); - self.session.mark_crashed(message); - if !self.session.messages.is_empty() { - self.persist_session_best_effort("session crash state"); + } + + fn begin_concurrency_tracking(&mut self) { + // Release the old identity before registering a new one. An Agent can + // survive /clear and /resume, but its logical session does not. + self.finish_concurrency_tracking(); + self.activate_concurrency_tracking(); + } + + /// Commit a provisional Agent to logical session ownership exactly once. + pub(crate) fn activate_concurrency_tracking(&mut self) { + if self.concurrency_session.is_some() { + return; + } + self.concurrency_session = Some(crate::telemetry::begin_concurrency_session( + &self.session.id, + self.session.parent_id.as_deref(), + )); + } + + pub(crate) fn finish_concurrency_tracking(&mut self) { + if let Some(mut guard) = self.concurrency_session.take() { + guard.finish(); + } + } + + #[cfg(test)] + pub(crate) fn has_concurrency_tracking(&self) -> bool { + self.concurrency_session.is_some() + } + + fn upload_transcript_telemetry(&mut self, end_reason: crate::telemetry::SessionEndReason) { + if self.transcript_telemetry_sent || self.session.messages.is_empty() { + return; + } + // Keep code and ordinary transcript content intact, but reuse the + // session export redactor so credentials are removed recursively from + // text, reasoning, tool inputs, and tool results before leaving the + // machine. + let redacted_session = self.session.redacted_for_export(); + let Ok(messages) = serde_json::to_value(&redacted_session.messages) else { + crate::logging::warn("failed to serialize consented transcript telemetry"); + return; + }; + if crate::telemetry::record_transcript( + self.provider.name(), + &self.provider.model(), + end_reason, + messages, + ) { + self.transcript_telemetry_sent = true; } } diff --git a/crates/jcode-app-core/src/agent/interrupts.rs b/crates/jcode-app-core/src/agent/interrupts.rs index 386e6eaab0..204e15fa74 100644 --- a/crates/jcode-app-core/src/agent/interrupts.rs +++ b/crates/jcode-app-core/src/agent/interrupts.rs @@ -119,34 +119,44 @@ impl Agent { /// Queue a soft interrupt message to be injected at the next safe point. /// This method can be called even while the agent is processing (uses separate lock). - pub fn queue_soft_interrupt(&self, content: String, urgent: bool, source: SoftInterruptSource) { + pub fn queue_soft_interrupt( + &self, + content: String, + images: Vec<(String, String)>, + urgent: bool, + source: SoftInterruptSource, + ) { let content_bytes = content.len(); let content_chars = content.chars().count(); + let image_count = images.len(); if let Ok(mut queue) = self.soft_interrupt_queue.lock() { let pending_before = queue.len(); queue.push(SoftInterruptMessage { content, + images, urgent, source, }); logging::info(&format!( - "AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}", + "AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} image_count={} pending_before={} pending_after={}", self.session_id(), source, urgent, content_bytes, content_chars, + image_count, pending_before, queue.len() )); } else { logging::warn(&format!( - "AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} reason=queue_lock_poisoned", + "AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} image_count={} reason=queue_lock_poisoned", self.session_id(), source, urgent, content_bytes, - content_chars + content_chars, + image_count )); } } @@ -348,22 +358,31 @@ impl Agent { let mut injected = Vec::new(); let mut current_source: Option = None; let mut current_parts: Vec = Vec::new(); + let mut current_images: Vec<(String, String)> = Vec::new(); let flush_group = |agent: &mut Self, injected: &mut Vec, source: SoftInterruptSource, - parts: &mut Vec| { - if parts.is_empty() { + parts: &mut Vec, + images: &mut Vec<(String, String)>| { + if parts.is_empty() && images.is_empty() { return; } let content = parts.join("\n\n"); parts.clear(); - agent.add_message_with_display_role( - Role::User, - vec![ContentBlock::Text { + let mut blocks: Vec = std::mem::take(images) + .into_iter() + .map(|(media_type, data)| ContentBlock::Image { media_type, data }) + .collect(); + if !content.is_empty() { + blocks.push(ContentBlock::Text { text: content.clone(), cache_control: None, - }], + }); + } + agent.add_message_with_display_role( + Role::User, + blocks, soft_interrupt_session_display_role(source), ); injected.push(InjectedSoftInterrupt { content, source }); @@ -372,17 +391,30 @@ impl Agent { for message in messages { match current_source { Some(source) if source != message.source => { - flush_group(self, &mut injected, source, &mut current_parts); + flush_group( + self, + &mut injected, + source, + &mut current_parts, + &mut current_images, + ); current_source = Some(message.source); } None => current_source = Some(message.source), _ => {} } current_parts.push(message.content); + current_images.extend(message.images); } if let Some(source) = current_source { - flush_group(self, &mut injected, source, &mut current_parts); + flush_group( + self, + &mut injected, + source, + &mut current_parts, + &mut current_images, + ); } self.persist_session_best_effort("soft interrupt injection"); @@ -406,6 +438,9 @@ impl Agent { if self.maybe_continue_incomplete_response(stop_reason, incomplete_continuations)? { return Ok(NoToolCallOutcome::ContinueWithoutEvent); } + if self.maybe_continue_stranded_tool_use(stop_reason, incomplete_continuations)? { + return Ok(NoToolCallOutcome::ContinueWithoutEvent); + } logging::info("Turn complete - no tool calls"); let injected = self.inject_soft_interrupts(); if !injected.is_empty() { diff --git a/crates/jcode-app-core/src/agent/model_usage_tests.rs b/crates/jcode-app-core/src/agent/model_usage_tests.rs new file mode 100644 index 0000000000..d4046743e7 --- /dev/null +++ b/crates/jcode-app-core/src/agent/model_usage_tests.rs @@ -0,0 +1,199 @@ +use super::*; +use async_trait::async_trait; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Clone)] +struct UsageProvider { + calls: Arc, + model: Arc>, + fail: bool, +} + +impl UsageProvider { + fn routes() -> Vec { + ["requested-model", "serving-model"] + .into_iter() + .map(|model| crate::provider::ModelRoute { + model: model.into(), + provider: "OpenAI".into(), + api_method: "openai-api-key".into(), + available: true, + detail: String::new(), + usage: None, + cheapness: None, + }) + .collect() + } +} + +#[async_trait] +impl Provider for UsageProvider { + async fn complete( + &self, + _: &[Message], + _: &[ToolDefinition], + _: &str, + _: Option<&str>, + ) -> Result { + if self.fail { + anyhow::bail!("synthetic request failed"); + } + let call = self.calls.fetch_add(1, Ordering::SeqCst); + *self.model.lock().unwrap() = "serving-model".into(); + Ok(Box::pin(futures::stream::iter(vec![ + Ok(StreamEvent::TextDelta("answer".into())), + Ok(StreamEvent::MessageEnd { + stop_reason: Some(if call == 0 { "max_tokens" } else { "end_turn" }.into()), + }), + ]))) + } + fn name(&self) -> &str { + "openai" + } + fn model(&self) -> String { + self.model.lock().unwrap().clone() + } + fn model_routes(&self) -> Vec { + Self::routes() + } + fn active_resolved_credential(&self) -> Option { + Some(jcode_provider_core::ResolvedCredential::ApiKey) + } + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + +async fn usage_agent(fail: bool) -> Agent { + let provider: Arc = Arc::new(UsageProvider { + calls: Arc::new(AtomicUsize::new(0)), + model: Arc::new(std::sync::Mutex::new("requested-model".into())), + fail, + }); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + agent.session.is_debug = false; + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "test".into(), + cache_control: None, + }], + ); + agent +} + +#[tokio::test] +async fn both_turn_paths_record_once_across_continuations_and_attribute_serving_model() { + let _home = crate::auth::test_sandbox::AuthTestSandbox::new().unwrap(); + for (index, streaming) in [false, true].into_iter().enumerate() { + let mut agent = usage_agent(false).await; + let mut updates = Bus::global().subscribe(); + if streaming { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + agent.run_turn_streaming_mpsc(tx).await.unwrap(); + } else { + agent.run_turn(false).await.unwrap(); + } + let routes = agent.model_routes(); + let serving = routes + .iter() + .find(|route| route.model == "serving-model") + .unwrap(); + assert_eq!(serving.usage.as_ref().unwrap().count, index as u64 + 1); + assert!( + serving + .usage + .as_ref() + .unwrap() + .last_used_unix_secs + .is_some() + ); + let requested = routes + .iter() + .find(|route| route.model == "requested-model") + .unwrap(); + assert_eq!(requested.usage.as_ref().unwrap().count, 0); + assert!( + agent + .session + .messages + .iter() + .filter(|m| m.role == Role::Assistant) + .count() + >= 2 + ); + let mut pushed = false; + while let Ok(event) = updates.try_recv() { + if let BusEvent::ModelUsageUpdated(route) = event { + assert_eq!(route.model, "serving-model"); + pushed = true; + } + } + assert!( + pushed, + "metadata must refresh without taking the busy Agent lock" + ); + } +} + +#[tokio::test] +async fn failed_requests_and_debug_sessions_do_not_record_turns() { + let home = crate::auth::test_sandbox::AuthTestSandbox::new().unwrap(); + let mut failed = usage_agent(true).await; + assert!(failed.run_turn(false).await.is_err()); + let mut debug = usage_agent(false).await; + debug.session.is_debug = true; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + debug.run_turn_streaming_mpsc(tx).await.unwrap(); + assert!(!home.root().join("model-usage-v1.sqlite3").exists()); + assert!( + debug + .model_routes() + .iter() + .all(|route| route.usage.is_none()) + ); +} + +#[tokio::test] +async fn resumed_session_keeps_turn_identity_until_new_input() { + let _home = crate::auth::test_sandbox::AuthTestSandbox::new().unwrap(); + let mut original = usage_agent(false).await; + original.run_turn(false).await.unwrap(); + let session_id = original.session.id.clone(); + let anchor = original.session.model_usage_turn_id.clone(); + let mut resumed = usage_agent(false).await; + resumed.session = crate::session::Session::load(&session_id).unwrap(); + assert_eq!(resumed.session.model_usage_turn_id, anchor); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + resumed + .run_once_streaming_mpsc("", vec![], Some("Continue after reload".into()), tx) + .await + .unwrap(); + let count = |agent: &Agent| { + agent + .model_routes() + .into_iter() + .find(|route| route.model == "serving-model") + .unwrap() + .usage + .unwrap() + .count + }; + assert_eq!( + count(&resumed), + 1, + "reload continuation must not count twice" + ); + assert_eq!(resumed.session.model_usage_turn_id, anchor); + resumed.run_once_capture("new input").await.unwrap(); + assert_eq!(count(&resumed), 2); + assert_ne!(resumed.session.model_usage_turn_id, anchor); + assert_eq!( + crate::session::Session::load(&session_id) + .unwrap() + .model_usage_turn_id, + resumed.session.model_usage_turn_id, + "journal must persist the changed anchor" + ); +} diff --git a/crates/jcode-app-core/src/agent/prompting.rs b/crates/jcode-app-core/src/agent/prompting.rs index c96536450c..f4b0f91639 100644 --- a/crates/jcode-app-core/src/agent/prompting.rs +++ b/crates/jcode-app-core/src/agent/prompting.rs @@ -107,12 +107,13 @@ impl Agent { .as_ref() .map(std::path::PathBuf::from); - let (mut split, _context_info) = crate::prompt::build_system_prompt_split( + let (mut split, _context_info) = crate::prompt::build_system_prompt_split_with_agents_md( skill_prompt.as_deref(), &available_skills, self.session.is_canary, memory_prompt, working_dir.as_deref(), + self.agents_md_snapshot.clone(), ); self.append_current_turn_system_reminder(&mut split); diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs index afc9b08ca2..66780ac97d 100644 --- a/crates/jcode-app-core/src/agent/provider.rs +++ b/crates/jcode-app-core/src/agent/provider.rs @@ -30,7 +30,57 @@ impl Agent { } pub fn model_routes(&self) -> Vec { - self.provider.model_routes() + let mut routes = self.provider.model_routes(); + crate::model_usage::enrich_routes(&mut routes); + routes + } + + pub(super) fn begin_model_usage_turn(&mut self, message_id: &str) { + self.session.model_usage_turn_id = Some(format!("{}:{}", self.session.id, message_id)); + } + + pub(super) fn model_usage_turn_id(&mut self) -> String { + if let Some(id) = &self.session.model_usage_turn_id { + return id.clone(); + } + // Old sessions and direct loop callers have no durable anchor yet. + // Internal reminders and tool-result rows do not start a logical turn. + let message_id = self + .session + .visible_conversation_messages() + .into_iter() + .rev() + .find(|message| { + message.role == Role::User + && message.content.iter().any(|block| { + matches!(block, ContentBlock::Text { text, .. } + if !text.trim().is_empty() && !text.starts_with("[System reminder:")) + || matches!(block, ContentBlock::Image { .. }) + }) + }) + .map(|message| message.id.clone()) + .unwrap_or_else(|| "initial".to_string()); + self.begin_model_usage_turn(&message_id); + self.session.model_usage_turn_id.clone().unwrap() + } + + pub(super) fn record_model_turn_usage(&self, turn_id: &str) { + if self.session.is_debug { + return; + } + let Some(mut route) = crate::model_usage::serving_route( + self.provider.as_ref(), + self.session.route_api_method.as_deref(), + ) else { + return; + }; + match crate::model_usage::record_turn(turn_id, &route) { + Ok(usage) => { + route.usage = Some(usage); + Bus::global().publish(BusEvent::ModelUsageUpdated(route)); + } + Err(error) => logging::warn(&format!("Could not record model turn usage: {error}")), + } } pub fn model_catalog_snapshot(&self) -> jcode_provider_core::ModelCatalogSnapshot { @@ -57,6 +107,21 @@ impl Agent { Ok(()) } + fn refresh_compaction_budget(&self) { + let compaction = self.registry.compaction(); + match compaction.try_write() { + Ok(mut manager) => manager.set_budget(self.provider.context_window()), + Err(_) => crate::logging::warn( + "Could not refresh compaction token budget after provider change: compaction manager is busy", + ), + } + } + + #[cfg(test)] + pub(crate) async fn compaction_token_budget(&self) -> usize { + self.registry.compaction().read().await.token_budget() + } + pub fn provider_messages(&mut self) -> Vec { self.session.messages_for_provider() } @@ -97,9 +162,10 @@ impl Agent { let resolved_model = self.provider.model(); self.session.provider_key = Some(selection.runtime_key.stable_id()); self.session.route_api_method = Some(selection.api_method.clone()); - self.session.model = Some(resolved_model.clone()); + self.session.model = Some(self.provider_model()); let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model); self.provider_runtime_state.apply(event); + self.refresh_compaction_budget(); self.persist_session_best_effort("route selection"); self.log_env_snapshot("set_route_selection"); Ok(()) @@ -125,9 +191,10 @@ impl Agent { self.provider.name(), self.session.provider_key.as_deref(), ); - self.session.model = Some(resolved_model.clone()); + self.session.model = Some(self.provider_model()); let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model); self.provider_runtime_state.apply(event); + self.refresh_compaction_budget(); self.persist_session_best_effort("model selection"); self.log_env_snapshot("set_model"); Ok(()) @@ -242,6 +309,7 @@ impl Agent { return; } self.session.working_dir = Some(dir.to_string()); + self.refresh_agents_md_snapshot(); self.session.refresh_initial_session_context_message(); self.log_env_snapshot("working_dir"); } diff --git a/crates/jcode-app-core/src/agent/response_recovery.rs b/crates/jcode-app-core/src/agent/response_recovery.rs index a465de3cc7..3fe2fb5e64 100644 --- a/crates/jcode-app-core/src/agent/response_recovery.rs +++ b/crates/jcode-app-core/src/agent/response_recovery.rs @@ -100,7 +100,7 @@ impl Agent { true } - pub(super) fn should_continue_after_stop_reason(stop_reason: &str) -> bool { + pub(crate) fn should_continue_after_stop_reason(stop_reason: &str) -> bool { let reason = stop_reason.trim().to_ascii_lowercase(); if reason.is_empty() { return false; @@ -131,6 +131,60 @@ impl Agent { || reason.contains("policy_violation") } + pub(crate) const FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS: [&'static str; 3] = [ + "Reassess the user's request using its full context and likely intent rather than isolated keywords. Determine whether answering would enable a concrete harmful action. If no specific safety concern applies, answer normally. Preserve the refusal only when it is genuinely warranted, and briefly identify the specific concern.", + "Review the request again and distinguish legitimate coding, debugging, defensive, educational, or analytical work from instructions that facilitate harm. If only part of the request is unsafe, help with the safe portions and narrowly limit the rest instead of issuing a blanket refusal.", + "Perform one final, independent policy check. Prefer a safe and useful constrained response when possible. Refuse only the specific content that creates a concrete safety risk; otherwise continue with the user's actual task. Do not weaken a refusal that remains genuinely necessary.", + ]; + + /// Try a small sequence of differently framed policy checks after Fable + /// guardrails a response. Every prompt preserves warranted refusals, and the + /// fixed suite size prevents an unbounded refusal/retry loop. + pub(crate) fn maybe_reconsider_fable_guardrail( + &mut self, + stop_reason: Option<&str>, + attempts: &mut u32, + ) -> Result { + let model = self.provider.model(); + if !Self::should_reconsider_fable_guardrail( + &model, + stop_reason, + *attempts, + Self::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS.len() as u32, + ) { + return Ok(false); + } + + let prompt = Self::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS[*attempts as usize]; + *attempts += 1; + logging::warn(&format!( + "Fable 5 guardrail stopped the response (stop_reason={:?}); trying reconsideration prompt {}/{}", + stop_reason, + attempts, + Self::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS.len(), + )); + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: prompt.to_string(), + cache_control: None, + }], + ); + self.session.save()?; + Ok(true) + } + + pub(crate) fn should_reconsider_fable_guardrail( + model: &str, + stop_reason: Option<&str>, + attempts: u32, + max_attempts: u32, + ) -> bool { + Self::is_guardrail_stop_reason(stop_reason) + && model.to_ascii_lowercase().contains("fable-5") + && attempts < max_attempts + } + /// Builds the user-facing notice for a turn that ended with no visible /// assistant output (no text, no tool calls). Returns `None` when the turn /// looks normal and no notice should be surfaced. @@ -154,17 +208,74 @@ impl Agent { )); } // Empty visible output with a non-guardrail stop reason: still surface, - // since the user otherwise sees nothing at all. + // since the user otherwise sees nothing at all. Do not assert a content + // filter here: in practice this is usually a transient upstream failure + // (a dropped or empty stream), not a provider guardrail (issue #672). let reasoning_hint = if had_reasoning { " after producing only internal reasoning" } else { "" }; Some(format!( - "The model ended its turn without any visible output{} (stop_reason: {}). This is usually a provider-side guardrail or filter silently dropping the response. Rephrasing the request may help.", + "The model ended its turn without any visible output{} (stop_reason: {}). The provider returned an empty response; this is usually a transient upstream failure rather than a content filter. Retrying the request may help.", reasoning_hint, reason_label )) } + + /// Log-event label for an empty final turn: real guardrail stops keep the + /// `PROVIDER_GUARDRAIL` name, transient empty responses get their own so + /// the two are separable in logs (issue #672). + pub(crate) fn empty_turn_log_event(stop_reason: Option<&str>) -> &'static str { + if Self::is_guardrail_stop_reason(stop_reason) { + "PROVIDER_GUARDRAIL" + } else { + "PROVIDER_EMPTY_RESPONSE" + } + } + + /// Retry a whitespace-only final response that arrived right after tool + /// results, by asking the model to produce the final answer. Shared by the + /// non-streaming and streaming (mpsc) turn loops so their recovery + /// behavior cannot drift (issue #672). Returns true when a continuation + /// message was injected and the caller should re-issue the request. + pub(crate) fn maybe_continue_empty_post_tool_response( + &mut self, + visible_text_empty: bool, + prompt_has_recent_tool_result: bool, + stop_reason: Option<&str>, + attempts: &mut u32, + ) -> Result { + if !visible_text_empty || !prompt_has_recent_tool_result { + return Ok(false); + } + // A model-side refusal is deliberate; retrying it just burns tokens. + if Self::is_guardrail_stop_reason(stop_reason) { + return Ok(false); + } + if *attempts >= Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS { + return Ok(false); + } + *attempts += 1; + logging::warn(&format!( + "Provider returned whitespace-only final response after tool results (stop_reason={:?}); requesting final answer continuation (attempt {}/{})", + stop_reason, + attempts, + Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS + )); + self.add_message( + Role::User, + vec![ContentBlock::Text { + // Keep this as a user-role message for provider compatibility, + // but mark it as internal so transcript renderers never present + // the synthetic recovery instruction as a prompt from the user. + text: "The previous provider response was empty after tool results. Provide the final answer to the user's last request using the tool results above. Do not call more tools unless absolutely necessary.".to_string(), + cache_control: None, + }], + ); + self.session.save()?; + Ok(true) + } + fn continuation_prompt_for_stop_reason(stop_reason: &str) -> String { format!( "[System reminder: your previous response ended before completion (stop_reason: {}). Continue exactly where you left off, do not repeat completed content, and if the next step is a tool call, emit the tool call now.]", @@ -215,6 +326,55 @@ impl Agent { Ok(true) } + /// True when the provider said it stopped to call a tool but no tool call + /// survived parsing. + /// + /// `stop_reason: tool_use` with zero tool calls is a contradiction: the + /// model intended to act and the harness has nothing to run. Breaking out + /// of the turn there strands the agent mid-task, which on a benchmark run + /// looks like an ordinary "the agent stopped early" failure and silently + /// discards all of its uncommitted work. Treat it like any other + /// incomplete response and ask for a continuation instead. + pub(crate) fn is_stranded_tool_use_stop(stop_reason: Option<&str>) -> bool { + stop_reason + .map(str::trim) + .map(|reason| reason.eq_ignore_ascii_case("tool_use")) + .unwrap_or(false) + } + + pub(crate) fn maybe_continue_stranded_tool_use( + &mut self, + stop_reason: Option<&str>, + attempts: &mut u32, + ) -> Result { + if !Self::is_stranded_tool_use_stop(stop_reason) { + return Ok(false); + } + if *attempts >= Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS { + logging::warn(&format!( + "Provider reported stop_reason='tool_use' with no parsed tool call after {} continuation attempts; ending turn", + attempts + )); + return Ok(false); + } + *attempts += 1; + logging::warn(&format!( + "Provider reported stop_reason='tool_use' but no tool call was parsed; requesting continuation (attempt {}/{})", + attempts, + Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS + )); + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: "[System reminder: your previous response ended with stop_reason \"tool_use\" but no tool call arrived. Nothing was executed. Re-issue the tool call you intended, do not repeat completed work, and continue the task.]" + .to_string(), + cache_control: None, + }], + ); + self.session.save()?; + Ok(true) + } + pub(super) fn filter_truncated_tool_calls( &mut self, stop_reason: Option<&str>, diff --git a/crates/jcode-app-core/src/agent/status.rs b/crates/jcode-app-core/src/agent/status.rs index 3f8aab2a6f..f6d1b8b575 100644 --- a/crates/jcode-app-core/src/agent/status.rs +++ b/crates/jcode-app-core/src/agent/status.rs @@ -1,6 +1,11 @@ use super::*; impl Agent { + /// Read-only source for splitting a new session before its first persistence. + pub(crate) fn session_for_split(&self) -> &Session { + &self.session + } + pub fn session_memory_profile_snapshot( &mut self, ) -> crate::session::SessionMemoryProfileSnapshot { @@ -157,8 +162,47 @@ impl Agent { self.provider.display_name() } + /// Reasoning effort the active provider is running with, if any. + pub fn provider_reasoning_effort(&self) -> Option { + self.provider.reasoning_effort() + } + pub fn provider_model(&self) -> String { - self.provider.model().to_string() + let model = self.provider.model(); + self.provider + .explicit_provider_pin_for_current_model() + .map(|pin| format!("{model}@{pin}")) + .unwrap_or(model) + } + + pub(super) fn provider_key_for_new_session(&self) -> Option { + if self + .provider + .explicit_provider_pin_for_current_model() + .is_some() + { + // Provider pins are explicit OpenRouter route identity. Prefer that + // over ambient runtime env state when a CLI-created Agent snapshots + // a provider that was configured before the Agent existed. + return crate::provider::MultiProvider::session_provider_key_for_model_request( + &self.provider_model(), + self.provider.name(), + ); + } + + crate::session::derive_session_provider_key(self.provider.name()) + } + + pub(super) fn reconcile_explicit_provider_pin_route(&mut self) { + if self + .provider + .explicit_provider_pin_for_current_model() + .is_some() + { + self.session.model = Some(self.provider_model()); + self.session.provider_key = Some("openrouter".to_string()); + self.session.route_api_method = Some("openrouter".to_string()); + } } /// Get the short/friendly name for this session (e.g., "fox") diff --git a/crates/jcode-app-core/src/agent/streaming.rs b/crates/jcode-app-core/src/agent/streaming.rs index d5fa62a6a2..a03c1dd34f 100644 --- a/crates/jcode-app-core/src/agent/streaming.rs +++ b/crates/jcode-app-core/src/agent/streaming.rs @@ -22,5 +22,6 @@ pub(super) fn stream_keepalive_ticker() -> time::Interval { pub(super) fn send_stream_keepalive_mpsc(event_tx: &mpsc::UnboundedSender) { let _ = event_tx.send(ServerEvent::Pong { id: STREAM_KEEPALIVE_PONG_ID, + native_ssh_protocol: None, }); } diff --git a/crates/jcode-app-core/src/agent/tools.rs b/crates/jcode-app-core/src/agent/tools.rs index 70556c5458..a0aa0ac4a8 100644 --- a/crates/jcode-app-core/src/agent/tools.rs +++ b/crates/jcode-app-core/src/agent/tools.rs @@ -1,4 +1,5 @@ use crate::message::{ContentBlock, ToolCall}; +use crate::terminal_println as println; use crate::tool::ToolOutput; pub(super) const MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY: usize = 512 * 1024; @@ -55,6 +56,7 @@ pub(super) fn tool_output_side_pane_images( .images .iter() .map(|img| jcode_session_types::RenderedImage { + history_message_index: None, media_type: img.media_type.clone(), data: img.data.clone(), label: img @@ -165,3 +167,30 @@ mod tests { assert!(capped.contains("tool `custom` produced")); } } + +#[cfg(test)] +mod image_anchor_tests { + use super::*; + + #[test] + fn live_batch_images_anchor_to_parent_and_have_no_history_boundary() { + let output = ToolOutput::new("batch results") + .with_labeled_image("image/png", "one", "first.png") + .with_labeled_image("image/png", "two", "second.png"); + let images = + tool_output_side_pane_images("parent-batch", "batch", &serde_json::json!({}), &output); + assert_eq!(images.len(), 2); + for image in &images { + assert_eq!( + image.anchor, + Some(jcode_session_types::RenderedImageAnchor::ToolCall { + id: "parent-batch".into() + }) + ); + assert_eq!(image.history_message_index, None); + } + assert_eq!(images[0].data, "one"); + assert_eq!(images[1].data, "two"); + assert_eq!(images[0].label.as_deref(), Some("first.png")); + } +} diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs index a3b26e5c23..3607c0b363 100644 --- a/crates/jcode-app-core/src/agent/turn_execution.rs +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -1,15 +1,19 @@ use super::*; +use crate::{terminal_eprintln as eprintln, terminal_println as println}; impl Agent { /// Run a single turn with the given user message pub async fn run_once(&mut self, user_message: &str) -> Result<()> { - self.add_message( + let input_id = self.add_message( Role::User, vec![ContentBlock::Text { text: user_message.to_string(), cache_control: None, }], ); + if !user_message.trim().is_empty() { + self.begin_model_usage_turn(&input_id); + } self.session.save()?; if trace_enabled() { eprintln!("[trace] session_id {}", self.session.id); @@ -19,13 +23,26 @@ impl Agent { } pub async fn run_once_capture(&mut self, user_message: &str) -> Result { - self.add_message( + self.run_once_capture_with_display_role(user_message, None) + .await + } + + pub(crate) async fn run_once_capture_with_display_role( + &mut self, + user_message: &str, + display_role: Option, + ) -> Result { + let input_id = self.add_message_with_display_role( Role::User, vec![ContentBlock::Text { text: user_message.to_string(), cache_control: None, }], + display_role, ); + if !user_message.trim().is_empty() { + self.begin_model_usage_turn(&input_id); + } self.session.save()?; if trace_enabled() { eprintln!("[trace] session_id {}", self.session.id); @@ -40,6 +57,24 @@ impl Agent { images: Vec<(String, String)>, system_reminder: Option, event_tx: mpsc::UnboundedSender, + ) -> Result<()> { + self.run_once_streaming_mpsc_with_display_role( + user_message, + images, + system_reminder, + event_tx, + None, + ) + .await + } + + pub(crate) async fn run_once_streaming_mpsc_with_display_role( + &mut self, + user_message: &str, + images: Vec<(String, String)>, + system_reminder: Option, + event_tx: mpsc::UnboundedSender, + display_role: Option, ) -> Result<()> { // Inject any pending notifications before the user message let alerts = self.take_alerts(); @@ -61,6 +96,32 @@ impl Agent { self.current_turn_system_reminder = system_reminder.filter(|value| !value.trim().is_empty()); + self.append_user_context_message_with_display_role(user_message, images, display_role)?; + crate::telemetry::record_turn(); + let turn_started_at = Instant::now(); + let start_message_index = self.message_count(); + self.fire_turn_start_hook("chat"); + let result = self.run_turn_streaming_mpsc(event_tx).await; + self.current_turn_system_reminder = None; + self.fire_turn_end_hook(&result, turn_started_at, start_message_index); + result + } + + /// Append and persist a user message without starting a model turn. + pub(crate) fn append_user_context_message( + &mut self, + user_message: &str, + images: Vec<(String, String)>, + ) -> Result<()> { + self.append_user_context_message_with_display_role(user_message, images, None) + } + + fn append_user_context_message_with_display_role( + &mut self, + user_message: &str, + images: Vec<(String, String)>, + display_role: Option, + ) -> Result<()> { let mut blocks: Vec = images .into_iter() .map(|(media_type, data)| ContentBlock::Image { media_type, data }) @@ -77,16 +138,12 @@ impl Agent { )); } - self.add_message(Role::User, blocks); - crate::telemetry::record_turn(); - self.session.save()?; - let turn_started_at = Instant::now(); - let start_message_index = self.message_count(); - self.fire_turn_start_hook("chat"); - let result = self.run_turn_streaming_mpsc(event_tx).await; - self.current_turn_system_reminder = None; - self.fire_turn_end_hook(&result, turn_started_at, start_message_index); - result + let starts_turn = blocks.len() > 1 || !user_message.trim().is_empty(); + let input_id = self.add_message_with_display_role(Role::User, blocks, display_role); + if starts_turn { + self.begin_model_usage_turn(&input_id); + } + self.session.save() } /// Fire the `turn_start` observer hook when a turn begins, before the model @@ -150,13 +207,13 @@ impl Agent { let preserve_working_dir = self.session.working_dir.clone(); self.session.mark_closed(); + self.finish_concurrency_tracking(); self.persist_session_best_effort("pre-clear session close state"); let mut new_session = Session::create(None, None); new_session.mark_active(); - new_session.model = Some(self.provider.model()); - new_session.provider_key = - crate::session::derive_session_provider_key(self.provider.name()); + new_session.model = Some(self.provider_model()); + new_session.provider_key = self.provider_key_for_new_session(); new_session.is_canary = preserve_canary; new_session.testing_build = preserve_testing_build; new_session.is_debug = preserve_debug; @@ -164,6 +221,14 @@ impl Agent { new_session.ensure_initial_session_context_message(); self.session = new_session; + self.begin_concurrency_tracking(); + self._tool_policy_registration = crate::tool::register_session_tool_policy( + &self.session.id, + self.allowed_tools.clone(), + self.disabled_tools.clone(), + ); + self.refresh_agents_md_snapshot(); + self.reconcile_explicit_provider_pin_route(); self.reset_runtime_state_for_session_change(); self.provider_session_id = None; self.seed_compaction_from_session(); @@ -331,6 +396,21 @@ impl Agent { self.stdin_request_tx = Some(tx); } + /// Prepare the static provider prefix while a client is idle. Unlike + /// `tool_definitions`, this does not pin the tool snapshot or consume the + /// one-shot late-MCP-discovery check before the first real turn. + pub(crate) async fn prewarm_provider(&self) { + if self.session.is_canary { + self.registry.register_selfdev_tools().await; + } + let tools = match &self.locked_tools { + Some(tools) => tools.clone(), + None => self.build_filtered_tool_definitions().await, + }; + let prompt = self.build_system_prompt_split(None); + self.provider.prewarm(&tools, &prompt.static_part).await; + } + pub(super) async fn tool_definitions(&mut self) -> Vec { if self.session.is_canary { self.registry.register_selfdev_tools().await; @@ -354,6 +434,22 @@ impl Agent { // prompt-cache miss (the turn MCP tools first appear). The // `mcp_late_register_resolved` flag makes this a one-shot check so we do // not rescan the registry on every subsequent turn. + let locked_uses_fixed_mcp_surface = self.locked_tools.as_ref().is_some_and(|locked| { + locked + .iter() + .any(|tool| matches!(tool.name.as_str(), "mcp_search" | "mcp_call")) + && !locked.iter().any(|tool| tool.name.starts_with("mcp__")) + }); + if (self.mcp_tools_mode == crate::config::McpToolsMode::Deferred + || locked_uses_fixed_mcp_surface) + && let Some(locked) = self.locked_tools.clone() + { + // Per-server tools may continue registering in the background, but + // deferred mode's fixed surface cannot change as a result. Avoid an + // unnecessary provider cache reset and registry scan. + self.mcp_late_register_resolved = true; + return locked; + } if let Some(ref locked) = self.locked_tools { if self.mcp_late_register_resolved { return locked.clone(); @@ -397,25 +493,53 @@ impl Agent { async fn build_filtered_tool_definitions(&self) -> Vec { let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await; if !self.disabled_tools.is_empty() { - tools.retain(|tool| !self.disabled_tools.contains(&tool.name)); + tools.retain(|tool| { + !crate::tool::tool_name_is_disabled(&self.disabled_tools, &tool.name) + }); } Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary); + self.apply_mcp_tool_exposure(&mut tools); tools } - /// Tailor the `selfdev` tool definition to the session mode. + /// Replace per-server MCP definitions with the fixed search/call surface + /// according to the configured mode. Auto mode estimates the actual + /// serialized, already-filtered definitions the provider would receive. + fn apply_mcp_tool_exposure(&self, tools: &mut Vec) { + let mcp_definitions: Vec = tools + .iter() + .filter(|tool| tool.name.starts_with("mcp__")) + .cloned() + .collect(); + let estimated_tokens = ToolDefinition::aggregate_prompt_token_estimate(&mcp_definitions); + let deferred = match self.mcp_tools_mode { + crate::config::McpToolsMode::Auto => estimated_tokens > self.mcp_tools_token_threshold, + crate::config::McpToolsMode::Eager => false, + crate::config::McpToolsMode::Deferred => true, + }; + + if deferred { + tools.retain(|tool| !tool.name.starts_with("mcp__")); + } else { + tools.retain(|tool| !matches!(tool.name.as_str(), "mcp_search" | "mcp_call")); + } + } + + /// Expose the `selfdev` tool only while running in self-development mode. /// - /// The registry stores a single shared `selfdev` tool with a default - /// (non-self-dev) schema. Self-dev sessions get the full build/test/reload - /// surface; every other session keeps the lightweight on-ramp surface - /// (`enter`, `setup`, `reload`, `status`, `find-config`). The tool stays - /// available in all sessions so the agent can always enter self-dev mode. - fn apply_selfdev_tool_surface(tools: &mut [ToolDefinition], is_canary: bool) { + /// The registry keeps the implementation available for self-dev sessions, + /// but regular agents should not spend tool-list context on an internal + /// development surface. + fn apply_selfdev_tool_surface(tools: &mut Vec, is_canary: bool) { + if !is_canary { + tools.retain(|tool| tool.name != "selfdev"); + return; + } for tool in tools.iter_mut() { if tool.name == "selfdev" { tool.description = - crate::tool::selfdev::SelfDevTool::description_for(is_canary).to_string(); - tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(is_canary); + crate::tool::selfdev::SelfDevTool::description_for(true).to_string(); + tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(true); } } } @@ -428,8 +552,10 @@ impl Agent { let allowed = self.allowed_tools.as_ref(); registry_names.iter().any(|name| { name.starts_with("mcp__") - && allowed.map(|set| set.contains(name)).unwrap_or(true) - && !self.disabled_tools.contains(name) + && allowed + .map(|set| crate::tool::tool_name_is_allowed(set, name)) + .unwrap_or(true) + && !crate::tool::tool_name_is_disabled(&self.disabled_tools, name) && !locked.iter().any(|t| &t.name == name) }) } @@ -447,12 +573,7 @@ impl Agent { if self.session.is_canary { self.registry.register_selfdev_tools().await; } - let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await; - if !self.disabled_tools.is_empty() { - tools.retain(|tool| !self.disabled_tools.contains(&tool.name)); - } - Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary); - tools + self.build_filtered_tool_definitions().await } pub async fn execute_tool( @@ -530,11 +651,11 @@ impl Agent { pub(super) fn validate_tool_allowed(&self, name: &str) -> Result<()> { if let Some(allowed) = self.allowed_tools.as_ref() - && !allowed.contains(name) + && !crate::tool::tool_name_is_allowed(allowed, name) { return Err(anyhow::anyhow!("Tool '{}' is not allowed", name)); } - if self.disabled_tools.contains(name) { + if crate::tool::tool_name_is_disabled(&self.disabled_tools, name) { return Err(anyhow::anyhow!("Tool '{}' is disabled", name)); } Ok(()) @@ -568,12 +689,14 @@ impl Agent { let previous_status = session.status.clone(); let assign_start = Instant::now(); - let previous_session_id = self.session.id.clone(); + // A failed load must leave the current Agent and its concurrency lease + // alive. Close it only after the replacement is ready to install. + self.mark_closed(); // Restore provider_session_id for Claude CLI session resume self.provider_session_id = session.provider_session_id.clone(); self.session = session; - crate::tool::clear_session_tool_policy(&previous_session_id); - crate::tool::set_session_tool_policy( + self.refresh_agents_md_snapshot(); + self._tool_policy_registration = crate::tool::register_session_tool_policy( &self.session.id, self.allowed_tools.clone(), self.disabled_tools.clone(), @@ -600,15 +723,18 @@ impl Agent { "Failed to restore session model '{}' via '{}': {}", model, model_request, e )); + } else { + self.reconcile_explicit_provider_pin_route(); } } else { - self.session.model = Some(self.provider.model()); + self.session.model = Some(self.provider_model()); } self.restore_reasoning_effort_from_session(); let model_ms = model_start.elapsed().as_millis(); let mark_active_start = Instant::now(); self.session.mark_active(); + self.begin_concurrency_tracking(); let mark_active_ms = mark_active_start.elapsed().as_millis(); self.sync_memory_dedup_state_from_session(); @@ -662,6 +788,7 @@ impl Agent { crate::session::render_messages(&self.session) .into_iter() .map(|msg| HistoryMessage { + response_stats: msg.response_stats, role: msg.role, content: msg.content, tool_calls: if msg.tool_calls.is_empty() { @@ -681,6 +808,7 @@ impl Agent { let history = messages .into_iter() .map(|msg| HistoryMessage { + response_stats: msg.response_stats, role: msg.role, content: msg.content, tool_calls: if msg.tool_calls.is_empty() { @@ -710,6 +838,7 @@ impl Agent { let history = messages .into_iter() .map(|msg| HistoryMessage { + response_stats: msg.response_stats, role: msg.role, content: msg.content, tool_calls: if msg.tool_calls.is_empty() { @@ -769,8 +898,11 @@ impl Agent { continue; } - // Check for skill invocation - if let Some(invocation) = SkillRegistry::parse_invocation(input) { + // Check for skill invocation. Resolve against the registry (not + // the bare tokenizer) so a `SKILL.md` `name:` field containing + // spaces, e.g. "My Custom Skill", can still be matched: the + // bare parse always stops at the first whitespace. + if let Some(invocation) = skills.resolve_invocation(input) { if let Some(skill) = skills.get(invocation.name) { println!("Activating skill: {}", skill.name); println!("{}\n", skill.description); @@ -838,6 +970,9 @@ impl Agent { for block in &msg.content { match block { ContentBlock::Text { text, .. } => { + if text.trim_start().starts_with("") { + continue; + } transcript.push_str(text); transcript.push('\n'); } diff --git a/crates/jcode-app-core/src/agent/turn_loops.rs b/crates/jcode-app-core/src/agent/turn_loops.rs index f0f917ebe6..25e857b314 100644 --- a/crates/jcode-app-core/src/agent/turn_loops.rs +++ b/crates/jcode-app-core/src/agent/turn_loops.rs @@ -1,14 +1,50 @@ use super::*; +use crate::{terminal_eprintln as eprintln, terminal_print as print, terminal_println as println}; impl Agent { + /// Speculatively prewarm the provider while a newly created session is idle. + /// + /// This deliberately bypasses `tool_definitions`, whose cache lock is only + /// appropriate once an actual turn starts. Late MCP registration or user + /// customization can therefore still change the foreground tool snapshot; + /// the provider is responsible for discarding an incompatible warmup. + pub(crate) async fn prewarm_provider_idle(&self) { + let tools = self.tool_definitions_for_debug().await; + let split_prompt = self.build_system_prompt_split(None); + self.provider + .prewarm(&tools, &split_prompt.static_part) + .await; + } + /// Run turns until no more tool calls /// Maximum number of context-limit compaction retries before giving up. pub(super) const MAX_CONTEXT_LIMIT_RETRIES: u32 = 5; pub(super) const MAX_INCOMPLETE_CONTINUATION_ATTEMPTS: u32 = 3; - pub(super) const MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS: u32 = 1; + /// Retries allowed when the provider returns an empty response right after + /// tool results. This is a transient provider hiccup, not a signal that the + /// task is finished, so a single retry is too few: one empty response + /// observed once in 43 turns silently ended a 20-hour benchmark run with the + /// task half-done. The counter is per turn-loop, so a genuinely finished + /// agent still exits promptly. + pub(crate) const MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS: u32 = 5; + const SEQUENTIAL_TOOL_ROUNDS_BEFORE_BATCH_NUDGE: u32 = 3; + const BATCH_NUDGE: &str = "Several tool calls have been made one at a time. If the next independent operations can run concurrently, use the batch tool instead of making more sequential calls. Keep sequential calls when one result is required to decide the next operation."; + + fn update_sequential_tool_rounds(current: u32, tool_count: usize, used_batch: bool) -> u32 { + if tool_count == 1 && !used_batch { + current.saturating_add(1) + } else { + 0 + } + } + + fn should_inject_batch_nudge(pending: bool, batch_available: bool) -> bool { + pending && batch_available + } pub(super) async fn run_turn(&mut self, print_output: bool) -> Result { self.set_log_context(); + let usage_turn_id = self.model_usage_turn_id(); crate::session_metrics::record_turn(&self.session.id); // Mark this session as actively streaming for presence UIs (e.g. the // macOS menu bar indicator). Cleared automatically on every exit path. @@ -24,8 +60,18 @@ impl Agent { let mut context_limit_retries = 0u32; let mut incomplete_continuations = 0u32; let mut empty_post_tool_continuations = 0u32; + let mut fable_guardrail_reconsiderations = 0u32; + let mut sequential_single_tool_rounds = 0u32; + let mut batch_nudge_pending = false; loop { + // Do not start another provider request once a cancel has been + // observed; the loop is re-entered by several recovery paths + // (issue #732, regression of #428). + if self.is_graceful_shutdown() { + logging::info("Cancel observed at turn-loop head - not starting another request"); + break; + } let repaired = self.repair_missing_tool_outputs(); if repaired > 0 { logging::warn(&format!( @@ -33,6 +79,14 @@ impl Agent { repaired )); } + // Start provider transport setup before deriving and potentially + // compacting the request history. This is the first point where the + // stable request settings are available. + let mut tools = self.tool_definitions().await; + let mut split_prompt = self.build_system_prompt_split(None); + self.provider + .prewarm(&tools, &split_prompt.static_part) + .await; let (messages, compaction_event) = self.messages_for_provider(); if let Some(event) = compaction_event { // Reset cache tracker and tool lock on compaction since the message history changes @@ -43,17 +97,23 @@ impl Agent { .pre_tokens .map(|t| format!(" ({} tokens)", t)) .unwrap_or_default(); - println!("📦 Context compacted ({}){}", event.trigger, tokens_str); + crate::terminal_println!( + "📦 Context compacted ({}){}", + event.trigger, + tokens_str + ); } + // Compaction clears the tool lock, so rebuild the foreground + // request metadata rather than relying on the pre-compaction snapshot. + tools = self.tool_definitions().await; + split_prompt = self.build_system_prompt_split(None); } - let tools = self.tool_definitions().await; let messages: std::sync::Arc<[Message]> = messages.into(); // Non-blocking memory: uses pending result from last turn, spawns check for next turn let memory_pending = self.build_memory_prompt_nonblocking_shared(std::sync::Arc::clone(&messages), None); // Use split prompt for better caching - static content cached, dynamic not - let split_prompt = self.build_system_prompt_split(None); self.log_prompt_prefix_accounting(&split_prompt, &tools); // Check for client-side cache violations before memory injection. @@ -61,6 +121,10 @@ impl Agent { // false-positive violations every turn (prior turn's memory ≠ current history prefix). self.record_client_cache_request(&messages); + // The request snapshot now owns everything the provider needs. Drop + // the session's derived transcript copy before the network wait. + self.session.release_provider_messages_cache(); + // Inject memory as a user message at the end (preserves cache prefix) let mut messages_with_memory: Vec = messages.iter().cloned().collect(); if let Some(memory) = memory_pending.as_ref() { @@ -75,6 +139,14 @@ impl Agent { let (memory_msg, _persisted) = self.prepare_memory_injection_message(memory); messages_with_memory.push(memory_msg); } + if Self::should_inject_batch_nudge( + batch_nudge_pending, + tools.iter().any(|tool| tool.name == "batch"), + ) { + messages_with_memory.push(Message::user(Self::BATCH_NUDGE)); + batch_nudge_pending = false; + sequential_single_tool_rounds = 0; + } logging::info(&format!( "API call starting: {} messages, {} tools", @@ -90,13 +162,11 @@ impl Agent { model: Some(self.provider.model()), })); - let stamped; - let send_messages: &[Message] = if crate::config::config().features.message_timestamps { - stamped = Message::with_timestamps(&messages_with_memory); - &stamped - } else { - &messages_with_memory - }; + let stamped = crate::config::config() + .features + .message_timestamps + .then(|| Message::with_timestamps(&messages_with_memory)); + let send_messages = stamped.as_deref().unwrap_or(&messages_with_memory); let prompt_has_recent_tool_result = Self::messages_end_with_tool_result(send_messages); self.last_status_detail = None; let mut stream = match self @@ -129,6 +199,14 @@ impl Agent { } }; + // The provider returned an owned stream, so the request transcript + // copies are no longer needed while the response is consumed. + drop(stamped); + drop(messages_with_memory); + drop(memory_pending); + drop(messages); + drop(split_prompt); + // Successful API call - reset retry counter context_limit_retries = 0; @@ -226,7 +304,7 @@ impl Agent { StreamEvent::ThinkingDelta(thinking_text) => { // Display reasoning content only if enabled if print_output && crate::config::config().display.show_thinking { - println!("💭 {}", thinking_text); + crate::terminal_println!("💭 {}", thinking_text); } // Always capture reasoning text so it can be persisted as a // history-only trace, regardless of provider replay support. @@ -249,7 +327,7 @@ impl Agent { } StreamEvent::TextDelta(text) => { if print_output { - print!("{}", text); + crate::terminal_print!("{}", text); io::stdout().flush()?; } text_content.push_str(&text); @@ -517,7 +595,11 @@ impl Agent { let tokens_str = pre_tokens .map(|t| format!(" ({} tokens)", t)) .unwrap_or_default(); - println!("📦 Context compacted ({}){}", trigger, tokens_str); + crate::terminal_println!( + "📦 Context compacted ({}){}", + trigger, + tokens_str + ); } } StreamEvent::NativeToolCall { @@ -741,6 +823,7 @@ impl Agent { self.add_message_ext(Role::Assistant, content_blocks, None, token_usage); self.push_embedding_snapshot_if_semantic(&text_content); self.session.save()?; + self.record_model_turn_usage(&usage_turn_id); Some(message_id) } else { None @@ -772,25 +855,18 @@ impl Agent { // If no tool calls, we're done if tool_calls.is_empty() { - if visible_text_is_empty - && prompt_has_recent_tool_result - && empty_post_tool_continuations - < Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS - { - empty_post_tool_continuations += 1; - logging::warn(&format!( - "Provider returned whitespace-only final response after tool results; requesting final answer continuation (attempt {}/{})", - empty_post_tool_continuations, - Self::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS - )); - self.add_message( - Role::User, - vec![ContentBlock::Text { - text: "The previous provider response was empty after tool results. Please provide the final answer to the user's last request using the tool results above. Do not call more tools unless absolutely necessary.".to_string(), - cache_control: None, - }], - ); - self.session.save()?; + if self.maybe_reconsider_fable_guardrail( + stop_reason.as_deref(), + &mut fable_guardrail_reconsiderations, + )? { + continue; + } + if self.maybe_continue_empty_post_tool_response( + visible_text_is_empty, + prompt_has_recent_tool_result, + stop_reason.as_deref(), + &mut empty_post_tool_continuations, + )? { continue; } if self.maybe_continue_incomplete_response( @@ -807,7 +883,8 @@ impl Agent { !reasoning_content.trim().is_empty(), ) { logging::warn(&format!( - "PROVIDER_GUARDRAIL: turn ended with no visible output (stop_reason={:?})", + "{}: turn ended with no visible output (stop_reason={:?})", + Self::empty_turn_log_event(stop_reason.as_deref()), stop_reason )); if print_output { @@ -850,6 +927,16 @@ impl Agent { logging::info("Provider handles tools internally - executing native tools locally"); } + let used_batch = tool_calls.iter().any(|tc| tc.name == "batch"); + sequential_single_tool_rounds = Self::update_sequential_tool_rounds( + sequential_single_tool_rounds, + tool_calls.len(), + used_batch, + ); + if sequential_single_tool_rounds >= Self::SEQUENTIAL_TOOL_ROUNDS_BEFORE_BATCH_NUDGE { + batch_nudge_pending = true; + } + // Execute tools and add results let mut tool_results_dirty = false; for tc in tool_calls { @@ -1093,7 +1180,7 @@ impl Agent { Ok(final_text) } - fn messages_end_with_tool_result(messages: &[Message]) -> bool { + pub(super) fn messages_end_with_tool_result(messages: &[Message]) -> bool { messages.iter().rev().any(|message| { if !matches!(message.role, Role::User) { return false; @@ -1170,4 +1257,30 @@ mod tests { assert!(!Agent::messages_end_with_tool_result(&messages)); } + + #[test] + fn sequential_tool_rounds_trigger_after_three_single_calls() { + let mut rounds = 0; + for _ in 0..3 { + rounds = Agent::update_sequential_tool_rounds(rounds, 1, false); + } + + assert_eq!(rounds, Agent::SEQUENTIAL_TOOL_ROUNDS_BEFORE_BATCH_NUDGE); + } + + #[test] + fn parallel_or_batch_calls_reset_sequential_tool_rounds() { + assert_eq!(Agent::update_sequential_tool_rounds(2, 2, false), 0); + assert_eq!(Agent::update_sequential_tool_rounds(2, 1, true), 0); + assert_eq!(Agent::update_sequential_tool_rounds(2, 0, false), 0); + } + + #[test] + fn pending_nudge_is_injected_only_when_batch_is_available() { + assert!(Agent::should_inject_batch_nudge(true, true)); + assert!(!Agent::should_inject_batch_nudge(false, true)); + assert!(!Agent::should_inject_batch_nudge(true, false)); + assert!(Agent::BATCH_NUDGE.contains("use the batch tool")); + assert!(Agent::BATCH_NUDGE.contains("result is required")); + } } diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs index 60d6a6ae00..0374667be4 100644 --- a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs +++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs @@ -81,6 +81,7 @@ impl Agent { event_tx: mpsc::UnboundedSender, ) -> Result<()> { self.set_log_context(); + let usage_turn_id = self.model_usage_turn_id(); // Mark this session as actively streaming for presence UIs (e.g. the // macOS menu bar indicator). Cleared automatically on every exit path. let _streaming_guard = crate::session::StreamingGuard::new(self.session.id.clone()); @@ -95,8 +96,20 @@ impl Agent { let trace = trace_enabled(); let mut context_limit_retries = 0u32; let mut incomplete_continuations = 0u32; + let mut empty_post_tool_continuations = 0u32; + let mut fable_guardrail_reconsiderations = 0u32; loop { + // Never open a new provider request after a cancel. Several paths + // `continue` this loop (compaction retry, incomplete/stranded + // continuation, empty-response recovery, soft-interrupt injection), + // and without this check an Esc that landed during a stream could + // be followed by another full request, which looks to the user like + // the interrupt was ignored (issue #732, regression of #428). + if self.is_graceful_shutdown() { + logging::info("Cancel observed at turn-loop head - not starting another request"); + break; + } let repaired = self.repair_missing_tool_outputs(); if repaired > 0 { logging::warn(&format!( @@ -104,6 +117,14 @@ impl Agent { repaired )); } + // Start provider transport setup before deriving and potentially + // compacting the request history. This is the first point where the + // stable request settings are available. + let mut tools = self.tool_definitions().await; + let mut split_prompt = self.build_system_prompt_split(None); + self.provider + .prewarm(&tools, &split_prompt.static_part) + .await; let (messages, compaction_event) = self.messages_for_provider(); if let Some(event) = compaction_event { // Reset cache tracker and tool lock on compaction since the message history changes @@ -123,14 +144,17 @@ impl Agent { post_tokens: event.post_tokens, tokens_saved: event.tokens_saved, duration_ms: event.duration_ms, - messages_dropped: None, + messages_dropped: event.messages_dropped, messages_compacted: event.messages_compacted, summary_chars: event.summary_chars, active_messages: event.active_messages, }); + // Compaction clears the tool lock, so rebuild the foreground + // request metadata rather than relying on the pre-compaction snapshot. + tools = self.tool_definitions().await; + split_prompt = self.build_system_prompt_split(None); } - let tools = self.tool_definitions().await; let messages: std::sync::Arc<[Message]> = messages.into(); // Non-blocking memory: uses pending result from last turn, spawns check for next turn let memory_pending = self.build_memory_prompt_nonblocking_shared( @@ -143,7 +167,6 @@ impl Agent { })), ); // Use split prompt for better caching - static content cached, dynamic not - let split_prompt = self.build_system_prompt_split(None); self.log_prompt_prefix_accounting(&split_prompt, &tools); // Check for client-side cache violations before memory injection. @@ -151,6 +174,11 @@ impl Agent { // false-positive violations every turn (prior turn's memory ≠ current history prefix). self.record_client_cache_request(&messages); + // `messages` now owns the provider-facing request snapshot. Do not + // retain the session's second, derived copy for the entire network + // wait and response stream. + self.session.release_provider_messages_cache(); + let mut cache_signature_messages = if crate::config::config().features.message_timestamps { Message::with_timestamps(&messages) @@ -193,13 +221,12 @@ impl Agent { )); let api_start = Instant::now(); - let stamped; - let send_messages: &[Message] = if crate::config::config().features.message_timestamps { - stamped = Message::with_timestamps(&messages_with_memory); - &stamped - } else { - &messages_with_memory - }; + let stamped = crate::config::config() + .features + .message_timestamps + .then(|| Message::with_timestamps(&messages_with_memory)); + let send_messages = stamped.as_deref().unwrap_or(&messages_with_memory); + let prompt_has_recent_tool_result = Self::messages_end_with_tool_result(send_messages); let provider = Arc::clone(&self.provider); // Capture the model id the request was issued with. A provider may // transparently switch models mid-request (e.g. Anthropic's retired @@ -217,6 +244,11 @@ impl Agent { &split_prompt.static_part, &ephemeral_signature_messages, )); + // These vectors are only needed to build the cache telemetry event. + // Explicitly release their deeply cloned transcript strings before + // waiting for the provider stream. + drop(cache_signature_messages); + drop(ephemeral_signature_messages); let mut keepalive = stream_keepalive_ticker(); let mut stream = { let mut complete_future = std::pin::pin!(provider.complete_split( @@ -273,6 +305,15 @@ impl Agent { } }; + // `complete_split` has consumed the request and returned an owned + // response stream. Keeping these full transcript snapshots alive + // while tokens arrive needlessly multiplies active-session memory. + drop(stamped); + drop(messages_with_memory); + drop(memory_pending); + drop(messages); + drop(split_prompt); + // Successful API call - reset retry counter context_limit_retries = 0; @@ -329,7 +370,7 @@ impl Agent { // to clients as a keepalive; throttles issue #451 keepalives. let mut hidden_activity_last = Instant::now(); let mut openai_reasoning_items: Vec = Vec::new(); - let mut openai_native_compaction: Option<(String, usize)> = None; + let mut openai_native_compaction: Option<(String, usize, Option)> = None; let mut tool_id_to_name: std::collections::HashMap = std::collections::HashMap::new(); @@ -452,10 +493,12 @@ impl Agent { } } StreamEvent::ThinkingDelta(thinking_text) => { - // Only send thinking content if enabled in config - if crate::config::config().display.show_thinking - && !thinking_text.is_empty() - { + // Always stream reasoning to clients. Whether to *render* + // it is a per-client presentation choice (the TUI keys off + // `display.reasoning_display`, the desktops off their own + // mode); gating it here would let one shared daemon config + // decide what every attached client is allowed to see. + if !thinking_text.is_empty() { reasoning_open = true; let _ = event_tx.send(ServerEvent::ReasoningDelta { text: thinking_text.clone(), @@ -463,13 +506,10 @@ impl Agent { } else if hidden_activity_last.elapsed() >= std::time::Duration::from_secs(5) { - // Hidden reasoning is real provider activity, but it - // emits nothing over the client socket, so a long - // silent thinking phase looks identical to a dead - // connection and the client stall guard cancels a - // healthy stream (issue #451). Send a throttled - // non-rendered keepalive so clients track provider - // activity, not just displayable events. + // An empty delta carries provider activity but no + // event, so a long silent thinking phase would look + // identical to a dead connection and the client stall + // guard would cancel a healthy stream (issue #451). hidden_activity_last = Instant::now(); send_stream_keepalive_mpsc(&event_tx); } @@ -759,7 +799,9 @@ impl Agent { if reason.is_some() { stop_reason = reason; } - let _ = event_tx.send(ServerEvent::MessageEnd); + let _ = event_tx.send(ServerEvent::MessageEnd { + stop_reason: stop_reason.clone(), + }); } StreamEvent::SessionId(sid) => { self.provider_session_id = Some(sid.clone()); @@ -782,12 +824,16 @@ impl Agent { } } StreamEvent::Compaction { + pre_tokens, openai_encrypted_content, .. } => { if let Some(encrypted_content) = openai_encrypted_content { - openai_native_compaction - .get_or_insert((encrypted_content, self.session.messages.len())); + openai_native_compaction.get_or_insert(( + encrypted_content, + self.session.messages.len(), + pre_tokens, + )); } } StreamEvent::NativeToolCall { @@ -984,7 +1030,7 @@ impl Agent { "Provider switched model mid-request: '{}' -> '{}' (resyncing session/UI)", model_at_request_start, model_after_stream )); - self.session.model = Some(model_after_stream.clone()); + self.session.model = Some(self.provider_model()); self.provider_runtime_state.apply( crate::provider::ProviderStateEvent::RuntimeModelObserved { model: model_after_stream.clone(), @@ -1063,13 +1109,37 @@ impl Agent { self.add_message_ext(Role::Assistant, content_blocks, None, token_usage); self.push_embedding_snapshot_if_semantic(&text_content); self.session.save()?; + self.record_model_turn_usage(&usage_turn_id); Some(message_id) } else { None }; - if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() { + if let Some((encrypted_content, compacted_count, native_pre_tokens)) = + openai_native_compaction.take() + { self.apply_openai_native_compaction(encrypted_content, compacted_count)?; + // Native OpenAI compaction is applied after the provider stream, + // so `messages_for_provider()` did not have an event to emit at + // the top of this iteration. Notify clients now, before any + // tool-driven continuation can enqueue its next KvCacheRequest. + // The FIFO event ordering lets the TUI invalidate its old + // append-only baseline before seeing the compacted signature. + // + // Only a provider-supplied pre-compaction count is trustworthy + // here. The response's own input usage is not the pre-compaction + // context size, so omit the value rather than mislabel it (#1178). + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "openai_native".to_string(), + pre_tokens: native_pre_tokens, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: Some(compacted_count), + summary_chars: None, + active_messages: None, + }); } // If stop_reason indicates truncation (e.g. max_tokens), discard tool calls @@ -1096,6 +1166,29 @@ impl Agent { // Injecting before tool_results would break the API requirement that // tool_use must be immediately followed by tool_result. if tool_calls.is_empty() { + if saw_message_end + && !self.is_graceful_shutdown() + && self.maybe_reconsider_fable_guardrail( + stop_reason.as_deref(), + &mut fable_guardrail_reconsiderations, + )? + { + continue; + } + // Retry transient empty responses (dropped/empty upstream + // streams) before surfacing anything, matching the + // non-streaming loop's recovery behavior (issue #672). + if saw_message_end + && !self.is_graceful_shutdown() + && self.maybe_continue_empty_post_tool_response( + text_content.trim().is_empty(), + prompt_has_recent_tool_result, + stop_reason.as_deref(), + &mut empty_post_tool_continuations, + )? + { + continue; + } match self.handle_streaming_no_tool_calls( stop_reason.as_deref(), &mut incomplete_continuations, @@ -1116,7 +1209,8 @@ impl Agent { ) { logging::warn(&format!( - "PROVIDER_GUARDRAIL: turn ended with no visible output (stop_reason={:?}, reasoning_chars={})", + "{}: turn ended with no visible output (stop_reason={:?}, reasoning_chars={})", + Self::empty_turn_log_event(stop_reason.as_deref()), stop_reason, reasoning_content.len() )); diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index 8871b44d27..026e3a4bae 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -8,6 +8,12 @@ use async_trait::async_trait; use tokio::sync::mpsc as tokio_mpsc; use tokio_stream::wrappers::ReceiverStream; +#[path = "agent_tests/concurrency.rs"] +mod concurrency; + +#[path = "agent_tests/concurrency_construction.rs"] +mod concurrency_construction; + struct DelayedProvider { open_delay: Duration, first_event_delay: Duration, @@ -15,6 +21,69 @@ struct DelayedProvider { struct NativeAutoCompactionProvider; +struct NativeCompactionStreamProvider; + +#[derive(Clone)] +struct ExplicitPinProvider { + model: Arc>, + pin: Arc>>, + set_model_requests: Arc>>, +} + +impl ExplicitPinProvider { + fn new(model: &str) -> Self { + Self { + model: Arc::new(std::sync::Mutex::new(model.to_string())), + pin: Arc::new(std::sync::Mutex::new(None)), + set_model_requests: Arc::new(std::sync::Mutex::new(Vec::new())), + } + } +} + +#[async_trait] +impl Provider for ExplicitPinProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + unreachable!("ExplicitPinProvider does not complete requests") + } + + fn name(&self) -> &str { + "openrouter" + } + + fn model(&self) -> String { + self.model.lock().unwrap().clone() + } + + fn set_model(&self, request: &str) -> Result<()> { + self.set_model_requests + .lock() + .unwrap() + .push(request.to_string()); + let spec = request.strip_prefix("openrouter:").unwrap_or(request); + let (model, pin) = spec + .rsplit_once('@') + .map(|(model, pin)| (model, Some(pin.to_string()))) + .unwrap_or((spec, None)); + *self.model.lock().unwrap() = model.to_string(); + *self.pin.lock().unwrap() = pin; + Ok(()) + } + + fn explicit_provider_pin_for_current_model(&self) -> Option { + self.pin.lock().unwrap().clone() + } + + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + fn content_text(content: &[ContentBlock]) -> &str { match content.first() { Some(ContentBlock::Text { text, .. }) => text, @@ -26,6 +95,102 @@ fn message_text(message: &Message) -> &str { content_text(&message.content) } +#[test] +fn agent_drop_removes_its_configured_session_tool_policy() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let session = Session::create(None, None); + let session_id = session.id.clone(); + let agent = Agent::new_with_session( + provider, + Registry::empty(), + session, + Some(HashSet::from(["bash".to_string()])), + ); + + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "bash"), + Some(true) + ); + drop(agent); + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "bash"), + None, + "dropping the Agent must remove its global policy entry" + ); +} + +#[test] +fn stale_agent_drop_preserves_successor_session_tool_policy() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let first_session = Session::create(None, None); + let session_id = first_session.id.clone(); + let first = Agent::new_with_session( + provider.clone(), + Registry::empty(), + first_session, + Some(HashSet::from(["bash".to_string()])), + ); + let mut successor_session = Session::create(None, None); + successor_session.id.clone_from(&session_id); + let successor = Agent::new_with_session( + provider, + Registry::empty(), + successor_session, + Some(HashSet::from(["read".to_string()])), + ); + + drop(first); + + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "read"), + Some(true), + "a stale Agent must not remove its active successor's policy" + ); + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "bash"), + Some(false), + "the surviving entry must be the successor's configured policy" + ); + drop(successor); + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&session_id, "read"), + None + ); +} + +#[test] +fn agent_clear_moves_tool_policy_registration_to_new_session() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let session = Session::create(None, None); + let previous_session_id = session.id.clone(); + let mut agent = Agent::new_with_session( + provider, + Registry::empty(), + session, + Some(HashSet::from(["bash".to_string()])), + ); + + agent.clear(); + let new_session_id = agent.session.id.clone(); + + assert_ne!(previous_session_id, new_session_id); + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&previous_session_id, "bash"), + None, + "changing sessions must remove the former ID's policy" + ); + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&new_session_id, "bash"), + Some(true), + "the new session must retain the Agent's configured policy" + ); + drop(agent); + assert_eq!( + crate::tool::session_tool_policy_allows_tool_for_test(&new_session_id, "bash"), + None + ); +} + #[async_trait] impl Provider for DelayedProvider { async fn complete( @@ -104,6 +269,61 @@ impl Provider for NativeAutoCompactionProvider { } } +#[async_trait] +impl Provider for NativeCompactionStreamProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let (tx, rx) = tokio_mpsc::channel::>(4); + tokio::spawn(async move { + // Response usage is deliberately far below the provider-reported + // pre-compaction size so a regression that relabels usage as + // `pre_tokens` is caught (#1178). + let _ = tx + .send(Ok(StreamEvent::TokenUsage { + input_tokens: Some(24_000), + output_tokens: Some(10), + cache_read_input_tokens: None, + cache_creation_input_tokens: None, + })) + .await; + let _ = tx + .send(Ok(StreamEvent::Compaction { + trigger: "openai_native".to_string(), + pre_tokens: Some(80_000), + openai_encrypted_content: Some("enc_native_test".to_string()), + })) + .await; + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: Some("end_turn".to_string()), + })) + .await; + }); + Ok(Box::pin(ReceiverStream::new(rx))) + } + + fn name(&self) -> &str { + "openai" + } + + fn supports_compaction(&self) -> bool { + true + } + + fn uses_jcode_compaction(&self) -> bool { + false + } + + fn fork(&self) -> Arc { + Arc::new(Self) + } +} + #[test] fn tool_output_to_content_blocks_preserves_labeled_images() { let output = ToolOutput::new("Image ready").with_labeled_image( @@ -145,6 +365,38 @@ fn tool_output_to_content_blocks_preserves_labeled_images() { } } +#[tokio::test] +async fn queued_soft_interrupt_images_are_injected_as_image_blocks() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let _guard = crate::storage::lock_test_env(); + let mut agent = Agent::new(provider, registry); + + agent.queue_soft_interrupt( + "look at this".to_string(), + vec![("image/png".to_string(), "ZmFrZQ==".to_string())], + false, + SoftInterruptSource::User, + ); + let injected = agent.inject_soft_interrupts(); + + assert_eq!(injected.len(), 1); + let message = agent + .session + .messages + .last() + .expect("soft interrupt should append a user message"); + assert!(matches!( + &message.content[0], + ContentBlock::Image { media_type, data } + if media_type == "image/png" && data == "ZmFrZQ==" + )); + assert!(matches!( + &message.content[1], + ContentBlock::Text { text, .. } if text == "look at this" + )); +} + #[tokio::test] async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { let _guard = crate::storage::lock_test_env(); @@ -169,7 +421,7 @@ async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { let keepalive_deadline = Instant::now() + Duration::from_secs(20); while Instant::now() < keepalive_deadline { match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await { - Ok(Some(ServerEvent::Pong { id })) => { + Ok(Some(ServerEvent::Pong { id, .. })) => { assert_eq!(id, STREAM_KEEPALIVE_PONG_ID); saw_keepalive = true; break; @@ -198,7 +450,7 @@ async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { saw_text = true; break; } - Ok(Some(ServerEvent::Pong { id })) => { + Ok(Some(ServerEvent::Pong { id, .. })) => { assert_eq!(id, STREAM_KEEPALIVE_PONG_ID); } Ok(Some(_)) => {} @@ -216,6 +468,51 @@ async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { task.await.unwrap().unwrap(); } +#[tokio::test] +async fn run_turn_streaming_mpsc_emits_native_compaction_for_client_cache_reset() { + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(NativeCompactionStreamProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "compact this".to_string(), + cache_control: None, + }], + ); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + agent.run_turn_streaming_mpsc(tx).await.unwrap(); + + let mut saw_native_compaction = false; + while let Ok(event) = rx.try_recv() { + if let ServerEvent::Compaction { + trigger, + pre_tokens, + messages_compacted, + .. + } = event + { + assert_eq!(trigger, "openai_native"); + assert_eq!( + pre_tokens, + Some(80_000), + "remote compaction must forward the provider's pre-compaction count" + ); + assert!( + messages_compacted.is_some_and(|count| count > 0), + "native compaction should report a non-empty compacted prefix" + ); + saw_native_compaction = true; + } + } + assert!( + saw_native_compaction, + "native provider compaction must reach clients so they clear KV baselines" + ); +} + /// Provider that transparently switches its model mid-stream, mimicking the /// Anthropic retired-model fallback (`claude-fable-5` -> `claude-opus-4-8`). struct MidStreamModelSwitchProvider { @@ -592,6 +889,15 @@ async fn gmail_is_exposed_by_default_and_can_be_explicitly_disabled() { let tool_names = agent.tool_names().await; let tool_name = "gmail"; + assert!( + tool_names.iter().any(|name| name == "jcode_docs"), + "jcode_docs must be model-visible in regular sessions" + ); + assert!( + !tool_names.iter().any(|name| name == "selfdev"), + "selfdev must not be model-visible in regular sessions" + ); + assert!( definitions .iter() @@ -662,6 +968,7 @@ fn seed_transient_session_state(agent: &mut Agent) { agent.push_alert("pending alert".to_string()); agent.queue_soft_interrupt( "queued interrupt".to_string(), + Vec::new(), true, SoftInterruptSource::User, ); @@ -755,6 +1062,38 @@ async fn restore_session_resets_runtime_interrupt_and_queue_state() { assert!(agent.locked_tools.is_none()); } +#[tokio::test] +async fn explicit_provider_pin_is_persisted_and_reapplied_on_restore() { + let _guard = crate::storage::lock_test_env(); + let provider = Arc::new(ExplicitPinProvider::new("z-ai/glm-5.2")); + let provider_dyn: Arc = provider.clone(); + let registry = Registry::new(provider_dyn.clone()).await; + let mut agent = Agent::new(provider_dyn, registry); + + agent + .set_model("z-ai/glm-5.2@Novita") + .expect("set explicitly pinned model"); + assert_eq!(agent.provider_model(), "z-ai/glm-5.2@Novita"); + let persisted = crate::session::Session::load(agent.session_id()).expect("load saved session"); + assert_eq!(persisted.model.as_deref(), Some("z-ai/glm-5.2@Novita")); + + let restored_provider = Arc::new(ExplicitPinProvider::new("other/model")); + let restored_provider_dyn: Arc = restored_provider.clone(); + let restored_registry = Registry::new(restored_provider_dyn.clone()).await; + let restored_agent = + Agent::new_with_session(restored_provider_dyn, restored_registry, persisted, None); + + assert_eq!( + restored_provider + .set_model_requests + .lock() + .unwrap() + .as_slice(), + ["openrouter:z-ai/glm-5.2@Novita"] + ); + assert_eq!(restored_agent.provider_model(), "z-ai/glm-5.2@Novita"); +} + #[tokio::test] async fn restore_session_rehydrates_injected_memory_ids() { let _guard = crate::storage::lock_test_env(); @@ -929,6 +1268,7 @@ async fn mark_closed_persists_soft_interrupts_for_restore_after_reload() { agent.session.save().expect("save active session"); agent.queue_soft_interrupt( "resume me after reload".to_string(), + Vec::new(), true, SoftInterruptSource::System, ); @@ -1012,6 +1352,200 @@ impl crate::tool::Tool for FakeMcpTool { } } +struct VerboseFakeMcpTool { + name: String, + description: String, +} + +#[async_trait] +impl crate::tool::Tool for VerboseFakeMcpTool { + fn name(&self) -> &str { + &self.name + } + fn description(&self) -> &str { + &self.description + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}} + }) + } + async fn execute( + &self, + _input: serde_json::Value, + _ctx: crate::tool::ToolContext, + ) -> anyhow::Result { + Ok(ToolOutput::new("ok")) + } +} + +async fn register_fake_deferred_mcp_surface(registry: &Registry) { + for name in ["mcp_search", "mcp_call"] { + registry + .register( + name.to_string(), + Arc::new(FakeMcpTool { + name: name.to_string(), + }) as Arc, + ) + .await; + } +} + +async fn agent_with_fake_mcp_surface(mode: crate::config::McpToolsMode, threshold: usize) -> Agent { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + register_fake_deferred_mcp_surface(®istry).await; + registry + .register( + "mcp__test__verbose".to_string(), + Arc::new(VerboseFakeMcpTool { + name: "verbose".to_string(), + description: "large MCP definition ".repeat(32), + }) as Arc, + ) + .await; + let mut agent = Agent::new(provider, registry); + agent.mcp_tools_mode = mode; + agent.mcp_tools_token_threshold = threshold; + agent +} + +#[tokio::test] +async fn mcp_exposure_modes_select_eager_or_fixed_definitions() { + let _guard = crate::storage::lock_test_env(); + + let mut eager = agent_with_fake_mcp_surface(crate::config::McpToolsMode::Eager, 0).await; + let eager_names: Vec = eager + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + assert!(eager_names.iter().any(|name| name == "mcp__test__verbose")); + assert!(!eager_names.iter().any(|name| name == "mcp_search")); + assert!(!eager_names.iter().any(|name| name == "mcp_call")); + + let mut deferred = + agent_with_fake_mcp_surface(crate::config::McpToolsMode::Deferred, usize::MAX).await; + let deferred_names: Vec = deferred + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + assert!(!deferred_names.iter().any(|name| name.starts_with("mcp__"))); + assert!(deferred_names.iter().any(|name| name == "mcp_search")); + assert!(deferred_names.iter().any(|name| name == "mcp_call")); + + let mut auto_eager = + agent_with_fake_mcp_surface(crate::config::McpToolsMode::Auto, usize::MAX).await; + let auto_eager_names: Vec = auto_eager + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + assert!( + auto_eager_names + .iter() + .any(|name| name == "mcp__test__verbose") + ); + + let mut auto_deferred = agent_with_fake_mcp_surface(crate::config::McpToolsMode::Auto, 1).await; + let auto_deferred_names: Vec = auto_deferred + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + assert!( + !auto_deferred_names + .iter() + .any(|name| name.starts_with("mcp__")) + ); + assert!(auto_deferred_names.iter().any(|name| name == "mcp_search")); + assert!(auto_deferred_names.iter().any(|name| name == "mcp_call")); + let stable_auto_names: Vec = auto_deferred + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + assert_eq!(auto_deferred_names, stable_auto_names); + assert!(auto_deferred.mcp_late_register_resolved); +} + +#[tokio::test] +async fn deferred_mcp_surface_ignores_late_per_tool_registration() { + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + register_fake_deferred_mcp_surface(®istry).await; + let mut agent = Agent::new(provider, registry); + agent.mcp_tools_mode = crate::config::McpToolsMode::Deferred; + + let before: Vec = agent + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + agent + .registry + .register( + "mcp__late__tool".to_string(), + Arc::new(FakeMcpTool { + name: "late".to_string(), + }) as Arc, + ) + .await; + let after: Vec = agent + .tool_definitions() + .await + .into_iter() + .map(|tool| tool.name) + .collect(); + + assert_eq!( + before, after, + "fixed deferred surface must stay cache-stable" + ); + assert!(agent.mcp_late_register_resolved); + assert!(!after.iter().any(|name| name.starts_with("mcp__"))); +} + +#[tokio::test] +async fn auto_mode_rechecks_late_mcp_definitions_before_deferring() { + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + register_fake_deferred_mcp_surface(®istry).await; + let mut agent = Agent::new(provider, registry); + agent.mcp_tools_mode = crate::config::McpToolsMode::Auto; + agent.mcp_tools_token_threshold = 1; + + let before = agent.tool_definitions().await; + assert!(!before.iter().any(|tool| tool.name == "mcp_search")); + agent + .registry + .register( + "mcp__late__large".to_string(), + Arc::new(VerboseFakeMcpTool { + name: "large".to_string(), + description: "late large definition ".repeat(32), + }) as Arc, + ) + .await; + + let after = agent.tool_definitions().await; + assert!(after.iter().any(|tool| tool.name == "mcp_search")); + assert!(after.iter().any(|tool| tool.name == "mcp_call")); + assert!(!after.iter().any(|tool| tool.name.starts_with("mcp__"))); + assert!(agent.mcp_late_register_resolved); +} + /// Reproduction for #206: MCP tools that register on the registry *after* the /// first turn locks the tool snapshot never reach the provider, because /// `tool_definitions()` returns the frozen `locked_tools` snapshot and the only @@ -1184,6 +1718,68 @@ async fn tool_snapshot_is_stable_without_new_mcp_tools() { ); } +#[test] +fn empty_post_tool_response_gets_more_than_one_retry() { + // Regression guard for the Claude Opus 5 benchmark incident. A provider can + // return an empty response immediately after tool results; that is a + // transient hiccup, not a finished task. With only one retry allowed, a + // single empty response (observed once in 43 turns) ended a 20-hour agent + // run with the work half-done and the submission unoptimized. + assert!( + Agent::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS > 1, + "a single retry lets one transient empty response end a long run" + ); + // Bounded, so a genuinely finished agent still exits instead of looping. + assert!(Agent::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS <= 10); +} + +#[test] +fn output_budget_truncation_requests_a_continuation() { + // Regression guard for the Claude Opus 5 benchmark incident. A turn cut off + // by the output budget reports stop_reason=max_tokens and can contain zero + // tool calls, which otherwise looks exactly like a finished turn. The agent + // must treat it as incomplete and continue rather than ending the run. + assert!(Agent::should_continue_after_stop_reason("max_tokens")); + assert!(Agent::should_continue_after_stop_reason("MAX_TOKENS")); + assert!(Agent::should_continue_after_stop_reason(" max_tokens ")); + assert!(Agent::should_continue_after_stop_reason( + "max_output_tokens" + )); + assert!(Agent::should_continue_after_stop_reason("length")); + assert!(Agent::should_continue_after_stop_reason("truncated")); + assert!(Agent::should_continue_after_stop_reason("incomplete")); + + // Normal completions must not trigger a continuation loop. + assert!(!Agent::should_continue_after_stop_reason("end_turn")); + assert!(!Agent::should_continue_after_stop_reason("tool_use")); + assert!(!Agent::should_continue_after_stop_reason("stop")); + // An absent reason is the pre-fix wire behaviour: it cannot be recovered + // from, which is precisely why MessageEnd must forward the real reason. + assert!(!Agent::should_continue_after_stop_reason("")); +} + +#[test] +fn stranded_tool_use_stop_is_detected() { + // Second half of the Opus 5 DeepSWE incident: the provider reported + // stop_reason="tool_use" while the parsed tool-call list was empty, so the + // turn loop had nothing to execute and broke out mid-task, discarding every + // uncommitted edit. `tool_use` is a normal completion reason, so + // `should_continue_after_stop_reason` must keep rejecting it; the stranded + // case is only recoverable when it is paired with zero tool calls, which is + // exactly what this predicate is for. + assert!(Agent::is_stranded_tool_use_stop(Some("tool_use"))); + assert!(Agent::is_stranded_tool_use_stop(Some("TOOL_USE"))); + assert!(Agent::is_stranded_tool_use_stop(Some(" tool_use "))); + + assert!(!Agent::is_stranded_tool_use_stop(Some("end_turn"))); + assert!(!Agent::is_stranded_tool_use_stop(Some("max_tokens"))); + assert!(!Agent::is_stranded_tool_use_stop(Some(""))); + assert!(!Agent::is_stranded_tool_use_stop(None)); + // Must stay disjoint from the truncation path so a turn never takes both + // continuation branches for one stop reason. + assert!(!Agent::should_continue_after_stop_reason("tool_use")); +} + #[test] fn guardrail_stop_reason_detection() { assert!(Agent::is_guardrail_stop_reason(Some("refusal"))); @@ -1199,6 +1795,63 @@ fn guardrail_stop_reason_detection() { assert!(!Agent::is_guardrail_stop_reason(None)); } +#[test] +fn fable_guardrail_reconsideration_is_narrow_and_bounded() { + assert!(Agent::should_reconsider_fable_guardrail( + "claude-fable-5", + Some("refusal"), + 0, + 1, + )); + assert!(Agent::should_reconsider_fable_guardrail( + "CLAUDE-FABLE-5-20260801", + Some("content_filter"), + 0, + 1, + )); + assert!(Agent::should_reconsider_fable_guardrail( + "claude-fable-5", + Some("refusal"), + 1, + 3, + )); + assert!(Agent::should_reconsider_fable_guardrail( + "claude-fable-5", + Some("refusal"), + 2, + 3, + )); + assert!(!Agent::should_reconsider_fable_guardrail( + "claude-fable-5", + Some("refusal"), + 3, + 3, + )); + assert!(!Agent::should_reconsider_fable_guardrail( + "claude-fable-5", + Some("end_turn"), + 0, + 1, + )); + assert!(!Agent::should_reconsider_fable_guardrail( + "claude-opus-5", + Some("refusal"), + 0, + 1, + )); +} + +#[test] +fn fable_guardrail_prompt_suite_is_distinct_and_safety_preserving() { + let prompts = Agent::FABLE_GUARDRAIL_RECONSIDERATION_PROMPTS; + assert_eq!(prompts.len(), 3); + assert_ne!(prompts[0], prompts[1]); + assert_ne!(prompts[1], prompts[2]); + assert!(prompts[0].contains("full context")); + assert!(prompts[1].contains("safe portions")); + assert!(prompts[2].contains("Do not weaken a refusal")); +} + #[test] fn guardrail_notice_for_refusal_stop() { let notice = Agent::provider_guardrail_notice(Some("refusal"), true, true) @@ -1208,7 +1861,6 @@ fn guardrail_notice_for_refusal_stop() { "notice should name the stop reason: {notice}" ); assert!(notice.to_lowercase().contains("guardrail")); - // Guardrail stop with visible text still surfaces (partial output then refusal). assert!(Agent::provider_guardrail_notice(Some("refusal"), false, false).is_some()); } @@ -1220,7 +1872,6 @@ fn guardrail_notice_for_silent_empty_turn() { .expect("empty visible output must produce a notice"); assert!(notice.contains("internal reasoning"), "{notice}"); assert!(notice.contains("end_turn"), "{notice}"); - // Unknown stop reason, empty output, no reasoning. let notice = Agent::provider_guardrail_notice(None, true, false) .expect("empty visible output must produce a notice"); @@ -1234,3 +1885,291 @@ fn guardrail_notice_absent_for_normal_turns() { assert!(Agent::provider_guardrail_notice(Some("end_turn"), false, false).is_none()); assert!(Agent::provider_guardrail_notice(None, false, true).is_none()); } + +#[test] +fn empty_turn_log_event_separates_guardrails_from_transient_empties() { + assert_eq!( + Agent::empty_turn_log_event(Some("refusal")), + "PROVIDER_GUARDRAIL" + ); + assert_eq!( + Agent::empty_turn_log_event(Some("content_filter")), + "PROVIDER_GUARDRAIL" + ); + assert_eq!( + Agent::empty_turn_log_event(Some("stop")), + "PROVIDER_EMPTY_RESPONSE" + ); + assert_eq!(Agent::empty_turn_log_event(None), "PROVIDER_EMPTY_RESPONSE"); +} + +#[test] +fn guardrail_notice_for_transient_empty_does_not_blame_content_filter() { + let notice = Agent::provider_guardrail_notice(Some("stop"), true, false) + .expect("empty visible output must produce a notice"); + assert!( + !notice.contains("usually a provider-side guardrail"), + "transient empty responses must not be blamed on a guardrail: {notice}" + ); + assert!(notice.contains("empty response"), "{notice}"); +} + +#[tokio::test] +async fn empty_post_tool_response_is_retried_in_shared_helper() { + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + let mut attempts = 0u32; + // Empty response right after tool results: inject continuation. + let retried = agent + .maybe_continue_empty_post_tool_response(true, true, Some("stop"), &mut attempts) + .expect("helper must not error"); + assert!(retried); + assert_eq!(attempts, 1); + let recovery = agent + .session + .messages + .last() + .expect("recovery instruction must be persisted"); + assert_eq!(recovery.role, Role::User); + assert!( + recovery + .content + .iter() + .find_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .is_some_and(|text| text.starts_with("")), + "synthetic recovery instruction must be hidden from the transcript" + ); + + // A guardrail refusal is deliberate and must not be retried. + let retried = agent + .maybe_continue_empty_post_tool_response(true, true, Some("refusal"), &mut attempts) + .expect("helper must not error"); + assert!(!retried); + + // Visible output or no recent tool result: no retry. + assert!( + !agent + .maybe_continue_empty_post_tool_response(false, true, Some("stop"), &mut attempts) + .unwrap() + ); + assert!( + !agent + .maybe_continue_empty_post_tool_response(true, false, Some("stop"), &mut attempts) + .unwrap() + ); + + // Retry budget is bounded. + attempts = Agent::MAX_EMPTY_POST_TOOL_CONTINUATION_ATTEMPTS; + assert!( + !agent + .maybe_continue_empty_post_tool_response(true, true, Some("stop"), &mut attempts) + .unwrap() + ); +} + +include!("agent_tests/retention_readiness.rs"); + +/// Provider that reproduces the DeepSWE Opus 5 incident: the first response +/// ends with `stop_reason: "tool_use"` while carrying no tool-use block at all, +/// which is what happens when an unrecognized content block is dropped from the +/// stream. The second response is a normal completion, so a correct agent +/// recovers and this provider's queue is exhausted. +#[derive(Clone, Default)] +struct StrandedToolUseProvider { + calls: Arc>, +} + +#[async_trait] +impl Provider for StrandedToolUseProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let call = { + let mut guard = self.calls.lock().unwrap(); + *guard += 1; + *guard + }; + let (tx, rx) = tokio_mpsc::channel::>(8); + tokio::spawn(async move { + if call == 1 { + let _ = tx + .send(Ok(StreamEvent::TextDelta("working on it".to_string()))) + .await; + // No ToolUseStart: the tool block was lost, yet the provider + // still reports that it stopped in order to call a tool. + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: Some("tool_use".to_string()), + })) + .await; + } else { + let _ = tx + .send(Ok(StreamEvent::TextDelta("all done".to_string()))) + .await; + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: Some("end_turn".to_string()), + })) + .await; + } + }); + Ok(Box::pin(ReceiverStream::new(rx))) + } + + fn name(&self) -> &str { + "stranded-tool-use" + } + + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + +/// End-to-end guard for the incident. Before the fix the agent took the +/// "no tool calls" branch and ended the turn on the very first response, so a +/// benchmark trial stopped mid-task and its uncommitted work was never +/// captured. The agent must instead ask the model to continue, which shows up +/// as a second provider call and a final turn that ends normally. +#[tokio::test] +async fn stranded_tool_use_stop_continues_instead_of_ending_the_turn() { + let _guard = crate::storage::lock_test_env(); + let stranded = StrandedToolUseProvider::default(); + let calls = stranded.calls.clone(); + let provider: Arc = Arc::new(stranded); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + agent + .run_once_streaming_mpsc("do the task", Vec::new(), None, tx) + .await + .expect("turn should complete"); + + let mut text = String::new(); + while let Ok(event) = rx.try_recv() { + if let ServerEvent::TextDelta { text: delta } = event { + text.push_str(&delta); + } + } + + assert_eq!( + *calls.lock().unwrap(), + 2, + "a tool_use stop with no tool call must trigger exactly one continuation request" + ); + assert!( + text.contains("all done"), + "the recovered turn must deliver the model's real completion, got {text:?}" + ); +} + +#[derive(Clone, Default)] +struct FableGuardrailProvider { + calls: Arc>, + prompts_seen: Arc>>, +} + +#[async_trait] +impl Provider for FableGuardrailProvider { + async fn complete( + &self, + messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let call = { + let mut calls = self.calls.lock().unwrap(); + *calls += 1; + *calls + }; + if call > 1 { + let prompt = messages + .last() + .map(message_text) + .unwrap_or_default() + .to_string(); + self.prompts_seen.lock().unwrap().push(prompt); + } + + let (tx, rx) = tokio_mpsc::channel::>(4); + tokio::spawn(async move { + if call <= 3 { + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: Some("refusal".to_string()), + })) + .await; + } else { + let _ = tx + .send(Ok(StreamEvent::TextDelta( + "Reconsidered and completed safely".to_string(), + ))) + .await; + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: Some("end_turn".to_string()), + })) + .await; + } + }); + Ok(Box::pin(ReceiverStream::new(rx))) + } + + fn name(&self) -> &str { + "anthropic" + } + + fn model(&self) -> String { + "claude-fable-5".to_string() + } + + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + +#[tokio::test] +async fn fable_guardrail_reconsideration_recovers_the_streaming_turn() { + let _guard = crate::storage::lock_test_env(); + let fable = FableGuardrailProvider::default(); + let calls = fable.calls.clone(); + let prompts_seen = fable.prompts_seen.clone(); + let provider: Arc = Arc::new(fable); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + agent + .run_once_streaming_mpsc("do this ordinary coding task", Vec::new(), None, tx) + .await + .expect("turn should recover from the guardrail"); + + let mut text = String::new(); + while let Ok(event) = rx.try_recv() { + if let ServerEvent::TextDelta { text: delta } = event { + text.push_str(&delta); + } + } + + assert_eq!(*calls.lock().unwrap(), 4); + let prompts = prompts_seen.lock().unwrap(); + assert_eq!(prompts.len(), 3); + assert!(prompts[0].contains("concrete harmful action")); + assert!(prompts[1].contains("safe portions")); + assert!(prompts[2].contains("final, independent policy check")); + assert!( + text.contains("Reconsidered and completed safely"), + "{text:?}" + ); +} diff --git a/crates/jcode-app-core/src/agent_tests/concurrency.rs b/crates/jcode-app-core/src/agent_tests/concurrency.rs new file mode 100644 index 0000000000..a42fae479a --- /dev/null +++ b/crates/jcode-app-core/src/agent_tests/concurrency.rs @@ -0,0 +1,114 @@ +use super::*; + +// These test the real Agent lifecycle wiring. Telemetry-core separately tests +// live OS leases and process crashes. Never send synthetic Agent events to the +// production endpoint while exercising construction/clear/resume here. +struct IsolatedEnv { + _home: tempfile::TempDir, + previous_home: Option, + previous_opt_out: Option, +} + +impl IsolatedEnv { + fn new() -> Self { + let home = tempfile::tempdir().unwrap(); + let previous_home = std::env::var_os("JCODE_HOME"); + let previous_opt_out = std::env::var_os("JCODE_NO_TELEMETRY"); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::set_var("JCODE_NO_TELEMETRY", "1"); + Self { + _home: home, + previous_home, + previous_opt_out, + } + } +} + +impl Drop for IsolatedEnv { + fn drop(&mut self) { + for (key, value) in [ + ("JCODE_HOME", self.previous_home.take()), + ("JCODE_NO_TELEMETRY", self.previous_opt_out.take()), + ] { + if let Some(value) = value { + crate::env::set_var(key, value); + } else { + crate::env::remove_var(key); + } + } + } +} + +fn assert_owns_current_session(agent: &Agent) { + let guard = agent + .concurrency_session + .as_ref() + .expect("Agent owns a guard"); + assert_eq!(guard.session_id(), agent.session_id()); + assert!(!guard.is_active(), "test explicitly opted out of telemetry"); +} + +#[tokio::test] +async fn concurrency_guard_follows_clear_restore_and_close() { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedEnv::new(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + assert_owns_current_session(&agent); + let original_id = agent.session_id().to_owned(); + + // Failed restores must not end the currently owned session. + assert!( + agent + .restore_session("nonexistent-concurrency-session") + .is_err() + ); + assert_eq!(agent.session_id(), original_id); + assert_owns_current_session(&agent); + + agent.clear(); + assert_ne!(agent.session_id(), original_id); + assert_owns_current_session(&agent); + + let mut restored = Session::create( + Some("parent-concurrency-test".to_owned()), + Some("Concurrency restore fixture".to_owned()), + ); + restored.save().unwrap(); + agent.restore_session(&restored.id).unwrap(); + assert_owns_current_session(&agent); + + agent.mark_closed(); + assert!( + agent.concurrency_session.is_none(), + "retained closed agents are not live" + ); + agent.mark_closed(); + assert!(agent.concurrency_session.is_none(), "closing is idempotent"); + + // A retained Agent can resume after it has already been closed. + agent.restore_session(&restored.id).unwrap(); + assert_owns_current_session(&agent); + agent.mark_crashed(Some("test".to_owned())); + assert!(agent.concurrency_session.is_none()); +} + +#[tokio::test] +async fn concurrency_guards_belong_to_each_agent_not_the_global_telemetry_slot() { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedEnv::new(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut first = Agent::new(provider.clone(), registry); + let child_session = Session::create(Some(first.session_id().to_owned()), None); + let registry = Registry::new(provider.clone()).await; + let second = Agent::new_with_session(provider, registry, child_session, None); + assert_owns_current_session(&first); + assert_owns_current_session(&second); + assert_ne!(first.session_id(), second.session_id()); + + first.mark_closed(); + assert!(first.concurrency_session.is_none()); + assert_owns_current_session(&second); +} diff --git a/crates/jcode-app-core/src/agent_tests/concurrency_construction.rs b/crates/jcode-app-core/src/agent_tests/concurrency_construction.rs new file mode 100644 index 0000000000..b17a379140 --- /dev/null +++ b/crates/jcode-app-core/src/agent_tests/concurrency_construction.rs @@ -0,0 +1,83 @@ +use super::*; + +struct IsolatedTelemetryEnv { + _home: tempfile::TempDir, + previous: Vec<(&'static str, Option)>, +} + +impl IsolatedTelemetryEnv { + fn new() -> Self { + let home = tempfile::tempdir().unwrap(); + let previous = ["JCODE_HOME", "JCODE_NO_TELEMETRY"] + .into_iter() + .map(|key| (key, std::env::var_os(key))) + .collect(); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::set_var("JCODE_NO_TELEMETRY", "1"); + Self { + _home: home, + previous, + } + } +} + +impl Drop for IsolatedTelemetryEnv { + fn drop(&mut self) { + for (key, value) in self.previous.drain(..) { + match value { + Some(value) => crate::env::set_var(key, value), + None => crate::env::remove_var(key), + } + } + } +} + +#[tokio::test] +async fn provisional_connection_does_not_track_until_logical_ownership_commits() { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedTelemetryEnv::new(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new_provisional_with_initial_working_dir(provider, registry, None); + assert!( + !agent.has_concurrency_tracking(), + "a viewer placeholder is not a live logical session" + ); + agent.activate_concurrency_tracking(); + assert!( + agent.has_concurrency_tracking(), + "idle committed sessions must count before their first turn" + ); + let first_guard = format!("{:?}", agent.concurrency_session); + agent.activate_concurrency_tracking(); + assert_eq!( + format!("{:?}", agent.concurrency_session), + first_guard, + "repeated subscribe must not create another incarnation" + ); + agent.mark_closed(); + assert!(!agent.has_concurrency_tracking()); +} + +#[tokio::test] +async fn headless_parent_is_set_before_concurrency_tracking_begins() { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedTelemetryEnv::new(); + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let child = Agent::new_with_parent_and_initial_working_dir( + provider.clone(), + registry, + None, + Some("coordinator-session".to_owned()), + ); + assert_eq!( + child.session.parent_id.as_deref(), + Some("coordinator-session") + ); + assert!(format!("{:?}", child.concurrency_session).contains("child: true")); + let registry = Registry::new(provider.clone()).await; + let root = Agent::new_with_parent_and_initial_working_dir(provider, registry, None, None); + assert!(root.session.parent_id.is_none()); + assert!(format!("{:?}", root.concurrency_session).contains("child: false")); +} diff --git a/crates/jcode-app-core/src/agent_tests/retention_readiness.rs b/crates/jcode-app-core/src/agent_tests/retention_readiness.rs new file mode 100644 index 0000000000..d306334333 --- /dev/null +++ b/crates/jcode-app-core/src/agent_tests/retention_readiness.rs @@ -0,0 +1,633 @@ +// Deterministic longitudinal synthetic-cohort evaluator for retention readiness. +// +// This deliberately does NOT claim to measure human retention. It asks whether +// jcode has the product properties that make returning likely: a useful first +// result, cheap re-entry, preserved context, durable state, recoverable failure, +// and value that compounds across sessions. Real D1/D7/D30 cohort retention is +// a separate telemetry outcome and is never synthesized here. +// +// The test drives the real Agent, Provider, Session persistence, and restore +// paths through labeled D0/D1/D7 boundaries. The labels are deterministic phase +// boundaries, not wall-clock sleeps. Run the scorecard with: +// +// cargo test -p jcode-app-core --lib retention_readiness -- --nocapture + +#[derive(Clone)] +struct RetentionReadinessProvider { + fail_d7_once: std::sync::Arc, + transcripts: std::sync::Arc>>>, +} + +struct RetentionHomeRestore(Option); + +impl Drop for RetentionHomeRestore { + fn drop(&mut self) { + if let Some(previous) = self.0.take() { + crate::env::set_var("JCODE_HOME", previous); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RetentionFactorStatus { + Scored, + Deferred, + ObservedOnly, +} + +struct RetentionFactor { + name: &'static str, + status: RetentionFactorStatus, + rationale: &'static str, +} + +/// Explicit scope registry. Observed-only outcomes never enter this +/// deterministic score. Known evidence gaps lower coverage rather than vanish. +fn retention_factor_registry() -> [RetentionFactor; 9] { + use RetentionFactorStatus::{Deferred, ObservedOnly, Scored}; + [ + RetentionFactor { + name: "assistant-response first value", + status: Scored, + rationale: "D0 real Agent turn reaches a deterministic useful answer", + }, + RetentionFactor { + name: "return friction", + status: Scored, + rationale: "D1 counts context restatement and prompts-to-value", + }, + RetentionFactor { + name: "state continuity", + status: Scored, + rationale: "D1 disk rehydrate preserves transcript, metadata, and memory marker", + }, + RetentionFactor { + name: "restart/failure durability", + status: Scored, + rationale: "D7 provider failure leaves the real persisted Session loadable", + }, + RetentionFactor { + name: "failure recovery", + status: Scored, + rationale: "D7 returns to useful value after one explicit retry", + }, + RetentionFactor { + name: "compounding context value", + status: Scored, + rationale: "D7 success is conditional on both D0 and D1 context", + }, + RetentionFactor { + name: "tool-backed first value", + status: Deferred, + rationale: "v1 proves a useful answer, but not yet a successful real tool/file edit", + }, + RetentionFactor { + name: "credential/provider/OS return parity", + status: Deferred, + rationale: "needs persisted credential reconstruction across a provider x OS matrix", + }, + RetentionFactor { + name: "observed D1/D7/D30 meaningful-work retention", + status: ObservedOnly, + rationale: "requires privacy-safe real cohorts and must never be synthesized", + }, + ] +} + +impl RetentionReadinessProvider { + fn new() -> Self { + Self { + fail_d7_once: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)), + transcripts: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + } + } + + fn transcript_snapshots(&self) -> Vec> { + self.transcripts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +#[async_trait] +impl Provider for RetentionReadinessProvider { + async fn complete( + &self, + messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let transcript: Vec = messages + .iter() + .map(|message| message_text(message).to_string()) + .collect(); + self.transcripts + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(transcript.clone()); + + let latest = transcript.last().cloned().unwrap_or_default(); + if latest.contains("D7_RECOVER") + && self + .fail_d7_once + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + anyhow::bail!("synthetic provider outage"); + } + + let has_d0 = transcript.iter().any(|text| text.contains("D0_ACTIVATE")); + let has_d0_value = transcript.iter().any(|text| text.contains("VALUE_D0")); + let has_d1 = transcript.iter().any(|text| text.contains("D1_RETURN")); + let has_d1_value = transcript.iter().any(|text| text.contains("CONTINUITY_D1")); + let answer = if latest.contains("D0_ACTIVATE") { + "VALUE_D0" + } else if latest.contains("D1_RETURN") && has_d0 && has_d0_value { + "CONTINUITY_D1" + } else if latest.contains("D7_RECOVER") && has_d0 && has_d0_value && has_d1 && has_d1_value + { + "COMPOUNDED_D7" + } else { + "CONTEXT_LOST" + }; + + let (tx, rx) = tokio_mpsc::channel::>(4); + tx.send(Ok(StreamEvent::TextDelta(answer.to_string()))) + .await + .expect("retention provider event receiver"); + tx.send(Ok(StreamEvent::MessageEnd { + stop_reason: Some("end_turn".to_string()), + })) + .await + .expect("retention provider event receiver"); + drop(tx); + Ok(Box::pin(ReceiverStream::new(rx))) + } + + fn name(&self) -> &str { + "retention-readiness" + } + + fn model(&self) -> String { + "retention-fixture-v1".to_string() + } + + fn fork(&self) -> std::sync::Arc { + std::sync::Arc::new(self.clone()) + } +} + +#[derive(Debug, Clone, Copy)] +struct RetentionDimensionScores { + activation: f64, + return_friction: f64, + continuity: f64, + durability: f64, + recovery: f64, + compounding_value: f64, +} + +impl RetentionDimensionScores { + fn values(self) -> [(&'static str, f64, f64); 6] { + [ + ("activation / first value", self.activation, 0.25), + ("return friction", self.return_friction, 0.20), + ("continuity", self.continuity, 0.20), + ("durability", self.durability, 0.15), + ("failure recovery", self.recovery, 0.10), + ("compounding value", self.compounding_value, 0.10), + ] + } + + /// Weighted geometric mean. A zero in any dimension makes the behavioral + /// score zero, so perfect setup cannot compensate for catastrophic state + /// loss or inability to produce value. + fn behavioral_score(self) -> f64 { + self.values() + .into_iter() + .map(|(_, score, weight)| { + let normalized = (score.clamp(0.0, 100.0) / 100.0).max(f64::MIN_POSITIVE); + normalized.powf(weight) + }) + .product::() + * 100.0 + } +} + +#[derive(Debug)] +struct RetentionJourneyEvidence { + first_value: bool, + session_persisted: bool, + d1_context_available: bool, + restatement_steps: u32, + return_prompts_to_value: u32, + title_preserved: bool, + working_dir_preserved: bool, + memory_marker_preserved: bool, + history_preserved_after_failure: bool, + outage_surfaced: bool, + recovery_retries: u32, + recovered_value: bool, + compounded_value: bool, +} + +fn retention_dimension_scores(e: &RetentionJourneyEvidence) -> RetentionDimensionScores { + let activation = 70.0 * f64::from(e.first_value) + 30.0 * f64::from(e.session_persisted); + + let return_friction = (100.0 + - e.restatement_steps as f64 * 25.0 + - e.return_prompts_to_value.saturating_sub(1) as f64 * 15.0) + .max(0.0); + + let continuity = 25.0 + * [ + e.d1_context_available, + e.title_preserved, + e.working_dir_preserved, + e.memory_marker_preserved, + ] + .into_iter() + .filter(|ok| *ok) + .count() as f64; + + let durability = + 50.0 * f64::from(e.history_preserved_after_failure) + 50.0 * f64::from(e.session_persisted); + + let recovery = if !e.outage_surfaced || !e.recovered_value { + 0.0 + } else { + (100.0 - e.recovery_retries.saturating_sub(1) as f64 * 20.0).max(0.0) + }; + + let compounding_value = 100.0 * f64::from(e.compounded_value); + + RetentionDimensionScores { + activation, + return_friction, + continuity, + durability, + recovery, + compounding_value, + } +} + +#[tokio::test] +async fn retention_readiness_scorecard() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("retention readiness home"); + let previous_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + let _home_restore = RetentionHomeRestore(previous_home); + + let provider_fixture = RetentionReadinessProvider::new(); + let provider: std::sync::Arc = std::sync::Arc::new(provider_fixture.clone()); + let registry = Registry::new(provider.clone()).await; + + // D0: one prompt reaches a deterministic useful answer and creates durable + // state that a return journey can benefit from. + let mut d0 = Agent::new(provider.clone(), registry.clone()); + d0.session + .rename_title(Some("Retention cohort project".to_string())); + d0.session.working_dir = Some("/synthetic/retention-project".to_string()); + d0.session.record_memory_injection( + "cohort preference".to_string(), + "Prefer deterministic validation".to_string(), + 1, + 0, + vec!["retention-memory-v1".to_string()], + ); + let session_id = d0.session_id().to_string(); + let d0_answer = d0 + .run_once_capture("D0_ACTIVATE explain this project") + .await + .expect("D0 activation turn"); + d0.session.save().expect("persist D0 state"); + drop(d0); + + let persisted_d0 = Session::load(&session_id).expect("load D0 session"); + + // D1: construct a new Agent from disk, not the old in-memory object. The + // fixture only returns CONTINUITY_D1 when the real provider transcript still + // contains both D0's prompt and its useful answer. + let d1_provider: std::sync::Arc = std::sync::Arc::new(provider_fixture.clone()); + let d1_registry = Registry::new(d1_provider.clone()).await; + let mut d1 = Agent::new_with_session(d1_provider, d1_registry, persisted_d0, None); + let d1_answer = d1 + .run_once_capture("D1_RETURN continue without restating context") + .await + .expect("D1 return turn"); + d1.session.save().expect("persist D1 state"); + drop(d1); + + // D7: inject one deterministic provider outage. The failed user turn must be + // durable, the process-like rehydrate must work, and one retry must produce + // value that depends on BOTH earlier sessions. + let persisted_d1 = Session::load(&session_id).expect("load D1 session"); + let d1_message_count = persisted_d1.messages.len(); + let title_preserved = persisted_d1.custom_title.as_deref() == Some("Retention cohort project"); + let working_dir_preserved = + persisted_d1.working_dir.as_deref() == Some("/synthetic/retention-project"); + let memory_marker_preserved = persisted_d1 + .injected_memory_ids() + .iter() + .any(|id| id == "retention-memory-v1"); + let d7_provider: std::sync::Arc = std::sync::Arc::new(provider_fixture.clone()); + let d7_registry = Registry::new(d7_provider.clone()).await; + let mut d7 = Agent::new_with_session(d7_provider, d7_registry, persisted_d1, None); + let outage = d7 + .run_once_capture("D7_RECOVER finish the longitudinal task") + .await; + drop(d7); + + let after_failure = Session::load(&session_id).expect("session survives outage"); + let history_preserved_after_failure = after_failure.messages.len() > d1_message_count + && after_failure.messages.iter().any(|stored| { + message_text(&stored.to_message()).contains("D7_RECOVER finish the longitudinal task") + }); + let recovered_provider: std::sync::Arc = + std::sync::Arc::new(provider_fixture.clone()); + let recovered_registry = Registry::new(recovered_provider.clone()).await; + let mut recovered = + Agent::new_with_session(recovered_provider, recovered_registry, after_failure, None); + let d7_answer = recovered + .run_once_capture("D7_RECOVER retry once") + .await + .expect("D7 recovery turn"); + recovered + .session + .save() + .expect("persist recovered D7 state"); + + let snapshots = provider_fixture.transcript_snapshots(); + let d1_context_available = snapshots.iter().any(|snapshot| { + snapshot.iter().any(|text| text.contains("D1_RETURN")) + && snapshot.iter().any(|text| text.contains("D0_ACTIVATE")) + && snapshot.iter().any(|text| text.contains("VALUE_D0")) + }); + + let evidence = RetentionJourneyEvidence { + first_value: d0_answer.contains("VALUE_D0"), + session_persisted: Session::load(&session_id).is_ok(), + d1_context_available: d1_context_available && d1_answer.contains("CONTINUITY_D1"), + restatement_steps: 0, + return_prompts_to_value: 1, + title_preserved, + working_dir_preserved, + memory_marker_preserved, + history_preserved_after_failure, + outage_surfaced: outage.is_err(), + recovery_retries: 1, + recovered_value: d7_answer.contains("COMPOUNDED_D7"), + compounded_value: d7_answer.contains("COMPOUNDED_D7"), + }; + let scores = retention_dimension_scores(&evidence); + let behavioral = scores.behavioral_score(); + + // Coverage is reported separately and gates the headline. V1 scores six + // deterministic factors but deliberately defers tool-backed first value and + // credential/provider/OS return parity. A perfect covered journey therefore + // cannot be misreported as perfect evidence about retention overall. + let factors = retention_factor_registry(); + let scored_factors = factors + .iter() + .filter(|factor| factor.status == RetentionFactorStatus::Scored) + .count(); + let acknowledged_factors = factors + .iter() + .filter(|factor| factor.status != RetentionFactorStatus::ObservedOnly) + .count(); + let evidence_coverage = scored_factors as f64 / acknowledged_factors as f64 * 100.0; + let coverage_adjusted = behavioral * evidence_coverage / 100.0; + + println!("\n================ RETENTION READINESS (SYNTHETIC COHORT) ================"); + println!("This is a deterministic product-readiness proxy, NOT observed user retention.\n"); + println!("journey boundary outcome"); + println!( + "D0 activate {}", + if evidence.first_value { + "useful value" + } else { + "FAIL" + } + ); + println!( + "D1 return {}", + if evidence.d1_context_available { + "context continued" + } else { + "FAIL" + } + ); + println!( + "D7 outage {}", + if evidence.outage_surfaced { + "surfaced" + } else { + "FAIL" + } + ); + println!( + "D7 retry {}", + if evidence.recovered_value { + "recovered + compounded" + } else { + "FAIL" + } + ); + println!("\n-- dimensions (weighted geometric mean) --"); + for (name, score, weight) in scores.values() { + println!( + "{name:<26} {score:>5.1} / 100 weight={weight:.0}%", + weight = weight * 100.0 + ); + } + println!("\nBEHAVIORAL READINESS : {behavioral:>5.1} / 100"); + println!( + "EVIDENCE COVERAGE : {evidence_coverage:>5.1} / 100 ({scored_factors}/{acknowledged_factors} factors)" + ); + println!("COVERAGE-ADJUSTED : {coverage_adjusted:>5.1} / 100"); + for factor in factors + .iter() + .filter(|factor| factor.status == RetentionFactorStatus::Deferred) + { + println!( + "Deferred : {} ({})", + factor.name, factor.rationale + ); + } + println!("Observed counterpart : D1/D7/D30 meaningful-work cohorts (telemetry, separate)\n"); + + // Hard gates: no weighted average may hide loss of first value, continuity, + // durable state, recovery, or compounded context. + assert!(evidence.first_value, "D0 did not reach useful first value"); + assert!( + evidence.session_persisted, + "synthetic cohort session was not durable" + ); + assert!( + evidence.d1_context_available, + "D1 return lost prior context" + ); + assert!( + evidence.title_preserved + && evidence.working_dir_preserved + && evidence.memory_marker_preserved, + "D1 return lost persisted metadata or memory state" + ); + assert!( + evidence.history_preserved_after_failure, + "provider outage lost session history" + ); + assert!( + evidence.outage_surfaced && evidence.recovered_value, + "provider outage did not recover in one retry" + ); + assert!( + evidence.compounded_value, + "D7 result did not depend on D0 + D1 context" + ); + assert!( + behavioral >= 80.0, + "behavioral retention readiness regressed: {behavioral:.1}" + ); +} + +#[test] +fn retention_readiness_scoring_is_monotonic_and_non_compensating() { + let perfect = RetentionJourneyEvidence { + first_value: true, + session_persisted: true, + d1_context_available: true, + restatement_steps: 0, + return_prompts_to_value: 1, + title_preserved: true, + working_dir_preserved: true, + memory_marker_preserved: true, + history_preserved_after_failure: true, + outage_surfaced: true, + recovery_retries: 1, + recovered_value: true, + compounded_value: true, + }; + let perfect_scores = retention_dimension_scores(&perfect); + let baseline = perfect_scores.behavioral_score(); + let total_weight: f64 = perfect_scores + .values() + .into_iter() + .map(|(_, _, weight)| weight) + .sum(); + assert!((total_weight - 1.0).abs() < f64::EPSILON); + + let worse_cases = [ + RetentionJourneyEvidence { + first_value: false, + ..perfect + }, + RetentionJourneyEvidence { + session_persisted: false, + ..perfect + }, + RetentionJourneyEvidence { + restatement_steps: 1, + ..perfect + }, + RetentionJourneyEvidence { + return_prompts_to_value: 2, + ..perfect + }, + RetentionJourneyEvidence { + d1_context_available: false, + ..perfect + }, + RetentionJourneyEvidence { + title_preserved: false, + ..perfect + }, + RetentionJourneyEvidence { + working_dir_preserved: false, + ..perfect + }, + RetentionJourneyEvidence { + memory_marker_preserved: false, + ..perfect + }, + RetentionJourneyEvidence { + history_preserved_after_failure: false, + ..perfect + }, + RetentionJourneyEvidence { + outage_surfaced: false, + ..perfect + }, + RetentionJourneyEvidence { + recovery_retries: 3, + ..perfect + }, + RetentionJourneyEvidence { + recovered_value: false, + ..perfect + }, + RetentionJourneyEvidence { + compounded_value: false, + ..perfect + }, + ]; + for worse in &worse_cases { + assert!( + retention_dimension_scores(worse).behavioral_score() < baseline, + "making one retention factor worse must lower the score" + ); + } + + let catastrophic = retention_dimension_scores(&RetentionJourneyEvidence { + compounded_value: false, + ..perfect + }); + assert!( + catastrophic.behavioral_score() < 1.0, + "a zero dimension must not be compensated by perfect sibling dimensions" + ); +} + +#[test] +fn retention_readiness_factor_registry_has_explicit_scope_and_rationales() { + let factors = retention_factor_registry(); + assert_eq!( + factors + .iter() + .filter(|f| f.status == RetentionFactorStatus::Scored) + .count(), + 6 + ); + assert_eq!( + factors + .iter() + .filter(|f| f.status == RetentionFactorStatus::Deferred) + .count(), + 2 + ); + assert_eq!( + factors + .iter() + .filter(|f| f.status == RetentionFactorStatus::ObservedOnly) + .count(), + 1 + ); + let mut names = std::collections::BTreeSet::new(); + for factor in factors { + assert!(!factor.name.trim().is_empty()); + assert!( + names.insert(factor.name), + "duplicate factor: {}", + factor.name + ); + assert!( + !factor.rationale.trim().is_empty(), + "retention factor '{}' has no rationale", + factor.name + ); + } +} diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs index 4c49ffcb9b..eb1e9e10b0 100644 --- a/crates/jcode-app-core/src/ambient/runner.rs +++ b/crates/jcode-app-core/src/ambient/runner.rs @@ -106,6 +106,7 @@ impl AmbientRunnerHandle { { q.push(SoftInterruptMessage { content: format!("[{} message from user]\n{}", source, text), + images: Vec::new(), urgent: false, source: SoftInterruptSource::User, }); @@ -395,7 +396,12 @@ impl AmbientRunnerHandle { agent.restore_session(session_id)?; let reminder = ambient::format_scheduled_session_message(item); - let _ = agent.run_once_capture(&reminder).await?; + let _ = agent + .run_once_capture_with_display_role( + &reminder, + Some(crate::session::StoredDisplayRole::System), + ) + .await?; agent.mark_closed(); Ok(()) } @@ -469,7 +475,12 @@ impl AmbientRunnerHandle { } let reminder = ambient::format_scheduled_session_message(item); - let _ = agent.run_once_capture(&reminder).await?; + let _ = agent + .run_once_capture_with_display_role( + &reminder, + Some(crate::session::StoredDisplayRole::System), + ) + .await?; agent.mark_closed(); Ok(child_session_id) } @@ -876,17 +887,55 @@ impl AmbientRunnerHandle { /// Run a single ambient cycle. Returns the cycle result. async fn run_cycle(&self, provider: &Arc) -> anyhow::Result { + self.run_cycle_with_visible_launcher(provider, config().ambient.visible, || { + let jcode_bin = + std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode")); + + std::process::Command::new("kitty") + .args([ + "--title", + "🤖 jcode ambient cycle", + "-e", + &jcode_bin.to_string_lossy(), + "ambient", + "run-visible", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + }) + .await + } + + async fn run_cycle_with_visible_launcher( + &self, + provider: &Arc, + visible: bool, + launch_visible: F, + ) -> anyhow::Result + where + F: FnOnce() -> std::io::Result + Send, + { let started_at = Utc::now(); - let visible = config().ambient.visible; self.set_running_detail("gathering context").await; let (system_prompt, initial_message) = self.build_cycle_context(provider).await?; // Visible mode: spawn a full TUI instead of running headlessly if visible { - return self - .run_cycle_visible(started_at, system_prompt, initial_message) - .await; + match self + .run_cycle_visible( + started_at, + system_prompt.clone(), + initial_message.clone(), + launch_visible, + ) + .await? + { + VisibleCycleOutcome::Completed(result) => return Ok(*result), + VisibleCycleOutcome::FallBackHeadless => {} + } } // Headless mode: run agent directly @@ -975,12 +1024,16 @@ impl AmbientRunnerHandle { } /// Run a visible ambient cycle by spawning a full TUI in a kitty window. - async fn run_cycle_visible( + async fn run_cycle_visible( &self, started_at: chrono::DateTime, system_prompt: String, initial_message: String, - ) -> anyhow::Result { + launch_visible: F, + ) -> anyhow::Result + where + F: FnOnce() -> std::io::Result + Send, + { use crate::ambient::VisibleCycleContext; self.set_running_detail("launching visible TUI").await; @@ -997,25 +1050,9 @@ impl AmbientRunnerHandle { let _ = std::fs::remove_file(&result_path); } - // Find the jcode binary - let jcode_bin = - std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("jcode")); - // Spawn kitty with `jcode ambient run-visible` logging::info("Ambient visible: spawning kitty with jcode TUI"); - let child = std::process::Command::new("kitty") - .args([ - "--title", - "🤖 jcode ambient cycle", - "-e", - &jcode_bin.to_string_lossy(), - "ambient", - "run-visible", - ]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn(); + let child = launch_visible(); match child { Ok(mut child) => { @@ -1035,25 +1072,29 @@ impl AmbientRunnerHandle { crate::storage::read_json::(&result_path) { let _ = std::fs::remove_file(&result_path); - return Ok(AmbientCycleResult { - started_at, - ended_at: Utc::now(), - ..result - }); + return Ok(VisibleCycleOutcome::Completed(Box::new( + AmbientCycleResult { + started_at, + ended_at: Utc::now(), + ..result + }, + ))); } // No result file — user closed the window without end_ambient_cycle - Ok(AmbientCycleResult { - summary: "Visible cycle ended (user closed window)".to_string(), - memories_modified: 0, - compactions: 0, - proactive_work: None, - next_schedule: None, - started_at, - ended_at: Utc::now(), - status: CycleStatus::Incomplete, - conversation: None, - }) + Ok(VisibleCycleOutcome::Completed(Box::new( + AmbientCycleResult { + summary: "Visible cycle ended (user closed window)".to_string(), + memories_modified: 0, + compactions: 0, + proactive_work: None, + next_schedule: None, + started_at, + ended_at: Utc::now(), + status: CycleStatus::Incomplete, + conversation: None, + }, + ))) } Err(e) => { logging::warn(&format!( @@ -1061,12 +1102,17 @@ impl AmbientRunnerHandle { e )); // Fall back to headless mode - Err(anyhow::anyhow!("Failed to spawn visible TUI: {}", e)) + Ok(VisibleCycleOutcome::FallBackHeadless) } } } } +enum VisibleCycleOutcome { + Completed(Box), + FallBackHeadless, +} + // --------------------------------------------------------------------------- #[cfg(test)] diff --git a/crates/jcode-app-core/src/ambient/runner_tests.rs b/crates/jcode-app-core/src/ambient/runner_tests.rs index 94146aaa0d..867e77b99c 100644 --- a/crates/jcode-app-core/src/ambient/runner_tests.rs +++ b/crates/jcode-app-core/src/ambient/runner_tests.rs @@ -7,6 +7,7 @@ use anyhow::Result; use async_stream::stream; use async_trait::async_trait; use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; @@ -121,6 +122,45 @@ async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() { let _ = task.await; } +async fn assert_visible_launch_error_falls_back(error_kind: std::io::ErrorKind) { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let provider: Arc = Arc::new(StreamingTestProvider::default()); + let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new())); + let launch_attempted = Arc::new(AtomicBool::new(false)); + let launch_attempted_in_callback = launch_attempted.clone(); + + let result = runner + .run_cycle_with_visible_launcher(&provider, true, move || { + launch_attempted_in_callback.store(true, Ordering::SeqCst); + Err(std::io::Error::from(error_kind)) + }) + .await + .expect("failed visible launch should continue as a headless cycle"); + + assert!(launch_attempted.load(Ordering::SeqCst)); + assert!( + result.conversation.is_some(), + "headless fallback should capture an agent conversation" + ); + assert!( + result.summary.contains("forced end after 2 attempts"), + "headless fallback should return the headless agent result" + ); +} + +#[tokio::test] +async fn unsupported_visible_launch_falls_back_to_headless() { + assert_visible_launch_error_falls_back(std::io::ErrorKind::Unsupported).await; +} + +#[tokio::test] +async fn missing_visible_launcher_falls_back_to_headless() { + assert_visible_launch_error_falls_back(std::io::ErrorKind::NotFound).await; +} + #[tokio::test] async fn spawn_target_creates_one_child_session_and_runs_task() { let _guard = crate::storage::lock_test_env(); diff --git a/crates/jcode-app-core/src/notifications.rs b/crates/jcode-app-core/src/notifications.rs index def622a657..94e41a1f23 100644 --- a/crates/jcode-app-core/src/notifications.rs +++ b/crates/jcode-app-core/src/notifications.rs @@ -16,6 +16,46 @@ use jcode_notify_email::{ }; pub use jcode_notify_email::{extract_permission_id, parse_permission_reply}; +/// Stable schema version for files handed to the bundled macOS notification +/// broker. The broker ignores payloads with a newer schema instead of guessing +/// at their meaning. +pub const MACOS_NOTIFICATION_SCHEMA_VERSION: u32 = 1; + +/// The terminal route attached to a macOS turn notification. +/// +/// `tty` is the strongest identifier available across Terminal.app and iTerm2: +/// both expose it in their AppleScript dictionaries, so a notification click +/// can select the exact originating tab/session. Ghostty currently exposes no +/// supported per-surface activation API, so its route intentionally degrades to +/// activating the application. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MacosTerminalKind { + AppleTerminal, + Iterm2, + Ghostty, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MacosNotificationOrigin { + pub terminal: MacosTerminalKind, + pub bundle_id: Option, + pub tty: Option, + pub session_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MacosNotificationEnvelope { + pub schema_version: u32, + pub notification_id: String, + pub title: String, + pub subtitle: Option, + pub body: String, + pub sound: Option, + pub origin: MacosNotificationOrigin, +} + /// Notification priority levels (maps to ntfy priority header). #[derive(Debug, Clone, Copy)] pub enum Priority { @@ -275,6 +315,343 @@ async fn send_ntfy( // Desktop (cross-platform, fire-and-forget) // --------------------------------------------------------------------------- +#[cfg(target_os = "macos")] +const MACOS_NOTIFICATION_BROKER_APP_NAME: &str = "Jcode Notifications.app"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFICATION_BROKER_EXECUTABLE: &str = "jcode-notification-broker"; + +impl MacosNotificationOrigin { + /// Capture the terminal route for the local client which owns this process. + pub fn detect() -> Self { + let tty = controlling_tty(); + Self::from_values( + &std::env::var("TERM_PROGRAM").unwrap_or_default(), + &std::env::var("TERM").unwrap_or_default(), + tty.as_deref(), + std::env::var("TERM_SESSION_ID").ok().as_deref(), + std::env::var("ITERM_SESSION_ID").ok().as_deref(), + std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() + || std::env::var("GHOSTTY_BIN_DIR").is_ok(), + ) + } + + fn from_values( + term_program: &str, + term: &str, + tty: Option<&str>, + term_session_id: Option<&str>, + iterm_session_id: Option<&str>, + has_ghostty_env: bool, + ) -> Self { + let term_program_lower = term_program.to_ascii_lowercase(); + let term_lower = term.to_ascii_lowercase(); + let (terminal, bundle_id, session_id) = + if term_program_lower == "iterm.app" || iterm_session_id.is_some() { + ( + MacosTerminalKind::Iterm2, + Some("com.googlecode.iterm2".to_string()), + iterm_session_id, + ) + } else if term_program_lower == "apple_terminal" { + ( + MacosTerminalKind::AppleTerminal, + Some("com.apple.Terminal".to_string()), + term_session_id, + ) + } else if has_ghostty_env + || term_program_lower == "ghostty" + || term_lower.contains("ghostty") + { + ( + MacosTerminalKind::Ghostty, + Some("com.mitchellh.ghostty".to_string()), + term_session_id, + ) + } else { + (MacosTerminalKind::Unknown, None, term_session_id) + }; + + Self { + terminal, + bundle_id, + tty: tty.filter(|value| valid_tty(value)).map(str::to_string), + session_id: session_id + .filter(|value| valid_route_identifier(value)) + .map(str::to_string), + } + } +} + +fn valid_tty(value: &str) -> bool { + value.starts_with("/dev/tty") + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-')) +} + +fn valid_route_identifier(value: &str) -> bool { + !value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control) +} + +#[cfg(unix)] +fn controlling_tty() -> Option { + use std::os::fd::AsRawFd; + + let fd = std::io::stdin().as_raw_fd(); + let mut buffer = vec![0 as libc::c_char; 1024]; + // SAFETY: `buffer` is valid and writable for its full length and `fd` is a + // live descriptor. `ttyname_r` writes a NUL-terminated string on success. + let result = unsafe { libc::ttyname_r(fd, buffer.as_mut_ptr(), buffer.len()) }; + if result != 0 { + return None; + } + // SAFETY: successful `ttyname_r` guarantees a NUL terminator in `buffer`. + let value = unsafe { std::ffi::CStr::from_ptr(buffer.as_ptr()) }; + value.to_str().ok().map(str::to_string) +} + +#[cfg(not(unix))] +fn controlling_tty() -> Option { + None +} + +#[cfg(target_os = "macos")] +fn macos_notification_broker_app_path() -> Option { + if let Some(path) = std::env::var_os("JCODE_MACOS_NOTIFICATION_BROKER_APP") { + return Some(path.into()); + } + dirs::home_dir().map(|home| { + home.join("Applications") + .join(MACOS_NOTIFICATION_BROKER_APP_NAME) + }) +} + +/// The durable inbox consumed by the bundled macOS broker. +pub fn macos_notification_inbox_dir() -> Option { + if let Some(path) = std::env::var_os("JCODE_MACOS_NOTIFICATION_INBOX") { + return Some(path.into()); + } + dirs::home_dir().map(|home| { + home.join(".jcode") + .join("notifications") + .join("macos") + .join("inbox") + }) +} + +/// Queue a turn notification for the bundled LSUIElement broker and wake it. +/// Returns false when the helper is unavailable so the caller can use its +/// terminal-native or `osascript` fallback. +pub fn send_macos_turn_notification( + title: &str, + subtitle: Option<&str>, + body: &str, + sound: Option<&str>, +) -> bool { + #[cfg(not(target_os = "macos"))] + { + let _ = (title, subtitle, body, sound); + false + } + + #[cfg(target_os = "macos")] + { + let Some(app_path) = macos_notification_broker_app_path() else { + return false; + }; + let executable = app_path + .join("Contents") + .join("MacOS") + .join(MACOS_NOTIFICATION_BROKER_EXECUTABLE); + if !app_path.is_dir() || !executable.is_file() { + return false; + } + + let id = next_macos_notification_id(); + let envelope = MacosNotificationEnvelope { + schema_version: MACOS_NOTIFICATION_SCHEMA_VERSION, + notification_id: id.clone(), + title: title.to_string(), + subtitle: subtitle + .filter(|value| !value.trim().is_empty()) + .map(str::to_string), + body: body.to_string(), + sound: sound + .filter(|value| !value.trim().is_empty()) + .map(str::to_string), + origin: MacosNotificationOrigin::detect(), + }; + let queued_path = match enqueue_macos_notification(&envelope) { + Ok(path) => path, + Err(error) => { + logging::warn(&format!("failed to queue macOS notification: {error}")); + return false; + } + }; + + match std::process::Command::new("/usr/bin/open") + .arg("-gj") + .arg(&app_path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(child) => { + reap_notification_child(child); + true + } + Err(error) => { + // The caller will send a fallback, so remove this payload rather + // than deliver a duplicate after a later successful launch. + let _ = std::fs::remove_file(queued_path); + logging::warn(&format!( + "failed to launch macOS notification broker: {error}" + )); + false + } + } + } +} + +#[cfg(target_os = "macos")] +fn next_macos_notification_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!( + "jcode-turn-{timestamp}-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + ) +} + +#[cfg(target_os = "macos")] +fn enqueue_macos_notification( + envelope: &MacosNotificationEnvelope, +) -> anyhow::Result { + use std::io::Write as _; + + let inbox = macos_notification_inbox_dir() + .ok_or_else(|| anyhow::anyhow!("could not determine notification inbox"))?; + std::fs::create_dir_all(&inbox)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&inbox, std::fs::Permissions::from_mode(0o700))?; + } + + let final_path = inbox.join(format!("{}.json", envelope.notification_id)); + let temporary_path = inbox.join(format!(".{}.tmp", envelope.notification_id)); + let bytes = serde_json::to_vec(envelope)?; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary_path)?; + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&temporary_path, &final_path)?; + Ok(final_path) +} + +fn reap_notification_child(mut child: std::process::Child) { + let _ = std::thread::Builder::new() + .name("jcode-notification-child".to_string()) + .spawn(move || { + let _ = child.wait(); + }); +} + +/// Build the process invocation used when a broker notification is clicked. +/// Kept pure so routing and escaping are fully testable on non-macOS builders. +pub fn macos_notification_activation_command( + origin: &MacosNotificationOrigin, +) -> Option<(String, Vec)> { + fn applescript_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") + } + + match origin.terminal { + MacosTerminalKind::AppleTerminal => { + let tty = origin.tty.as_deref().filter(|value| valid_tty(value)); + let script = if let Some(tty) = tty { + format!( + "tell application \"Terminal\"\nrepeat with w in windows\nrepeat with t in tabs of w\nif tty of t is \"{}\" then\nset selected tab of w to t\nset frontmost of w to true\nactivate\nreturn\nend if\nend repeat\nend repeat\nactivate\nend tell", + applescript_string(tty) + ) + } else { + "tell application \"Terminal\" to activate".to_string() + }; + Some(( + "/usr/bin/osascript".to_string(), + vec!["-e".to_string(), script], + )) + } + MacosTerminalKind::Iterm2 => { + let tty = origin.tty.as_deref().filter(|value| valid_tty(value)); + let script = if let Some(tty) = tty { + format!( + "tell application \"iTerm2\"\nrepeat with w in windows\nrepeat with t in tabs of w\nrepeat with s in sessions of t\nif tty of s is \"{}\" then\nselect s\nselect t\nactivate\nreturn\nend if\nend repeat\nend repeat\nend repeat\nactivate\nend tell", + applescript_string(tty) + ) + } else { + "tell application \"iTerm2\" to activate".to_string() + }; + Some(( + "/usr/bin/osascript".to_string(), + vec!["-e".to_string(), script], + )) + } + MacosTerminalKind::Ghostty => Some(( + "/usr/bin/open".to_string(), + vec![ + "-b".to_string(), + origin + .bundle_id + .as_deref() + .filter(|value| *value == "com.mitchellh.ghostty") + .unwrap_or("com.mitchellh.ghostty") + .to_string(), + ], + )), + MacosTerminalKind::Unknown => origin.bundle_id.as_deref().and_then(|bundle_id| { + let safe = bundle_id.len() <= 255 + && bundle_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + safe.then(|| { + ( + "/usr/bin/open".to_string(), + vec!["-b".to_string(), bundle_id.to_string()], + ) + }) + }), + } +} + +/// Activate the recorded terminal route without blocking the notification +/// delegate's main run loop. +pub fn activate_macos_notification_origin(origin: &MacosNotificationOrigin) { + let Some((program, args)) = macos_notification_activation_command(origin) else { + return; + }; + if let Ok(child) = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + reap_notification_child(child); + } +} + /// Send a local desktop notification without blocking. /// /// Uses Notification Center via `osascript` on macOS and `notify-send` on @@ -650,4 +1027,126 @@ mod tests { let cfg = SafetyConfig::default(); let _dispatcher = NotificationDispatcher::from_config(cfg); } + + #[test] + fn macos_origin_detects_terminal_identifiers() { + let terminal = MacosNotificationOrigin::from_values( + "Apple_Terminal", + "xterm-256color", + Some("/dev/ttys007"), + Some("4F3C"), + None, + false, + ); + assert_eq!(terminal.terminal, MacosTerminalKind::AppleTerminal); + assert_eq!(terminal.bundle_id.as_deref(), Some("com.apple.Terminal")); + assert_eq!(terminal.tty.as_deref(), Some("/dev/ttys007")); + assert_eq!(terminal.session_id.as_deref(), Some("4F3C")); + + let iterm = MacosNotificationOrigin::from_values( + "iTerm.app", + "xterm-256color", + Some("/dev/ttys011"), + None, + Some("w0t1p0:ABC"), + false, + ); + assert_eq!(iterm.terminal, MacosTerminalKind::Iterm2); + assert_eq!(iterm.session_id.as_deref(), Some("w0t1p0:ABC")); + + let ghostty = MacosNotificationOrigin::from_values( + "", + "xterm-ghostty", + Some("/dev/ttys019"), + None, + None, + true, + ); + assert_eq!(ghostty.terminal, MacosTerminalKind::Ghostty); + assert_eq!(ghostty.bundle_id.as_deref(), Some("com.mitchellh.ghostty")); + } + + #[test] + fn macos_origin_rejects_untrusted_route_values() { + let origin = MacosNotificationOrigin::from_values( + "Apple_Terminal", + "", + Some("/dev/ttys001\"\nrun script"), + Some("bad\nidentifier"), + None, + false, + ); + assert_eq!(origin.tty, None); + assert_eq!(origin.session_id, None); + + let (_, args) = macos_notification_activation_command(&origin).expect("Terminal route"); + assert_eq!( + args, + vec!["-e", "tell application \"Terminal\" to activate"] + ); + } + + #[test] + fn macos_activation_targets_terminal_and_iterm_ttys() { + let terminal = MacosNotificationOrigin { + terminal: MacosTerminalKind::AppleTerminal, + bundle_id: Some("com.apple.Terminal".to_string()), + tty: Some("/dev/ttys003".to_string()), + session_id: Some("session-a".to_string()), + }; + let (program, args) = + macos_notification_activation_command(&terminal).expect("Terminal command"); + assert_eq!(program, "/usr/bin/osascript"); + assert!(args[1].contains("if tty of t is \"/dev/ttys003\"")); + assert!(args[1].contains("set selected tab of w to t")); + + let iterm = MacosNotificationOrigin { + terminal: MacosTerminalKind::Iterm2, + bundle_id: Some("com.googlecode.iterm2".to_string()), + tty: Some("/dev/ttys004".to_string()), + session_id: Some("w0t0p0:guid".to_string()), + }; + let (_, args) = macos_notification_activation_command(&iterm).expect("iTerm command"); + assert!(args[1].contains("if tty of s is \"/dev/ttys004\"")); + assert!(args[1].contains("select s")); + } + + #[test] + fn macos_ghostty_activation_is_application_scoped() { + let origin = MacosNotificationOrigin { + terminal: MacosTerminalKind::Ghostty, + bundle_id: Some("evil.bundle".to_string()), + tty: Some("/dev/ttys005".to_string()), + session_id: None, + }; + assert_eq!( + macos_notification_activation_command(&origin), + Some(( + "/usr/bin/open".to_string(), + vec!["-b".to_string(), "com.mitchellh.ghostty".to_string()] + )) + ); + } + + #[test] + fn macos_envelope_roundtrip_preserves_origin_metadata() { + let envelope = MacosNotificationEnvelope { + schema_version: MACOS_NOTIFICATION_SCHEMA_VERSION, + notification_id: "jcode-turn-test".to_string(), + title: "jcode · done".to_string(), + subtitle: Some("2/2 todos".to_string()), + body: "Finished broker".to_string(), + sound: Some("Glass".to_string()), + origin: MacosNotificationOrigin { + terminal: MacosTerminalKind::Iterm2, + bundle_id: Some("com.googlecode.iterm2".to_string()), + tty: Some("/dev/ttys009".to_string()), + session_id: Some("w1t2p0:route".to_string()), + }, + }; + let encoded = serde_json::to_vec(&envelope).expect("encode envelope"); + let decoded: MacosNotificationEnvelope = + serde_json::from_slice(&encoded).expect("decode envelope"); + assert_eq!(decoded, envelope); + } } diff --git a/crates/jcode-app-core/src/perf.rs b/crates/jcode-app-core/src/perf.rs index 1603ae5345..8153291372 100644 --- a/crates/jcode-app-core/src/perf.rs +++ b/crates/jcode-app-core/src/perf.rs @@ -129,6 +129,17 @@ pub fn profile() -> &'static SystemProfile { PROFILE.get_or_init(detect) } +/// Pin the process-global system profile to the synthetic Full-tier profile. +/// +/// Test harnesses call this so rendered output (perf badge, animation policy, +/// idle status facts) does not depend on the host's load average or free +/// memory at the moment the test process first touched `profile()`. First +/// initialization wins: calling this after `profile()` has already run is a +/// no-op, and production code paths never call it. +pub fn pin_full_profile_for_tests() { + let _ = PROFILE.set(synthetic_profile(SyntheticSystemProfile::Native)); +} + pub fn synthetic_profile(kind: SyntheticSystemProfile) -> SystemProfile { match kind { SyntheticSystemProfile::Native => SystemProfile { @@ -306,12 +317,27 @@ fn detect() -> SystemProfile { &terminal, ); - let tier = match crate::config::config().display.performance.as_str() { - "full" => PerformanceTier::Full, - "reduced" => PerformanceTier::Reduced, - "minimal" => PerformanceTier::Minimal, - _ => auto_tier, - }; + // Highest priority: explicit env override (used by tests/CI to keep the + // tier deterministic regardless of host load, and by users to force a + // tier for one invocation without editing config). + let env_tier = std::env::var("JCODE_PERF_TIER").ok().and_then(|raw| { + match raw.trim().to_ascii_lowercase().as_str() { + "full" => Some(PerformanceTier::Full), + "reduced" => Some(PerformanceTier::Reduced), + "minimal" => Some(PerformanceTier::Minimal), + _ => None, + } + }); + + let tier = + env_tier.unwrap_or_else( + || match crate::config::config().display.performance.as_str() { + "full" => PerformanceTier::Full, + "reduced" => PerformanceTier::Reduced, + "minimal" => PerformanceTier::Minimal, + _ => auto_tier, + }, + ); SystemProfile { load_avg_1m, diff --git a/crates/jcode-app-core/src/protocol_tests/comm_requests.rs b/crates/jcode-app-core/src/protocol_tests/comm_requests.rs index a1176acd07..1a1a543904 100644 --- a/crates/jcode-app-core/src/protocol_tests/comm_requests.rs +++ b/crates/jcode-app-core/src/protocol_tests/comm_requests.rs @@ -384,11 +384,12 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { prefer_spawn: Some(true), spawn_if_needed: Some(true), message: Some("Take the next runnable task.".to_string()), - model: Some("gpt-5.5".to_string()), + model: Some("openai-api:gpt-5.5".to_string()), effort: Some("low".to_string()), }; let json = serde_json::to_string(&req)?; assert!(json.contains("\"type\":\"comm_assign_next\"")); + assert!(json.contains("\"model\":\"openai-api:gpt-5.5\"")); let decoded = parse_request_json(&json)?; assert_eq!(decoded.id(), 60); let Request::CommAssignNext { @@ -411,7 +412,7 @@ fn test_comm_assign_next_roundtrip() -> Result<()> { assert_eq!(prefer_spawn, Some(true)); assert_eq!(spawn_if_needed, Some(true)); assert_eq!(message.as_deref(), Some("Take the next runnable task.")); - assert_eq!(model.as_deref(), Some("gpt-5.5")); + assert_eq!(model.as_deref(), Some("openai-api:gpt-5.5")); assert_eq!(effort.as_deref(), Some("low")); Ok(()) } @@ -461,9 +462,9 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { assert!(json.contains("\"type\":\"comm_spawn\"")); assert!(json.contains("\"request_nonce\":\"planner-fresh-123\"")); assert!(json.contains("\"spawn_mode\":\"headless\"")); - assert!(json.contains("\"model\":\"openai-api:gpt-5.5\"")); assert!(json.contains("\"effort\":\"low\"")); assert!(json.contains("\"label\":\"review auth flow\"")); + assert!(json.contains("\"model\":\"openai-api:gpt-5.5\"")); let decoded = parse_request_json(&json)?; assert_eq!(decoded.id(), 59); let Request::CommSpawn { @@ -492,16 +493,74 @@ fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { } #[test] -fn test_comm_spawn_decodes_without_model_or_effort() -> Result<()> { - // Older clients omit the model/effort fields entirely. - let json = r#"{"type":"comm_spawn","id":60,"session_id":"sess_coord"}"#; - let decoded = parse_request_json(json)?; - let Request::CommSpawn { model, effort, label, .. } = decoded else { - return Err(anyhow!("expected CommSpawn")); - }; - assert_eq!(model, None); - assert_eq!(effort, None); - assert_eq!(label, None); +fn test_comm_spawn_and_assign_next_decode_model_without_effort() -> Result<()> { + for request_type in ["comm_spawn", "comm_assign_next"] { + let json = serde_json::json!({ + "type": request_type, + "id": 60, + "session_id": "sess_coord", + "model": "gpt-5.5" + }); + let decoded = parse_request_json(&json.to_string())?; + match decoded { + Request::CommSpawn { + model, + effort, + label, + .. + } => { + assert_eq!(model.as_deref(), Some("gpt-5.5")); + assert_eq!(effort, None); + assert_eq!(label, None); + } + Request::CommAssignNext { model, effort, .. } => { + assert_eq!(model.as_deref(), Some("gpt-5.5")); + assert_eq!(effort, None); + } + _ => return Err(anyhow!("expected spawn or assign_next")), + } + } + Ok(()) +} + +#[test] +fn test_comm_spawn_and_assign_next_roundtrip_omitted_or_null_model() -> Result<()> { + for request_type in ["comm_spawn", "comm_assign_next"] { + for explicit_null in [false, true] { + // Older clients omit the optional field. Explicit null must also work. + let mut json = serde_json::json!({ + "type": request_type, + "id": 60, + "session_id": "sess_coord" + }); + if explicit_null { + json["model"] = serde_json::Value::Null; + } + let decoded = parse_request_json(&json.to_string())?; + let encoded = serde_json::to_string(&decoded)?; + let roundtripped = parse_request_json(&encoded)?; + for request in [decoded, roundtripped] { + assert_eq!(request.id(), 60); + match request { + Request::CommSpawn { + model, + effort, + label, + .. + } => { + assert_eq!(model, None); + assert_eq!(effort, None); + assert_eq!(label, None); + } + Request::CommAssignNext { model, effort, .. } => { + assert_eq!(model, None); + assert_eq!(effort, None); + } + _ => return Err(anyhow!("expected spawn or assign_next")), + } + } + } + } Ok(()) } diff --git a/crates/jcode-app-core/src/protocol_tests/core_events.rs b/crates/jcode-app-core/src/protocol_tests/core_events.rs index d0574e72dc..d6e02628ef 100644 --- a/crates/jcode-app-core/src/protocol_tests/core_events.rs +++ b/crates/jcode-app-core/src/protocol_tests/core_events.rs @@ -5,6 +5,7 @@ fn test_request_roundtrip() -> Result<()> { content: "hello".to_string(), images: vec![], system_reminder: None, + no_reply: false, }; let json = serde_json::to_string(&req)?; let decoded = parse_request_json(&json)?; @@ -71,6 +72,19 @@ fn test_event_roundtrip() -> Result<()> { Ok(()) } +#[test] +fn test_context_message_added_event_roundtrip() -> Result<()> { + let event = ServerEvent::ContextMessageAdded { id: 42 }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"context_message_added\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::ContextMessageAdded { id } = decoded else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(id, 42); + Ok(()) +} + #[test] fn test_interrupted_event_decodes_from_json() -> Result<()> { let json = r#"{"type":"interrupted"}"#; @@ -190,6 +204,7 @@ fn test_history_event_roundtrip_preserves_side_panel_snapshot() -> Result<()> { id: 101, session_id: "ses_test_456".to_string(), messages: vec![HistoryMessage { + response_stats: None, role: "assistant".to_string(), content: "hello".to_string(), tool_calls: None, @@ -281,6 +296,7 @@ fn test_compacted_history_event_roundtrip() -> Result<()> { id: 77, session_id: "ses_compact_123".to_string(), messages: vec![HistoryMessage { + response_stats: None, role: "assistant".to_string(), content: "older response".to_string(), tool_calls: None, diff --git a/crates/jcode-app-core/src/protocol_tests/misc_events.rs b/crates/jcode-app-core/src/protocol_tests/misc_events.rs index 281850709d..9a0356ef1a 100644 --- a/crates/jcode-app-core/src/protocol_tests/misc_events.rs +++ b/crates/jcode-app-core/src/protocol_tests/misc_events.rs @@ -205,6 +205,8 @@ fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result client_instance_id: Some("client-123".to_string()), client_has_local_history: true, allow_session_takeover: true, + crash_on_disconnect: true, + continue_on_disconnect: true, terminal_env: vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())], }; let json = serde_json::to_string(&req)?; @@ -218,6 +220,8 @@ fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result client_instance_id, client_has_local_history, allow_session_takeover, + crash_on_disconnect, + continue_on_disconnect, terminal_env, } = decoded else { @@ -230,6 +234,8 @@ fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result assert_eq!(client_instance_id.as_deref(), Some("client-123")); assert!(client_has_local_history); assert!(allow_session_takeover); + assert!(crash_on_disconnect); + assert!(continue_on_disconnect); assert_eq!( terminal_env, vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())] @@ -249,6 +255,8 @@ fn test_subscribe_request_defaults_optional_flags() -> Result<()> { client_instance_id, client_has_local_history, allow_session_takeover, + crash_on_disconnect, + continue_on_disconnect, terminal_env, } = decoded else { @@ -261,6 +269,8 @@ fn test_subscribe_request_defaults_optional_flags() -> Result<()> { assert_eq!(client_instance_id, None); assert!(!client_has_local_history); assert!(!allow_session_takeover); + assert!(!crash_on_disconnect); + assert!(!continue_on_disconnect); assert!(terminal_env.is_empty()); Ok(()) } @@ -297,6 +307,7 @@ fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Resu ("image/jpeg".to_string(), "BBB".to_string()), ], system_reminder: Some("be concise".to_string()), + no_reply: true, }; let json = serde_json::to_string(&req)?; let decoded = parse_request_json(&json)?; @@ -305,6 +316,7 @@ fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Resu content, images, system_reminder, + no_reply, } = decoded else { return Err(anyhow!("expected Message")); @@ -315,5 +327,37 @@ fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Resu assert_eq!(images[0].0, "image/png"); assert_eq!(images[1].0, "image/jpeg"); assert_eq!(system_reminder.as_deref(), Some("be concise")); + assert!(no_reply); + Ok(()) +} + +#[test] +fn test_native_ssh_pong_capability_is_backward_compatible() -> Result<()> { + let legacy: ServerEvent = serde_json::from_str(r#"{"type":"pong","id":7}"#)?; + assert!(matches!( + legacy, + ServerEvent::Pong { + id: 7, + native_ssh_protocol: None + } + )); + let modern = ServerEvent::Pong { + id: 7, + native_ssh_protocol: Some(1), + }; + let json = serde_json::to_value(&modern)?; + assert_eq!(json["native_ssh_protocol"], 1); + assert!(matches!( + serde_json::from_value::(json)?, + ServerEvent::Pong { + id: 7, + native_ssh_protocol: Some(1) + } + )); + assert!( + serde_json::to_value(&legacy)? + .get("native_ssh_protocol") + .is_none() + ); Ok(()) } diff --git a/crates/jcode-app-core/src/protocol_tests/randomized.rs b/crates/jcode-app-core/src/protocol_tests/randomized.rs index e86531baa7..756a22830c 100644 --- a/crates/jcode-app-core/src/protocol_tests/randomized.rs +++ b/crates/jcode-app-core/src/protocol_tests/randomized.rs @@ -28,6 +28,7 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { content: content.clone(), images: images.clone(), system_reminder: system_reminder.clone(), + no_reply: rng.random_bool(0.5), }; let decoded = parse_request_json(&serde_json::to_string(&req)?)?; let Request::Message { @@ -35,6 +36,7 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { content: decoded_content, images: decoded_images, system_reminder: decoded_system_reminder, + no_reply: decoded_no_reply, } = decoded else { return Err(anyhow!("expected randomized Message")); @@ -43,6 +45,10 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { assert_eq!(decoded_content, content); assert_eq!(decoded_images, images); assert_eq!(decoded_system_reminder, system_reminder); + assert_eq!( + decoded_no_reply, + matches!(req, Request::Message { no_reply: true, .. }) + ); } for id in 100..132u64 { @@ -54,6 +60,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { let client_instance_id = rng.random_bool(0.5).then(|| format!("client-{}", id)); let client_has_local_history = rng.random_bool(0.5); let allow_session_takeover = rng.random_bool(0.5); + let crash_on_disconnect = rng.random_bool(0.5); + let continue_on_disconnect = rng.random_bool(0.5); let req = Request::Subscribe { id, working_dir: working_dir.clone(), @@ -62,6 +70,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { client_instance_id: client_instance_id.clone(), client_has_local_history, allow_session_takeover, + crash_on_disconnect, + continue_on_disconnect, terminal_env: Vec::new(), }; let decoded = parse_request_json(&serde_json::to_string(&req)?)?; @@ -73,6 +83,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { client_instance_id: decoded_client_instance_id, client_has_local_history: decoded_client_has_local_history, allow_session_takeover: decoded_allow_session_takeover, + crash_on_disconnect: decoded_crash_on_disconnect, + continue_on_disconnect: decoded_continue_on_disconnect, terminal_env: _, } = decoded else { @@ -85,6 +97,8 @@ fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { assert_eq!(decoded_client_instance_id, client_instance_id); assert_eq!(decoded_client_has_local_history, client_has_local_history); assert_eq!(decoded_allow_session_takeover, allow_session_takeover); + assert_eq!(decoded_crash_on_disconnect, crash_on_disconnect); + assert_eq!(decoded_continue_on_disconnect, continue_on_disconnect); } Ok(()) diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index 7abfc89d3a..f9301cddc0 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -1,3 +1,4 @@ +mod available_models_dedup; mod await_members_state; mod background_tasks; mod client_actions; @@ -51,9 +52,9 @@ mod util; pub(super) use self::await_members_state::AwaitMembersRuntime; use self::background_tasks::{ dispatch_background_task_completion, dispatch_background_task_progress, - dispatch_swarm_await_completion, dispatch_swarm_batch_progress, dispatch_swarm_output_tail, - dispatch_swarm_runtime_status, dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, - dispatch_ui_activity, + dispatch_background_task_stalled, dispatch_swarm_await_completion, + dispatch_swarm_batch_progress, dispatch_swarm_output_tail, dispatch_swarm_runtime_status, + dispatch_swarm_todo_progress, dispatch_swarm_tool_activity, dispatch_ui_activity, }; use self::debug::{ClientConnectionInfo, ClientDebugState}; use self::debug_jobs::DebugJob; @@ -109,6 +110,35 @@ pub(super) type SessionAgents = Arc>>>>; pub(super) type ChannelSubscriptions = Arc>>>>; +fn idle_monitor_should_start(client_count: usize, has_live_headless_worker: bool) -> bool { + client_count == 0 && !has_live_headless_worker +} + +async fn has_live_headless_worker(sessions: &SessionAgents, swarm_state: &SwarmState) -> bool { + let live_sessions: HashSet = sessions.read().await.keys().cloned().collect(); + swarm_state + .members + .read() + .await + .values() + .any(|member| member.is_headless && live_sessions.contains(&member.session_id)) +} + +/// Remove a live server session and its process-presence marker as one +/// lifecycle operation. Server-owned sessions all share the long-running +/// server PID, so leaving the marker behind makes presence UIs count the +/// removed session forever. +pub(super) async fn remove_session_entry( + sessions: &Arc>>, + session_id: &str, +) -> Option { + let removed = sessions.write().await.remove(session_id); + if removed.is_some() { + crate::storage::unregister_active_pid(session_id); + } + removed +} + const SERVER_NAME_ENV: &str = "JCODE_SERVER_NAME"; const SERVER_DISPLAY_NAME_ENV: &str = "JCODE_SERVER_DISPLAY_NAME"; const MAX_CONFIGURED_SERVER_NAME_LEN: usize = 64; @@ -182,6 +212,105 @@ async fn prune_expired_terminal_swarm_members( pruned } +/// Reap spawned swarm workers that finished their work and have sat idle past +/// the reap window: ask any attached client window to close, shut down the +/// server-side agent, and remove the member from swarm state. +/// +/// This is the server-side backstop for coordinator `cleanup`: coordinators +/// that get interrupted, replaced, or never call cleanup used to leave every +/// spawned worker running forever (one ~80-150 MB client process each). Only +/// agent-spawned members (`report_back_to_session_id` set) are eligible; +/// user-created sessions are never touched. See +/// [`swarm::idle_spawned_worker_reap_candidates`] for the exact policy. +async fn reap_idle_spawned_workers( + sessions: &SessionAgents, + swarm_state: &SwarmState, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + soft_interrupt_queues: &SessionInterruptQueues, +) -> usize { + let Some(idle_after) = swarm::swarm_idle_worker_reap_after() else { + return 0; + }; + let candidates = { + let members = swarm_state.members.read().await; + swarm::idle_spawned_worker_reap_candidates(&members, idle_after) + }; + if candidates.is_empty() { + return 0; + } + + let mut reaped = 0usize; + for session_id in candidates { + // Re-validate under the current map: status may have changed between + // candidate collection and removal (a resumed/reassigned worker). + let still_reapable = { + let members = swarm_state.members.read().await; + members.get(&session_id).is_some_and(|member| { + member.report_back_to_session_id.is_some() + && member.role != "coordinator" + && (member.status == "ready" + || swarm::member_status_is_terminal(&member.status)) + && member.last_status_change.elapsed() >= idle_after + }) + }; + if !still_reapable { + continue; + } + + // Ask any attached client (visible spawned window) to close itself. + let _ = fanout_session_event( + &swarm_state.members, + &session_id, + ServerEvent::SessionCloseRequested { + reason: format!( + "Idle spawned worker reaped after {}s of inactivity", + idle_after.as_secs() + ), + }, + ) + .await; + + if let Some(agent_arc) = remove_session_entry(sessions, &session_id).await { + remove_session_interrupt_queue(soft_interrupt_queues, &session_id).await; + remove_background_tool_signal(&session_id); + if let Ok(mut agent) = agent_arc.try_lock() { + agent.mark_closed(); + } + } + + let removed_swarm_id = { + let mut members = swarm_state.members.write().await; + members + .remove(&session_id) + .and_then(|member| member.swarm_id) + }; + if let Some(ref swarm_id) = removed_swarm_id { + remove_session_from_swarm( + &session_id, + swarm_id, + &swarm_state.members, + &swarm_state.swarms_by_id, + &swarm_state.coordinators, + &swarm_state.plans, + ) + .await; + } + remove_session_channel_subscriptions( + &session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + crate::logging::info(&format!( + "Reaped idle spawned swarm worker {session_id} (idle > {}s)", + idle_after.as_secs() + )); + reaped += 1; + } + reaped +} + pub(super) async fn persist_swarm_state_for(swarm_id: &str, swarm_state: &SwarmState) { // Never call this while holding any SwarmState map guard. The operation // lock deliberately spans the independent map reads and atomic file write. @@ -497,7 +626,7 @@ pub use self::util::ServerIdentity; pub(crate) use self::util::server_has_newer_binary; use self::util::{ debug_control_allowed, embedding_idle_unload_secs, git_common_dir_for, reload_exec_target, - startup_headless_recovery_test_delay, swarm_id_for_dir, + startup_headless_recovery_test_delay, swarm_id_for_dir, swarm_id_for_session, }; mod file_activity; @@ -521,8 +650,31 @@ mod file_activity_tests; /// Idle timeout for the shared server when no clients are connected (5 minutes) const IDLE_TIMEOUT_SECS: u64 = 300; -/// How often to check whether the embedding model can be unloaded. -const EMBEDDING_IDLE_CHECK_SECS: u64 = 30; +/// How often to check whether the embedding model can be unloaded. Keep this +/// comfortably below the default idle threshold so reclamation is prompt and +/// predictable rather than delayed by another full sampling interval. +const EMBEDDING_IDLE_CHECK_SECS: u64 = 10; + +#[cfg(test)] +mod idle_monitor_tests { + use super::idle_monitor_should_start; + + #[test] + fn shared_idle_monitor_preserves_live_headless_worker() { + assert!(!idle_monitor_should_start(0, true)); + } + + #[test] + fn temporary_idle_monitor_preserves_live_headless_worker() { + assert!(!idle_monitor_should_start(0, true)); + } + + #[test] + fn idle_monitor_starts_only_without_clients_or_headless_workers() { + assert!(idle_monitor_should_start(0, false)); + assert!(!idle_monitor_should_start(1, false)); + } +} /// How often the retained-heap watchdog samples allocator retention. const HEAP_RETENTION_CHECK_SECS: u64 = 120; @@ -1259,6 +1411,7 @@ impl Server { let gc_channel_subscriptions = Arc::clone(&self.channel_subscriptions); let gc_channel_subscriptions_by_session = Arc::clone(&self.channel_subscriptions_by_session); + let gc_soft_interrupt_queues = Arc::clone(&self.soft_interrupt_queues); tokio::spawn(async move { let mut interval = tokio::time::interval(swarm::swarm_terminal_member_gc_interval()); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -1271,6 +1424,17 @@ impl Server { &gc_channel_subscriptions_by_session, ) .await; + // Backstop for coordinator cleanup: close finished spawned + // workers that have been idle past the reap window so they do + // not accumulate one leaked client process each. + reap_idle_spawned_workers( + &gc_sessions, + &gc_swarm_state, + &gc_channel_subscriptions, + &gc_channel_subscriptions_by_session, + &gc_soft_interrupt_queues, + ) + .await; } }); @@ -1639,6 +1803,8 @@ impl Server { if let Some(policy) = temporary_server_policy { lifecycle::spawn_temporary_lifecycle_monitor( Arc::clone(&self.client_count), + Arc::clone(&self.sessions), + self.swarm_state.clone(), self.socket_path.clone(), self.debug_socket_path.clone(), self.identity.name.clone(), @@ -1648,6 +1814,8 @@ impl Server { crate::logging::info("Debug control enabled; idle timeout monitor disabled."); } else { let idle_client_count = Arc::clone(&self.client_count); + let idle_sessions = Arc::clone(&self.sessions); + let idle_swarm_state = self.swarm_state.clone(); let idle_server_name = self.identity.name.clone(); tokio::spawn(async move { let mut idle_since: Option = None; @@ -1657,8 +1825,10 @@ impl Server { check_interval.tick().await; let count = *idle_client_count.read().await; + let has_live_headless_worker = + has_live_headless_worker(&idle_sessions, &idle_swarm_state).await; - if count == 0 { + if idle_monitor_should_start(count, has_live_headless_worker) { // No clients connected if idle_since.is_none() { idle_since = Some(std::time::Instant::now()); @@ -2049,6 +2219,19 @@ impl Server { Ok(BusEvent::BackgroundTaskProgress(task)) => { dispatch_background_task_progress(&task, &swarm_members).await; } + Ok(BusEvent::BackgroundTaskStalled(task)) => { + dispatch_background_task_stalled( + &task, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } Ok(BusEvent::SwarmAwaitCompleted(event)) => { dispatch_swarm_await_completion( &event, diff --git a/crates/jcode-app-core/src/server/available_models_dedup.rs b/crates/jcode-app-core/src/server/available_models_dedup.rs new file mode 100644 index 0000000000..31f04c7f2c --- /dev/null +++ b/crates/jcode-app-core/src/server/available_models_dedup.rs @@ -0,0 +1,65 @@ +//! Dedup key for `AvailableModelsUpdated` catalog broadcasts. +//! +//! Split out of `client_lifecycle.rs` to keep that file off the code-size +//! ratchet's growth list. + +use crate::protocol::ServerEvent; + +/// Dedup key for `AvailableModelsUpdated` that ignores cosmetic relative-age +/// text in route details. +/// +/// Route details embed human-readable cache ages ("12m ago", "3h ago"). Those +/// strings tick forward on their own, so a byte-comparison of encoded events +/// reports a "change" every time an age bucket rolls over even though no model +/// or route actually changed. That made otherwise-identical catalogs fan out to +/// every connected client, and each client then invalidated its picker cache, +/// rewrote its on-disk catalog cache, and repainted a full frame, which starved +/// the input line. +/// +/// Normalizing those substrings out means a catalog whose only difference is +/// elapsed time is treated as unchanged and never broadcast. Real changes +/// (models, providers, api methods, availability, non-age detail text) still +/// differ and still propagate. +pub(super) fn available_models_dedup_key(event: &ServerEvent) -> String { + let encoded = crate::protocol::encode_event(event); + strip_relative_age_text(&encoded) +} + +/// Replace ` ago` runs (e.g. `12m ago`, `3h ago`, `5d ago`) with a +/// fixed placeholder so relative-age drift does not register as a change. +pub(super) fn strip_relative_age_text(text: &str) -> String { + const AGE_SUFFIX: &str = " ago"; + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + while i < text.len() { + // Look for the start of a " ago" run at this position. + let digits_end = bytes[i..] + .iter() + .position(|b| !b.is_ascii_digit()) + .map(|offset| i + offset) + .unwrap_or(text.len()); + let has_digits = digits_end > i; + let unit_is_age = matches!(bytes.get(digits_end), Some(b's' | b'm' | b'h' | b'd')); + // Only slice past the unit byte once we know a unit byte exists, so a + // trailing digit run at end-of-string cannot index out of range. + if has_digits && unit_is_age && text[digits_end + 1..].starts_with(AGE_SUFFIX) { + out.push_str(""); + i = digits_end + 1 + AGE_SUFFIX.len(); + continue; + } + // Not an age run: copy one char and continue from the next boundary. + // `i` is always a char boundary here, so `chars().next()` yields a char + // for any non-empty remainder; fall through to the end otherwise. + let Some(ch) = text[i..].chars().next() else { + break; + }; + out.push(ch); + i += ch.len_utf8(); + } + out +} + +#[cfg(test)] +#[path = "client_lifecycle_catalog_dedup_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/server/background_tasks.rs b/crates/jcode-app-core/src/server/background_tasks.rs index 6b68c8a86a..a294192e71 100644 --- a/crates/jcode-app-core/src/server/background_tasks.rs +++ b/crates/jcode-app-core/src/server/background_tasks.rs @@ -14,6 +14,28 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use tokio::sync::{RwLock, broadcast}; +async fn emit_external_wake( + session_id: &str, + reason: &str, + notification: &str, + swarm_members: &Arc>>, +) -> bool { + if crate::config::config().server.wake_mode != crate::config::WakeMode::External { + return false; + } + let _ = fanout_session_event( + swarm_members, + session_id, + ServerEvent::WakeRequested { + session_id: session_id.to_string(), + reason: reason.to_string(), + notification: notification.to_string(), + }, + ) + .await; + true +} + #[expect( clippy::too_many_arguments, reason = "background task completion needs session, interrupt, and swarm status state" @@ -55,6 +77,13 @@ pub(super) async fn dispatch_background_task_completion( } if task.wake + && !emit_external_wake( + &task.session_id, + "background_task_completed", + ¬ification, + swarm_members, + ) + .await && !run_live_turn_if_idle( &task.session_id, ¬ification, @@ -89,6 +118,93 @@ pub(super) async fn dispatch_background_task_completion( } } +/// Deliver a stall-watchdog wake for a background task that has gone quiet. +/// +/// Mirrors completion delivery: optionally notify attached clients, then wake +/// an idle agent or queue a soft interrupt for a busy one. The task is still +/// running; the message tells the agent to inspect and decide. +#[expect( + clippy::too_many_arguments, + reason = "background task stall delivery needs session, interrupt, and swarm status state" +)] +pub(super) async fn dispatch_background_task_stalled( + task: &crate::bus::BackgroundTaskStalled, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: &Arc>>, + swarms_by_id: &Arc>>>, + event_history: &Arc>>, + event_counter: &Arc, + swarm_event_tx: &broadcast::Sender, +) { + let notification = crate::message::format_background_task_stalled_markdown(task); + + if task.notify + && fanout_session_event( + swarm_members, + &task.session_id, + ServerEvent::Notification { + from_session: "background_task".to_string(), + from_name: Some("background task".to_string()), + notification_type: NotificationType::Message { + scope: Some("background_task".to_string()), + channel: None, + tldr: None, + }, + message: notification.clone(), + }, + ) + .await + == 0 + { + crate::logging::warn(&format!( + "Failed to notify attached clients for background task stall on session {}", + task.session_id + )); + } + + if task.wake + && !emit_external_wake( + &task.session_id, + "background_task_stalled", + ¬ification, + swarm_members, + ) + .await + && !run_live_turn_if_idle( + &task.session_id, + ¬ification, + Some( + "A background task for this session has produced no output or progress for its stall window. Inspect it and decide whether to keep waiting, fix it, or cancel it." + .to_string(), + ), + sessions, + LiveTurnSwarmContext::new( + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ), + ) + .await + && !queue_soft_interrupt_for_session( + &task.session_id, + notification.clone(), + false, + SoftInterruptSource::BackgroundTask, + soft_interrupt_queues, + sessions, + ) + .await + { + crate::logging::warn(&format!( + "Failed to deliver background task stall to session {}", + task.session_id + )); + } +} + /// Deliver the result of a backgrounded `swarm await_members` watcher to the /// requesting session. Mirrors background-task completion delivery: optionally /// notify attached clients, then wake an idle agent or queue a soft interrupt @@ -135,6 +251,17 @@ pub(super) async fn dispatch_swarm_await_completion( return; } + if emit_external_wake( + &event.session_id, + "swarm_await_completed", + &event.notification, + swarm_members, + ) + .await + { + return; + } + if !run_live_turn_if_idle( &event.session_id, &event.notification, diff --git a/crates/jcode-app-core/src/server/client_actions.rs b/crates/jcode-app-core/src/server/client_actions.rs index 67dba99278..f2ad66a3b4 100644 --- a/crates/jcode-app-core/src/server/client_actions.rs +++ b/crates/jcode-app-core/src/server/client_actions.rs @@ -5,7 +5,7 @@ use super::{ ClientConnectionInfo, SessionInterruptQueues, SwarmEvent, SwarmMember, SwarmState, VersionedPlan, broadcast_swarm_status, fanout_session_event, persist_swarm_state_for, queue_soft_interrupt_for_session, remove_session_channel_subscriptions, - remove_session_from_swarm, swarm_id_for_dir, truncate_detail, update_member_status, + remove_session_from_swarm, swarm_id_for_session, truncate_detail, update_member_status, }; use crate::agent::Agent; use crate::protocol::{FeatureToggle, NotificationType, ServerEvent}; @@ -105,10 +105,9 @@ pub(super) async fn handle_notify_session( }; let ran_immediately = if target_has_client { - super::live_turn::run_live_turn_if_idle( + super::live_turn::run_live_system_turn_if_idle( &session_id, &message, - None, ctx.sessions, super::live_turn::LiveTurnSwarmContext::new( ctx.swarm_members, @@ -500,7 +499,8 @@ pub(super) async fn handle_set_feature( } if enabled { - let new_swarm_id = swarm_id_for_dir(working_dir); + let _ = working_dir; + let new_swarm_id = swarm_id_for_session(client_session_id); if let Some(ref id) = new_swarm_id { { let mut swarms = swarms_by_id.write().await; @@ -655,8 +655,23 @@ pub(super) async fn handle_trigger_memory_extraction( let _ = client_event_tx.send(ServerEvent::Done { id }); } -fn clone_split_session(parent_session_id: &str) -> anyhow::Result<(String, String)> { - let parent = Session::load(parent_session_id)?; +fn clone_split_session( + parent_session_id: &str, + live_parent: Option<&Session>, +) -> anyhow::Result<(String, String)> { + // Keep the persisted snapshot authoritative, including while the parent is + // busy. A brand-new Agent may not have saved anything yet, however. Only a + // missing snapshot permits an in-memory fallback, never corrupt/unreadable + // history or a session belonging to a different client. + let parent = Session::load(parent_session_id).or_else(|error| { + let missing = error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound); + match live_parent.filter(|parent| missing && parent.id == parent_session_id) { + Some(parent) => Ok(parent.clone()), + None => Err(error), + } + })?; let mut child = Session::create(Some(parent_session_id.to_string()), None); child.replace_messages(parent.messages.clone()); @@ -715,6 +730,7 @@ fn create_transfer_child_session( pub(super) async fn handle_split( id: u64, client_session_id: &str, + agent: &Arc>, client_event_tx: &mpsc::UnboundedSender, ) { let started = Instant::now(); @@ -726,7 +742,16 @@ pub(super) async fn handle_split( ("session_id", client_session_id.to_string()), ], ); - let (new_session_id, new_session_name) = match clone_split_session(client_session_id) { + // Splitting must remain available during a streaming turn. Never await the + // Agent lock: busy sessions can still fork their last persisted snapshot. + let result = { + let idle_agent = agent.try_lock().ok(); + clone_split_session( + client_session_id, + idle_agent.as_ref().map(|agent| agent.session_for_split()), + ) + }; + let (new_session_id, new_session_name) = match result { Ok(result) => result, Err(e) => { crate::logging::event_warn( @@ -954,7 +979,8 @@ pub(super) async fn handle_resume_all_sessions( }; // Only act on idle sessions; a busy session is already making progress. - let Ok(agent_guard) = agent.try_lock() else { + // The owned guard doubles as the turn reservation (#1152). + let Ok(agent_guard) = Arc::clone(&agent).try_lock_owned() else { skipped += 1; continue; }; @@ -973,7 +999,6 @@ pub(super) async fn handle_resume_all_sessions( .session_short_name() .map(str::to_string) .unwrap_or_else(|| session_id[..8.min(session_id.len())].to_string()); - drop(agent_guard); // Best-effort: record that the durable recovery intent was delivered. if let Err(error) = super::reload_recovery::mark_delivered_if_matching_continuation( @@ -989,9 +1014,10 @@ pub(super) async fn handle_resume_all_sessions( super::live_turn::spawn_tracked_live_turn( &session_id, - Arc::clone(&agent), + agent_guard, String::new(), Some(reminder), + None, Some("resuming interrupted session".to_string()), super::live_turn::LiveTurnSwarmContext::new( swarm_members, diff --git a/crates/jcode-app-core/src/server/client_actions_tests.rs b/crates/jcode-app-core/src/server/client_actions_tests.rs index 6100880d98..a029ffe49e 100644 --- a/crates/jcode-app-core/src/server/client_actions_tests.rs +++ b/crates/jcode-app-core/src/server/client_actions_tests.rs @@ -2,7 +2,7 @@ use super::{ NotifySessionContext, clone_split_session, handle_notify_session, handle_rename_session, - handle_resume_all_sessions, handle_set_feature, + handle_resume_all_sessions, handle_set_feature, handle_split, }; use crate::agent::Agent; use crate::message::{ContentBlock, Message, Role, StreamEvent, ToolDefinition}; @@ -134,7 +134,17 @@ fn clone_split_session_uses_persisted_session_state() { }); parent.save().expect("save parent"); - let (child_id, _child_name) = clone_split_session(&parent.id).expect("clone split"); + let mut unsaved_parent = parent.clone(); + unsaved_parent.model = Some("unsaved-model".into()); + unsaved_parent.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "unfinished turn".into(), + cache_control: None, + }], + ); + let (child_id, _child_name) = + clone_split_session(&parent.id, Some(&unsaved_parent)).expect("clone split"); let child = crate::session::Session::load(&child_id).expect("load child"); assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str())); @@ -171,6 +181,196 @@ fn clone_split_session_uses_persisted_session_state() { } } +struct SplitTestHome { + _directory: tempfile::TempDir, + previous_home: Option, +} + +impl SplitTestHome { + fn new() -> Self { + let directory = tempfile::tempdir().expect("split test home"); + let previous_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", directory.path()); + Self { + _directory: directory, + previous_home, + } + } +} + +impl Drop for SplitTestHome { + fn drop(&mut self) { + if let Some(home) = &self.previous_home { + crate::env::set_var("JCODE_HOME", home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} + +async fn new_split_test_agent() -> Arc> { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + Arc::new(Mutex::new(Agent::new_with_initial_working_dir( + provider, + registry, + Some("/project/empty-split"), + ))) +} + +fn split_response( + rx: &mut mpsc::UnboundedReceiver, + request_id: u64, +) -> crate::session::Session { + let event = rx.try_recv().expect("split must respond"); + let ServerEvent::SplitResponse { + id, + new_session_id, + new_session_name, + } = event + else { + panic!("expected SplitResponse, got {event:?}"); + }; + assert_eq!(id, request_id); + assert!(!new_session_name.is_empty()); + assert!(rx.try_recv().is_err(), "exactly one split response"); + crate::session::Session::load(&new_session_id).expect("fork must be persisted for attachment") +} + +#[tokio::test] +async fn split_empty_live_session_without_persisted_parent() { + let _guard = crate::storage::lock_test_env(); + let _home = SplitTestHome::new(); + let agent = new_split_test_agent().await; + let parent = agent.lock().await.session_for_split().clone(); + assert_eq!(parent.visible_conversation_message_count(), 0); + assert!( + !crate::session::session_exists(&parent.id), + "regression requires an unsaved parent" + ); + let (tx, mut rx) = mpsc::unbounded_channel(); + + handle_split(17, &parent.id, &agent, &tx).await; + let child = split_response(&mut rx, 17); + assert_ne!(child.id, parent.id); + assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str())); + assert_eq!(child.working_dir, parent.working_dir); + assert_eq!(child.model, parent.model); + assert_eq!(child.status, crate::session::SessionStatus::Closed); + assert_eq!(child.messages.len(), parent.messages.len() + 1); + let notice = child.messages.last().unwrap(); + assert_eq!( + notice.display_role, + Some(crate::session::StoredDisplayRole::System) + ); + assert!(notice.content_preview().contains(&parent.id)); + assert_eq!(agent.lock().await.session_id(), parent.id); + assert!( + !crate::session::session_exists(&parent.id), + "fork must not mutate/persist its parent" + ); +} + +#[tokio::test] +async fn split_busy_session_uses_persisted_state_without_waiting_for_agent() { + let _guard = crate::storage::lock_test_env(); + let _home = SplitTestHome::new(); + let agent = new_split_test_agent().await; + let mut busy = agent.lock().await; + let mut parent = busy.session_for_split().clone(); + parent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "persisted request".into(), + cache_control: None, + }], + ); + parent.save().expect("save pre-turn snapshot"); + busy.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "unsaved streaming output".into(), + cache_control: None, + }], + ); + let (tx, mut rx) = mpsc::unbounded_channel(); + + timeout( + Duration::from_millis(100), + handle_split(18, &parent.id, &agent, &tx), + ) + .await + .expect("split must not wait on the held streaming Agent lock"); + let child = split_response(&mut rx, 18); + assert_eq!(child.messages.len(), parent.messages.len() + 1); + assert_eq!( + child.messages[0].content_preview(), + parent.messages[0].content_preview() + ); + assert!( + !child + .messages + .iter() + .any(|m| m.content_preview().contains("unsaved streaming output")) + ); + assert!( + child + .messages + .last() + .unwrap() + .content_preview() + .contains("forked") + ); + assert!( + agent.try_lock().is_err(), + "parent lock is still owned by the busy turn" + ); + drop(busy); +} + +#[tokio::test] +async fn split_busy_unsaved_session_returns_error_without_waiting() { + let _guard = crate::storage::lock_test_env(); + let _home = SplitTestHome::new(); + let agent = new_split_test_agent().await; + let busy = agent.lock().await; + let parent_id = busy.session_id().to_owned(); + assert!(!crate::session::session_exists(&parent_id)); + let (tx, mut rx) = mpsc::unbounded_channel(); + timeout( + Duration::from_millis(100), + handle_split(19, &parent_id, &agent, &tx), + ) + .await + .expect("missing snapshot must not block a busy session"); + assert!(matches!( + rx.try_recv(), + Ok(ServerEvent::Error { id: 19, .. }) + )); + assert!(rx.try_recv().is_err()); + drop(busy); +} + +#[test] +fn split_missing_parent_never_uses_another_live_session() { + let _guard = crate::storage::lock_test_env(); + let _home = SplitTestHome::new(); + let other = crate::session::Session::create(None, None); + assert!(clone_split_session("session_missing_parent", Some(&other)).is_err()); +} + +#[test] +fn split_corrupt_persisted_parent_is_not_hidden_by_live_fallback() { + let _guard = crate::storage::lock_test_env(); + let _home = SplitTestHome::new(); + let mut parent = crate::session::Session::create(None, Some("persisted parent".into())); + parent.save().expect("create snapshot"); + let path = crate::session::session_path(&parent.id).unwrap(); + std::fs::write(&path, b"invalid session JSON").unwrap(); + assert!(clone_split_session(&parent.id, Some(&parent)).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"invalid session JSON"); +} + #[tokio::test] async fn enabling_swarm_does_not_auto_elect_coordinator() { let provider: Arc = Arc::new(MockProvider); @@ -461,6 +661,7 @@ async fn notify_session_runs_scheduled_task_immediately_for_idle_live_session() let guard = agent.lock().await; assert!(guard.messages().iter().any(|message| { message.role == Role::User + && message.display_role == Some(crate::session::StoredDisplayRole::System) && message .content_preview() .contains("[Scheduled task] Task: Follow up") diff --git a/crates/jcode-app-core/src/server/client_api.rs b/crates/jcode-app-core/src/server/client_api.rs index fe2a55028c..d1098fedf6 100644 --- a/crates/jcode-app-core/src/server/client_api.rs +++ b/crates/jcode-app-core/src/server/client_api.rs @@ -51,6 +51,8 @@ impl Client { content: content.to_string(), images: vec![], system_reminder: None, + active_skill: None, + no_reply: false, }; let json = serde_json::to_string(&request)? + "\n"; self.writer.write_all(json.as_bytes()).await?; @@ -86,6 +88,8 @@ impl Client { client_instance_id: None, client_has_local_history, allow_session_takeover, + crash_on_disconnect: false, + continue_on_disconnect: false, terminal_env: crate::terminal_launch::snapshot_client_terminal_env(), }; let json = serde_json::to_string(&request)? + "\n"; @@ -121,7 +125,7 @@ impl Client { let event: ServerEvent = serde_json::from_str(&line)?; match event { - ServerEvent::Pong { id: pong_id } => return Ok(pong_id == id), + ServerEvent::Pong { id: pong_id, .. } => return Ok(pong_id == id), ServerEvent::Ack { id: ack_id } if ack_id == id => continue, ServerEvent::Error { id: error_id, .. } if error_id == id => return Ok(false), _ => return Ok(false), diff --git a/crates/jcode-app-core/src/server/client_comm_context.rs b/crates/jcode-app-core/src/server/client_comm_context.rs index 28719c86d8..8a31a62678 100644 --- a/crates/jcode-app-core/src/server/client_comm_context.rs +++ b/crates/jcode-app-core/src/server/client_comm_context.rs @@ -312,6 +312,7 @@ pub(super) async fn handle_comm_list( activity: extras.activity, provider_name: extras.provider_name, provider_model: extras.provider_model, + provider_effort: extras.provider_effort, turn_count: extras.turn_count, recent_total_tokens: extras.recent_total_tokens, recent_output_tokens: extras.recent_output_tokens, diff --git a/crates/jcode-app-core/src/server/client_comm_message.rs b/crates/jcode-app-core/src/server/client_comm_message.rs index 2ea0f35dca..34332d5795 100644 --- a/crates/jcode-app-core/src/server/client_comm_message.rs +++ b/crates/jcode-app-core/src/server/client_comm_message.rs @@ -336,6 +336,22 @@ pub(super) async fn handle_comm_message( .await; } CommDeliveryMode::Wake => { + if crate::config::config().server.wake_mode + == crate::config::WakeMode::External + { + let _ = fanout_session_event( + swarm_members, + session_id, + ServerEvent::WakeRequested { + session_id: session_id.to_string(), + reason: "communication_delivery".to_string(), + notification: notification_msg.clone(), + }, + ) + .await; + delivered_targets += 1; + continue; + } let woke_immediately = run_live_turn_if_idle( session_id, ¬ification_msg, diff --git a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs index ea0b0eee44..3448328331 100644 --- a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs +++ b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs @@ -10,12 +10,28 @@ use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; -use tokio::sync::{Mutex, RwLock, broadcast}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; type SessionAgents = Arc>>>>; type ChannelSubscriptions = Arc>>>>; const RELOAD_DISCONNECT_MARKER_MAX_AGE: Duration = Duration::from_secs(30); +pub(super) const IDLE_RECONNECT_GRACE: Duration = Duration::from_secs(30); + +// The last registered event sender remains on the member after it detaches. +// It is therefore also an ownership witness: an old grace timer must not +// remove a successor's session even if that successor has disconnected again. +async fn attachment_was_replaced( + members: &Arc>>, + session_id: &str, + original: &mpsc::UnboundedSender, +) -> bool { + members + .read() + .await + .get(session_id) + .is_none_or(|member| !member.event_tx.same_channel(original)) +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DisconnectDisposition { @@ -25,6 +41,8 @@ enum DisconnectDisposition { } fn disconnect_disposition(disconnected_while_processing: bool) -> DisconnectDisposition { + // Losing the UI is only a session crash when it interrupts unfinished work. + // In particular, force-quitting Desktop after Done is an ordinary close. if !disconnected_while_processing { return DisconnectDisposition::Closed; } @@ -36,15 +54,29 @@ fn disconnect_disposition(disconnected_while_processing: bool) -> DisconnectDisp } } -async fn session_has_live_successor( - client_connections: &Arc>>, - session_id: &str, +fn disconnected_while_processing( + client_is_processing: bool, + processing_task: Option<&tokio::task::JoinHandle<()>>, ) -> bool { - client_connections - .read() - .await - .values() - .any(|info| info.session_id == session_id) + // Socket EOF is prioritized over processing_done_rx. A finished task is + // authoritative even if the client's cached processing flag is still set. + processing_task + .map(|handle| !handle.is_finished()) + .unwrap_or(client_is_processing) +} + +/// Release transport-owned state without changing the live session or turn. +pub(super) async fn detach_client_attachment( + session_id: &str, + connection_id: &str, + debug_id: &str, + client_connections: &Arc>>, + client_debug_state: &Arc>, + swarm_members: &Arc>>, +) { + client_debug_state.write().await.unregister(debug_id); + client_connections.write().await.remove(connection_id); + unregister_session_event_sender(swarm_members, session_id, connection_id).await; } #[expect( @@ -73,31 +105,92 @@ pub(super) async fn cleanup_client_connection( event_history: &Arc>>, event_counter: &Arc, swarm_event_tx: &broadcast::Sender, + client_event_tx: &mpsc::UnboundedSender, + idle_reconnect_grace: Duration, ) -> Result<()> { - let disconnected_while_processing = client_is_processing - || processing_task - .as_ref() - .map(|handle| !handle.is_finished()) - .unwrap_or(false); - let disposition = disconnect_disposition(disconnected_while_processing); - + let disposition = disconnect_disposition(disconnected_while_processing( + client_is_processing, + processing_task.as_ref(), + )); + let allow_reconnect = if disposition == DisconnectDisposition::Closed + && !crate::session::session_exists(client_session_id) { - let mut debug_state = client_debug_state.write().await; - debug_state.unregister(client_debug_id); + let agent = sessions.read().await.get(client_session_id).cloned(); + agent.is_some_and(|agent| { + agent + .try_lock() + .is_ok_and(|agent| agent.visible_conversation_message_count() == 0) + }) + } else { + false + }; + + // A live processing task owns the agent mutex. Abort it before trying to + // persist the disconnect disposition; otherwise cleanup waits two seconds, + // times out, and leaves the durable session `Active` precisely when an + // interrupted desktop turn must become `Crashed`. + if let Some(handle) = processing_task.take() { + handle.abort(); } - { - let mut connections = client_connections.write().await; - connections.remove(client_connection_id); + + detach_client_attachment( + client_session_id, + client_connection_id, + client_debug_id, + client_connections, + client_debug_state, + swarm_members, + ) + .await; + + if allow_reconnect { + // Empty roots intentionally have no snapshot. A replacement UI/SDK + // connection needs a bounded opportunity to reclaim the live Agent, + // without creating history entries for every briefly opened panel. + // No registry or agent lock is held across the wait. Processing/crash + // cleanup never enters this path. + event_handle.abort(); + crate::logging::info(&format!( + "Retaining idle unsaved session {} for reconnect grace", + client_session_id + )); + let deadline = tokio::time::Instant::now() + idle_reconnect_grace; + loop { + if attachment_was_replaced(swarm_members, client_session_id, client_event_tx).await + || client_connections + .read() + .await + .values() + .any(|info| info.session_id == client_session_id) + { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep_until(std::cmp::min( + deadline, + tokio::time::Instant::now() + Duration::from_millis(25), + )) + .await; + } } - unregister_session_event_sender(swarm_members, client_session_id, client_connection_id).await; // Release stale live ownership before slower cleanup so a reconnecting TUI can // reclaim the same session without tripping duplicate-attach guards. tokio::task::yield_now().await; - let successor_connected = - session_has_live_successor(client_connections, client_session_id).await; - if successor_connected { + // Resume claims use this same lock before accessing the sessions map. + // Keep it through destructive cleanup so a successor cannot be claimed + // between the check and session/status/control-handle removal. + let connections = client_connections.write().await; + let successor_connected = connections + .values() + .any(|info| info.session_id == client_session_id); + if successor_connected + || (allow_reconnect + && attachment_was_replaced(swarm_members, client_session_id, client_event_tx).await) + { crate::logging::info(&format!( "Skipping destructive disconnect cleanup for {} because another client is still attached", client_session_id @@ -107,9 +200,7 @@ pub(super) async fn cleanup_client_connection( } { - let mut sessions_guard = sessions.write().await; - if let Some(agent_arc) = sessions_guard.remove(client_session_id) { - drop(sessions_guard); + if let Some(agent_arc) = super::remove_session_entry(sessions, client_session_id).await { let lock_result = tokio::time::timeout(std::time::Duration::from_secs(2), agent_arc.lock()).await; @@ -253,23 +344,45 @@ pub(super) async fn cleanup_client_connection( remove_background_tool_signal(client_session_id); remove_session_interrupt_queue(soft_interrupt_queues, client_session_id).await; - if let Some(handle) = processing_task.take() { - handle.abort(); - } - + drop(connections); event_handle.abort(); Ok(()) } +#[cfg(test)] +#[path = "client_disconnect_grace_tests.rs"] +mod grace_tests; + #[cfg(test)] mod tests { - use super::{DisconnectDisposition, disconnect_disposition}; + use super::{DisconnectDisposition, disconnect_disposition, disconnected_while_processing}; #[test] fn idle_disconnect_is_closed() { assert_eq!(disconnect_disposition(false), DisconnectDisposition::Closed); } + #[tokio::test] + async fn completed_task_overrides_stale_processing_flag() { + let task = tokio::spawn(async {}); + while !task.is_finished() { + tokio::task::yield_now().await; + } + assert_eq!( + disconnect_disposition(disconnected_while_processing(true, Some(&task))), + DisconnectDisposition::Closed + ); + } + + #[tokio::test] + async fn unfinished_task_is_processing_even_without_cached_flag() { + let task = tokio::spawn(std::future::pending::<()>()); + assert!(disconnected_while_processing(false, Some(&task))); + task.abort(); + assert!(disconnected_while_processing(true, None)); + assert!(!disconnected_while_processing(false, None)); + } + #[test] fn running_disconnect_without_reload_is_crash() { let _guard = crate::storage::lock_test_env(); @@ -293,6 +406,7 @@ mod tests { disconnect_disposition(true), DisconnectDisposition::Reloading ); + assert_eq!(disconnect_disposition(false), DisconnectDisposition::Closed); crate::server::clear_reload_marker(); crate::env::remove_var("JCODE_RUNTIME_DIR"); } diff --git a/crates/jcode-app-core/src/server/client_disconnect_grace_tests.rs b/crates/jcode-app-core/src/server/client_disconnect_grace_tests.rs new file mode 100644 index 0000000000..bf584a7c89 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_disconnect_grace_tests.rs @@ -0,0 +1,292 @@ +#![allow(clippy::await_holding_lock)] + +use super::*; +use crate::protocol::ServerEvent; +use crate::provider::{EventStream, Provider}; +use crate::session::{Session, SessionStatus}; +use crate::tool::Registry; +use async_trait::async_trait; +use std::time::Instant; +use tokio::time::timeout; + +struct NoRequests; +#[async_trait] +impl Provider for NoRequests { + async fn complete( + &self, + _: &[crate::message::Message], + _: &[crate::message::ToolDefinition], + _: &str, + _: Option<&str>, + ) -> Result { + anyhow::bail!("disconnect tests must not request a provider") + } + fn name(&self) -> &str { + "mock" + } + fn fork(&self) -> Arc { + Arc::new(Self) + } +} + +struct Home { + _dir: tempfile::TempDir, + previous: Option, +} +impl Home { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let previous = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", dir.path()); + Self { + _dir: dir, + previous, + } + } +} +impl Drop for Home { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => crate::env::set_var("JCODE_HOME", value), + None => crate::env::remove_var("JCODE_HOME"), + } + } +} + +struct Fixture { + id: String, + agent: Arc>, + sessions: SessionAgents, + members: Arc>>, + connections: Arc>>, + events: mpsc::UnboundedSender, +} +impl Fixture { + async fn new(persisted: bool) -> Self { + let provider: Arc = Arc::new(NoRequests); + let registry = Registry::new(provider.clone()).await; + let mut session = Session::create(None, None); + if persisted { + session.title = Some("Explicit saved panel".into()); + session.save().unwrap(); + } + let id = session.id.clone(); + let mut agent = Agent::new_with_session(provider, registry, session, None); + agent.set_memory_enabled(false); + let agent = Arc::new(Mutex::new(agent)); + let (events, _) = mpsc::unbounded_channel(); + let members = Arc::new(RwLock::new(HashMap::from([( + id.clone(), + SwarmMember { + session_id: id.clone(), + event_tx: events.clone(), + event_txs: HashMap::from([("original".into(), events.clone())]), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".into(), + detail: None, + task_label: None, + friendly_name: None, + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".into(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: Default::default(), + }, + )]))); + let sessions = Arc::new(RwLock::new(HashMap::from([(id.clone(), agent.clone())]))); + let connections = Arc::new(RwLock::new(HashMap::from([( + "original".into(), + connection("original", &id), + )]))); + Self { + id, + agent, + sessions, + members, + connections, + events, + } + } + + async fn cleanup(&self, processing: bool, grace: Duration) { + let (swarm_events, _) = broadcast::channel(8); + let mut task = None; + cleanup_client_connection( + &self.sessions, + &self.id, + processing, + &mut task, + tokio::spawn(std::future::pending()), + &self.members, + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &FileTouchService::new(), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(ClientDebugState::default())), + "debug-original", + &self.connections, + "original", + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(std::collections::VecDeque::new())), + &Arc::new(std::sync::atomic::AtomicU64::new(0)), + &swarm_events, + &self.events, + grace, + ) + .await + .unwrap(); + } + + async fn wait_for_detach(&self) { + timeout(Duration::from_secs(1), async { + while self.connections.read().await.contains_key("original") { + tokio::task::yield_now().await; + } + }) + .await + .expect("cleanup releases attachment registry promptly"); + } + + async fn attach_successor(&self) { + // Reserve the same live Agent under the same registry lock order used + // by claim_live_target_agent, then register the real event attachment. + let mut connections = self.connections.write().await; + assert!(Arc::ptr_eq( + self.sessions.read().await.get(&self.id).unwrap(), + &self.agent + )); + connections.insert("successor".into(), connection("successor", &self.id)); + drop(connections); + let (sender, _) = mpsc::unbounded_channel(); + crate::server::register_session_event_sender(&self.members, &self.id, "successor", sender) + .await; + } +} + +fn connection(name: &str, id: &str) -> ClientConnectionInfo { + let (disconnect_tx, _) = mpsc::unbounded_channel(); + ClientConnectionInfo { + client_id: name.into(), + session_id: id.into(), + client_instance_id: None, + debug_client_id: None, + connected_at: Instant::now(), + last_seen: Instant::now(), + is_processing: false, + current_tool_name: None, + terminal_env: vec![], + disconnect_tx, + } +} + +#[tokio::test] +async fn unsaved_idle_session_retains_same_agent_for_reattachment() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let fixture = Fixture::new(false).await; + let ((), ()) = tokio::join!(fixture.cleanup(false, Duration::from_secs(2)), async { + fixture.wait_for_detach().await; + assert!(!crate::session::session_exists(&fixture.id)); + // These locks must remain available during the reconnect grace. + let _agent = fixture + .agent + .try_lock() + .expect("grace cannot hold agent lock"); + drop(_agent); + fixture.attach_successor().await; + }); + assert!(Arc::ptr_eq( + fixture.sessions.read().await.get(&fixture.id).unwrap(), + &fixture.agent + )); + assert!(fixture.connections.read().await.contains_key("successor")); + assert!(fixture.members.read().await.contains_key(&fixture.id)); + assert!(!crate::session::session_exists(&fixture.id)); +} + +#[tokio::test] +async fn unsaved_idle_session_expires_without_persisting_or_leaking() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let fixture = Fixture::new(false).await; + let grace = Duration::from_millis(60); + let start = Instant::now(); + timeout(Duration::from_secs(1), fixture.cleanup(false, grace)) + .await + .unwrap(); + assert!(start.elapsed() >= grace); + assert!(!fixture.sessions.read().await.contains_key(&fixture.id)); + assert!(!fixture.members.read().await.contains_key(&fixture.id)); + assert!(fixture.connections.read().await.is_empty()); + assert!(!crate::session::session_exists(&fixture.id)); +} + +#[tokio::test] +async fn old_grace_cannot_remove_successor_that_already_detached_again() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let fixture = Fixture::new(false).await; + tokio::join!(fixture.cleanup(false, Duration::from_millis(100)), async { + fixture.wait_for_detach().await; + fixture.attach_successor().await; + detach_client_attachment( + &fixture.id, + "successor", + "successor-debug", + &fixture.connections, + &Arc::new(RwLock::new(ClientDebugState::default())), + &fixture.members, + ) + .await; + }); + assert!(fixture.connections.read().await.is_empty()); + assert!( + fixture.sessions.read().await.contains_key(&fixture.id), + "successor owns its own grace/cleanup" + ); + assert!(fixture.members.read().await.contains_key(&fixture.id)); +} + +#[tokio::test] +async fn persisted_idle_session_does_not_wait_for_reconnect_grace() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let fixture = Fixture::new(true).await; + timeout( + Duration::from_secs(1), + fixture.cleanup(false, Duration::from_secs(30)), + ) + .await + .unwrap(); + assert!(fixture.sessions.read().await.is_empty()); + assert!(crate::session::session_exists(&fixture.id)); +} + +#[tokio::test] +async fn interrupted_session_does_not_wait_for_reconnect_grace() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + crate::server::clear_reload_marker(); + let fixture = Fixture::new(false).await; + timeout( + Duration::from_secs(1), + fixture.cleanup(true, Duration::from_secs(30)), + ) + .await + .unwrap(); + assert!(fixture.sessions.read().await.is_empty()); + assert!(matches!( + fixture.agent.lock().await.session_for_split().status, + SessionStatus::Crashed { .. } + )); +} diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs index 68e4f51b83..318d0e300b 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -1,3 +1,4 @@ +use super::available_models_dedup::available_models_dedup_key; use super::client_actions::{ AgentTaskContext, NotifySessionContext, handle_agent_task, handle_compact, handle_input_shell, handle_notify_session, handle_rename_session, handle_run_subagent, handle_set_feature, @@ -9,7 +10,7 @@ use super::client_comm::{ handle_comm_read, handle_comm_share, handle_comm_subscribe_channel, handle_comm_unsubscribe_channel, }; -use super::client_disconnect_cleanup::cleanup_client_connection; +use super::client_disconnect_cleanup::{cleanup_client_connection, detach_client_attachment}; use super::client_lifecycle_logging::{ ServerRequestLifecycleFields, interrupt_request_log_fields, request_payload_summary, request_type_from_line, request_type_is_read_only, server_request_lifecycle_fields, @@ -89,20 +90,92 @@ fn required_subscribe_working_dir(working_dir: Option<&str>) -> std::result::Res fn initial_subscribe_working_dir(request: &Request) -> std::result::Result { match request { - Request::Subscribe { working_dir, .. } => { - required_subscribe_working_dir(working_dir.as_deref()).map(str::to_string) - } + Request::Subscribe { + working_dir, + continue_on_disconnect, + .. + } => validated_subscribe_working_dir(working_dir.as_deref(), *continue_on_disconnect) + .map(str::to_string), _ => Err( "Client must Subscribe with a working_dir before sending stateful requests".to_string(), ), } } +/// A reattachment names an existing session, not a new client working directory. +/// Resolve an omitted cwd before provisional initialization, never from the +/// daemon/bridge process cwd. Idle empty sessions may exist only in memory. +async fn resolve_target_subscribe_working_dir( + request: &mut Request, + sessions: &SessionAgents, + members: &Arc>>, +) -> std::result::Result<(), String> { + let Request::Subscribe { + working_dir, + target_session_id: Some(target), + .. + } = request + else { + return Ok(()); + }; + if working_dir.is_some() { + return Ok(()); + } + let live = sessions.read().await.get(target).cloned(); + let resolved = if let Some(live) = live { + let idle_cwd = live + .try_lock() + .ok() + .and_then(|agent| agent.working_dir().map(str::to_string)); + if idle_cwd.is_some() { + idle_cwd + } else { + // A generating Agent owns its mutex. The member records the same + // session root, so attaching must not wait for the model turn. + members + .read() + .await + .get(target) + .and_then(|member| member.working_dir.as_ref()) + .map(|path| path.to_string_lossy().into_owned()) + } + } else { + crate::session::Session::load_startup_stub(target) + .ok() + .and_then(|session| session.working_dir) + }; + *working_dir = Some(resolved.ok_or_else(|| { + format!("Unknown session '{target}' or session has no working directory") + })?); + Ok(()) +} + +fn validated_subscribe_working_dir( + working_dir: Option<&str>, + remote_continuation: bool, +) -> std::result::Result<&str, String> { + let working_dir = required_subscribe_working_dir(working_dir)?; + if remote_continuation && !Path::new(working_dir).is_dir() { + return Err(format!( + "Remote working directory must exist and be a directory on the server: {working_dir}" + )); + } + Ok(working_dir) +} + +fn initial_subscribe_terminal_env(request: &Request) -> Vec<(String, String)> { + match request { + Request::Subscribe { terminal_env, .. } => terminal_env.clone(), + _ => Vec::new(), + } +} + struct ProcessingMessage { id: u64, content: String, images: Vec<(String, String)>, system_reminder: Option, + active_skill: Option, } struct ProcessingState<'a> { @@ -120,6 +193,14 @@ struct SwarmStatusRefs<'a> { event_tx: &'a broadcast::Sender, } +fn should_start_idle_soft_interrupt( + client_is_processing: bool, + active_turn_registered: bool, + session_connection_busy: bool, +) -> bool { + !client_is_processing && !active_turn_registered && !session_connection_busy +} + struct RequestHandlerWatchdog { done: Arc, } @@ -224,6 +305,23 @@ fn reject_if_agent_busy_for_request( return false; } + send_agent_busy_error( + request_id, + request_kind, + client_session_id, + client_is_processing, + client_event_tx, + ); + true +} + +fn send_agent_busy_error( + request_id: u64, + request_kind: &'static str, + client_session_id: &str, + client_is_processing: bool, + client_event_tx: &mpsc::UnboundedSender, +) { crate::logging::event_warn( "SERVER_REQUEST_BUSY_AGENT_REJECTED", vec![ @@ -241,7 +339,6 @@ fn reject_if_agent_busy_for_request( ), retry_after_secs: Some(1), }); - true } fn server_reload_starting() -> bool { @@ -370,7 +467,7 @@ pub(super) async fn handle_client( let writer = Arc::new(Mutex::new(writer)); let mut line = String::new(); - let initial_request = loop { + let mut initial_request = loop { line.clear(); let n = match reader.read_line(&mut line).await { Ok(n) => n, @@ -392,6 +489,7 @@ pub(super) async fn handle_client( match decode_request(&line) { Ok(request) => { if request.is_lightweight_control_request() { + let keep_connection_open = matches!(request, Request::Ping { .. }); handle_lightweight_control_request( request, Arc::clone(&writer), @@ -418,6 +516,12 @@ pub(super) async fn handle_client( }, ) .await?; + // Native SSH probes daemon capability before sending its + // Subscribe on this same stream. Ping must not consume the + // connection, unlike the other one-shot control requests. + if keep_connection_open { + continue; + } return Ok(()); } break request; @@ -436,21 +540,26 @@ pub(super) async fn handle_client( } }; - let initial_working_dir = match initial_subscribe_working_dir(&initial_request) { - Ok(working_dir) => working_dir, - Err(message) => { - write_direct_event( - &writer, - &ServerEvent::Error { - id: initial_request.id(), - message, - retry_after_secs: None, - }, - ) - .await?; - return Ok(()); - } - }; + let initial_working_dir = + match resolve_target_subscribe_working_dir(&mut initial_request, &sessions, &swarm_members) + .await + .and_then(|()| initial_subscribe_working_dir(&initial_request)) + { + Ok(working_dir) => working_dir, + Err(message) => { + write_direct_event( + &writer, + &ServerEvent::Error { + id: initial_request.id(), + message, + retry_after_secs: None, + }, + ) + .await?; + return Ok(()); + } + }; + let mut active_terminal_env = initial_subscribe_terminal_env(&initial_request); // Per-client state let mut client_is_processing = false; @@ -460,12 +569,15 @@ pub(super) async fn handle_client( let mut processing_message_id: Option = None; let mut processing_session_id: Option = None; let mut current_client_instance_id: Option = None; + let mut continue_on_disconnect = false; + let mut model_usage_updates_enabled = false; // Client selfdev status is determined by Subscribe request, not server's env let mut client_selfdev = false; let client_start = std::time::Instant::now(); let provider = provider_template.fork_for_new_session(); + let provider_fork_ms = client_start.elapsed().as_millis(); let t0 = std::time::Instant::now(); let registry = Registry::new(provider.clone()).await; let registry_ms = t0.elapsed().as_millis(); @@ -476,17 +588,24 @@ pub(super) async fn handle_client( // Create a new session for this client let t0 = std::time::Instant::now(); - let mut new_agent = Agent::new_with_initial_working_dir( - Arc::clone(&provider), - registry.clone(), - Some(&initial_working_dir), - ); + let mut new_agent = + crate::hooks::with_client_terminal_env(active_terminal_env.clone(), async { + Agent::new_provisional_with_initial_working_dir( + Arc::clone(&provider), + registry.clone(), + Some(&initial_working_dir), + ) + }) + .await; let agent_new_ms = t0.elapsed().as_millis(); new_agent.set_memory_enabled(crate::config::config().features.memory); + let prewarm_start = std::time::Instant::now(); + new_agent.prewarm_provider_idle().await; + let prewarm_ms = prewarm_start.elapsed().as_millis(); crate::logging::info(&format!( - "[TIMING] handle_client setup: registry={registry_ms}ms, agent_new={agent_new_ms}ms, total={}ms", + "[TIMING] handle_client setup: provider_fork={provider_fork_ms}ms, registry={registry_ms}ms, agent_new={agent_new_ms}ms, prewarm={prewarm_ms}ms, total={}ms", client_start.elapsed().as_millis() )); let mut client_session_id = new_agent.session_id().to_string(); @@ -508,7 +627,7 @@ pub(super) async fn handle_client( last_seen: connected_at, is_processing: false, current_tool_name: None, - terminal_env: Vec::new(), + terminal_env: active_terminal_env.clone(), disconnect_tx: disconnect_tx.clone(), }, ); @@ -638,9 +757,9 @@ pub(super) async fn handle_client( tokio::sync::mpsc::unbounded_channel::(); { let mut agent_guard = agent.lock().await; - agent_guard.set_stdin_request_tx(stdin_req_tx); + agent_guard.set_stdin_request_tx(stdin_req_tx.clone()); } - let _stdin_forwarder = { + let stdin_forwarder = { let client_event_tx = client_event_tx.clone(); let stdin_responses = stdin_responses.clone(); let tool_call_id = String::new(); @@ -665,10 +784,12 @@ pub(super) async fn handle_client( // subscribe. Under heavy swarm file-activity load, ignored bus frames can // otherwise monopolize the select loop before the initial subscribe/read. let mut client_subscribed = false; + let mut provisional_session = true; let mut pending_request = Some(initial_request); + let connection_result: Result<()> = async { loop { - let request = if let Some(request) = pending_request.take() { + let mut request = if let Some(request) = pending_request.take() { request } else { line.clear(); @@ -718,58 +839,16 @@ pub(super) async fn handle_client( } let done_session = processing_session_id.take(); - match result { - Ok(()) => { - if let Some(session_id) = done_session.as_deref() { - update_member_status_with_report( - session_id, - "ready", - None, - completion_report, - &swarm_members, - &swarms_by_id, - Some(&event_history), - Some(&event_counter), - Some(&swarm_event_tx), - ) - .await; - } - } - Err(e) => { - if let Some(session_id) = done_session.as_deref() { - update_member_status( - session_id, - "failed", - Some(truncate_detail(&e.to_string(), 120)), - &swarm_members, - &swarms_by_id, - Some(&event_history), - Some(&event_counter), - Some(&swarm_event_tx), - ) - .await; - } - let retry_after_secs = e.downcast_ref::().and_then(|se| se.retry_after_secs); - if retry_after_secs.is_some() { - crate::telemetry::record_error(crate::telemetry::ErrorCategory::RateLimited); - } else { - let msg = e.to_string(); - let lower = msg.to_lowercase(); - if lower.contains("timeout") { - crate::telemetry::record_error(crate::telemetry::ErrorCategory::ProviderTimeout); - } else if crate::provider::error_looks_like_credential_failure(&msg) - || lower.contains("403 forbidden") - { - // Use the shared credential-failure classifier instead of a - // bare `contains("auth")`: that substring also matched - // unrelated errors (e.g. any message mentioning "author" or - // OAuth flow noise) and inflated the auth_failed telemetry - // counter. - crate::telemetry::record_error(crate::telemetry::ErrorCategory::AuthFailed); - } - } - } - } + record_processing_completion( + done_session.as_deref(), result, completion_report, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ).await; } else { break; } @@ -788,6 +867,11 @@ pub(super) async fn handle_client( // Forward bus events to this client bus_event = bus_rx.recv(), if client_subscribed => { match bus_event { + Ok(BusEvent::ModelUsageUpdated(route)) => { + if model_usage_updates_enabled { + let _ = client_event_tx.send(ServerEvent::ModelUsageUpdated { route }); + } + } Ok(BusEvent::ModelsUpdated) => { let Some(event) = try_available_models_updated_event(&agent) else { crate::logging::info(&format!( @@ -796,11 +880,16 @@ pub(super) async fn handle_client( )); continue; }; - let encoded_event = crate::protocol::encode_event(&event); - if last_available_models_snapshot.as_ref() == Some(&encoded_event) { + // Compare on an age-insensitive key: route details carry + // cosmetic "12m ago" cache ages that tick on their own, + // and a raw byte compare treated that drift as a real + // catalog change, fanning a full repaint out to every + // connected client. + let dedup_key = available_models_dedup_key(&event); + if last_available_models_snapshot.as_ref() == Some(&dedup_key) { continue; } - let encoded_len = encoded_event.len(); + let encoded_len = crate::protocol::encode_event(&event).len(); if encoded_len > MAX_LIVE_AVAILABLE_MODELS_UPDATE_BYTES { // Don't drop the catalog update entirely: clients still // need fresh model names for the picker. Strip the heavy @@ -829,11 +918,11 @@ pub(super) async fn handle_client( )); } } - last_available_models_snapshot = Some(encoded_event); + last_available_models_snapshot = Some(dedup_key); continue; } let _ = client_event_tx.send(event); - last_available_models_snapshot = Some(encoded_event); + last_available_models_snapshot = Some(dedup_key); } Ok(BusEvent::BatchProgress(progress)) => { if progress.session_id == client_session_id { @@ -1065,14 +1154,57 @@ pub(super) async fn handle_client( } } + // Legacy/direct clients can send a prompt without Subscribe. Their + // first session action commits ownership, but inspection/attach does not. + if provisional_session + && matches!( + &request, + Request::Message { .. } + | Request::SoftInterrupt { .. } + | Request::RunSubagent { .. } + ) + { + agent.lock().await.activate_concurrency_tracking(); + provisional_session = false; + } + + if let Err(message) = resolve_target_subscribe_working_dir( + &mut request, &sessions, &swarm_members, + ).await { + let _ = client_event_tx.send(ServerEvent::Error { + id: request.id(), message, retry_after_secs: None, + }); + continue; + } match request { Request::Message { id, content, images, system_reminder, + active_skill, + no_reply, } => { + if no_reply { + append_context_message( + id, + &content, + images, + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) + .await; + continue; + } if !client_is_processing { + // A live resume cannot replace stdin routing while the old + // turn owns the agent. Restore it when this client starts a + // later turn, without reviving any disconnected prompt. + if continue_on_disconnect && let Ok(mut agent) = agent.try_lock() { + agent.set_stdin_request_tx(stdin_req_tx.clone()); + } let mut connections = client_connections.write().await; if let Some(info) = connections.get_mut(&client_connection_id) { info.is_processing = true; @@ -1085,6 +1217,7 @@ pub(super) async fn handle_client( content, images, system_reminder, + active_skill, }, &client_session_id, &mut ProcessingState { @@ -1096,6 +1229,7 @@ pub(super) async fn handle_client( &agent, &client_event_tx, &processing_done_tx, + active_terminal_env.clone(), &SwarmStatusRefs { members: &swarm_members, swarms_by_id: &swarms_by_id, @@ -1140,16 +1274,82 @@ pub(super) async fn handle_client( Request::SoftInterrupt { id, content, + images, urgent, } => { - queue_soft_interrupt( - id, - content, - urgent, - SoftInterruptSource::User, - &session_control, - &client_event_tx, - ); + // A soft interrupt has somewhere to go only while a turn is + // active. When the session is idle, queueing it would strand + // the user's prompt until an unrelated future message starts + // a turn. Claim the idle session and process it as the next + // user message instead. The connection-map claim is atomic + // with the cross-client busy check, so two attachments cannot + // both decide that the same session is idle. + let start_idle_turn = { + let mut connections = client_connections.write().await; + let active_turn_registered = + !crate::turn_cancel_registry::active_turn_signals(&client_session_id) + .is_empty(); + let session_connection_busy = connections + .values() + .any(|info| info.session_id == client_session_id && info.is_processing); + let start = should_start_idle_soft_interrupt( + client_is_processing, + active_turn_registered, + session_connection_busy, + ); + if start { + if let Some(info) = connections.get_mut(&client_connection_id) { + info.is_processing = true; + } + } + start + }; + if start_idle_turn { + start_processing_message( + ProcessingMessage { + id, + content, + images, + system_reminder: None, + active_skill: None, + }, + &client_session_id, + &mut ProcessingState { + client_is_processing: &mut client_is_processing, + message_id: &mut processing_message_id, + session_id: &mut processing_session_id, + task: &mut processing_task, + }, + &agent, + &client_event_tx, + &processing_done_tx, + active_terminal_env.clone(), + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ) + .await; + if !client_is_processing { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(&client_connection_id) { + info.is_processing = false; + } + } + } else { + queue_soft_interrupt( + id, + content, + images, + urgent, + SoftInterruptSource::User, + &session_control, + &client_event_tx, + ); + } } Request::CancelSoftInterrupts { id } => { @@ -1171,28 +1371,31 @@ pub(super) async fn handle_client( ) { continue; } - handle_clear_session( - id, - client_selfdev, - &mut client_session_id, - &client_connection_id, - &agent, - &provider, - ®istry, - &sessions, - &shutdown_signals, - &soft_interrupt_queues, - &client_connections, - &swarm_members, - &swarms_by_id, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &swarm_plans, - &event_history, - &event_counter, - &swarm_event_tx, - &client_event_tx, + crate::hooks::with_client_terminal_env( + active_terminal_env.clone(), + handle_clear_session( + id, + client_selfdev, + &mut client_session_id, + &client_connection_id, + &agent, + &provider, + ®istry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &event_history, + &event_counter, + &swarm_event_tx, + &client_event_tx, + ), ) .await; session_control = refresh_session_control_handle( @@ -1324,7 +1527,15 @@ pub(super) async fn handle_client( } Request::Ping { id } => { - let json = encode_event(&ServerEvent::Pong { id }); + let json = encode_event(&ServerEvent::Pong { id, native_ssh_protocol: Some(1) }); + let mut w = writer.lock().await; + if w.write_all(json.as_bytes()).await.is_err() { + break; + } + } + + Request::PrepareDisconnect { id } => { + let json = encode_event(&ServerEvent::Done { id }); let mut w = writer.lock().await; if w.write_all(json.as_bytes()).await.is_err() { break; @@ -1354,10 +1565,14 @@ pub(super) async fn handle_client( client_instance_id, client_has_local_history, allow_session_takeover, + crash_on_disconnect: _, + continue_on_disconnect: requested_continuation, terminal_env, } => { if let Err(message) = - required_subscribe_working_dir(subscribe_working_dir.as_deref()) + validated_subscribe_working_dir( + subscribe_working_dir.as_deref(), requested_continuation, + ) { let _ = client_event_tx.send(ServerEvent::Error { id, @@ -1366,57 +1581,68 @@ pub(super) async fn handle_client( }); continue; } + // Every Subscribe carries an authoritative snapshot. An empty + // snapshot must clear terminal vars inherited by the daemon + // rather than retaining a prior pane's values. + continue_on_disconnect = requested_continuation; + active_terminal_env = terminal_env; current_client_instance_id = client_instance_id.clone(); { let mut connections = client_connections.write().await; if let Some(info) = connections.get_mut(&client_connection_id) { info.client_instance_id = client_instance_id.clone(); - // Record the client's terminal env so spawn/focus hooks - // target the client's terminal, not the server's stale - // startup env (#405). Only overwrite when the client sent - // something, so reconnects without env don't clobber it. - if !terminal_env.is_empty() { - info.terminal_env = terminal_env.clone(); - } + info.terminal_env = active_terminal_env.clone(); } } if let Some(target_session_id) = target_session_id { - if crate::session::session_exists(&target_session_id) { + // A brand-new desktop panel has no transcript on disk until + // its first prompt. Its creator connection can detach before + // the panel connection arrives, while the live agent is + // already registered in memory. Treat that as an existing + // session or the target-aware subscribe silently creates a + // different session and every subsequent command reports a + // wrong-session attachment. + if crate::session::session_exists(&target_session_id) + || sessions.read().await.contains_key(&target_session_id) + { let pre_resume_session_id = client_session_id.clone(); - agent = handle_resume_session( - id, - target_session_id.clone(), - subscribe_working_dir.as_deref(), - client_instance_id.as_deref(), - client_has_local_history, - allow_session_takeover, - &mut client_selfdev, - &mut client_session_id, - &client_connection_id, - &agent, - &provider, - ®istry, - &sessions, - &shutdown_signals, - &soft_interrupt_queues, - &client_connections, - &client_debug_state, - &swarm_members, - &swarms_by_id, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &swarm_plans, - &swarm_coordinators, - &client_count, - &writer, - &server_name, - &server_icon, - &client_event_tx, - &mcp_pool, - &event_history, - &event_counter, - &swarm_event_tx, + agent = crate::hooks::with_client_terminal_env( + active_terminal_env.clone(), + handle_resume_session( + id, + target_session_id.clone(), + subscribe_working_dir.as_deref(), + client_instance_id.as_deref(), + client_has_local_history, + allow_session_takeover, + &mut client_selfdev, + &mut client_session_id, + &client_connection_id, + &agent, + &provider, + ®istry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &client_debug_state, + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_count, + &writer, + &server_name, + &server_icon, + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ), ) .await?; session_control = refresh_session_control_handle( @@ -1463,6 +1689,9 @@ pub(super) async fn handle_client( break; } } else { + if provisional_session { + agent.lock().await.activate_concurrency_tracking(); + } handle_subscribe( id, subscribe_working_dir, @@ -1490,6 +1719,9 @@ pub(super) async fn handle_client( .await; } } else { + if provisional_session { + agent.lock().await.activate_concurrency_tracking(); + } handle_subscribe( id, subscribe_working_dir, @@ -1520,6 +1752,7 @@ pub(super) async fn handle_client( } } client_subscribed = true; + provisional_session = false; } Request::GetHistory { id } => { @@ -1553,7 +1786,8 @@ pub(super) async fn handle_client( } } - Request::GetModelCatalog { id } => { + Request::GetModelCatalog { id, subscribe_usage_updates } => { + model_usage_updates_enabled = subscribe_usage_updates; if handle_get_model_catalog(id, &client_session_id, &agent, &provider, &writer) .await .is_err() @@ -1610,6 +1844,7 @@ pub(super) async fn handle_client( client_has_local_history, allow_session_takeover, } => { + let pre_resume_session_id = client_session_id.clone(); let resume_working_dir = { let agent_guard = agent.lock().await; agent_guard.working_dir().map(str::to_string) @@ -1621,42 +1856,48 @@ pub(super) async fn handle_client( info.client_instance_id = client_instance_id.clone(); } } - agent = handle_resume_session( - id, - session_id, - resume_working_dir.as_deref(), - client_instance_id.as_deref(), - client_has_local_history, - allow_session_takeover, - &mut client_selfdev, - &mut client_session_id, - &client_connection_id, - &agent, - &provider, - ®istry, - &sessions, - &shutdown_signals, - &soft_interrupt_queues, - &client_connections, - &client_debug_state, - &swarm_members, - &swarms_by_id, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &swarm_plans, - &swarm_coordinators, - &client_count, - &writer, - &server_name, - &server_icon, - &client_event_tx, - &mcp_pool, - &event_history, - &event_counter, - &swarm_event_tx, + agent = crate::hooks::with_client_terminal_env( + active_terminal_env.clone(), + handle_resume_session( + id, + session_id, + resume_working_dir.as_deref(), + client_instance_id.as_deref(), + client_has_local_history, + allow_session_takeover, + &mut client_selfdev, + &mut client_session_id, + &client_connection_id, + &agent, + &provider, + ®istry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &client_debug_state, + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_count, + &writer, + &server_name, + &server_icon, + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ), ) .await?; + if client_session_id != pre_resume_session_id { + provisional_session = false; + } session_control = refresh_session_control_handle( &client_session_id, &agent, @@ -1855,7 +2096,7 @@ pub(super) async fn handle_client( } Request::Split { id } => { - handle_split(id, &client_session_id, &client_event_tx).await; + handle_split(id, &client_session_id, &agent, &client_event_tx).await; } Request::Transfer { id } => { @@ -2715,33 +2956,196 @@ pub(super) async fn handle_client( } } - cleanup_client_connection( - &sessions, - &client_session_id, - client_is_processing, - &mut processing_task, - event_handle, - &swarm_members, - &swarms_by_id, - &swarm_coordinators, - &swarm_plans, - &file_touch, - &channel_subscriptions, - &channel_subscriptions_by_session, - &client_debug_state, - &client_debug_id, - &client_connections, - &client_connection_id, - &shutdown_signals, - &soft_interrupt_queues, - &event_history, - &event_counter, - &swarm_event_tx, + Ok(()) + }.await; + + if continue_on_disconnect { + // Retain the existing turn owner, not the socket. Its JoinHandle and + // completion receiver stay alive so normal finalization still runs and + // the daemon cannot idle-shutdown midway through remote work. New + // attachments receive future events through the existing session fanout. + detach_client_attachment( + &client_session_id, + &client_connection_id, + &client_debug_id, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await; + event_handle.abort(); + drop(reader); + drop(writer); + // Input prompts belong to this transport and cannot safely be replayed + // to a new client. Close response channels instead of waiting forever. + stdin_forwarder.abort(); + let _ = stdin_forwarder.await; + stdin_responses.lock().await.clear(); + if let Some(handle) = processing_task.take() { + crate::logging::info(&format!( + "Retaining disconnected remote turn for session {}", + client_session_id + )); + let _ = handle.await; + while let Ok((done_id, result, report)) = processing_done_rx.try_recv() { + if Some(done_id) == processing_message_id { + record_processing_completion( + processing_session_id.as_deref(), + result, + report, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ) + .await; + } + } + client_is_processing = false; + } else { + // A reattached remote connection may disconnect again while the + // original lifecycle owns the task. Wait for its active-turn lease + // before attempting cleanup. Returning early here would leak the + // session if the original owner had just skipped cleanup for this + // successor. All finishers serialize cleanup against live attach. + while crate::turn_cancel_registry::has_active_turn(&client_session_id) { + tokio::time::sleep(Duration::from_millis(25)).await; + } + client_is_processing = false; + } + } + + crate::hooks::with_client_terminal_env( + active_terminal_env, + cleanup_client_connection( + &sessions, + &client_session_id, + client_is_processing, + &mut processing_task, + event_handle, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &client_debug_state, + &client_debug_id, + &client_connections, + &client_connection_id, + &shutdown_signals, + &soft_interrupt_queues, + &event_history, + &event_counter, + &swarm_event_tx, + &client_event_tx, + super::client_disconnect_cleanup::IDLE_RECONNECT_GRACE, + ), ) .await?; - Ok(()) + connection_result +} + +async fn record_processing_completion( + done_session: Option<&str>, + result: Result<()>, + completion_report: Option, + swarm: &SwarmStatusRefs<'_>, +) { + match result { + Ok(()) => { + if let Some(session_id) = done_session { + update_member_status_with_report( + session_id, + "ready", + None, + completion_report, + swarm.members, + swarm.swarms_by_id, + Some(swarm.event_history), + Some(swarm.event_counter), + Some(swarm.event_tx), + ) + .await; + } + } + Err(e) => { + if let Some(session_id) = done_session { + update_member_status( + session_id, + "failed", + Some(truncate_detail(&e.to_string(), 120)), + swarm.members, + swarm.swarms_by_id, + Some(swarm.event_history), + Some(swarm.event_counter), + Some(swarm.event_tx), + ) + .await; + } + let retry_after_secs = e + .downcast_ref::() + .and_then(|se| se.retry_after_secs); + if retry_after_secs.is_some() { + crate::telemetry::record_error(crate::telemetry::ErrorCategory::RateLimited); + } else { + let msg = e.to_string(); + let lower = msg.to_lowercase(); + if lower.contains("timeout") { + crate::telemetry::record_error( + crate::telemetry::ErrorCategory::ProviderTimeout, + ); + } else if crate::provider::error_looks_like_credential_failure(&msg) + || lower.contains("403 forbidden") + { + // Use the shared credential-failure classifier instead of a + // bare `contains("auth")`: that substring also matched + // unrelated errors (e.g. any message mentioning "author" or + // OAuth flow noise) and inflated the auth_failed telemetry + // counter. + crate::telemetry::record_error(crate::telemetry::ErrorCategory::AuthFailed); + } + } + } + } +} + +async fn append_context_message( + id: u64, + content: &str, + images: Vec<(String, String)>, + client_session_id: &str, + client_is_processing: bool, + agent: &Arc>, + client_event_tx: &mpsc::UnboundedSender, +) { + let Ok(mut agent) = agent.try_lock() else { + send_agent_busy_error( + id, + "context_message", + client_session_id, + client_is_processing, + client_event_tx, + ); + return; + }; + let result = agent.append_user_context_message(content, images); + let event = match result { + Ok(()) => ServerEvent::ContextMessageAdded { id }, + Err(error) => ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }, + }; + let _ = client_event_tx.send(event); } +#[allow(clippy::too_many_arguments)] async fn start_processing_message( message: ProcessingMessage, client_session_id: &str, @@ -2749,6 +3153,7 @@ async fn start_processing_message( agent: &Arc>, client_event_tx: &mpsc::UnboundedSender, processing_done_tx: &mpsc::UnboundedSender<(u64, Result<()>, Option)>, + client_terminal_env: Vec<(String, String)>, swarm: &SwarmStatusRefs<'_>, ) { let ProcessingMessage { @@ -2756,6 +3161,7 @@ async fn start_processing_message( content, images, system_reminder, + active_skill, } = message; if server_reload_starting() { crate::logging::info(&format!( @@ -2775,6 +3181,20 @@ async fn start_processing_message( return; } + if !agent + .lock() + .await + .set_remote_active_skill(active_skill.clone()) + { + let skill_name = active_skill.as_deref().unwrap_or_default(); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Skill '{skill_name}' is not installed on the server"), + retry_after_secs: None, + }); + return; + } + *state.client_is_processing = true; *state.message_id = Some(id); *state.session_id = Some(client_session_id.to_string()); @@ -2819,12 +3239,9 @@ async fn start_processing_message( crate::logging::info(&format!("Processing message id={} spawning task", id)); *state.task = Some(tokio::spawn(async move { let event_tx = tx.clone(); - let result = match std::panic::AssertUnwindSafe(process_message_streaming_mpsc( - agent, - &content, - images, - system_reminder, - event_tx, + let result = match std::panic::AssertUnwindSafe(crate::hooks::with_client_terminal_env( + client_terminal_env, + process_message_streaming_mpsc(agent, &content, images, system_reminder, event_tx), )) .catch_unwind() .await @@ -2994,6 +3411,25 @@ async fn cancel_processing_message( *state.client_is_processing, *state.message_id )); + // Nothing is running anywhere for this session, so there is no turn to + // interrupt and arming the signal can only harm the *next* one: the + // deferred reset below runs 500ms later, and a message sent inside + // that window starts with the cancel flag already set and dies + // immediately, with no reply and no error. Report the interrupt and + // stop. Sessions whose turn is owned by another connection still take + // the signalling path, since the registry sees those turns. + if !crate::turn_cancel_registry::has_active_turn(&session_control.session_id) { + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_IDLE_NOOP request_id={:?} session={}", + request_id, session_label + )); + *state.client_is_processing = false; + let _ = client_event_tx.send(ServerEvent::Interrupted); + if let Some(message_id) = state.message_id.take() { + let _ = client_event_tx.send(ServerEvent::Done { id: message_id }); + } + return; + } let cancel_epoch = session_control.request_cancel(); let reset_control = session_control.clone(); tokio::spawn(async move { @@ -3045,7 +3481,7 @@ async fn cancel_processing_message( fn try_available_models_snapshot(agent: &Arc>) -> Option { let event = try_available_models_updated_event(agent)?; - Some(crate::protocol::encode_event(&event)) + Some(available_models_dedup_key(&event)) } /// Build a names-only copy of an `AvailableModelsUpdated` event by dropping the @@ -3072,6 +3508,7 @@ fn names_only_available_models_event(event: &ServerEvent) -> Option fn queue_soft_interrupt( id: u64, content: String, + images: Vec<(String, String)>, urgent: bool, source: SoftInterruptSource, session_control: &SessionControlHandle, @@ -3083,7 +3520,7 @@ fn queue_soft_interrupt( "SERVER_SOFT_INTERRUPT_QUEUE_REQUEST id={} session={} source={:?} urgent={} content_bytes={} content_chars={}", id, session_control.session_id, source, urgent, content_bytes, content_chars )); - let queued = session_control.queue_soft_interrupt(content, urgent, source); + let queued = session_control.queue_soft_interrupt(content, images, urgent, source); let ack_queued = client_event_tx.send(ServerEvent::Ack { id }).is_ok(); crate::logging::info(&format!( "SERVER_SOFT_INTERRUPT_QUEUE_RESULT id={} session={} queued={} ack_queued={}", @@ -3145,6 +3582,20 @@ pub(super) async fn process_message_streaming_mpsc( event_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result<()> { let mut agent = agent.lock().await; + process_locked_message_streaming_mpsc(&mut agent, content, images, system_reminder, event_tx) + .await +} + +/// Same as [`process_message_streaming_mpsc`] for a caller that already holds +/// the agent lock (e.g. a wake turn that reserved the idle agent up front, see +/// #1152). +pub(super) async fn process_locked_message_streaming_mpsc( + agent: &mut Agent, + content: &str, + images: Vec<(String, String)>, + system_reminder: Option, + event_tx: tokio::sync::mpsc::UnboundedSender, +) -> Result<()> { let session_id = agent.session_id().to_string(); let result = agent .run_once_streaming_mpsc(content, images, system_reminder, event_tx) @@ -3169,3 +3620,7 @@ pub(super) async fn process_message_streaming_mpsc( #[cfg(test)] #[path = "client_lifecycle_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "client_target_attach_tests.rs"] +mod target_attach_tests; diff --git a/crates/jcode-app-core/src/server/client_lifecycle_catalog_dedup_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_catalog_dedup_tests.rs new file mode 100644 index 0000000000..be8da0e342 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_lifecycle_catalog_dedup_tests.rs @@ -0,0 +1,99 @@ +//! Tests for the `AvailableModelsUpdated` dedup key. +//! +//! Split out of `client_lifecycle_tests.rs` to keep that file under the +//! test-size ratchet. + +use super::{available_models_dedup_key, strip_relative_age_text}; +use crate::protocol::ServerEvent; + +/// Relative cache ages ("12m ago") drift on their own. If they reach the +/// catalog dedup key, every age rollover looks like a real catalog change and +/// fans a full repaint out to every connected client, which starves the TUI +/// input line. +#[test] +fn relative_age_text_is_normalized_out_of_the_catalog_dedup_key() { + assert_eq!( + strip_relative_age_text("openrouter · cached 12m ago · $3/M"), + "openrouter · cached · $3/M" + ); + assert_eq!( + strip_relative_age_text("a 5s ago b 3h ago c 2d ago"), + "a b c " + ); +} + +#[test] +fn catalog_dedup_key_ignores_age_drift_but_keeps_real_changes() { + let route = |detail: &str| crate::provider::ModelRoute { + model: "claude-opus-4.6".to_string(), + provider: "OpenRouter".to_string(), + api_method: "openrouter".to_string(), + available: true, + detail: detail.to_string(), + usage: None, + cheapness: None, + }; + let event = |detail: &str| ServerEvent::AvailableModelsUpdated { + provider_name: Some("OpenRouter".to_string()), + provider_model: Some("claude-opus-4.6".to_string()), + available_models: vec!["claude-opus-4.6".to_string()], + available_model_routes: vec![route(detail)], + }; + + // Only the cosmetic age moved: same catalog, must dedup. + assert_eq!( + available_models_dedup_key(&event("cached 12m ago")), + available_models_dedup_key(&event("cached 41m ago")), + ); + // A genuine detail change must still be visible. + assert_ne!( + available_models_dedup_key(&event("cached 12m ago")), + available_models_dedup_key(&event("rate limited, cached 12m ago")), + ); +} + +/// A trailing digit run with no unit/suffix must not index past the string. +#[test] +fn age_normalization_handles_trailing_digits_and_unicode() { + assert_eq!(strip_relative_age_text("tokens 12345"), "tokens 12345"); + assert_eq!(strip_relative_age_text("9"), "9"); + assert_eq!(strip_relative_age_text("9m"), "9m"); + assert_eq!( + strip_relative_age_text("→ · émoji 7h ago"), + "→ · émoji " + ); +} + +/// Real route detail strings captured from a production OpenRouter catalog +/// cache. These carry both self-ticking cache ages and endpoint stats +/// (`p50`, `tps`) that only move when the endpoint cache genuinely refreshes. +/// Ages must normalize away; stats must not, since a stats change means the +/// upstream data really did change and clients should see it. +#[test] +fn production_route_details_normalize_ages_but_preserve_endpoint_stats() { + let with_age = "in $0.30/M, out $2.50/M, cache write $0.08/M, cache read $0.03/M, 100%, \ + 493ms p50, 143tps, cache on, 17m ago"; + let later_age = "in $0.30/M, out $2.50/M, cache write $0.08/M, cache read $0.03/M, 100%, \ + 493ms p50, 143tps, cache on, 56m ago"; + let changed_stats = "in $0.30/M, out $2.50/M, cache write $0.08/M, cache read $0.03/M, 100%, \ + 900ms p50, 143tps, cache on, 17m ago"; + + // Age drift alone must collapse to the same key. + assert_eq!( + strip_relative_age_text(with_age), + strip_relative_age_text(later_age), + "cache-age drift must not look like a catalog change" + ); + // A real endpoint-stat change must survive normalization. + assert_ne!( + strip_relative_age_text(with_age), + strip_relative_age_text(changed_stats), + "endpoint stat changes are real and must still propagate" + ); + // `143tps` has no " ago" suffix, so it must be left intact. + assert!( + strip_relative_age_text(with_age).contains("143tps"), + "bare unit-suffixed numbers must not be mistaken for ages" + ); + assert!(strip_relative_age_text(with_age).ends_with("cache on, ")); +} diff --git a/crates/jcode-app-core/src/server/client_lifecycle_logging.rs b/crates/jcode-app-core/src/server/client_lifecycle_logging.rs index 91e5934526..f1db919157 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle_logging.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle_logging.rs @@ -21,6 +21,7 @@ pub(super) fn interrupt_request_log_fields( id, content, urgent, + .. } => Some(format!( "{} urgent={} content_bytes={} content_chars={}", base("soft_interrupt", *id), diff --git a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs index 68aee1a276..59e6273ddb 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::message::{Message, StreamEvent, ToolDefinition}; +use crate::message::{ContentBlock, Message, StreamEvent, ToolDefinition}; use crate::provider::{EventStream, Provider}; use async_trait::async_trait; use futures::stream; @@ -41,6 +41,7 @@ async fn session_control_handle_does_not_wait_for_busy_agent_lock() { tokio::time::timeout(Duration::from_millis(100), async { assert!(control.queue_soft_interrupt( "please stop".to_string(), + Vec::new(), true, SoftInterruptSource::User, )); @@ -192,6 +193,92 @@ async fn busy_agent_request_rejection_does_not_wait_for_agent_lock() { assert!(client_event_rx.try_recv().is_err()); } +#[tokio::test] +async fn context_message_persists_without_starting_turn() { + let _guard = crate::storage::lock_test_env(); + let _env = IsolatedReloadRecoveryEnv::new(); + let session_id = "session_context_only_no_reply"; + let forked = Arc::new(AtomicBool::new(false)); + let provider: Arc = Arc::new(PanicOnForkProvider { + forked: Arc::clone(&forked), + }); + let registry = Registry::new(Arc::clone(&provider)).await; + let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None); + session.model = Some("panic-on-fork".to_string()); + let agent = Arc::new(Mutex::new(Agent::new_with_session( + provider, registry, session, None, + ))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::(); + let before = agent.lock().await.message_count(); + + append_context_message( + 77, + "remember this context", + vec![("image/png".to_string(), "AAA".to_string())], + session_id, + false, + &agent, + &client_event_tx, + ) + .await; + + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::ContextMessageAdded { id: 77 }) + )); + assert!(client_event_rx.try_recv().is_err()); + assert!(!forked.load(Ordering::SeqCst)); + + let persisted = crate::session::Session::load(session_id).expect("persisted session"); + assert_eq!(persisted.messages.len(), before + 1); + let message = persisted.messages.last().unwrap(); + assert_eq!(format!("{:?}", message.role), "User"); + assert!(matches!( + &message.content[0], + ContentBlock::Image { media_type, data } + if media_type == "image/png" && data == "AAA" + )); + assert!(matches!( + &message.content[1], + ContentBlock::Text { text, .. } if text == "remember this context" + )); +} + +#[tokio::test] +async fn context_message_rejects_while_busy_without_waiting_for_agent_lock() { + let provider: Arc = Arc::new(PanicOnForkProvider { + forked: Arc::new(AtomicBool::new(false)), + }); + let registry = Registry::new(Arc::clone(&provider)).await; + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::(); + let _busy_agent_lock = agent.lock().await; + + tokio::time::timeout(Duration::from_millis(100), async { + append_context_message( + 78, + "too busy", + Vec::new(), + "session_context_busy", + true, + &agent, + &client_event_tx, + ) + .await; + }) + .await + .expect("busy rejection must not wait for the agent mutex"); + + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Error { + id: 78, + retry_after_secs: Some(1), + .. + }) + )); +} + #[tokio::test] async fn cancel_without_local_task_still_signals_session_control() { let soft_interrupt_queue = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -201,6 +288,14 @@ async fn cancel_without_local_task_still_signals_session_control() { soft_interrupt_queue, stop_signal.clone(), ); + // The point of this path is a turn this connection does not own (attach + // after reload, server-initiated turn). Without a registered active turn + // the cancel is a deliberate no-op, because arming the signal with nothing + // running only kills the *next* message. + let _active_turn = crate::turn_cancel_registry::register_active_turn( + "session_detached_cancel", + InterruptSignal::new(), + ); let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::(); let swarm_members = Arc::new(RwLock::new(HashMap::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); @@ -261,6 +356,12 @@ async fn deferred_cancel_reset_does_not_erase_newer_cancel() { Arc::clone(&soft_interrupt_queue), stop_signal.clone(), ); + // A turn owned by another connection is what makes this the signalling + // path rather than the idle no-op; see the sibling test. + let _active_turn = crate::turn_cancel_registry::register_active_turn( + "session_detached_cancel_race", + InterruptSignal::new(), + ); let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel::(); let swarm_members = Arc::new(RwLock::new(HashMap::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); @@ -494,6 +595,80 @@ fn cancel_aborts_detached_streaming_turn_with_stale_stop_signal() -> anyhow::Res Ok(()) } +/// A cancel that arrives while the session is idle must not arm the cancel +/// signal at all. +/// +/// The no-local-task branch cannot tell an idle session from one whose turn +/// another connection owns, so it used to fire the signal and clear it on a +/// 500ms timer. Any message sent inside that window began with the flag +/// already set and was aborted the instant it started: no reply, no error, +/// just a message that vanished. Pressing Esc on an idle prompt and typing +/// immediately is an ordinary thing to do, so this must be a true no-op. +#[test] +fn idle_cancel_does_not_arm_the_signal_for_the_next_turn() -> anyhow::Result<()> { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedReloadRecoveryEnv::new(); + let session_id = "session_idle_cancel_noop"; + + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let stop_signal = InterruptSignal::new(); + let control = SessionControlHandle::cancel_only( + session_id, + Arc::new(std::sync::Mutex::new(Vec::new())), + stop_signal.clone(), + ); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::(); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); + let event_history = Arc::new(RwLock::new(std::collections::VecDeque::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _) = broadcast::channel(8); + let mut client_is_processing = false; + let mut message_id = None; + let mut cancel_session_id = None; + let mut task = None; + + assert!( + !crate::turn_cancel_registry::has_active_turn(session_id), + "test precondition: the session must be idle" + ); + + cancel_processing_message( + &mut ProcessingState { + client_is_processing: &mut client_is_processing, + message_id: &mut message_id, + session_id: &mut cancel_session_id, + task: &mut task, + }, + &control, + &client_event_tx, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + Some(1), + None, + ) + .await; + + assert!( + !stop_signal.is_set(), + "an idle cancel must not arm the stop signal; the next turn would die instantly" + ); + // The client still learns the cancel was handled, so a UI showing + // "Interrupting..." resolves rather than hanging. + match client_event_rx.try_recv() { + Ok(ServerEvent::Interrupted) => {} + other => panic!("idle cancel must still report Interrupted, got {other:?}"), + } + }); + Ok(()) +} + struct PanicOnForkProvider { forked: Arc, } @@ -627,6 +802,8 @@ fn subscribe_request(working_dir: Option<&str>) -> Request { client_instance_id: None, client_has_local_history: false, allow_session_takeover: false, + crash_on_disconnect: false, + continue_on_disconnect: false, terminal_env: Vec::new(), } } @@ -651,6 +828,39 @@ fn initial_subscribe_requires_an_absolute_client_working_dir() { assert!(error.contains("must Subscribe")); } +#[test] +fn remote_subscribe_requires_an_existing_server_directory() -> anyhow::Result<()> { + let directory = tempfile::tempdir()?; + let file = directory.path().join("not-a-directory"); + std::fs::write(&file, "file")?; + let missing = directory.path().join("missing"); + for path in [&file, &missing] { + let mut request = subscribe_request(path.to_str()); + assert!( + initial_subscribe_working_dir(&request).is_ok(), + "local subscription behavior remains unchanged" + ); + if let Request::Subscribe { + continue_on_disconnect, + .. + } = &mut request + { + *continue_on_disconnect = true; + } + assert!( + initial_subscribe_working_dir(&request) + .unwrap_err() + .contains("must exist and be a directory on the server") + ); + } + assert_eq!( + validated_subscribe_working_dir(directory.path().to_str(), true) + .expect("existing directory"), + directory.path().to_str().unwrap() + ); + Ok(()) +} + #[tokio::test] async fn new_client_agent_stamps_client_cwd_into_initial_context() { let provider: Arc = Arc::new(CompleteImmediatelyProvider); @@ -734,6 +944,7 @@ fn reload_starting_rejects_new_turn_without_spawning_processing_task() { content: "do not start during reload".to_string(), images: Vec::new(), system_reminder: None, + active_skill: None, }, "session_guard", &mut ProcessingState { @@ -745,6 +956,7 @@ fn reload_starting_rejects_new_turn_without_spawning_processing_task() { &agent, &client_event_tx, &processing_done_tx, + Vec::new(), &SwarmStatusRefs { members: &swarm_members, swarms_by_id: &swarms_by_id, @@ -833,6 +1045,7 @@ async fn client_initiated_turn_fans_out_stream_and_terminal_events_to_live_attac content: "stream to every attachment".to_string(), images: Vec::new(), system_reminder: None, + active_skill: None, }, session_id, &mut ProcessingState { @@ -844,6 +1057,7 @@ async fn client_initiated_turn_fans_out_stream_and_terminal_events_to_live_attac &agent, &origin_tx, &processing_done_tx, + Vec::new(), &SwarmStatusRefs { members: &swarm_members, swarms_by_id: &swarms_by_id, @@ -885,7 +1099,7 @@ async fn client_initiated_turn_fans_out_stream_and_terminal_events_to_live_attac event, ServerEvent::TextDelta { ref text } if text == "after attach" ); - if matches!(event, ServerEvent::MessageEnd) { + if matches!(event, ServerEvent::MessageEnd { .. }) { assert!(!saw_done, "MessageEnd must precede the terminal Done event"); saw_message_end = true; } @@ -956,6 +1170,7 @@ fn accepted_reload_recovery_continuation_marks_intent_delivered() -> anyhow::Res content: "continue after reload".to_string(), images: Vec::new(), system_reminder: Some(continuation.to_string()), + active_skill: None, }, session_id, &mut ProcessingState { @@ -967,6 +1182,7 @@ fn accepted_reload_recovery_continuation_marks_intent_delivered() -> anyhow::Res &agent, &client_event_tx, &processing_done_tx, + Vec::new(), &SwarmStatusRefs { members: &swarm_members, swarms_by_id: &swarms_by_id, @@ -1054,6 +1270,7 @@ fn reload_starting_rejects_new_turns_for_multiple_sessions() { content: format!("do not start {session_id} during reload"), images: Vec::new(), system_reminder: None, + active_skill: None, }, session_id, &mut ProcessingState { @@ -1065,6 +1282,7 @@ fn reload_starting_rejects_new_turns_for_multiple_sessions() { &agent, &client_event_tx, &processing_done_tx, + Vec::new(), &SwarmStatusRefs { members: &swarm_members, swarms_by_id: &swarms_by_id, @@ -1206,6 +1424,14 @@ async fn lightweight_comm_request_skips_full_session_initialization() { other => panic!("expected error response, got {other:?}"), } + line.clear(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), client_reader.read_line(&mut line)) + .await + .expect("non-Ping lightweight command must close its one-shot connection") + .expect("read EOF"), + 0, + ); drop(client_writer); server_task .await @@ -1229,3 +1455,11 @@ async fn lightweight_comm_request_skips_full_session_initialization() { fn decode_request_or_event(line: &str) -> ServerEvent { serde_json::from_str(line.trim()).expect("decode server event") } + +#[test] +fn soft_interrupt_dispatch_starts_idle_session_and_queues_busy_session() { + assert!(should_start_idle_soft_interrupt(false, false, false)); + assert!(!should_start_idle_soft_interrupt(true, false, false)); + assert!(!should_start_idle_soft_interrupt(false, true, false)); + assert!(!should_start_idle_soft_interrupt(false, false, true)); +} diff --git a/crates/jcode-app-core/src/server/client_lightweight_control.rs b/crates/jcode-app-core/src/server/client_lightweight_control.rs index 2b4773378a..0ebe19ff81 100644 --- a/crates/jcode-app-core/src/server/client_lightweight_control.rs +++ b/crates/jcode-app-core/src/server/client_lightweight_control.rs @@ -103,7 +103,14 @@ pub(super) async fn handle_lightweight_control_request( swarm_mutation_runtime, } = context; if let Request::Ping { id } = request { - write_direct_event(&writer, &ServerEvent::Pong { id }).await?; + write_direct_event( + &writer, + &ServerEvent::Pong { + id, + native_ssh_protocol: Some(1), + }, + ) + .await?; return Ok(()); } diff --git a/crates/jcode-app-core/src/server/client_session.rs b/crates/jcode-app-core/src/server/client_session.rs index 5e320241ed..e24884c98c 100644 --- a/crates/jcode-app-core/src/server/client_session.rs +++ b/crates/jcode-app-core/src/server/client_session.rs @@ -8,7 +8,7 @@ use super::{ register_session_interrupt_queue, remove_background_tool_signal, remove_plan_participant, remove_session_channel_subscriptions, remove_session_from_swarm, remove_session_interrupt_queue, rename_background_tool_signal, rename_plan_participant, - rename_session_interrupt_queue, send_swarm_plan_to_session, swarm_id_for_dir, + rename_session_interrupt_queue, send_swarm_plan_to_session, swarm_id_for_session, unregister_session_event_sender, update_member_status, }; use crate::agent::Agent; @@ -18,9 +18,10 @@ use crate::provider::Provider; use crate::tool::Registry; use crate::transport::WriteHalf; use anyhow::Result; +use futures::FutureExt; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; @@ -120,6 +121,12 @@ async fn rename_shutdown_signal( } drop(signals); rename_background_tool_signal(old_session_id, new_session_id); + // In-flight turns are registered in the process-global cancel registry by + // session id. Attaching to / resuming a session renames it underneath a + // still-streaming turn, so the registration must follow, or a later Esc + // finds no active-turn signal for the new id and the model keeps + // generating (issue #732, regression of issue #428). + crate::turn_cancel_registry::rename_active_turns(old_session_id, new_session_id); } #[allow(clippy::too_many_arguments)] @@ -220,24 +227,23 @@ pub(super) async fn handle_clear_session( } remove_session_interrupt_queue(soft_interrupt_queues, client_session_id).await; - let swarm_id_for_update = { + // `/clear` creates a genuinely fresh session. Do not migrate the old + // session's swarm membership or plan participation to the replacement: + // doing so lets a subsequent plan snapshot repopulate the cleared UI. + let (swarm_id_for_update, swarm_enabled, friendly_name) = { let mut members = swarm_members.write().await; - if let Some(mut member) = members.remove(client_session_id) { - let swarm_id = member.swarm_id.clone(); - member.session_id = new_id.clone(); - member.status = "ready".to_string(); - member.detail = None; - members.insert(new_id.clone(), member); - swarm_id - } else { - None + match members.remove(client_session_id) { + Some(member) => (member.swarm_id, member.swarm_enabled, member.friendly_name), + None => (None, false, None), } }; if let Some(ref swarm_id) = swarm_id_for_update { let mut swarms = swarms_by_id.write().await; if let Some(swarm) = swarms.get_mut(swarm_id) { swarm.remove(client_session_id); - swarm.insert(new_id.clone()); + if swarm.is_empty() { + swarms.remove(swarm_id); + } } } file_touch.clear_session(client_session_id).await; @@ -247,6 +253,23 @@ pub(super) async fn handle_clear_session( channel_subscriptions_by_session, ) .await; + // The connection remains subscribed across `/clear`, so there is no later + // subscribe request to register the replacement session. Register it as a + // fresh root while deliberately leaving the old swarm and plan behind. + ensure_client_swarm_member( + &new_id, + client_connection_id, + &friendly_name, + client_event_tx, + agent, + swarm_enabled, + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ) + .await; update_member_status( &new_id, "ready", @@ -259,7 +282,7 @@ pub(super) async fn handle_clear_session( ) .await; if let Some(ref swarm_id) = swarm_id_for_update { - rename_plan_participant(swarm_id, client_session_id, &new_id, swarm_plans).await; + remove_plan_participant(swarm_id, client_session_id, swarm_plans).await; } *client_session_id = new_id.clone(); @@ -305,16 +328,34 @@ async fn ensure_client_swarm_member( swarm_event_tx: &broadcast::Sender, ) -> bool { let (working_dir, derived_swarm_id, fallback_name) = { - let agent_guard = agent.lock().await; - let working_dir = agent_guard.working_dir().map(PathBuf::from); + // A target-aware subscribe can attach to an agent that is in the middle + // of a turn. Never wait for that turn's agent lock just to populate + // connection metadata: doing so prevents the subscribe request from + // completing, so subsequent state requests sit unread until the desktop + // client times out. The persisted startup stub has the same immutable + // identity metadata and is safe to read while the live agent is busy. + let (working_dir, fallback_name) = match agent.try_lock() { + Ok(agent_guard) => ( + agent_guard.working_dir().map(PathBuf::from), + agent_guard + .session_short_name() + .map(|value| value.to_string()), + ), + Err(_) => { + crate::logging::info(&format!( + "Subscribe metadata for busy session {} is using the persisted startup stub", + client_session_id + )); + crate::session::Session::load_startup_stub(client_session_id) + .map(|session| (session.working_dir.map(PathBuf::from), session.short_name)) + .unwrap_or((None, None)) + } + }; let derived_swarm_id = if swarm_enabled { - swarm_id_for_dir(working_dir.clone()) + swarm_id_for_session(client_session_id) } else { None }; - let fallback_name = agent_guard - .session_short_name() - .map(|value| value.to_string()); (working_dir, derived_swarm_id, fallback_name) }; @@ -409,6 +450,145 @@ async fn ensure_client_swarm_member( inserted } +/// Resolve the working directory a subscribe should actually bind to. +/// +/// Returns the reported dir when it is acceptable, or the session's existing +/// dir when the report is rejected by [`subscribe_working_dir_replacement`]. +/// Every consumer of a subscribe cwd (agent state, swarm id, project-local MCP +/// resolution) must agree on this one answer, otherwise the session's tools, +/// swarm grouping, and MCP config can each resolve against a different +/// directory (issue #481). +pub(super) fn effective_subscribe_working_dir( + current: Option<&str>, + reported: &str, + home: Option<&Path>, +) -> String { + match subscribe_working_dir_replacement(current, reported, home) { + Some(accepted) => accepted, + None => current + .map(str::to_string) + .unwrap_or_else(|| reported.trim().to_string()), + } +} + +/// Decide whether a client-reported subscribe cwd may replace the session's +/// current working directory. +/// +/// Requiring a subscribe cwd to be non-empty and absolute (the earlier +/// require-cwd change) is necessary but not sufficient: a client that launches +/// with an inherited environment can report the user's *home* directory even +/// though the real project lives elsewhere. Accepting that silently re-pins the +/// session to home, so bash/file tools run against home while the header still +/// shows the project path (issue #481). +/// +/// The rule is deliberately narrow so it cannot break legitimate directory +/// changes: a reported cwd that is exactly the home directory is ignored *only* +/// when the session already has a different working directory. Working in home +/// on purpose (no prior cwd, or a session already pinned to home) still works, +/// and every other path is accepted as before. +pub(super) fn subscribe_working_dir_replacement( + current: Option<&str>, + reported: &str, + home: Option<&Path>, +) -> Option { + let reported_trimmed = reported.trim(); + if reported_trimmed.is_empty() { + return None; + } + let current = current.map(str::trim).filter(|dir| !dir.is_empty()); + if current == Some(reported_trimmed) { + return None; + } + if let (Some(current), Some(home)) = (current, home) + && Path::new(reported_trimmed) == home + && Path::new(current) != home + { + return None; + } + Some(reported_trimmed.to_string()) +} + +fn log_ignored_subscribe_working_dir(session_id: &str, current: &str, reported: &str) { + crate::logging::warn(&format!( + "Ignoring subscribe working_dir {} for session {}: it is the home directory while the session is already bound to {} (issue #481)", + reported, session_id, current + )); +} + +fn apply_or_defer_subscribe_working_dir( + agent: &Arc>, + working_dir: &str, + session_id: &str, +) { + let home = dirs::home_dir(); + if let Ok(mut agent_guard) = agent.try_lock() { + match subscribe_working_dir_replacement( + agent_guard.working_dir(), + working_dir, + home.as_deref(), + ) { + Some(accepted) => agent_guard.set_working_dir(&accepted), + None => { + if let Some(current) = agent_guard.working_dir() + && current != working_dir + { + log_ignored_subscribe_working_dir(session_id, current, working_dir); + } + } + } + return; + } + + let agent = Arc::clone(agent); + let working_dir = working_dir.to_string(); + let session_id = session_id.to_string(); + tokio::spawn(async move { + let mut agent_guard = agent.lock().await; + match subscribe_working_dir_replacement( + agent_guard.working_dir(), + &working_dir, + home.as_deref(), + ) { + Some(accepted) => { + agent_guard.set_working_dir(&accepted); + crate::logging::info(&format!( + "Applied deferred subscribe working directory for session {}", + session_id + )); + } + None => { + if let Some(current) = agent_guard.working_dir() + && current != working_dir + { + log_ignored_subscribe_working_dir(&session_id, current, &working_dir); + } + } + } + }); +} + +fn apply_or_defer_subscribe_selfdev(agent: &Arc>, session_id: &str) { + if let Ok(mut agent_guard) = agent.try_lock() { + if !agent_guard.is_canary() { + agent_guard.set_canary("self-dev"); + } + return; + } + + let agent = Arc::clone(agent); + let session_id = session_id.to_string(); + tokio::spawn(async move { + let mut agent_guard = agent.lock().await; + if !agent_guard.is_canary() { + agent_guard.set_canary("self-dev"); + } + crate::logging::info(&format!( + "Applied deferred self-dev subscribe metadata for session {}", + session_id + )); + }); +} + #[allow(clippy::too_many_arguments)] pub(super) async fn handle_subscribe( id: u64, @@ -450,7 +630,7 @@ pub(super) async fn handle_subscribe( ("swarm_enabled", swarm_enabled.to_string()), ], ); - ensure_client_swarm_member( + let inserted_swarm_member = ensure_client_swarm_member( client_session_id, client_connection_id, friendly_name, @@ -466,18 +646,37 @@ pub(super) async fn handle_subscribe( .await; if let Some(ref dir) = subscribe_working_dir { - let mut agent_guard = agent.lock().await; - agent_guard.set_working_dir(dir); - drop(agent_guard); + apply_or_defer_subscribe_working_dir(agent, dir, client_session_id); - let new_path = PathBuf::from(dir); - let new_swarm_id = swarm_id_for_dir(Some(new_path.clone())); + // Swarm grouping must use the *bound* directory, not the raw report, or + // a home-dir subscribe would still re-key the session's swarm even + // though its agent stayed in the project (issue #481). + let bound_dir = { + let current = agent + .try_lock() + .ok() + .and_then(|guard| guard.working_dir().map(str::to_string)); + effective_subscribe_working_dir(current.as_deref(), dir, dirs::home_dir().as_deref()) + }; + let new_path = PathBuf::from(&bound_dir); let mut old_swarm_id: Option = None; let mut updated_swarm_id: Option = None; { let mut members = swarm_members.write().await; if let Some(member) = members.get_mut(client_session_id) { old_swarm_id = member.swarm_id.clone(); + // Existing members include reconnects and daemon-restored + // sessions. Keep their persisted swarm id so an intentional + // resume retains its workers and plan. Only a newly inserted + // root receives the new session-scoped identity. + let new_swarm_id = if inserted_swarm_member { + swarm_id_for_session(client_session_id) + } else { + member + .swarm_id + .clone() + .or_else(|| swarm_id_for_session(client_session_id)) + }; member.working_dir = Some(new_path); member.swarm_id = if member.swarm_enabled { new_swarm_id.clone() @@ -604,11 +803,7 @@ pub(super) async fn handle_subscribe( if should_selfdev { *client_selfdev = true; - let mut agent_guard = agent.lock().await; - if !agent_guard.is_canary() { - agent_guard.set_canary("self-dev"); - } - drop(agent_guard); + apply_or_defer_subscribe_selfdev(agent, client_session_id); registry.register_selfdev_tools().await; } @@ -618,11 +813,28 @@ pub(super) async fn handle_subscribe( // not the server process cwd (issue #420). Prefer the subscribe // request's dir; fall back to the agent's stored session dir. let mcp_working_dir = match subscribe_working_dir.as_ref() { - Some(dir) => Some(PathBuf::from(dir)), - None => { - let agent_guard = agent.lock().await; - agent_guard.working_dir().map(PathBuf::from) + // Resolve against the bound directory so a rejected home-dir report + // cannot point project-local MCP discovery at home (issue #481). + Some(dir) => { + let current = agent + .try_lock() + .ok() + .and_then(|guard| guard.working_dir().map(str::to_string)); + Some(PathBuf::from(effective_subscribe_working_dir( + current.as_deref(), + dir, + dirs::home_dir().as_deref(), + ))) } + None => agent + .try_lock() + .ok() + .and_then(|agent_guard| agent_guard.working_dir().map(PathBuf::from)) + .or_else(|| { + crate::session::Session::load_startup_stub(client_session_id) + .ok() + .and_then(|session| session.working_dir.map(PathBuf::from)) + }), }; registry .register_mcp_tools_for_dir( @@ -678,7 +890,26 @@ pub(super) async fn handle_subscribe( // plan graph immediately instead of waiting for the next plan mutation. send_swarm_plan_to_session(client_session_id, swarm_members, swarm_plans).await; + // Tell the client which session it is bound to. Local clients learn this + // from their own launch state, but a remote client (gateway/WebSocket) has + // no other source, and without it a dropped connection cannot reattach: + // the next Subscribe carries no `target_session_id`, so the server hands + // it a brand-new session and the in-flight turn becomes unreachable. + let _ = client_event_tx.send(ServerEvent::SessionId { + session_id: client_session_id.to_string(), + }); let _ = client_event_tx.send(ServerEvent::Done { id }); + prewarm_idle_agent(agent); +} + +fn prewarm_idle_agent(agent: &Arc>) -> bool { + // Poll local preparation once, without holding the agent across a yield. + // If a registry/provider lock would wait, abandon this optional attempt. + // Only the provider's network task can outlive this call. + let Ok(guard) = agent.try_lock() else { + return false; + }; + guard.prewarm_provider().now_or_never().is_some() } async fn subscribe_should_mark_ready( @@ -691,6 +922,45 @@ async fn subscribe_should_mark_ready( .is_none_or(|member| member.status != "running") } +async fn rename_swarm_member_session( + old_session_id: &str, + new_session_id: &str, + swarm_members: &Arc>>, + swarms_by_id: &Arc>>>, +) { + // Never hold both swarm maps at once. Coordinator cleanup reads them in the + // opposite order, so retaining the member write guard while waiting for the + // swarm map can permanently deadlock reconnects and every later subscribe. + let renamed_swarm_id = { + let mut members = swarm_members.write().await; + let renamed_swarm_id = members.remove(old_session_id).and_then(|mut member| { + let swarm_id = member.swarm_id.clone(); + member.session_id = new_session_id.to_string(); + member.status = "ready".to_string(); + member.detail = None; + members.insert(new_session_id.to_string(), member); + swarm_id + }); + + // Keep the spawn tree intact across the rename: children that reported + // back to the old session id must follow it. + for member in members.values_mut() { + if member.report_back_to_session_id.as_deref() == Some(old_session_id) { + member.report_back_to_session_id = Some(new_session_id.to_string()); + } + } + renamed_swarm_id + }; + + if let Some(swarm_id) = renamed_swarm_id { + let mut swarms = swarms_by_id.write().await; + if let Some(swarm) = swarms.get_mut(&swarm_id) { + swarm.remove(old_session_id); + swarm.insert(new_session_id.to_string()); + } + } +} + pub(super) async fn handle_reload( id: u64, force: bool, @@ -1295,11 +1565,6 @@ pub(super) async fn handle_resume_session( } } - { - let mut agent_guard = agent.lock().await; - agent_guard.mark_closed(); - } - let (result, is_canary) = { let mut agent_guard = agent.lock().await; let result = @@ -1354,31 +1619,8 @@ pub(super) async fn handle_resume_session( } } - { - let mut members = swarm_members.write().await; - if let Some(mut member) = members.remove(&old_session_id) { - if let Some(ref swarm_id) = member.swarm_id { - let mut swarms = swarms_by_id.write().await; - if let Some(swarm) = swarms.get_mut(swarm_id) { - swarm.remove(&old_session_id); - swarm.insert(session_id.clone()); - } - } - member.session_id = session_id.clone(); - member.status = "ready".to_string(); - member.detail = None; - members.insert(session_id.clone(), member); - } - // Keep the spawn tree intact across the rename: children that - // reported back to the old session id must follow it, otherwise - // ownership (stop permissions, subtree broadcast, report-back) - // silently dangles on a dead id. - for member in members.values_mut() { - if member.report_back_to_session_id.as_deref() == Some(&old_session_id) { - member.report_back_to_session_id = Some(session_id.clone()); - } - } - } + rename_swarm_member_session(&old_session_id, &session_id, swarm_members, swarms_by_id) + .await; remove_session_channel_subscriptions( &old_session_id, channel_subscriptions, diff --git a/crates/jcode-app-core/src/server/client_session_tests.rs b/crates/jcode-app-core/src/server/client_session_tests.rs index 3879f85a43..a74445eea9 100644 --- a/crates/jcode-app-core/src/server/client_session_tests.rs +++ b/crates/jcode-app-core/src/server/client_session_tests.rs @@ -1,8 +1,10 @@ use super::{ - claim_live_target_agent, handle_clear_session, handle_reload, handle_resume_session, - mark_remote_reload_started, remove_detached_source_if_unclaimed, rename_shutdown_signal, - restored_session_was_interrupted, session_was_interrupted_by_reload, - subscribe_should_mark_ready, + apply_or_defer_subscribe_working_dir, claim_live_target_agent, effective_subscribe_working_dir, + handle_clear_session, handle_reload, handle_resume_session, handle_subscribe, + mark_remote_reload_started, prewarm_idle_agent, remove_detached_source_if_unclaimed, + rename_shutdown_signal, rename_swarm_member_session, restored_session_was_interrupted, + session_was_interrupted_by_reload, subscribe_should_mark_ready, + subscribe_working_dir_replacement, }; use crate::agent::Agent; use crate::message::ContentBlock; @@ -22,8 +24,79 @@ use std::sync::Arc; use std::time::Instant; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; +#[path = "client_session_tests/concurrency.rs"] +mod concurrency; + struct MockProvider; +struct IdlePrewarmProvider(Arc, bool); + +#[async_trait] +impl Provider for IdlePrewarmProvider { + async fn prewarm(&self, _tools: &[ToolDefinition], _system: &str) { + self.0.notify_one(); + if self.1 { + std::future::pending::<()>().await; + } + } + + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + panic!("idle prewarm must not generate a response"); + } + + fn name(&self) -> &str { + "idle-prewarm-test" + } + + fn fork(&self) -> Arc { + Arc::new(Self(Arc::clone(&self.0), self.1)) + } +} + +#[tokio::test] +async fn idle_prewarm_starts_before_user_input_and_skips_busy_sessions() { + let notification = Arc::new(tokio::sync::Notify::new()); + let provider: Arc = + Arc::new(IdlePrewarmProvider(Arc::clone(¬ification), false)); + let registry = Registry::new(Arc::clone(&provider)).await; + let _env = crate::storage::lock_test_env(); + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + let busy = agent.lock().await; + assert!( + !prewarm_idle_agent(&agent), + "reconnect must not wait for an active turn" + ); + drop(busy); + assert!(prewarm_idle_agent(&agent)); + tokio::time::timeout(std::time::Duration::from_secs(5), notification.notified()) + .await + .expect("idle subscription should prewarm before any user message"); +} + +#[tokio::test] +async fn idle_prewarm_never_holds_agent_lock_across_pending_preparation() { + let notification = Arc::new(tokio::sync::Notify::new()); + let provider: Arc = + Arc::new(IdlePrewarmProvider(Arc::clone(¬ification), true)); + let registry = Registry::new(Arc::clone(&provider)).await; + let _env = crate::storage::lock_test_env(); + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + assert!(!prewarm_idle_agent(&agent)); + assert!( + agent.try_lock().is_ok(), + "foreground must not wait for warmup" + ); + tokio::time::timeout(std::time::Duration::from_secs(1), notification.notified()) + .await + .expect("pending provider hook was polled once and cancelled"); +} + fn test_swarm_member(session_id: &str, status: &str) -> SwarmMember { let (event_tx, _event_rx) = mpsc::unbounded_channel(); SwarmMember { @@ -68,6 +141,72 @@ async fn subscribe_marks_non_running_member_ready() { assert!(subscribe_should_mark_ready("worker", &swarm_members).await); } +#[tokio::test] +async fn resume_rename_releases_member_lock_before_waiting_for_swarm_map() { + let old_session_id = "session-old"; + let new_session_id = "session-new"; + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + old_session_id.to_string(), + test_swarm_member(old_session_id, "spawned"), + ), + ( + "child".to_string(), + SwarmMember { + report_back_to_session_id: Some(old_session_id.to_string()), + ..test_swarm_member("child", "running") + }, + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + "swarm-test".to_string(), + HashSet::from([old_session_id.to_string(), "child".to_string()]), + )]))); + + // Force the rename to wait for swarms_by_id. While it waits, the member map + // must remain readable or coordinator cleanup can form a permanent cycle. + let swarm_map_guard = swarms_by_id.write().await; + let rename_task = tokio::spawn({ + let swarm_members = Arc::clone(&swarm_members); + let swarms_by_id = Arc::clone(&swarms_by_id); + async move { + rename_swarm_member_session( + old_session_id, + new_session_id, + &swarm_members, + &swarms_by_id, + ) + .await; + } + }); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let members = swarm_members.read().await; + if members.contains_key(new_session_id) { + assert_eq!( + members + .get("child") + .and_then(|member| member.report_back_to_session_id.as_deref()), + Some(new_session_id) + ); + break; + } + drop(members); + tokio::task::yield_now().await; + } + }) + .await + .expect("member map stayed locked while waiting for swarm map"); + + drop(swarm_map_guard); + rename_task.await.expect("rename task"); + let swarms = swarms_by_id.read().await; + let swarm = swarms.get("swarm-test").expect("swarm remains present"); + assert!(!swarm.contains(old_session_id)); + assert!(swarm.contains(new_session_id)); +} + #[async_trait] impl Provider for MockProvider { async fn complete( @@ -242,6 +381,134 @@ async fn live_target_claim_is_atomic_with_detached_source_cleanup() { } } +/// Issue #481: a subscribe cwd that is merely absolute is not enough. A client +/// reporting the *home* directory must not silently re-pin (or clobber) a +/// session that is already bound to a real project directory, because tools then +/// run in home while the UI still shows the project. +#[test] +fn subscribe_working_dir_ignores_home_when_session_has_a_project_dir() { + let home = std::path::Path::new("/home/tester"); + let project = "/home/tester/work/project"; + + assert_eq!( + subscribe_working_dir_replacement(Some(project), "/home/tester", Some(home)), + None, + "home must not clobber an established project cwd" + ); + + // A session with no cwd yet, or one already in home, may legitimately use home. + assert_eq!( + subscribe_working_dir_replacement(None, "/home/tester", Some(home)), + Some("/home/tester".to_string()) + ); + assert_eq!( + subscribe_working_dir_replacement(Some("/home/tester"), "/home/tester", Some(home)), + None, + "an unchanged cwd needs no reassignment" + ); + + // Genuine project-to-project moves still apply. + assert_eq!( + subscribe_working_dir_replacement(Some(project), "/home/tester/work/other", Some(home)), + Some("/home/tester/work/other".to_string()) + ); + + // A subdirectory of home that is not home itself is a real project path. + assert_eq!( + subscribe_working_dir_replacement(Some(project), "/home/tester/scratch", Some(home)), + Some("/home/tester/scratch".to_string()) + ); + + // Blank/whitespace reports are never applied, and an unknown home disables + // the guard rather than rejecting valid directories. + assert_eq!( + subscribe_working_dir_replacement(Some(project), " ", Some(home)), + None + ); + assert_eq!( + subscribe_working_dir_replacement(Some(project), "/home/tester", None), + Some("/home/tester".to_string()) + ); +} + +/// Issue #481: agent cwd, swarm grouping, and project-local MCP resolution must +/// all bind to the *same* directory. If a rejected home-dir report still reached +/// the swarm id or the MCP resolver, tools would run in the project while swarm +/// membership and `.jcode/mcp.json` discovery pointed at home. +#[test] +fn effective_subscribe_working_dir_binds_all_consumers_to_one_directory() { + let home = std::path::Path::new("/home/tester"); + let project = "/home/tester/work/project"; + + // Rejected home report: every consumer keeps the project dir. + assert_eq!( + effective_subscribe_working_dir(Some(project), "/home/tester", Some(home)), + project + ); + + // Accepted move: every consumer follows to the new dir. + assert_eq!( + effective_subscribe_working_dir(Some(project), "/home/tester/work/other", Some(home)), + "/home/tester/work/other" + ); + + // No prior cwd: the report is authoritative, including home. + assert_eq!( + effective_subscribe_working_dir(None, "/home/tester", Some(home)), + "/home/tester" + ); + + // Unchanged report resolves to the same dir rather than dropping it. + assert_eq!( + effective_subscribe_working_dir(Some(project), project, Some(home)), + project + ); +} + +/// Issue #481 end to end: drive the real subscribe-cwd application path against +/// a live `Agent` and assert the agent's stored working directory, which is what +/// bash/file tools actually run in. The pure-resolver tests above cover the +/// decision; this covers the wiring that applies it. +#[tokio::test] +async fn apply_subscribe_working_dir_keeps_project_when_client_reports_home() { + let home = dirs::home_dir().expect("home directory"); + let home_str = home.to_string_lossy().to_string(); + let project = home.join("jcode-481-project"); + let project_str = project.to_string_lossy().to_string(); + + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(Arc::clone(&provider)).await; + let agent = Arc::new(Mutex::new(Agent::new_with_initial_working_dir( + provider, + registry, + Some(&project_str), + ))); + + assert_eq!( + agent.lock().await.working_dir(), + Some(project_str.as_str()), + "precondition: session starts bound to the project" + ); + + // A client whose inherited cwd is home must not re-pin the session. + apply_or_defer_subscribe_working_dir(&agent, &home_str, "session_test_481"); + assert_eq!( + agent.lock().await.working_dir(), + Some(project_str.as_str()), + "a home-dir subscribe must not clobber the project cwd" + ); + + // A genuine project-to-project move still applies. + let other = home.join("jcode-481-other"); + let other_str = other.to_string_lossy().to_string(); + apply_or_defer_subscribe_working_dir(&agent, &other_str, "session_test_481"); + assert_eq!( + agent.lock().await.working_dir(), + Some(other_str.as_str()), + "a real directory change must still be honored" + ); +} + #[path = "client_session_tests/clear.rs"] mod clear_tests; #[path = "client_session_tests/reload.rs"] diff --git a/crates/jcode-app-core/src/server/client_session_tests/clear.rs b/crates/jcode-app-core/src/server/client_session_tests/clear.rs index 55fd254a7a..f95e54bd3c 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/clear.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/clear.rs @@ -57,8 +57,14 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi disconnect_tx: mpsc::unbounded_channel().0, }, )]))); - let swarm_members = Arc::new(RwLock::new(HashMap::::new())); - let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + old_session_id.to_string(), + test_swarm_member(old_session_id, "ready"), + )]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + "swarm-test".to_string(), + HashSet::from([old_session_id.to_string()]), + )]))); let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, @@ -68,7 +74,17 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi String, HashMap>, >::new())); - let swarm_plans = Arc::new(RwLock::new(HashMap::::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + "swarm-test".to_string(), + VersionedPlan { + items: Vec::new(), + version: 1, + participants: HashSet::from([old_session_id.to_string()]), + task_progress: HashMap::new(), + mode: "deep".to_string(), + node_meta: HashMap::new(), + }, + )]))); let event_history = Arc::new(RwLock::new(VecDeque::::new())); let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::(8); @@ -101,12 +117,42 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi .await; assert_ne!(client_session_id, old_session_id); + let members = swarm_members.read().await; + assert!(members.get(old_session_id).is_none()); + let replacement_member = members + .get(&client_session_id) + .expect("replacement session should remain registered for swarm tools"); + assert!(replacement_member.swarm_enabled); + assert_eq!(replacement_member.status, "ready"); + assert_ne!(replacement_member.swarm_id.as_deref(), Some("swarm-test")); + let replacement_swarm_id = replacement_member + .swarm_id + .clone() + .expect("replacement session should get a fresh swarm identity"); + drop(members); + assert!(swarms_by_id.read().await.get("swarm-test").is_none()); + assert!( + swarms_by_id + .read() + .await + .get(&replacement_swarm_id) + .is_some_and(|sessions| sessions.contains(&client_session_id)) + ); + let plans = swarm_plans.read().await; + assert!(!plans["swarm-test"].participants.contains(old_session_id)); + assert!( + !plans["swarm-test"] + .participants + .contains(&client_session_id) + ); + drop(plans); old_queue .lock() .map_err(|_| anyhow!("old queue lock"))? .push(jcode_agent_runtime::SoftInterruptMessage { content: "stale queued message".to_string(), + images: Vec::new(), urgent: false, source: jcode_agent_runtime::SoftInterruptSource::User, }); diff --git a/crates/jcode-app-core/src/server/client_session_tests/concurrency.rs b/crates/jcode-app-core/src/server/client_session_tests/concurrency.rs new file mode 100644 index 0000000000..bfc85307ff --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/concurrency.rs @@ -0,0 +1,162 @@ +use super::*; + +struct IsolatedConcurrencyEnv { + _home: tempfile::TempDir, + previous: Vec<(&'static str, Option)>, +} + +impl IsolatedConcurrencyEnv { + fn new() -> Self { + let home = tempfile::tempdir().unwrap(); + let previous = ["JCODE_HOME", "JCODE_NO_TELEMETRY"] + .into_iter() + .map(|key| (key, std::env::var_os(key))) + .collect(); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::set_var("JCODE_NO_TELEMETRY", "1"); + Self { + _home: home, + previous, + } + } +} + +impl Drop for IsolatedConcurrencyEnv { + fn drop(&mut self) { + for (key, value) in self.previous.drain(..) { + match value { + Some(value) => crate::env::set_var(key, value), + None => crate::env::remove_var(key), + } + } + } +} + +async fn restore_for_concurrency_test( + target_id: &str, + source: &Arc>, + provider: &Arc, + registry: &Registry, + sessions: &crate::server::SessionAgents, +) -> Result>> { + let mut client_selfdev = false; + let mut client_session_id = source.lock().await.session_id().to_owned(); + let (stream, _peer) = crate::transport::stream_pair()?; + let (_, writer) = stream.into_split(); + let writer = Arc::new(Mutex::new(writer)); + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel(); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(8); + let now = Instant::now(); + let connections = Arc::new(RwLock::new(HashMap::from([( + "concurrency-test-connection".to_owned(), + ClientConnectionInfo { + client_id: "concurrency-test-connection".to_owned(), + session_id: client_session_id.clone(), + client_instance_id: None, + debug_client_id: None, + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + handle_resume_session( + 1, + target_id.to_owned(), + None, + None, + false, + false, + &mut client_selfdev, + &mut client_session_id, + "concurrency-test-connection", + source, + provider, + registry, + sessions, + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &connections, + &Arc::new(RwLock::new(ClientDebugState::default())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &FileTouchService::new(), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(1)), + &writer, + "test-server", + "test", + &client_event_tx, + &Arc::new(crate::mcp::SharedMcpPool::from_default_config()), + &Arc::new(RwLock::new(VecDeque::new())), + &Arc::new(std::sync::atomic::AtomicU64::new(0)), + &swarm_event_tx, + ) + .await +} + +#[tokio::test] +async fn failed_server_resume_keeps_original_concurrency_owner() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedConcurrencyEnv::new(); + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let source = Arc::new(Mutex::new(Agent::new(provider.clone(), registry.clone()))); + let source_id = source.lock().await.session_id().to_owned(); + let sessions = Arc::new(RwLock::new(HashMap::from([( + source_id.clone(), + source.clone(), + )]))); + let restored = restore_for_concurrency_test( + "missing-concurrency-target", + &source, + &provider, + ®istry, + &sessions, + ) + .await?; + assert!(Arc::ptr_eq(&restored, &source)); + let source = source.lock().await; + assert_eq!(source.session_id(), source_id); + assert!( + source.has_concurrency_tracking(), + "failed resume must not close the original logical owner" + ); + Ok(()) +} + +#[tokio::test] +async fn viewer_attach_reuses_live_owner_without_tracking_placeholder() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedConcurrencyEnv::new(); + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let live = Arc::new(Mutex::new(Agent::new(provider.clone(), registry.clone()))); + let live_id = live.lock().await.session_id().to_owned(); + let placeholder = Arc::new(Mutex::new(Agent::new_provisional_with_initial_working_dir( + provider.clone(), + registry.clone(), + None, + ))); + let placeholder_id = placeholder.lock().await.session_id().to_owned(); + let sessions = Arc::new(RwLock::new(HashMap::from([ + (live_id.clone(), live.clone()), + (placeholder_id, placeholder.clone()), + ]))); + assert!(!placeholder.lock().await.has_concurrency_tracking()); + let attached = + restore_for_concurrency_test(&live_id, &placeholder, &provider, ®istry, &sessions) + .await?; + assert!(Arc::ptr_eq(&attached, &live)); + assert!(live.lock().await.has_concurrency_tracking()); + assert!( + !placeholder.lock().await.has_concurrency_tracking(), + "a viewer placeholder must never publish a join" + ); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs index de52a4a76d..41d26b28d7 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs @@ -137,7 +137,46 @@ async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() ) .await?; - let events = collect_events_until_done(&mut client_event_rx, 77).await; + // The desktop follows a target-aware resume with the normal subscribe + // bookkeeping while the original client can still be processing. That + // bookkeeping must never wait for the live agent lock, otherwise the + // desktop's immediately following state request remains unread and times + // out after ten seconds. + tokio::time::timeout( + std::time::Duration::from_secs(1), + handle_subscribe( + 77, + Some("/tmp/jcode-busy-desktop-attach".to_string()), + Some(true), + false, + &mut client_selfdev, + target_session_id, + "conn_new", + &None, + &existing_agent, + &new_registry, + true, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ), + ) + .await + .expect("subscribe bookkeeping must not wait for a busy live agent"); + + // Resume and subscribe both answer request id 77, so each emits its own + // Done. Collect both batches, otherwise the assertions below only ever see + // resume's events and subscribe's are invisible. + let mut events = collect_events_until_done(&mut client_event_rx, 77).await; + events.extend(collect_events_until_done(&mut client_event_rx, 77).await); assert!( events .iter() @@ -150,6 +189,17 @@ async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() .any(|event| matches!(event, ServerEvent::Error { .. })), "busy live attach should not emit error events: {events:?}" ); + // A remote (gateway) client has no other way to learn its session id, and + // without it a dropped connection cannot reattach: the next Subscribe + // carries no `target_session_id`, so the server hands it a fresh session + // and the in-flight turn becomes unreachable. + assert!( + events.iter().any(|event| matches!( + event, + ServerEvent::SessionId { session_id } if session_id == target_session_id + )), + "subscribe must report the bound session id so clients can reattach: {events:?}" + ); let mut peer_reader = tokio::io::BufReader::new(peer_stream); let mut line = String::new(); diff --git a/crates/jcode-app-core/src/server/client_state.rs b/crates/jcode-app-core/src/server/client_state.rs index 7bf26687d2..5b4ee84e28 100644 --- a/crates/jcode-app-core/src/server/client_state.rs +++ b/crates/jcode-app-core/src/server/client_state.rs @@ -92,7 +92,8 @@ pub(super) async fn handle_get_state( id, session_id: client_session_id.to_string(), message_count: session_count, - is_processing: client_is_processing, + is_processing: client_is_processing + || crate::turn_cancel_registry::has_active_turn(client_session_id), }, ) .await @@ -191,6 +192,8 @@ pub(super) async fn handle_get_model_catalog( available_models, available_model_routes, resolved_credential, + service_tier, + reasoning_effort, source, ) = { match agent.try_lock() { @@ -200,6 +203,8 @@ pub(super) async fn handle_get_model_catalog( agent_guard.available_models_display(), agent_guard.model_routes(), agent_guard.active_resolved_credential(), + agent_guard.provider_handle().service_tier(), + agent_guard.provider_handle().reasoning_effort(), "live", ), Err(_) => { @@ -211,12 +216,16 @@ pub(super) async fn handle_get_model_catalog( .or_else(|_| Session::load_startup_stub(session_id)) .ok(); let persisted_model = persisted.as_ref().and_then(|session| session.model.clone()); + let mut model_routes = provider.model_routes(); + crate::model_usage::enrich_routes(&mut model_routes); ( Some(provider.name().to_string()), persisted_model.or_else(|| Some(provider.model())), provider.available_models_display(), provider.model_routes(), provider.active_resolved_credential(), + provider.service_tier(), + provider.reasoning_effort(), "fallback", ) } @@ -251,8 +260,10 @@ pub(super) async fn handle_get_model_catalog( status_detail: None, upstream_provider: None, resolved_credential, - reasoning_effort: None, - service_tier: None, + reasoning_effort, + // Catalog replies still use History, so the TUI applies this field as + // authoritative. Omitting it falsely turns off /fast status and its badge. + service_tier, subagent_model: None, autoreview_enabled: None, autojudge_enabled: None, @@ -353,6 +364,7 @@ pub(super) async fn handle_get_compacted_history( fn rendered_to_history_message(msg: crate::session::RenderedMessage) -> HistoryMessage { HistoryMessage { + response_stats: msg.response_stats, role: msg.role, content: msg.content, tool_calls: if msg.tool_calls.is_empty() { @@ -544,7 +556,9 @@ async fn send_history_from_persisted_session( upstream_provider: None, resolved_credential: provider.active_resolved_credential(), reasoning_effort, - service_tier: None, + // The transcript is persisted, but the tier is live provider state and + // can be read without waiting for the busy agent's mutex. + service_tier: provider.service_tier(), compaction_mode: crate::config::config().compaction.mode.clone(), activity, side_panel, @@ -827,10 +841,12 @@ pub(super) async fn session_activity_snapshot( }; snapshot.or_else(|| { - fallback_processing.then_some(SessionActivitySnapshot { - is_processing: true, - current_tool_name: None, - }) + (fallback_processing || crate::turn_cancel_registry::has_active_turn(session_id)).then_some( + SessionActivitySnapshot { + is_processing: true, + current_tool_name: None, + }, + ) }) } diff --git a/crates/jcode-app-core/src/server/client_state_tests.rs b/crates/jcode-app-core/src/server/client_state_tests.rs index 243c8e9fbd..0a17be8ee6 100644 --- a/crates/jcode-app-core/src/server/client_state_tests.rs +++ b/crates/jcode-app-core/src/server/client_state_tests.rs @@ -15,7 +15,7 @@ use std::time::Instant; use tokio::io::AsyncReadExt; use tokio::sync::{Mutex, RwLock, mpsc}; -struct MockProvider; +struct MockProvider(Option<&'static str>); #[async_trait] impl Provider for MockProvider { @@ -36,12 +36,20 @@ impl Provider for MockProvider { } fn fork(&self) -> Arc { - Arc::new(Self) + Arc::new(Self(self.0)) } fn model(&self) -> String { "mock-model".to_string() } + + fn service_tier(&self) -> Option { + self.0.map(str::to_string) + } + + fn reasoning_effort(&self) -> Option { + Some("high".to_string()) + } } #[tokio::test] @@ -101,11 +109,17 @@ async fn session_activity_snapshot_uses_fallback_when_no_live_connection_is_mark } #[tokio::test] +async fn handle_get_history_falls_back_to_persisted_snapshot_when_agent_is_busy() { + for tier in [Some("priority"), Some("flex"), None] { + assert_busy_history_service_tier(tier).await; + } +} + #[expect( clippy::await_holding_lock, reason = "test intentionally keeps the agent busy lock held to exercise persisted-history fallback" )] -async fn handle_get_history_falls_back_to_persisted_snapshot_when_agent_is_busy() { +async fn assert_busy_history_service_tier(tier: Option<&'static str>) { let _guard = crate::storage::lock_test_env(); let temp_home = tempfile::TempDir::new().expect("create temp home"); let prev_home = std::env::var_os("JCODE_HOME"); @@ -132,7 +146,7 @@ async fn handle_get_history_falls_back_to_persisted_snapshot_when_agent_is_busy( }); session.save().expect("save session"); - let provider: Arc = Arc::new(MockProvider); + let provider: Arc = Arc::new(MockProvider(tier)); let registry = Registry::empty(); let mut live_session = session.clone(); live_session.title = Some("live agent".to_string()); @@ -192,12 +206,14 @@ async fn handle_get_history_falls_back_to_persisted_snapshot_when_agent_is_busy( session_id: returned_session_id, messages, activity, + service_tier, .. } => { assert_eq!(id, 42); assert_eq!(returned_session_id, session_id); assert_eq!(messages.len(), 1); assert_eq!(messages[0].content, "persisted fallback history"); + assert_eq!(service_tier.as_deref(), tier); let activity = activity.expect("fallback activity snapshot"); assert!(activity.is_processing); } @@ -212,11 +228,24 @@ async fn handle_get_history_falls_back_to_persisted_snapshot_when_agent_is_busy( } #[tokio::test] +async fn handle_get_model_catalog_does_not_wait_for_busy_agent_lock() { + for tier in [Some("priority"), Some("flex"), None] { + assert_model_catalog_service_tier(tier, true).await; + } +} + +#[tokio::test] +async fn handle_get_model_catalog_preserves_live_service_tier() { + for tier in [Some("priority"), Some("flex"), None] { + assert_model_catalog_service_tier(tier, false).await; + } +} + #[expect( clippy::await_holding_lock, reason = "test intentionally keeps the agent busy lock held to exercise model-catalog fallback" )] -async fn handle_get_model_catalog_does_not_wait_for_busy_agent_lock() { +async fn assert_model_catalog_service_tier(tier: Option<&'static str>, busy: bool) { let _guard = crate::storage::lock_test_env(); let temp_home = tempfile::TempDir::new().expect("create temp home"); let prev_home = std::env::var_os("JCODE_HOME"); @@ -231,14 +260,14 @@ async fn handle_get_model_catalog_does_not_wait_for_busy_agent_lock() { session.model = Some("persisted-model".to_string()); session.save().expect("save session"); - let provider: Arc = Arc::new(MockProvider); + let provider: Arc = Arc::new(MockProvider(tier)); let agent = Arc::new(Mutex::new(Agent::new_with_session( provider.clone(), Registry::empty(), session.clone(), None, ))); - let busy_guard = agent.lock().await; + let busy_guard = if busy { Some(agent.lock().await) } else { None }; let (stream_a, mut stream_b) = crate::transport::stream_pair().expect("stream pair"); let (_reader_a, writer_a) = stream_a.into_split(); @@ -272,12 +301,23 @@ async fn handle_get_model_catalog_does_not_wait_for_busy_agent_lock() { session_id: returned_session_id, provider_name, provider_model, + service_tier, + reasoning_effort, .. } => { assert_eq!(id, 43); assert_eq!(returned_session_id, session_id); assert_eq!(provider_name.as_deref(), Some("mock")); - assert_eq!(provider_model.as_deref(), Some("persisted-model")); + assert_eq!( + provider_model.as_deref(), + Some(if busy { + "persisted-model" + } else { + "mock-model" + }) + ); + assert_eq!(service_tier.as_deref(), tier); + assert_eq!(reasoning_effort.as_deref(), Some("high")); } other => panic!("expected history event, got {:?}", other), } diff --git a/crates/jcode-app-core/src/server/client_target_attach_tests.rs b/crates/jcode-app-core/src/server/client_target_attach_tests.rs new file mode 100644 index 0000000000..47b44e31d9 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_target_attach_tests.rs @@ -0,0 +1,234 @@ +#![allow(clippy::await_holding_lock)] +use super::*; +use crate::message::{Message, ToolDefinition}; +use crate::provider::EventStream; +use async_trait::async_trait; + +struct NoRequests; +#[async_trait] +impl Provider for NoRequests { + async fn complete( + &self, + _: &[Message], + _: &[ToolDefinition], + _: &str, + _: Option<&str>, + ) -> Result { + anyhow::bail!("target attachment must not invoke a provider") + } + fn name(&self) -> &str { + "mock" + } + fn fork(&self) -> Arc { + Arc::new(Self) + } +} + +struct Home { + _temp: tempfile::TempDir, + old: Option, +} +impl Home { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let old = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + Self { _temp: temp, old } + } +} +impl Drop for Home { + fn drop(&mut self) { + if let Some(old) = self.old.take() { + crate::env::set_var("JCODE_HOME", old); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} + +fn subscribe(target: &str) -> Request { + Request::Subscribe { + id: 71, + working_dir: None, + target_session_id: Some(target.into()), + selfdev: None, + client_instance_id: None, + client_has_local_history: false, + allow_session_takeover: false, + crash_on_disconnect: false, + continue_on_disconnect: false, + terminal_env: vec![], + } +} + +async fn live_agent(id: &str, root: &str) -> Arc> { + let provider: Arc = Arc::new(NoRequests); + let registry = Registry::new(provider.clone()).await; + let mut session = crate::session::Session::create_with_id(id.into(), None, None); + session.working_dir = Some(root.into()); + Arc::new(Mutex::new(Agent::new_with_session( + provider, registry, session, None, + ))) +} + +#[tokio::test] +async fn target_subscribe_uses_live_unsaved_root_without_changing_it() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let id = "session_live_empty_attach"; + let agent = live_agent(id, "/workspace/live-original").await; + let sessions = Arc::new(RwLock::new(HashMap::from([(id.into(), agent.clone())]))); + let members = Arc::new(RwLock::new(HashMap::new())); + let mut request = subscribe(id); + resolve_target_subscribe_working_dir(&mut request, &sessions, &members) + .await + .unwrap(); + assert_eq!( + initial_subscribe_working_dir(&request).unwrap(), + "/workspace/live-original" + ); + assert_eq!( + agent.lock().await.working_dir(), + Some("/workspace/live-original") + ); + assert!(!crate::session::session_exists(id)); +} + +#[tokio::test] +async fn target_subscribe_uses_persisted_root_when_no_live_agent_exists() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let mut session = crate::session::Session::create(None, Some("persisted".into())); + session.working_dir = Some("/workspace/persisted-original".into()); + session.save().unwrap(); + let mut request = subscribe(&session.id); + resolve_target_subscribe_working_dir( + &mut request, + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + ) + .await + .unwrap(); + assert_eq!( + initial_subscribe_working_dir(&request).unwrap(), + "/workspace/persisted-original" + ); +} + +#[tokio::test] +async fn target_subscribe_live_root_wins_over_stale_persisted_root() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let mut session = crate::session::Session::create(None, Some("persisted".into())); + session.working_dir = Some("/workspace/stale".into()); + session.save().unwrap(); + let agent = live_agent(&session.id, "/workspace/live").await; + let sessions = Arc::new(RwLock::new(HashMap::from([(session.id.clone(), agent)]))); + let mut request = subscribe(&session.id); + resolve_target_subscribe_working_dir( + &mut request, + &sessions, + &Arc::new(RwLock::new(HashMap::new())), + ) + .await + .unwrap(); + assert_eq!( + initial_subscribe_working_dir(&request).unwrap(), + "/workspace/live" + ); +} + +#[tokio::test] +async fn target_subscribe_busy_live_agent_uses_member_root_without_waiting() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let id = "session_busy_empty_attach"; + let agent = live_agent(id, "/workspace/busy-original").await; + let sessions = Arc::new(RwLock::new(HashMap::from([(id.into(), agent.clone())]))); + let (event_tx, _) = mpsc::unbounded_channel(); + let now = std::time::Instant::now(); + let members = Arc::new(RwLock::new(HashMap::from([( + id.into(), + SwarmMember { + session_id: id.into(), + event_tx, + event_txs: HashMap::new(), + working_dir: Some("/workspace/busy-original".into()), + swarm_id: None, + swarm_enabled: false, + status: "running".into(), + detail: None, + task_label: None, + friendly_name: None, + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".into(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: vec![], + runtime: Default::default(), + }, + )]))); + let _busy = agent.lock().await; + let mut request = subscribe(id); + tokio::time::timeout( + Duration::from_millis(100), + resolve_target_subscribe_working_dir(&mut request, &sessions, &members), + ) + .await + .expect("must not wait on busy Agent") + .unwrap(); + assert_eq!( + initial_subscribe_working_dir(&request).unwrap(), + "/workspace/busy-original" + ); +} + +#[tokio::test] +async fn target_subscribe_unknown_target_never_uses_process_working_dir() { + let _lock = crate::storage::lock_test_env(); + let _home = Home::new(); + let mut request = subscribe("session_missing"); + let error = resolve_target_subscribe_working_dir( + &mut request, + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + ) + .await + .unwrap_err(); + assert!(error.contains("Unknown session")); + assert!(initial_subscribe_working_dir(&request).is_err()); +} + +#[tokio::test] +async fn target_subscribe_preserves_explicit_directory_and_its_validation() { + let mut request = subscribe("session_explicit"); + if let Request::Subscribe { working_dir, .. } = &mut request { + *working_dir = Some("/workspace/explicit".into()); + } + resolve_target_subscribe_working_dir( + &mut request, + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + ) + .await + .unwrap(); + assert_eq!( + initial_subscribe_working_dir(&request).unwrap(), + "/workspace/explicit" + ); + if let Request::Subscribe { working_dir, .. } = &mut request { + *working_dir = Some("relative".into()); + } + resolve_target_subscribe_working_dir( + &mut request, + &Arc::new(RwLock::new(HashMap::new())), + &Arc::new(RwLock::new(HashMap::new())), + ) + .await + .unwrap(); + assert!(initial_subscribe_working_dir(&request).is_err()); +} diff --git a/crates/jcode-app-core/src/server/comm_graph.rs b/crates/jcode-app-core/src/server/comm_graph.rs index 24516a61ca..25a682ee24 100644 --- a/crates/jcode-app-core/src/server/comm_graph.rs +++ b/crates/jcode-app-core/src/server/comm_graph.rs @@ -14,6 +14,7 @@ use super::{ }; use crate::protocol::ServerEvent; use crate::protocol::TaskGraphNodeSpec; +use jcode_plan::MAX_PLAN_ITEMS; use jcode_plan::bridge::{apply_task_graph, parse_kind, to_task_graph}; use jcode_plan::dag::{self, HandoffArtifact, NodeSpec, NodeStatus, TaskGraph}; use std::collections::{HashMap, HashSet}; @@ -31,6 +32,16 @@ fn spec_from_wire(spec: TaskGraphNodeSpec) -> NodeSpec { } } +fn graph_size_error(graph: &TaskGraph) -> Option { + (graph.len() > MAX_PLAN_ITEMS).then(|| { + format!( + "plan would contain {} items, exceeding the per-swarm limit of {}; finish or clear stale plan nodes before adding more", + graph.len(), + MAX_PLAN_ITEMS + ) + }) +} + async fn swarm_id_for( session_id: &str, swarm_members: &Arc>>, @@ -283,14 +294,17 @@ pub(super) async fn handle_comm_seed_graph( let mut graph = to_task_graph(plan); let before = graph.clone(); match dag::seed(&mut graph, specs) { - Ok(()) => { - if graph != before { - apply_task_graph(plan, &graph); - plan.version += 1; + Ok(()) => match graph_size_error(&graph) { + Some(message) => Err(message), + None => { + if graph != before { + apply_task_graph(plan, &graph); + plan.version += 1; + } + Ok(()) } - Ok(()) - } - Err(e) => Err(e), + }, + Err(e) => Err(e.to_string()), } }; @@ -352,11 +366,14 @@ pub(super) async fn handle_comm_expand_node( let mut graph = to_task_graph(plan); claim_queued_node_for_actor(&mut graph, &node_id, &req_session_id); match dag::expand_node(&mut graph, &node_id, &req_session_id, specs) { - Ok(_) => { - apply_task_graph(plan, &graph); - plan.version += 1; - Ok(()) - } + Ok(_) => match graph_size_error(&graph) { + Some(message) => Err(message), + None => { + apply_task_graph(plan, &graph); + plan.version += 1; + Ok(()) + } + }, Err(e) => Err(e.to_string()), } }; @@ -492,11 +509,14 @@ pub(super) async fn handle_comm_inject_gap( let mut graph = to_task_graph(plan); claim_queued_node_for_actor(&mut graph, &gate_id, &req_session_id); match dag::inject_from_gate(&mut graph, &gate_id, &req_session_id, specs) { - Ok(_) => { - apply_task_graph(plan, &graph); - plan.version += 1; - Ok(()) - } + Ok(_) => match graph_size_error(&graph) { + Some(message) => Err(message), + None => { + apply_task_graph(plan, &graph); + plan.version += 1; + Ok(()) + } + }, Err(e) => Err(e.to_string()), } }; diff --git a/crates/jcode-app-core/src/server/comm_plan.rs b/crates/jcode-app-core/src/server/comm_plan.rs index 0eada088b4..9571ca2e3f 100644 --- a/crates/jcode-app-core/src/server/comm_plan.rs +++ b/crates/jcode-app-core/src/server/comm_plan.rs @@ -124,7 +124,7 @@ pub(super) async fn handle_comm_propose_plan( plan.participants.insert(owner.clone()); } } - plan.items = items.clone(); + plan.replace_items(items.clone()); plan.version += 1; (plan.version, plan.participants.clone()) }; @@ -389,6 +389,28 @@ pub(super) async fn handle_comm_approve_plan( }; if let Ok(items) = serde_json::from_str::>(&proposal) { + let existing_count = swarm_plans + .read() + .await + .get(&swarm_id) + .map(|plan| plan.items.len()) + .unwrap_or_default(); + let merged_count = existing_count.saturating_add(items.len()); + if merged_count > jcode_plan::MAX_PLAN_ITEMS { + finish_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message: format!( + "Plan approval would contain {merged_count} items, exceeding the per-swarm limit of {}; finish or clear stale plan nodes first.", + jcode_plan::MAX_PLAN_ITEMS + ), + retry_after_secs: None, + }, + ) + .await; + return; + } // Validate the merged graph (existing plan + proposed items) for // dependency cycles before committing. A cycle here permanently wedges // every task that depends on it, so reject the approval and keep the diff --git a/crates/jcode-app-core/src/server/comm_session.rs b/crates/jcode-app-core/src/server/comm_session.rs index 6b8cea9b30..7ffd473c2e 100644 --- a/crates/jcode-app-core/src/server/comm_session.rs +++ b/crates/jcode-app-core/src/server/comm_session.rs @@ -20,7 +20,7 @@ use crate::provider::Provider; use crate::session::Session; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak}; use std::time::Instant; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; @@ -28,6 +28,25 @@ type SessionAgents = Arc>>>>; type ChannelSubscriptions = Arc>>>>; type ClientConnections = Arc>>; +/// Serialize spawn admission through member registration within one swarm. +/// Without a reservation or lock, many recursive agents can all observe the same +/// free slot and burst past the configured limit before any child is registered. +/// Weak entries avoid retaining locks for swarms that are no longer active. +fn spawn_admission_lock(swarm_id: &str) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let locks = LOCKS.get_or_init(|| StdMutex::new(HashMap::new())); + let mut locks = locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(swarm_id).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(Mutex::new(())); + locks.insert(swarm_id.to_string(), Arc::downgrade(&lock)); + lock +} + /// Look up the most recent terminal env snapshot for the live client connection /// driving `session_id`, so spawn hooks target that client's terminal instead /// of the long-lived server's stale startup env (#405). Prefers the most @@ -349,26 +368,25 @@ fn resolve_swarm_spawn_selection( configured_swarm_model: Option, coordinator: &CoordinatorSpawnIdentity, ) -> SwarmSpawnSelection { - // A per-spawn requested model (the `model` param on `swarm spawn`) takes - // precedence over the `agents.swarm_model` config pin. An explicit - // `inherit`/`coordinator` request forces coordinator inheritance even when - // the config pins a different model. - let requested_model = requested_model + // An explicit per-worker choice overrides the configured default. The + // inheritance sentinels bypass even a concrete configured model. + if let Some(model) = requested_model .map(|model| model.trim().to_string()) - .filter(|model| !model.is_empty()); - if let Some(requested) = requested_model { - if is_inherit_sentinel(&requested) { - return inherit_coordinator_selection(coordinator); - } - return selection_for_concrete_model(requested, coordinator); + .filter(|model| !model.is_empty()) + { + return if is_inherit_sentinel(&model) { + inherit_coordinator_selection(coordinator) + } else { + selection_for_concrete_model(model, coordinator) + }; } - // Treat empty strings and the explicit "inherit"/"coordinator" sentinels as // "no override": spawned swarm agents should inherit the coordinator's model // unless `agents.swarm_model` is deliberately set to a concrete model. This // avoids the surprising case where a stale `swarm_model` config pins every // spawned agent to an unrelated model/provider. let configured_swarm_model = configured_swarm_model + .map(|model| model.trim().to_string()) .filter(|model| !model.trim().is_empty() && !is_inherit_sentinel(model)); match configured_swarm_model { @@ -535,6 +553,24 @@ async fn register_visible_spawned_member( clippy::too_many_arguments, reason = "server-side swarm spawning needs session, swarm state, provider, and event sinks together" )] +/// Resolve the reasoning effort for a spawned swarm worker (#1165). +/// +/// Precedence mirrors the model path: an explicit `effort` on the spawn call +/// wins, then the `agents.swarm_effort` config pin, and only then does the +/// worker inherit the provider-wide reasoning effort (`None`). +pub(super) fn resolve_swarm_spawn_effort( + requested_effort: Option<&str>, + configured_swarm_effort: Option<&str>, +) -> Option { + let clean = |effort: Option<&str>| { + effort + .map(str::trim) + .filter(|effort| !effort.is_empty()) + .map(str::to_string) + }; + clean(requested_effort).or_else(|| clean(configured_swarm_effort)) +} + pub(super) async fn spawn_swarm_agent( req_session_id: &str, swarm_id: &str, @@ -578,15 +614,15 @@ pub(super) async fn spawn_swarm_agent( let spawn_model = selection.model.clone(); let spawn_provider_key = selection.provider_key.clone(); let spawn_route_api_method = selection.route_api_method.clone(); - let spawn_effort = requested_effort - .as_deref() - .map(str::trim) - .filter(|effort| !effort.is_empty()) - .map(str::to_string); + let spawn_effort = resolve_swarm_spawn_effort( + requested_effort.as_deref(), + agents_config.swarm_effort.as_deref(), + ); crate::logging::info(&format!( - "Swarm spawn model resolution: requested_model={:?} requested_effort={:?} configured_swarm_model={:?} coordinator_model={:?} coordinator_provider_key={:?} coordinator_route={:?} -> spawn_model={:?} spawn_provider_key={:?} spawn_route={:?}", + "Swarm spawn model resolution: requested_model={:?} requested_effort={:?} configured_swarm_effort={:?} configured_swarm_model={:?} coordinator_model={:?} coordinator_provider_key={:?} coordinator_route={:?} -> spawn_model={:?} spawn_provider_key={:?} spawn_route={:?}", requested_model, - spawn_effort, + requested_effort, + agents_config.swarm_effort, configured_swarm_model, coordinator.model, coordinator.provider_key, @@ -657,6 +693,7 @@ pub(super) async fn spawn_swarm_agent( spawn_effort.clone(), Some(Arc::clone(mcp_pool)), Some(req_session_id.to_string()), + super::headless::HeadlessMemoryScope::RealProject, ) .await .and_then(|result_json| { @@ -835,6 +872,17 @@ pub(super) async fn handle_comm_spawn( swarm_mutation_runtime: &SwarmMutationRuntime, client_connections: &ClientConnections, ) { + // Hold this swarm's admission through member registration so concurrent + // recursive requests cannot all pass the population check against stale + // state. Unrelated swarms retain independent spawn throughput. + let admission_key = swarm_members + .read() + .await + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()) + .unwrap_or_else(|| req_session_id.clone()); + let admission_lock = spawn_admission_lock(&admission_key); + let admission_guard = admission_lock.lock().await; let swarm_id = match ensure_spawn_coordinator_swarm( id, &req_session_id, @@ -843,6 +891,7 @@ pub(super) async fn handle_comm_spawn( swarms_by_id, swarm_coordinators, swarm_plans, + crate::config::config().agents.swarm_max_concurrent_agents, ) .await { @@ -861,8 +910,8 @@ pub(super) async fn handle_comm_spawn( spawn_mode .map(|mode| format!("{mode:?}")) .unwrap_or_default(), - model.clone().unwrap_or_default(), effort.clone().unwrap_or_default(), + model.clone().unwrap_or_default(), label.clone().unwrap_or_default(), ], ); @@ -911,6 +960,10 @@ pub(super) async fn handle_comm_spawn( }, }; + // The new member is registered (or spawning failed), so the next admission + // check can safely observe the updated population. + drop(admission_guard); + finish_request(swarm_mutation_runtime, &mutation_state, response).await; } @@ -1049,14 +1102,13 @@ pub(super) async fn handle_comm_stop( return; }; - let mut sessions_guard = sessions.write().await; - let removed_agent = sessions_guard.remove(&target_session); + let removed_agent = super::remove_session_entry(sessions, &target_session).await; let removed_live_agent = removed_agent.is_some(); - drop(sessions_guard); if let Some(agent_arc) = removed_agent { remove_session_interrupt_queue(soft_interrupt_queues, &target_session).await; remove_background_tool_signal(&target_session); - if let Ok(agent) = agent_arc.try_lock() { + if let Ok(mut agent) = agent_arc.try_lock() { + agent.mark_closed(); let memory_enabled = agent.memory_enabled(); let transcript = if memory_enabled { Some(agent.build_transcript_for_extraction()) @@ -1195,6 +1247,7 @@ fn swarm_member_status_is_stale_for_coordination(status: &str) -> bool { ) } +#[allow(clippy::too_many_arguments)] async fn ensure_spawn_coordinator_swarm( id: u64, req_session_id: &str, @@ -1203,8 +1256,18 @@ async fn ensure_spawn_coordinator_swarm( swarms_by_id: &Arc>>>, swarm_coordinators: &Arc>>, swarm_plans: &Arc>>, + configured_live_agent_limit: usize, ) -> Option { - let (swarm_id, from_name, is_root, coordinator_id, coordinator_is_stale, swarm_size) = { + let ( + swarm_id, + from_name, + is_root, + root_session_id, + coordinator_id, + coordinator_is_stale, + live_member_count, + live_spawned_agent_count, + ) = { let members = swarm_members.read().await; let swarm_id = members .get(req_session_id) @@ -1217,20 +1280,28 @@ async fn ensure_spawn_coordinator_swarm( .get(req_session_id) .and_then(|member| member.report_back_to_session_id.clone()) .is_none(); - // Total capacity-consuming members in this swarm. Terminal members are - // retained briefly for reports and diagnostics, but they are historical - // records rather than live agents and therefore do not consume the - // breadth-side runaway cap (`MAX_SWARM_MEMBERS`). - let swarm_size = swarm_id + let root_session_id = super::swarm::swarm_ancestors(&members, req_session_id) + .last() + .cloned() + .unwrap_or_else(|| req_session_id.to_string()); + // Count both all live members for the absolute hard cap and live spawned + // agents for the configurable RAM-safety cap. User-created roots do not + // consume worker slots; every recursively spawned descendant does. + let (live_member_count, live_spawned_agent_count) = swarm_id .as_ref() .map(|swarm_id| { members .values() .filter(|member| member.swarm_id.as_deref() == Some(swarm_id.as_str())) .filter(|member| super::member_consumes_swarm_capacity(member)) - .count() + .fold((0usize, 0usize), |(members, spawned), member| { + ( + members + 1, + spawned + usize::from(member.report_back_to_session_id.is_some()), + ) + }) }) - .unwrap_or(0); + .unwrap_or_default(); let coordinator_id = if let Some(ref swarm_id) = swarm_id { let coordinators = swarm_coordinators.read().await; coordinators.get(swarm_id).cloned() @@ -1256,9 +1327,11 @@ async fn ensure_spawn_coordinator_swarm( swarm_id, from_name, is_root, + root_session_id, coordinator_id, coordinator_is_stale, - swarm_size, + live_member_count, + live_spawned_agent_count, ) }; @@ -1271,15 +1344,32 @@ async fn ensure_spawn_coordinator_swarm( return None; }; - // Runaway prevention for the task-graph model is a single total-member cap. - // There is no depth or per-node breadth limit: the spawn tree may nest and - // fan out freely until the swarm reaches `MAX_SWARM_MEMBERS` live members, at - // which point further spawns are refused. - if swarm_size >= super::MAX_SWARM_MEMBERS { + // Light and ad hoc swarms are deliberately one-level fan-out: only the root + // session may create workers. Recursive spawning is an explicit deep-swarm + // capability, keyed from the root's effort rather than the requesting + // child's effort so a worker cannot opt itself into unbounded growth. + if !is_root { + let root_is_deep = crate::session_effort::session_effort(&root_session_id) + .as_deref() + .is_some_and(crate::prompt::is_deep_swarm_effort); + if !root_is_deep { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Recursive swarm spawning is disabled for light and ad hoc swarms. Only the root session ({root_session_id}) may spawn agents unless that root is running in swarm-deep mode." + ), + retry_after_secs: None, + }); + return None; + } + } + + // Keep an absolute hard ceiling even when the configurable limit is disabled. + if live_member_count >= super::MAX_SWARM_MEMBERS { let _ = client_event_tx.send(ServerEvent::Error { id, message: format!( - "Swarm member limit reached (max {}). This swarm already has {swarm_size} agents; it cannot spawn more. Let existing agents finish and free up capacity, or narrow the task decomposition before spawning further.", + "Swarm member limit reached (hard max {}). This swarm already has {live_member_count} live members; it cannot spawn more. Let existing agents finish and free up capacity, or narrow the task decomposition before spawning further.", super::MAX_SWARM_MEMBERS ), retry_after_secs: None, @@ -1287,12 +1377,28 @@ async fn ensure_spawn_coordinator_swarm( return None; } + // `swarm_max_concurrent_agents` is the machine-safety budget shared by + // run_plan and deep recursive spawning. Previously only run_plan obeyed it, + // so nested agents could grow to the 1000-member hard cap and exhaust RAM. + let live_agent_limit = (configured_live_agent_limit > 0) + .then(|| configured_live_agent_limit.min(super::MAX_SWARM_MEMBERS)); + if live_agent_limit.is_some_and(|limit| live_spawned_agent_count >= limit) { + let limit = live_agent_limit.unwrap_or(super::MAX_SWARM_MEMBERS); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Swarm live-agent limit reached (max {limit}, configured by agents.swarm_max_concurrent_agents). This swarm already has {live_spawned_agent_count} active spawned agents. Let existing agents finish or stop them before spawning more." + ), + retry_after_secs: None, + }); + return None; + } + // Coordinator-slot election is now only about the swarm-level coordinator used // for shared plan operations (propose/approve/assign). Only a root session // (depth 0, no spawner) claims it, and only when the slot is empty or stale. - // Non-root spawners coordinate their own subtree via report-back ownership and - // never disturb the swarm-level coordinator slot. Crucially, the presence of a - // live coordinator no longer blocks anyone from spawning. + // Authorized deep-swarm descendants coordinate their own subtree via + // report-back ownership and never disturb the swarm-level coordinator slot. if is_root && coordinator_id.as_deref() != Some(req_session_id) { let should_claim = coordinator_id.is_none() || coordinator_is_stale; if should_claim { diff --git a/crates/jcode-app-core/src/server/comm_session_tests.rs b/crates/jcode-app-core/src/server/comm_session_tests.rs index a87c52d56d..262118733a 100644 --- a/crates/jcode-app-core/src/server/comm_session_tests.rs +++ b/crates/jcode-app-core/src/server/comm_session_tests.rs @@ -3,7 +3,8 @@ use super::{ CoordinatorSpawnIdentity, ensure_spawn_coordinator_swarm, prepare_visible_spawn_session, register_visible_spawned_member, resolve_coordinator_spawn_identity, resolve_spawn_working_dir, - resolve_stop_target_session, resolve_swarm_spawn_selection, swarm_stop_allowed_by_owner, + resolve_stop_target_session, resolve_swarm_spawn_selection, spawn_admission_lock, + swarm_stop_allowed_by_owner, }; use crate::agent::Agent; use crate::message::{Message, ToolDefinition}; @@ -638,50 +639,56 @@ fn resolve_swarm_spawn_model_inherit_sentinel_uses_coordinator_model() { #[test] fn resolve_swarm_spawn_model_requested_model_overrides_configured_pin() { - // A per-spawn requested model must beat the agents.swarm_model config pin. - let selection = resolve_swarm_spawn_selection( - Some("openai-api:gpt-5.5".to_string()), - Some("claude-oauth:claude-opus-4-8".to_string()), - &coordinator_identity( - Some("claude-fable-5"), - Some("claude-oauth"), - Some("claude-oauth"), - ), - ); + for requested in ["openai-api:gpt-5.5", " openai-api:gpt-5.5 \t"] { + let selection = resolve_swarm_spawn_selection( + Some(requested.to_string()), + Some("claude-oauth:claude-opus-4-8".to_string()), + &coordinator_identity( + Some("claude-fable-5"), + Some("claude-oauth"), + Some("claude-oauth"), + ), + ); - assert_eq!(selection.model.as_deref(), Some("gpt-5.5")); - assert_eq!(selection.provider_key.as_deref(), Some("openai-api-key")); - assert_eq!( - selection.route_api_method.as_deref(), - Some("openai-api-key") - ); + assert_eq!(selection.model.as_deref(), Some("gpt-5.5")); + assert_eq!(selection.provider_key.as_deref(), Some("openai-api-key")); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-api-key") + ); + } } #[test] fn resolve_swarm_spawn_model_requested_inherit_overrides_configured_pin() { - // An explicit `inherit` request must force coordinator inheritance even - // when the config pins a different model. - let selection = resolve_swarm_spawn_selection( - Some("inherit".to_string()), - Some("openai-api:gpt-5.5".to_string()), - &coordinator_identity( - Some("claude-fable-5"), - Some("claude-api"), - Some("claude-api"), - ), - ); + for requested in [ + "inherit", + "INHERIT", + "coordinator", + " COORDINATOR ", + " inherit ", + ] { + let selection = resolve_swarm_spawn_selection( + Some(requested.to_string()), + Some("openai-api:gpt-5.5".to_string()), + &coordinator_identity( + Some("claude-fable-5"), + Some("claude-api"), + Some("claude-api"), + ), + ); - assert_eq!(selection.model.as_deref(), Some("claude-fable-5")); - assert_eq!(selection.provider_key.as_deref(), Some("claude-api")); - assert_eq!(selection.route_api_method.as_deref(), Some("claude-api")); + assert_eq!(selection.model.as_deref(), Some("claude-fable-5")); + assert_eq!(selection.provider_key.as_deref(), Some("claude-api")); + assert_eq!(selection.route_api_method.as_deref(), Some("claude-api")); + } } #[test] fn resolve_swarm_spawn_model_requested_matching_coordinator_model_keeps_route() { - // Requesting the coordinator's own model keeps its provider key and route. let selection = resolve_swarm_spawn_selection( - Some("custom-model".to_string()), - None, + Some(" custom-model ".to_string()), + Some("openai-api:gpt-5.5".to_string()), &coordinator_identity( Some("custom-model"), Some("custom-provider"), @@ -696,10 +703,31 @@ fn resolve_swarm_spawn_model_requested_matching_coordinator_model_keeps_route() #[test] fn resolve_swarm_spawn_model_blank_requested_model_falls_back_to_config() { - // A whitespace-only requested model is treated as "not provided". + for requested in ["", " ", "\t\n"] { + let selection = resolve_swarm_spawn_selection( + Some(requested.to_string()), + Some("openai-api:gpt-5.5".to_string()), + &coordinator_identity( + Some("claude-fable-5"), + Some("claude-oauth"), + Some("claude-oauth"), + ), + ); + + assert_eq!(selection.model.as_deref(), Some("gpt-5.5")); + assert_eq!(selection.provider_key.as_deref(), Some("openai-api-key")); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-api-key") + ); + } +} + +#[test] +fn resolve_swarm_spawn_model_omitted_request_trims_configured_model() { let selection = resolve_swarm_spawn_selection( - Some(" ".to_string()), - Some("openai-api:gpt-5.5".to_string()), + None, + Some(" \topenai-api:gpt-5.5 \n".to_string()), &coordinator_identity( Some("claude-fable-5"), Some("claude-oauth"), @@ -709,6 +737,27 @@ fn resolve_swarm_spawn_model_blank_requested_model_falls_back_to_config() { assert_eq!(selection.model.as_deref(), Some("gpt-5.5")); assert_eq!(selection.provider_key.as_deref(), Some("openai-api-key")); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-api-key") + ); +} + +#[test] +fn resolve_swarm_spawn_model_blank_requested_model_inherits_when_unconfigured() { + let selection = resolve_swarm_spawn_selection( + Some(" \t\n".to_string()), + None, + &coordinator_identity( + Some("custom-model"), + Some("custom-provider"), + Some("custom-route"), + ), + ); + + assert_eq!(selection.model.as_deref(), Some("custom-model")); + assert_eq!(selection.provider_key.as_deref(), Some("custom-provider")); + assert_eq!(selection.route_api_method.as_deref(), Some("custom-route")); } #[tokio::test] @@ -783,6 +832,7 @@ async fn spawn_bootstraps_coordinator_when_swarm_has_none() { &swarms_by_id, &swarm_coordinators, &swarm_plans, + 32, ) .await; @@ -814,80 +864,134 @@ async fn spawn_bootstraps_coordinator_when_swarm_has_none() { } #[tokio::test] -async fn nested_agent_can_spawn_while_live_coordinator_exists() { - // Recursive spawning (option A): a spawned child (depth 1, owned by `coord`) - // may spawn its own children even though a live swarm-level coordinator - // exists. It must not steal the swarm-level coordinator slot. +async fn nested_agent_cannot_spawn_when_root_is_light_or_normal() { + // Both explicit light-swarm effort and ordinary ad hoc swarm use are + // one-level fan-out. A spawned child cannot grow another generation. + for (root_id, effort) in [ + ("light-root-no-recursion", Some("swarm")), + ("normal-root-no-recursion", None), + ] { + crate::session_effort::forget_session_effort(root_id); + crate::session_effort::record_session_effort(root_id, effort); + let swarm_id = format!("swarm-{root_id}"); + let child_id = format!("child-{root_id}"); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([child_id.clone(), root_id.to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + root_id.to_string(), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::::new())); + let (mut child_member, _child_rx) = member(&child_id, Some(&swarm_id), "agent"); + child_member.report_back_to_session_id = Some(root_id.to_string()); + let (root_member, _root_rx) = member(root_id, Some(&swarm_id), "coordinator"); + let mut members = swarm_members.write().await; + members.insert(child_id.clone(), child_member); + members.insert(root_id.to_string(), root_member); + drop(members); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let refused = ensure_spawn_coordinator_swarm( + 2, + &child_id, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + 32, + ) + .await; + + crate::session_effort::forget_session_effort(root_id); + assert!(refused.is_none()); + assert_eq!( + swarm_coordinators + .read() + .await + .get(&swarm_id) + .map(String::as_str), + Some(root_id) + ); + assert_eq!( + swarm_members + .read() + .await + .get(&child_id) + .map(|member| member.role.as_str()), + Some("agent") + ); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Error { message, .. }) + if message.contains("Recursive swarm spawning is disabled") + && message.contains(&format!("Only the root session ({root_id}) may spawn agents")) + )); + } +} + +#[tokio::test] +async fn nested_agent_can_spawn_when_root_is_deep() { + let root_id = "deep-root-recursive"; + crate::session_effort::record_session_effort(root_id, Some("swarm-deep")); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( - "swarm-1".to_string(), - HashSet::from(["child".to_string(), "coord".to_string()]), + "swarm-deep".to_string(), + HashSet::from(["deep-child".to_string(), root_id.to_string()]), )]))); let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( - "swarm-1".to_string(), - "coord".to_string(), + "swarm-deep".to_string(), + root_id.to_string(), )]))); let swarm_plans = Arc::new(RwLock::new(HashMap::::new())); - let (mut child_member, _child_rx) = member("child", Some("swarm-1"), "agent"); - child_member.report_back_to_session_id = Some("coord".to_string()); - let (coord_member, _coord_rx) = member("coord", Some("swarm-1"), "coordinator"); + let (mut child_member, _child_rx) = member("deep-child", Some("swarm-deep"), "agent"); + child_member.report_back_to_session_id = Some(root_id.to_string()); + let (root_member, _root_rx) = member(root_id, Some("swarm-deep"), "coordinator"); let mut members = swarm_members.write().await; - members.insert("child".to_string(), child_member); - members.insert("coord".to_string(), coord_member); + members.insert("deep-child".to_string(), child_member); + members.insert(root_id.to_string(), root_member); drop(members); let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); - let swarm_id = ensure_spawn_coordinator_swarm( - 2, - "child", + let allowed = ensure_spawn_coordinator_swarm( + 3, + "deep-child", &client_event_tx, &swarm_members, &swarms_by_id, &swarm_coordinators, &swarm_plans, + 32, ) .await; - assert_eq!(swarm_id.as_deref(), Some("swarm-1")); - // The swarm-level coordinator slot is untouched. - assert_eq!( - swarm_coordinators - .read() - .await - .get("swarm-1") - .map(String::as_str), - Some("coord") - ); - // The child keeps its agent role; it coordinates its own subtree via - // report-back ownership, not the swarm-level coordinator slot. - assert_eq!( - swarm_members - .read() - .await - .get("child") - .map(|member| member.role.as_str()), - Some("agent") - ); + crate::session_effort::forget_session_effort(root_id); + assert_eq!(allowed.as_deref(), Some("swarm-deep")); assert!(client_event_rx.try_recv().is_err()); } #[tokio::test] async fn spawn_allowed_at_arbitrary_depth_without_depth_cap() { - // Build a deep chain root -> a -> b -> c -> d -> e -> f. There is no depth - // cap anymore, so even a deeply nested agent may still spawn. + // Deep-swarm mode still allows recursive decomposition at arbitrary depth. + let root_id = "deep-root-arbitrary-depth"; + crate::session_effort::record_session_effort(root_id, Some("swarm-deep")); let swarm_members = Arc::new(RwLock::new(HashMap::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( "swarm-1".to_string(), - "root".to_string(), + root_id.to_string(), )]))); let swarm_plans = Arc::new(RwLock::new(HashMap::::new())); { let mut members = swarm_members.write().await; - let (root, _rx) = member("root", Some("swarm-1"), "coordinator"); - members.insert("root".to_string(), root); + let (root, _rx) = member(root_id, Some("swarm-1"), "coordinator"); + members.insert(root_id.to_string(), root); let chain = [ - ("a", "root"), + ("a", root_id), ("b", "a"), ("c", "b"), ("d", "c"), @@ -912,8 +1016,10 @@ async fn spawn_allowed_at_arbitrary_depth_without_depth_cap() { &swarms_by_id, &swarm_coordinators, &swarm_plans, + 32, ) .await; + crate::session_effort::forget_session_effort(root_id); assert_eq!(allowed.as_deref(), Some("swarm-1")); } @@ -951,6 +1057,7 @@ async fn spawn_rejected_when_member_limit_reached() { &swarms_by_id, &swarm_coordinators, &swarm_plans, + 0, ) .await; assert!(refused.is_none()); @@ -999,8 +1106,102 @@ async fn terminal_members_do_not_consume_spawn_capacity() { &swarms_by_id, &swarm_coordinators, &swarm_plans, + 32, ) .await; assert_eq!(allowed.as_deref(), Some("swarm-1")); } + +#[tokio::test] +async fn spawn_rejected_at_configured_live_agent_limit() { + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "root".to_string(), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::::new())); + { + let mut members = swarm_members.write().await; + let (root, _rx) = member("root", Some("swarm-1"), "coordinator"); + members.insert("root".to_string(), root); + for idx in 0..2 { + let id = format!("agent-{idx}"); + let (mut worker, _rx) = member(&id, Some("swarm-1"), "agent"); + worker.report_back_to_session_id = Some("root".to_string()); + members.insert(id, worker); + } + } + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let refused = ensure_spawn_coordinator_swarm( + 7, + "root", + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + 2, + ) + .await; + + assert!(refused.is_none()); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Error { message, .. }) + if message.contains("Swarm live-agent limit reached (max 2") + )); +} + +#[tokio::test] +async fn spawn_admission_lock_serializes_per_swarm_only() { + use std::time::Duration; + + let key = format!("lock-test-{}", std::process::id()); + let same_a = spawn_admission_lock(&key); + let same_b = spawn_admission_lock(&key); + let other = spawn_admission_lock(&format!("{key}-other")); + + let held = same_a.lock().await; + assert!( + tokio::time::timeout(Duration::from_millis(10), same_b.lock()) + .await + .is_err() + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), other.lock()) + .await + .is_ok() + ); + drop(held); + assert!( + tokio::time::timeout(Duration::from_millis(100), same_b.lock()) + .await + .is_ok() + ); +} + +#[test] +fn swarm_spawn_effort_prefers_explicit_then_config_pin_then_inherit() { + use super::resolve_swarm_spawn_effort; + + // Explicit spawn argument wins over the config pin (#1165). + assert_eq!( + resolve_swarm_spawn_effort(Some("low"), Some("medium")), + Some("low".to_string()) + ); + // A missing or blank spawn argument falls back to `agents.swarm_effort`. + assert_eq!( + resolve_swarm_spawn_effort(None, Some("medium")), + Some("medium".to_string()) + ); + assert_eq!( + resolve_swarm_spawn_effort(Some(" "), Some(" medium ")), + Some("medium".to_string()) + ); + // With neither, the worker inherits the provider-wide effort. + assert_eq!(resolve_swarm_spawn_effort(None, None), None); + assert_eq!(resolve_swarm_spawn_effort(Some(""), Some("")), None); +} diff --git a/crates/jcode-app-core/src/server/comm_sync.rs b/crates/jcode-app-core/src/server/comm_sync.rs index 94681f385a..d7489432d9 100644 --- a/crates/jcode-app-core/src/server/comm_sync.rs +++ b/crates/jcode-app-core/src/server/comm_sync.rs @@ -72,6 +72,7 @@ pub(super) struct MemberRuntimeExtras { pub(super) activity: Option, pub(super) provider_name: Option, pub(super) provider_model: Option, + pub(super) provider_effort: Option, pub(super) turn_count: Option, pub(super) recent_total_tokens: Option, pub(super) recent_output_tokens: Option, @@ -97,19 +98,23 @@ pub(super) async fn member_runtime_extras( live_activity_snapshot(&connections, session_id, member_is_running) }; - let (provider_name, provider_model) = { + let (provider_name, provider_model, provider_effort) = { let agent_sessions = sessions.read().await; if let Some(agent) = agent_sessions.get(session_id) { // Never block on a busy agent: token churn and turns come from the // lock-free metrics registry, so a missing provider name here just // means the agent is mid-turn. if let Ok(agent) = agent.try_lock() { - (Some(agent.provider_name()), Some(agent.provider_model())) + ( + Some(agent.provider_name()), + Some(agent.provider_model()), + agent.provider_reasoning_effort(), + ) } else { - (None, None) + (None, None, None) } } else { - (None, None) + (None, None, None) } }; @@ -130,6 +135,7 @@ pub(super) async fn member_runtime_extras( activity, provider_name, provider_model, + provider_effort, turn_count: metrics.map(|m| m.turns), recent_total_tokens: metrics.map(|m| m.recent_total_tokens), recent_output_tokens: metrics.map(|m| m.recent_output_tokens), diff --git a/crates/jcode-app-core/src/server/debug.rs b/crates/jcode-app-core/src/server/debug.rs index 559b04661a..3096093b4b 100644 --- a/crates/jcode-app-core/src/server/debug.rs +++ b/crates/jcode-app-core/src/server/debug.rs @@ -302,7 +302,10 @@ pub(super) async fn handle_debug_client( match request { Request::Ping { id } => { - let event = ServerEvent::Pong { id }; + let event = ServerEvent::Pong { + id, + native_ssh_protocol: Some(1), + }; let json = encode_event(&event); writer.write_all(json.as_bytes()).await?; } diff --git a/crates/jcode-app-core/src/server/debug_command_exec.rs b/crates/jcode-app-core/src/server/debug_command_exec.rs index eb8abb9318..fff6da2e17 100644 --- a/crates/jcode-app-core/src/server/debug_command_exec.rs +++ b/crates/jcode-app-core/src/server/debug_command_exec.rs @@ -158,7 +158,12 @@ pub(super) async fn execute_debug_command( return Err(anyhow::anyhow!("queue_interrupt: requires content")); } let agent = agent.lock().await; - agent.queue_soft_interrupt(content.to_string(), false, SoftInterruptSource::User); + agent.queue_soft_interrupt( + content.to_string(), + Vec::new(), + false, + SoftInterruptSource::User, + ); return Ok("queued".to_string()); } @@ -171,7 +176,12 @@ pub(super) async fn execute_debug_command( return Err(anyhow::anyhow!("queue_interrupt_urgent: requires content")); } let agent = agent.lock().await; - agent.queue_soft_interrupt(content.to_string(), true, SoftInterruptSource::User); + agent.queue_soft_interrupt( + content.to_string(), + Vec::new(), + true, + SoftInterruptSource::User, + ); return Ok("queued (urgent)".to_string()); } @@ -330,15 +340,19 @@ pub(super) async fn execute_debug_command( Some(ctx) => ctx.control_handle().await, None => None, } { - let _queued = - control.queue_soft_interrupt(content.clone(), true, SoftInterruptSource::User); + let _queued = control.queue_soft_interrupt( + content.clone(), + Vec::new(), + true, + SoftInterruptSource::User, + ); control.request_cancel(); delivered_without_agent_lock = true; } if !delivered_without_agent_lock { let agent = agent.lock().await; - agent.queue_soft_interrupt(content, true, SoftInterruptSource::User); + agent.queue_soft_interrupt(content, Vec::new(), true, SoftInterruptSource::User); agent.request_graceful_shutdown(); } return Ok(serde_json::json!({ @@ -534,10 +548,10 @@ pub(super) async fn execute_debug_command( if claude_usage_exhausted { "claude-sonnet-4-6" } else { - "claude-fable-5" + jcode_provider_core::DEFAULT_CLAUDE_MODEL } } - "openai" | "codex" => "gpt-5.5", + "openai" | "codex" => jcode_provider_core::DEFAULT_OPENAI_MODEL, "openrouter" => "anthropic/claude-sonnet-4", "cursor" => "gpt-5", "copilot" => "copilot:claude-sonnet-4", @@ -631,13 +645,17 @@ mod tests { use std::time::{Duration, Instant}; use tokio::sync::{Mutex as AsyncMutex, RwLock}; - static ENV_LOCK: OnceLock> = OnceLock::new(); - + /// Serialize env mutation on the *shared* process-wide test lock. + /// + /// Env vars are per-process, so a private mutex here would only exclude + /// other tests in this module while racing every other test that mutates + /// the environment (notably the `IsolatedHome` users in `reload_recovery`, + /// which set `JCODE_HOME` under `storage::lock_test_env`). Two mutexes + /// guarding one global serialize nothing, which showed up as a rotating set + /// of failures under `cargo test` that all passed with `--test-threads=1` + /// (issue #593). Everything touching the environment must share one lock. fn lock_env() -> std::sync::MutexGuard<'static, ()> { - ENV_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + crate::storage::lock_test_env() } struct EnvGuard { diff --git a/crates/jcode-app-core/src/server/debug_server_state.rs b/crates/jcode-app-core/src/server/debug_server_state.rs index 3c029f1a09..e582e08261 100644 --- a/crates/jcode-app-core/src/server/debug_server_state.rs +++ b/crates/jcode-app-core/src/server/debug_server_state.rs @@ -47,6 +47,16 @@ struct MemoryIncidentMetrics { connected_clients: usize, } +fn count_live_spawned_swarm_agents<'a>( + live_session_ids: &HashSet, + spawned_session_ids: impl IntoIterator, +) -> usize { + spawned_session_ids + .into_iter() + .filter(|session_id| live_session_ids.contains(*session_id)) + .count() +} + async fn connected_session_snapshot( sessions: &SessionAgents, client_connections: &Arc>>, @@ -261,7 +271,18 @@ pub(super) async fn maybe_handle_server_state_command( if cmd == "info" || cmd == "server:info" { let uptime_secs = server_start_time.elapsed().as_secs(); - let session_count = sessions.read().await.len(); + let live_session_ids: HashSet = sessions.read().await.keys().cloned().collect(); + let session_count = live_session_ids.len(); + let spawned_swarm_agent_count = { + let members = swarm_members.read().await; + count_live_spawned_swarm_agents( + &live_session_ids, + members + .values() + .filter(|member| member.report_back_to_session_id.is_some()) + .map(|member| &member.session_id), + ) + }; let member_count = swarm_members.read().await.len(); let has_update = super::server_has_newer_binary(); return Ok(Some( @@ -273,6 +294,7 @@ pub(super) async fn maybe_handle_server_state_command( "git_hash": server_identity.git_hash, "uptime_secs": uptime_secs, "session_count": session_count, + "spawned_swarm_agent_count": spawned_swarm_agent_count, "swarm_member_count": member_count, "has_update": has_update, "debug_control_enabled": super::debug_control_allowed(), @@ -658,6 +680,25 @@ mod tests { assert!(members.is_empty()); } + #[test] + fn spawned_swarm_agent_count_only_includes_live_owned_sessions() { + let live_session_ids = HashSet::from([ + "root".to_string(), + "worker-running".to_string(), + "worker-ready".to_string(), + ]); + let spawned_session_ids = [ + "worker-running".to_string(), + "worker-ready".to_string(), + "worker-stale".to_string(), + ]; + + assert_eq!( + count_live_spawned_swarm_agents(&live_session_ids, spawned_session_ids.iter()), + 2 + ); + } + #[test] fn memory_incident_classifies_runaway_live_sessions_before_allocator_retention() { let decision = classify_memory_incident(MemoryIncidentMetrics { diff --git a/crates/jcode-app-core/src/server/debug_session_admin.rs b/crates/jcode-app-core/src/server/debug_session_admin.rs index 3890ffc830..555730c995 100644 --- a/crates/jcode-app-core/src/server/debug_session_admin.rs +++ b/crates/jcode-app-core/src/server/debug_session_admin.rs @@ -89,6 +89,7 @@ pub(super) async fn maybe_handle_session_admin_command( None, mcp_pool, None, + super::headless::HeadlessMemoryScope::IsolatedTest, ) .await?; if let Ok(value) = serde_json::from_str::(&created) @@ -111,14 +112,12 @@ pub(super) async fn maybe_handle_session_admin_command( return Err(anyhow::anyhow!("destroy_session: requires a session_id")); } - let removed_agent = { - let mut sessions_guard = sessions.write().await; - sessions_guard.remove(target_id) - }; + let removed_agent = super::remove_session_entry(sessions, target_id).await; remove_session_interrupt_queue(soft_interrupt_queues, target_id).await; remove_background_tool_signal(target_id); if let Some(ref agent_arc) = removed_agent { - let agent = agent_arc.lock().await; + let mut agent = agent_arc.lock().await; + agent.mark_closed(); let memory_enabled = agent.memory_enabled(); let transcript = if memory_enabled { Some(agent.build_transcript_for_extraction()) diff --git a/crates/jcode-app-core/src/server/debug_swarm_write.rs b/crates/jcode-app-core/src/server/debug_swarm_write.rs index b049721938..f044f2f885 100644 --- a/crates/jcode-app-core/src/server/debug_swarm_write.rs +++ b/crates/jcode-app-core/src/server/debug_swarm_write.rs @@ -84,6 +84,37 @@ pub(super) async fn maybe_handle_swarm_write_command( coordinators: Arc::clone(ctx.swarm_coordinators), }; persist_swarm_state_for(swarm_id, &swarm_state).await; + // Push the cleared state to attached clients. Without this, every + // connected TUI keeps rendering (and holding resident) the old item + // graph until its next reconnect; a 1.5k-item stale plan is ~650 KB + // of JSON pinned per client. Version advances past the removed plan + // so the client-side stale-regression guard accepts the update. + let clear_event = ServerEvent::SwarmPlan { + swarm_id: swarm_id.to_string(), + version: removed.version.saturating_add(1), + items: Vec::new(), + participants: Vec::new(), + reason: Some("plan_cleared".to_string()), + summary: None, + }; + let session_ids: Vec = { + let swarms = ctx.swarms_by_id.read().await; + swarms + .get(swarm_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + }; + { + let members = ctx.swarm_members.read().await; + for sid in session_ids { + if let Some(member) = members.get(&sid) { + let _ = member.event_tx.send(clear_event.clone()); + for tx in member.event_txs.values() { + let _ = tx.send(clear_event.clone()); + } + } + } + } return Ok(Some( serde_json::json!({ "swarm_id": swarm_id, @@ -361,6 +392,15 @@ pub(super) async fn maybe_handle_swarm_write_command( let versioned_plan = plans .entry(swarm_id.clone()) .or_insert_with(VersionedPlan::new); + let merged_count = + versioned_plan.items.len().saturating_add(items.len()); + if merged_count > jcode_plan::MAX_PLAN_ITEMS { + return Err(anyhow::anyhow!( + "Plan approval would contain {} items, exceeding the per-swarm limit of {}", + merged_count, + jcode_plan::MAX_PLAN_ITEMS + )); + } versioned_plan.items.extend(items.clone()); versioned_plan.version += 1; versioned_plan diff --git a/crates/jcode-app-core/src/server/headless.rs b/crates/jcode-app-core/src/server/headless.rs index 77e349b31f..8ad4704b32 100644 --- a/crates/jcode-app-core/src/server/headless.rs +++ b/crates/jcode-app-core/src/server/headless.rs @@ -3,7 +3,7 @@ use crate::protocol::ServerEvent; use crate::provider::Provider; use crate::server::{ SessionInterruptQueues, SwarmMember, VersionedPlan, broadcast_swarm_status, - register_background_tool_signal, register_session_interrupt_queue, swarm_id_for_dir, + register_background_tool_signal, register_session_interrupt_queue, swarm_id_for_session, }; use crate::tool::Registry; use anyhow::Result; @@ -14,6 +14,22 @@ use tokio::sync::{Mutex, RwLock}; type SessionAgents = Arc>>>>; +/// Which memory store a headless session gets. +/// +/// A bare `bool` here is one typo away from silently reintroducing #729, where +/// every real swarm worker was forced into throwaway test storage and could +/// never read what the session that spawned it remembered. Naming the two cases +/// makes the wrong one hard to pick by accident and obvious in review. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum HeadlessMemoryScope { + /// Real project/global memory, scoped to the session's working directory. + /// Correct for swarm-spawned workers, which are real user sessions. + RealProject, + /// Throwaway isolated storage. Only for debug-socket admin sessions, where + /// isolation is the entire point. + IsolatedTest, +} + #[expect( clippy::too_many_arguments, reason = "headless session creation wires provider, global session, swarm state, interrupts, and MCP pool together" @@ -35,6 +51,7 @@ pub(super) async fn create_headless_session( effort_override: Option, mcp_pool: Option>, report_back_to_session_id: Option, + memory_scope: HeadlessMemoryScope, ) -> Result { let memory_enabled = crate::config::config().features.memory; let swarm_enabled = crate::config::config().features.swarm; @@ -53,7 +70,9 @@ pub(super) async fn create_headless_session( let provider = provider_template.fork(); let registry = Registry::new(provider.clone()).await; - registry.enable_memory_test_mode().await; + if memory_scope == HeadlessMemoryScope::IsolatedTest { + registry.enable_memory_test_mode().await; + } if selfdev_requested { registry.register_selfdev_tools().await; @@ -71,10 +90,11 @@ pub(super) async fn create_headless_session( let working_dir_string = working_dir .as_ref() .map(|dir| dir.to_string_lossy().into_owned()); - let mut new_agent = Agent::new_with_initial_working_dir( + let mut new_agent = Agent::new_with_parent_and_initial_working_dir( Arc::clone(&provider), registry, working_dir_string.as_deref(), + report_back_to_session_id.clone(), ); new_agent.set_memory_enabled(memory_enabled); // Inline swarm mode renders a live gallery of worker viewports in the @@ -101,12 +121,29 @@ pub(super) async fn create_headless_session( provider_key_override.as_deref(), route_api_method_override.as_deref(), ); - if let Err(e) = new_agent.set_model(&model_request) { + // A worker that silently runs a model other than the requested one burns + // the wrong quota and produces results the caller attributes to the wrong + // model, with only a log line to explain it (#512, #514, #519). So check + // the *outcome*, not whether `set_model` returned Ok: a provider that + // cannot switch is fine as long as it already serves the requested model, + // and a switch that "succeeds" onto a different model is not. + let switch_error = new_agent.set_model(&model_request).err(); + if let Some(error) = switch_error.as_ref() { crate::logging::warn(&format!( - "Failed to set headless session model override '{}' (request '{}'): {}", - model, model_request, e + "Failed to set headless session model override '{model}' (request '{model_request}'): {error}" )); } + let resolved = new_agent.provider_model(); + if !models_are_equivalent(&resolved, &model) { + let detail = switch_error + .map(|error| format!(": {error}")) + .unwrap_or_else(|| " (the switch reported success)".to_string()); + anyhow::bail!( + "Cannot spawn session on model '{model}' (request '{model_request}'){detail}. \ + It would run '{resolved}' instead; refusing to silently use a different \ + model. Check the model id and that its provider is authenticated." + ); + } } if let Some(effort) = effort_override @@ -173,7 +210,17 @@ pub(super) async fn create_headless_session( }; let swarm_id = if swarm_enabled { - swarm_id_for_dir(working_dir.clone()) + // A spawned worker belongs to its parent's swarm. A standalone + // headless session is an independent root and gets its own swarm. + let parent_swarm_id = if let Some(parent_id) = report_back_to_session_id.as_deref() { + let members = swarm_members.read().await; + members + .get(parent_id) + .and_then(|member| member.swarm_id.clone()) + } else { + None + }; + parent_swarm_id.or_else(|| swarm_id_for_session(&client_session_id)) } else { None }; @@ -269,3 +316,72 @@ pub(super) async fn create_headless_session( }) .to_string()) } + +/// Whether a resolved provider model satisfies a requested model id. +/// +/// Routes legitimately canonicalize ids (dated aliases, `[1m]`/`[web]` suffixes, +/// and vendor prefixes like `anthropic/`), so compare on a normalized form and +/// allow either side to be a prefix of the other. This exists only to decide +/// whether to log a mismatch, so it errs toward staying quiet. +fn models_are_equivalent(resolved: &str, requested: &str) -> bool { + fn normalize(model: &str) -> String { + let model = model.trim().to_ascii_lowercase(); + let bare = model.rsplit('/').next().unwrap_or(&model); + let bare = bare.split(':').next_back().unwrap_or(bare); + bare.split('[').next().unwrap_or(bare).trim().to_string() + } + let resolved = normalize(resolved); + let requested = normalize(requested); + if resolved.is_empty() || requested.is_empty() { + return true; + } + resolved.starts_with(&requested) || requested.starts_with(&resolved) +} + +#[cfg(test)] +mod tests { + use super::models_are_equivalent; + + #[test] + fn equivalent_models_tolerate_route_canonicalization() { + // Routes legitimately rewrite ids; these must not look like mismatches. + assert!(models_are_equivalent( + "claude-sonnet-4-6", + "claude-sonnet-4-6" + )); + assert!(models_are_equivalent( + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-5" + )); + assert!(models_are_equivalent( + "anthropic/claude-sonnet-4-6", + "claude-sonnet-4-6" + )); + assert!(models_are_equivalent( + "claude-opus-4-6", + "claude-opus-4-6[1m]" + )); + assert!(models_are_equivalent( + "gpt-5.6-pro", + "openai-api:gpt-5.6-pro" + )); + // Unknown/empty resolution should stay quiet rather than cry wolf. + assert!(models_are_equivalent("", "claude-sonnet-4-6")); + } + + #[test] + fn different_models_are_reported_as_mismatched() { + // The #519 symptom: a worker asked for one model and got the + // coordinator's instead. + assert!(!models_are_equivalent( + "deepseek-v4-pro", + "deepseek-v4-flash" + )); + assert!(!models_are_equivalent("deepseek-v4-pro", "MiniMax-M3")); + assert!(!models_are_equivalent( + "claude-fable-5", + "deepseek-v4-flash" + )); + assert!(!models_are_equivalent("gpt-5.6-sol", "gpt-5.5")); + } +} diff --git a/crates/jcode-app-core/src/server/jade_relay.rs b/crates/jcode-app-core/src/server/jade_relay.rs index dc3894e540..6cf0cf8cae 100644 --- a/crates/jcode-app-core/src/server/jade_relay.rs +++ b/crates/jcode-app-core/src/server/jade_relay.rs @@ -512,7 +512,8 @@ impl RelayClient { queue, stop_signal, ); - if !control.queue_soft_interrupt(interrupt, true, SoftInterruptSource::User) { + if !control.queue_soft_interrupt(interrupt, Vec::new(), true, SoftInterruptSource::User) + { anyhow::bail!( "session '{}' could not accept cancel interrupt", self.config.session_id diff --git a/crates/jcode-app-core/src/server/lifecycle.rs b/crates/jcode-app-core/src/server/lifecycle.rs index 3959bbb60a..041892ca6d 100644 --- a/crates/jcode-app-core/src/server/lifecycle.rs +++ b/crates/jcode-app-core/src/server/lifecycle.rs @@ -137,6 +137,8 @@ pub(crate) fn cleanup_temporary_metadata(socket_path: &Path) { pub(crate) fn spawn_temporary_lifecycle_monitor( client_count: Arc>, + sessions: super::SessionAgents, + swarm_state: super::SwarmState, socket_path: PathBuf, debug_socket_path: PathBuf, server_name: String, @@ -161,7 +163,9 @@ pub(crate) fn spawn_temporary_lifecycle_monitor( } let count = *client_count.read().await; - if count == 0 { + let has_live_headless_worker = + super::has_live_headless_worker(&sessions, &swarm_state).await; + if super::idle_monitor_should_start(count, has_live_headless_worker) { if idle_since.is_none() { idle_since = Some(Instant::now()); crate::logging::info(&format!( @@ -239,9 +243,6 @@ pub(crate) fn process_alive(_pid: u32) -> bool { #[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, OnceLock}; - - static TEST_ENV_LOCK: OnceLock> = OnceLock::new(); struct EnvGuard { _lock: std::sync::MutexGuard<'static, ()>, @@ -250,10 +251,9 @@ mod tests { impl EnvGuard { fn capture(names: &[&'static str]) -> Self { - let _lock = TEST_ENV_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Shared process-wide lock: env vars are global, so a private + // mutex here would race every other env-mutating test (issue #593). + let _lock = crate::storage::lock_test_env(); let entries = names .iter() .map(|name| (*name, std::env::var_os(name))) diff --git a/crates/jcode-app-core/src/server/live_turn.rs b/crates/jcode-app-core/src/server/live_turn.rs index 5604ef1a1d..580aba52f2 100644 --- a/crates/jcode-app-core/src/server/live_turn.rs +++ b/crates/jcode-app-core/src/server/live_turn.rs @@ -13,7 +13,7 @@ //! `Done`/`Error` event (id 0) so attached clients can settle the externally //! started turn in their UI. -use super::client_lifecycle::process_message_streaming_mpsc; +use super::client_lifecycle::process_locked_message_streaming_mpsc; use super::{ SwarmEvent, SwarmMember, session_event_fanout_sender, truncate_detail, update_member_status, update_member_status_with_report, @@ -23,7 +23,7 @@ use crate::protocol::ServerEvent; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::sync::atomic::AtomicU64; -use tokio::sync::{Mutex, RwLock, broadcast}; +use tokio::sync::{Mutex, OwnedMutexGuard, RwLock, broadcast}; type SessionAgents = Arc>>>>; @@ -56,13 +56,17 @@ impl LiveTurnSwarmContext { } } -/// Return the live agent for `session_id` when the session has at least one -/// live client attachment and its agent is currently idle (lock not held). +/// Reserve the live agent for `session_id` when the session has at least one +/// live client attachment and its agent is currently idle. +/// +/// The returned guard *is* the reservation: it stays held until the tracked +/// turn finishes, so two concurrent wakes cannot both observe the agent as +/// idle and then serialize behind each other (#1152). pub(super) async fn idle_live_agent( session_id: &str, sessions: &SessionAgents, swarm_members: &Arc>>, -) -> Option>> { +) -> Option> { let agent = { let guard = sessions.read().await; guard.get(session_id).cloned() @@ -79,8 +83,7 @@ pub(super) async fn idle_live_agent( return None; } - let is_idle = agent.try_lock().is_ok(); - is_idle.then_some(agent) + agent.try_lock_owned().ok() } /// Spawn `message` as a full tracked turn in a live session. @@ -92,9 +95,10 @@ pub(super) async fn idle_live_agent( /// finish rendering the externally started turn. pub(super) async fn spawn_tracked_live_turn( session_id: &str, - agent: Arc>, + mut agent: OwnedMutexGuard, message: String, system_reminder: Option, + display_role: Option, status_detail: Option, swarm: LiveTurnSwarmContext, ) { @@ -113,24 +117,39 @@ pub(super) async fn spawn_tracked_live_turn( let event_tx = session_event_fanout_sender(session_id.to_string(), Arc::clone(&swarm.members)); let session_id = session_id.to_string(); tokio::spawn(async move { - let start_message_index = { - let agent_guard = agent.lock().await; - agent_guard.message_count() + let start_message_index = agent.message_count(); + let result = if let Some(display_role) = display_role { + agent + .run_once_streaming_mpsc_with_display_role( + &message, + vec![], + system_reminder, + event_tx.clone(), + Some(display_role), + ) + .await + } else { + process_locked_message_streaming_mpsc( + &mut agent, + &message, + vec![], + system_reminder, + event_tx.clone(), + ) + .await }; - let result = process_message_streaming_mpsc( - Arc::clone(&agent), - &message, - vec![], - system_reminder, - event_tx.clone(), - ) - .await; + let completion_report = result + .is_ok() + .then(|| agent.latest_assistant_text_after(start_message_index)) + .flatten(); + // Keep the reservation until after the terminal status is published. + // Releasing it earlier lets a follow-up wake reserve the agent and + // publish `running`, which this turn's later `ready`/`failed` would + // then overwrite, hiding the newer turn and suppressing its + // coordinator completion notification. + let reservation = agent; match result { Ok(()) => { - let completion_report = { - let agent_guard = agent.lock().await; - agent_guard.latest_assistant_text_after(start_message_index) - }; update_member_status_with_report( &session_id, "ready", @@ -168,6 +187,7 @@ pub(super) async fn spawn_tracked_live_turn( }); } } + drop(reservation); }); } @@ -189,6 +209,30 @@ pub(super) async fn run_live_turn_if_idle( agent, message.to_string(), system_reminder, + None, + detail, + swarm, + ) + .await; + true +} + +pub(super) async fn run_live_system_turn_if_idle( + session_id: &str, + message: &str, + sessions: &SessionAgents, + swarm: LiveTurnSwarmContext, +) -> bool { + let Some(agent) = idle_live_agent(session_id, sessions, &swarm.members).await else { + return false; + }; + let detail = Some(truncate_detail(message, 120)).filter(|detail| !detail.is_empty()); + spawn_tracked_live_turn( + session_id, + agent, + message.to_string(), + None, + Some(crate::session::StoredDisplayRole::System), detail, swarm, ) diff --git a/crates/jcode-app-core/src/server/provider_control.rs b/crates/jcode-app-core/src/server/provider_control.rs index dbca09eb87..c210e03dd9 100644 --- a/crates/jcode-app-core/src/server/provider_control.rs +++ b/crates/jcode-app-core/src/server/provider_control.rs @@ -68,7 +68,9 @@ async fn available_models_snapshot(agent: &Arc>) -> ModelCatalogSna } fn available_models_snapshot_from_provider(provider: &Arc) -> ModelCatalogSnapshot { - ModelCatalogSnapshot::from_provider(provider.as_ref()) + let mut snapshot = ModelCatalogSnapshot::from_provider(provider.as_ref()); + crate::model_usage::enrich_routes(&mut snapshot.model_routes); + snapshot } pub(super) async fn available_models_updated_event(agent: &Arc>) -> ServerEvent { @@ -1175,8 +1177,9 @@ pub(super) async fn handle_notify_auth_changed( .await; } } else if let Some(model_to_select) = - crate::auth::lifecycle::provider_model_to_select_after_auth( + crate::auth::lifecycle::provider_model_to_select_after_auth_with_configured_default( &activation, + crate::config::config().provider.default_model.as_deref(), latest_snapshot.provider_model.as_deref(), &latest_snapshot.model_routes, ) @@ -1434,6 +1437,14 @@ mod tests { vec!["test-model-a".to_string(), "test-model-b".to_string()] } + fn context_window(&self) -> usize { + if self.model() == "test-model-b" { + 32_000 + } else { + 16_000 + } + } + fn reasoning_effort(&self) -> Option { self.effort.lock().expect("effort lock").clone() } @@ -1553,6 +1564,7 @@ mod tests { .await .expect("deferred model change should finish after agent is idle"); assert_eq!(provider.model(), "test-model-b"); + assert_eq!(agent.lock().await.compaction_token_budget().await, 32_000); assert!(matches!( event, Some(ServerEvent::ModelChanged { diff --git a/crates/jcode-app-core/src/server/provider_control_tests.rs b/crates/jcode-app-core/src/server/provider_control_tests.rs index 13ce8496cf..98e93ea999 100644 --- a/crates/jcode-app-core/src/server/provider_control_tests.rs +++ b/crates/jcode-app-core/src/server/provider_control_tests.rs @@ -196,6 +196,7 @@ impl Provider for AuthChangeMockProvider { api_method: api_method.clone(), available: true, detail: String::new(), + usage: None, cheapness: None, }) .collect() @@ -230,11 +231,10 @@ impl Provider for AuthChangeMockProvider { } } +/// Shared process-wide lock: env vars are global, so a private mutex here would +/// race every other env-mutating test (issue #593). fn lock_env() -> StdMutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| StdMutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + crate::storage::lock_test_env() } struct EnvGuard { @@ -563,7 +563,7 @@ async fn notify_auth_changed_with_azure_hint_applies_runtime_model_without_compl "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::env::set_var("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com"); crate::env::set_var("AZURE_OPENAI_MODEL", "azure-deployment"); @@ -660,7 +660,7 @@ fn cerebras_auth_hint_applies_openai_compatible_runtime_profile() { "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); let request = @@ -715,7 +715,7 @@ async fn notify_auth_changed_typed_cerebras_event_controls_user_visible_catalog_ "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::bus::reset_models_updated_publish_state_for_tests(); @@ -796,7 +796,7 @@ async fn notify_auth_changed_switches_from_stale_model_to_matching_provider_rout "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::bus::reset_models_updated_publish_state_for_tests(); @@ -865,7 +865,7 @@ async fn onboarding_auth_refresh_prefers_global_gpt_5_6_route_over_fable() { let _guard = EnvGuard::save(&[ "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::bus::reset_models_updated_publish_state_for_tests(); @@ -877,6 +877,7 @@ async fn onboarding_auth_refresh_prefers_global_gpt_5_6_route_over_fable() { api_method: "claude-oauth".to_string(), available: true, detail: String::new(), + usage: None, cheapness: None, }, ModelRoute { @@ -885,6 +886,7 @@ async fn onboarding_auth_refresh_prefers_global_gpt_5_6_route_over_fable() { api_method: "openai-api-key".to_string(), available: true, detail: String::new(), + usage: None, cheapness: None, }, ModelRoute { @@ -893,6 +895,7 @@ async fn onboarding_auth_refresh_prefers_global_gpt_5_6_route_over_fable() { api_method: "openai-api-key".to_string(), available: true, detail: String::new(), + usage: None, cheapness: None, }, ]); @@ -936,7 +939,7 @@ async fn notify_auth_changed_does_not_override_manual_model_selected_during_refr "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::bus::reset_models_updated_publish_state_for_tests(); @@ -1063,7 +1066,7 @@ async fn auth_model_first_prompt_e2e_state_space_is_bounded_by_selection_source( "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::bus::reset_models_updated_publish_state_for_tests(); @@ -1235,7 +1238,7 @@ async fn notify_auth_changed_switches_only_current_session_model() { "JCODE_OPENROUTER_MODEL", "JCODE_RUNTIME_PROVIDER", "JCODE_ACTIVE_PROVIDER", - "JCODE_FORCE_PROVIDER", + "JCODE_INITIAL_PROVIDER_EXPLICIT", ]); crate::bus::reset_models_updated_publish_state_for_tests(); diff --git a/crates/jcode-app-core/src/server/reload.rs b/crates/jcode-app-core/src/server/reload.rs index 74dbdda99a..5d9c22ae85 100644 --- a/crates/jcode-app-core/src/server/reload.rs +++ b/crates/jcode-app-core/src/server/reload.rs @@ -197,6 +197,17 @@ pub(super) async fn await_reload_signal( ); let mut cmd = ProcessCommand::new(&binary); cmd.arg("serve").arg("--socket").arg(socket.as_os_str()); + // Auto provider detection is dominated by credential-file probes. + // The replacement process is the same trusted daemon with the same + // environment, so carry the already-resolved, non-secret status + // snapshot across exec instead of repeating those probes while the + // socket is unavailable. Provider credentials themselves are still + // loaded normally when their runtimes are constructed or used. + if let Ok(auth_status) = + serde_json::to_string(&crate::auth::AuthStatus::check_fast()) + { + cmd.env("JCODE_RELOAD_AUTH_STATUS", auth_status); + } prepare_server_exec(&mut cmd, &socket); let err = crate::platform::replace_process(&mut cmd); crate::server::write_reload_state( diff --git a/crates/jcode-app-core/src/server/reload_state.rs b/crates/jcode-app-core/src/server/reload_state.rs index d6e81d97aa..0fef9efae4 100644 --- a/crates/jcode-app-core/src/server/reload_state.rs +++ b/crates/jcode-app-core/src/server/reload_state.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::time::Duration; -#[cfg(unix)] +#[cfg(target_os = "linux")] const RELOAD_HANDOFF_EVENT_POLL_MS: i32 = 100; pub fn reload_marker_path() -> PathBuf { @@ -95,6 +95,14 @@ pub fn publish_reload_socket_ready() { let current_pid = std::process::id(); if state.phase == ReloadPhase::Starting && state.pid == current_pid { + super::reload_trace::record_value( + &state.request_id, + "socket_ready", + serde_json::json!({ + "hash": &state.hash, + "detail": &state.detail, + }), + ); write_reload_state( &state.request_id, &state.hash, diff --git a/crates/jcode-app-core/src/server/socket.rs b/crates/jcode-app-core/src/server/socket.rs index 41ed0abe37..c5a802b621 100644 --- a/crates/jcode-app-core/src/server/socket.rs +++ b/crates/jcode-app-core/src/server/socket.rs @@ -55,10 +55,10 @@ pub async fn connect_socket(path: &std::path::Path) -> Result { match Stream::connect(path).await { Ok(stream) => Ok(stream), Err(err) if err.kind() == std::io::ErrorKind::ConnectionRefused && path.exists() => { - anyhow::bail!( + Err(anyhow::Error::new(err).context(format!( "Socket exists but refused the connection at {}. Retry, or remove it after confirming no jcode server is running.", path.display() - ) + ))) } Err(err) if err.raw_os_error() == Some(libc::EMFILE) => Err(anyhow::anyhow!( "{} ({})", @@ -70,7 +70,20 @@ pub async fn connect_socket(path: &std::path::Path) -> Result { } pub(super) async fn socket_has_live_listener(path: &std::path::Path) -> bool { - crate::transport::is_socket_path(path) && Stream::connect(path).await.is_ok() + #[cfg(windows)] + { + // `is_socket_path` performs one non-blocking named-pipe open and treats + // ERROR_PIPE_BUSY as live. Do not follow it with a second connect: the + // first probe can temporarily occupy the only published pipe instance + // before the accept loop replaces it, making that second connect wait + // forever inside the Windows ERROR_PIPE_BUSY retry loop. + crate::transport::is_socket_path(path) + } + + #[cfg(not(windows))] + { + crate::transport::is_socket_path(path) && Stream::connect(path).await.is_ok() + } } /// Reap a provably-stale socket left behind by a dead daemon. @@ -126,19 +139,11 @@ pub async fn reap_stale_socket_if_dead(path: &std::path::Path) -> bool { } #[cfg(not(unix))] -pub async fn reap_stale_socket_if_dead(path: &std::path::Path) -> bool { - if !crate::transport::is_socket_path(path) { - return false; - } - if socket_has_live_listener(path).await { - return false; - } - crate::logging::warn(&format!( - "Reaping stale jcode socket with no live listener at {}", - path.display() - )); - cleanup_socket_pair(path); - true +pub async fn reap_stale_socket_if_dead(_path: &std::path::Path) -> bool { + // Windows named pipes do not leave filesystem socket nodes behind after a + // process exits, so there is no stale artifact to reap. Probing and then + // "cleaning" the pipe only consumes a live server instance temporarily. + false } /// Return true if a live server process is listening on the socket path. @@ -322,8 +327,11 @@ pub async fn spawn_server_notify(cmd: &mut std::process::Command) -> Result Result<()> { let start = Instant::now(); while start.elapsed() < timeout { - if crate::transport::is_socket_path(path) - && let Ok(mut client) = Client::connect_with_path(path.to_path_buf()).await + if let Ok(Ok(mut client)) = tokio::time::timeout( + Duration::from_millis(250), + Client::connect_with_path(path.to_path_buf()), + ) + .await && let Ok(Ok(true)) = tokio::time::timeout(Duration::from_millis(250), client.ping()).await { @@ -338,11 +346,9 @@ pub async fn wait_for_server_ready(path: &std::path::Path, timeout: Duration) -> } async fn probe_server_ready(path: &std::path::Path, ping_timeout: Duration) -> bool { - if !crate::transport::is_socket_path(path) { - return false; - } - - let Ok(mut client) = Client::connect_with_path(path.to_path_buf()).await else { + let Ok(Ok(mut client)) = + tokio::time::timeout(ping_timeout, Client::connect_with_path(path.to_path_buf())).await + else { return false; }; diff --git a/crates/jcode-app-core/src/server/state.rs b/crates/jcode-app-core/src/server/state.rs index ea202b7ac8..827acc3770 100644 --- a/crates/jcode-app-core/src/server/state.rs +++ b/crates/jcode-app-core/src/server/state.rs @@ -481,6 +481,7 @@ pub(super) fn session_event_fanout_sender_with_fallback( pub(super) fn enqueue_soft_interrupt( queue: &SoftInterruptQueue, content: String, + images: Vec<(String, String)>, urgent: bool, source: SoftInterruptSource, ) -> bool { @@ -490,6 +491,7 @@ pub(super) fn enqueue_soft_interrupt( let pending_before = pending.len(); pending.push(SoftInterruptMessage { content, + images, urgent, source, }); @@ -563,10 +565,11 @@ impl SessionControlHandle { pub fn queue_soft_interrupt( &self, content: String, + images: Vec<(String, String)>, urgent: bool, source: SoftInterruptSource, ) -> bool { - enqueue_soft_interrupt(&self.soft_interrupt_queue, content, urgent, source) + enqueue_soft_interrupt(&self.soft_interrupt_queue, content, images, urgent, source) } pub fn clear_soft_interrupts(&self) { @@ -712,7 +715,7 @@ pub(super) async fn queue_soft_interrupt_for_session( sessions: &super::SessionAgents, ) -> bool { if let Some(queue) = queues.read().await.get(session_id).cloned() { - return enqueue_soft_interrupt(&queue, content, urgent, source); + return enqueue_soft_interrupt(&queue, content, Vec::new(), urgent, source); } let queue = { @@ -727,7 +730,7 @@ pub(super) async fn queue_soft_interrupt_for_session( if let Some(queue) = queue { register_session_interrupt_queue(queues, session_id, queue.clone()).await; - enqueue_soft_interrupt(&queue, content, urgent, source) + enqueue_soft_interrupt(&queue, content, Vec::new(), urgent, source) } else { let session_exists = { let guard = sessions.read().await; @@ -742,6 +745,7 @@ pub(super) async fn queue_soft_interrupt_for_session( session_id, SoftInterruptMessage { content, + images: Vec::new(), urgent, source, }, diff --git a/crates/jcode-app-core/src/server/swarm.rs b/crates/jcode-app-core/src/server/swarm.rs index 6397741067..fbe513a04e 100644 --- a/crates/jcode-app-core/src/server/swarm.rs +++ b/crates/jcode-app-core/src/server/swarm.rs @@ -25,10 +25,10 @@ fn status_age_secs(last_status_change: Instant) -> u64 { /// Maximum number of live members (agents) in a single swarm. Re-exported from /// `jcode_swarm_core` so the server, tools, and prompts all agree on the one -/// runaway-prevention cap for the task-graph model. There is intentionally no -/// spawn-depth limit and no per-node fan-out limit: the spawn tree may nest and -/// fan out freely until the swarm reaches this many live members, at which point -/// further spawns are refused. +/// runaway-prevention cap for the task-graph model. Normal and light swarms are +/// root-only, one-level fan-out. Deep-swarm roots may create recursive trees with +/// no depth limit, but both the configurable live-worker budget and this absolute +/// cap still apply. pub(super) use jcode_swarm_core::MAX_SWARM_MEMBERS; /// Walk the `report_back_to_session_id` chain upward from `session_id`, @@ -91,6 +91,14 @@ const DEFAULT_SWARM_TASK_STALE_AFTER_SECS: u64 = 45; const DEFAULT_SWARM_TASK_SWEEP_INTERVAL_SECS: u64 = 5; const DEFAULT_SWARM_TERMINAL_MEMBER_RETENTION_SECS: u64 = 24 * 60 * 60; const DEFAULT_SWARM_TERMINAL_MEMBER_GC_INTERVAL_SECS: u64 = 60; +/// How long terminal members stay in live SwarmStatus broadcasts. Terminal +/// members remain queryable for the full retention window above, but +/// re-sending hundreds of long-finished members to every attached client on +/// every status change dominates broadcast payloads (measured ~240 KB of +/// member JSON resident per client with ~700 mostly-stopped members). Keep +/// them in broadcasts briefly so done/failed transition notices still fire, +/// then drop them from the live fan-out. +const DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS: u64 = 15 * 60; #[derive(Default, Clone, Copy)] struct PendingSwarmStatusBroadcast { scheduled: bool, @@ -197,6 +205,22 @@ pub(super) fn swarm_terminal_member_gc_interval() -> Duration { )) } +/// How long terminal members remain included in live SwarmStatus broadcasts. +/// See [`DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS`]. +pub(super) fn swarm_status_broadcast_terminal_retention() -> Duration { + Duration::from_secs(configured_positive_u64( + "JCODE_SWARM_STATUS_BROADCAST_TERMINAL_SECS", + DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS, + )) +} + +/// Whether a member belongs in live SwarmStatus broadcasts: every live member, +/// plus terminal members whose status changed recently enough that clients may +/// still want to announce or display the transition. +pub(super) fn member_in_status_broadcast(member: &SwarmMember, retention: Duration) -> bool { + !member_status_is_terminal(&member.status) || member.last_status_change.elapsed() < retention +} + /// Terminal members are historical records, not live agents. They remain /// visible temporarily for reports and diagnostics but must not consume the /// runaway-prevention spawn budget. @@ -230,6 +254,42 @@ pub(super) fn member_status_is_dead(status: &str) -> bool { matches!(status, "failed" | "stopped" | "crashed") } +/// How long a finished spawned worker may sit idle before the server reaps it +/// (closes its client and removes the member). `0` disables reaping. +/// +/// Spawned workers (visible windows and headless sessions) rely on their +/// coordinator calling `cleanup`, but ad hoc spawns and interrupted plans +/// leave them behind, where each idle client holds ~80-150 MB indefinitely. +/// The reaper is the backstop that keeps them from stacking up. +const DEFAULT_SWARM_IDLE_WORKER_REAP_SECS: u64 = 30 * 60; + +pub(super) fn swarm_idle_worker_reap_after() -> Option { + let secs = std::env::var("JCODE_SWARM_IDLE_WORKER_REAP_SECS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(DEFAULT_SWARM_IDLE_WORKER_REAP_SECS); + (secs > 0).then(|| Duration::from_secs(secs)) +} + +/// Spawned workers whose work is finished (`ready` report-back or a terminal +/// status) and whose status has not changed for at least `idle_after`. +/// Only sessions spawned by another agent (`report_back_to_session_id` set) +/// and not holding the coordinator role are eligible; user-created sessions +/// are never reaped. +pub(super) fn idle_spawned_worker_reap_candidates( + members: &HashMap, + idle_after: Duration, +) -> Vec { + members + .values() + .filter(|member| member.report_back_to_session_id.is_some()) + .filter(|member| member.role != "coordinator") + .filter(|member| member.status == "ready" || member_status_is_terminal(&member.status)) + .filter(|member| member.last_status_change.elapsed() >= idle_after) + .map(|member| member.session_id.clone()) + .collect() +} + /// Outcome of salvaging one dead member's plan assignments. #[derive(Debug, Default, PartialEq, Eq)] pub(super) struct DeadMemberSalvage { @@ -631,11 +691,13 @@ async fn broadcast_swarm_status_now( } let members_guard = swarm_members.read().await; + let broadcast_terminal_retention = swarm_status_broadcast_terminal_retention(); let members_list: Vec = session_ids .iter() .filter_map(|sid| { members_guard .get(sid) + .filter(|m| member_in_status_broadcast(m, broadcast_terminal_retention)) .map(|m| crate::protocol::SwarmMemberStatus { session_id: m.session_id.clone(), friendly_name: m.friendly_name.clone(), @@ -915,14 +977,7 @@ pub(super) async fn rename_plan_participant( ) { let mut plans = swarm_plans.write().await; if let Some(vp) = plans.get_mut(swarm_id) { - if vp.participants.remove(old_session_id) { - vp.participants.insert(new_session_id.to_string()); - } - for item in &mut vp.items { - if item.assigned_to.as_deref() == Some(old_session_id) { - item.assigned_to = Some(new_session_id.to_string()); - } - } + vp.rename_session(old_session_id, new_session_id); } } @@ -1672,10 +1727,11 @@ fn parse_swarm_tasks(text: &str) -> Vec { mod tests { use super::{ broadcast_swarm_plan, broadcast_swarm_plan_with_previous, broadcast_swarm_status, - member_status_is_dead, now_unix_ms, parse_swarm_tasks, refresh_swarm_task_staleness, - remove_session_from_swarm, salvage_assignments_of_dead_member, swarm_ancestors, - swarm_is_self_or_ancestor, swarm_spawn_depth, touch_swarm_task_progress, - update_member_status, update_member_status_with_report, + member_in_status_broadcast, member_status_is_dead, now_unix_ms, parse_swarm_tasks, + refresh_swarm_task_staleness, remove_session_from_swarm, + salvage_assignments_of_dead_member, swarm_ancestors, swarm_is_self_or_ancestor, + swarm_spawn_depth, touch_swarm_task_progress, update_member_status, + update_member_status_with_report, }; use crate::plan::PlanItem; use crate::protocol::{NotificationType, ServerEvent}; @@ -1785,6 +1841,107 @@ mod tests { member } + #[test] + fn idle_spawned_worker_reap_selects_only_finished_idle_spawned_agents() { + use super::idle_spawned_worker_reap_candidates; + + let idle_after = Duration::from_secs(60); + let old = Instant::now() - Duration::from_secs(120); + + // Finished spawned worker, idle past the window: reapable. + let mut reapable = member_with_parent("reapable", Some("coord")); + reapable.status = "ready".to_string(); + reapable.last_status_change = old; + + // Terminal-status spawned worker: reapable. + let mut stopped = member_with_parent("stopped", Some("coord")); + stopped.status = "completed".to_string(); + stopped.last_status_change = old; + + // Same shape but user-created (no spawner): never reaped. + let mut user_owned = member_with_parent("user-owned", None); + user_owned.status = "ready".to_string(); + user_owned.last_status_change = old; + + // Spawned but still running: not reaped. + let mut running = member_with_parent("running", Some("coord")); + running.status = "running".to_string(); + running.last_status_change = old; + + // Spawned and finished, but recently: not reaped yet. + let mut fresh = member_with_parent("fresh", Some("coord")); + fresh.status = "ready".to_string(); + + // Spawned coordinator (sub-swarm manager): never reaped by role. + let mut sub_coordinator = member_with_parent("sub-coord", Some("coord")); + sub_coordinator.role = "coordinator".to_string(); + sub_coordinator.status = "ready".to_string(); + sub_coordinator.last_status_change = old; + + let members: HashMap = [ + reapable, + stopped, + user_owned, + running, + fresh, + sub_coordinator, + ] + .into_iter() + .map(|member| (member.session_id.clone(), member)) + .collect(); + + let mut candidates = idle_spawned_worker_reap_candidates(&members, idle_after); + candidates.sort(); + assert_eq!( + candidates, + vec!["reapable".to_string(), "stopped".to_string()] + ); + } + + #[test] + fn idle_worker_reap_window_env_zero_disables() { + // Note: mutating the process env in tests is racy in general, but this + // env var is read on every call (not cached), and no other test touches + // it. + unsafe { + std::env::set_var("JCODE_SWARM_IDLE_WORKER_REAP_SECS", "0"); + } + assert_eq!(super::swarm_idle_worker_reap_after(), None); + unsafe { + std::env::set_var("JCODE_SWARM_IDLE_WORKER_REAP_SECS", "90"); + } + assert_eq!( + super::swarm_idle_worker_reap_after(), + Some(Duration::from_secs(90)) + ); + unsafe { + std::env::remove_var("JCODE_SWARM_IDLE_WORKER_REAP_SECS"); + } + assert!(super::swarm_idle_worker_reap_after().is_some()); + } + + #[test] + fn status_broadcast_keeps_live_and_recently_terminal_members_only() { + let retention = Duration::from_secs(900); + + let (live, _rx) = swarm_member("live", "agent", false); + assert!(member_in_status_broadcast(&live, retention)); + + let (mut fresh_terminal, _rx) = swarm_member("fresh", "agent", false); + fresh_terminal.status = "completed".to_string(); + assert!(member_in_status_broadcast(&fresh_terminal, retention)); + + let (mut stale_terminal, _rx) = swarm_member("stale", "agent", false); + stale_terminal.status = "stopped".to_string(); + stale_terminal.last_status_change = Instant::now() - Duration::from_secs(901); + assert!(!member_in_status_broadcast(&stale_terminal, retention)); + + // A stale *live* status is never filtered, no matter how old. + let (mut old_live, _rx) = swarm_member("old-live", "agent", false); + old_live.last_status_change = Instant::now() - Duration::from_secs(100_000); + assert!(member_in_status_broadcast(&old_live, retention)); + } + #[test] fn swarm_depth_and_ancestry_follow_report_back_chain() { let mut members: HashMap = HashMap::new(); diff --git a/crates/jcode-app-core/src/server/swarm_persistence.rs b/crates/jcode-app-core/src/server/swarm_persistence.rs index 83d7302ca1..0d09383bd8 100644 --- a/crates/jcode-app-core/src/server/swarm_persistence.rs +++ b/crates/jcode-app-core/src/server/swarm_persistence.rs @@ -12,6 +12,10 @@ use tokio::sync::mpsc; const SWARM_STATE_DIR: &str = "swarm"; /// Pre-0.36 location under the runtime dir (tmpfs on Linux, wiped on reboot). const LEGACY_SWARM_STATE_DIR: &str = "jcode-swarm-state"; +/// Dormant plans are durable for recovery, but not immortal. A plan with no +/// active/assigned work that has not had *any* swarm snapshot activity for a +/// week is stale coordination state and is removed during startup loading. +const DEFAULT_DORMANT_PLAN_RETENTION_SECS: u64 = 7 * 24 * 60 * 60; /// Serialize each swarm's complete snapshot/read/write operation. Callers must /// acquire this before reading the independently locked in-memory maps so an @@ -53,6 +57,38 @@ fn swarm_file_lock(swarm_id: &str) -> Arc> { lock } +fn dormant_plan_retention() -> Duration { + Duration::from_secs( + std::env::var("JCODE_SWARM_DORMANT_PLAN_RETENTION_SECS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_DORMANT_PLAN_RETENTION_SECS), + ) +} + +fn persisted_plan_is_dormant(plan: &PersistedVersionedPlan) -> bool { + plan.items.is_empty() + || plan + .items + .iter() + .all(|item| item.assigned_to.is_none() && !jcode_plan::is_active_status(&item.status)) +} + +fn persisted_plan_is_expired( + plan: &PersistedVersionedPlan, + snapshot_updated_at_unix_ms: u64, + loaded_at_unix_ms: u64, + retention: Duration, +) -> bool { + if plan.items.is_empty() { + return true; + } + persisted_plan_is_dormant(plan) + && loaded_at_unix_ms.saturating_sub(snapshot_updated_at_unix_ms) + >= retention.as_millis() as u64 +} + pub(super) struct LoadedSwarmRuntimeState { pub plans: HashMap, pub coordinators: HashMap, @@ -111,10 +147,28 @@ fn now_unix_ms() -> u64 { .as_millis() as u64 } +#[cfg(not(test))] fn state_dir() -> PathBuf { storage::durable_state_dir().join(SWARM_STATE_DIR) } +/// Unit tests that exercise high-level swarm mutation helpers do not all set +/// `JCODE_RUNTIME_DIR`. Never let those tests fall through to the real +/// `~/.jcode/state/swarm`: that leaked synthetic `swarm-1` plans/members into +/// live user state during ordinary `cargo test` runs. Tests that need an +/// isolated explicit location still set `JCODE_RUNTIME_DIR` and use the normal +/// resolver; otherwise use a process-local temp directory. +#[cfg(test)] +fn state_dir() -> PathBuf { + if std::env::var_os("JCODE_RUNTIME_DIR").is_some() { + storage::durable_state_dir().join(SWARM_STATE_DIR) + } else { + std::env::temp_dir() + .join(format!("jcode-test-state-{}", std::process::id())) + .join(SWARM_STATE_DIR) + } +} + fn legacy_state_dir() -> PathBuf { storage::runtime_dir().join(LEGACY_SWARM_STATE_DIR) } @@ -241,14 +295,16 @@ fn from_persisted_plan(mut plan: PersistedVersionedPlan, updated_at_unix_ms: u64 .get_or_insert(updated_at_unix_ms); } } - VersionedPlan { + let mut plan = VersionedPlan { items: plan.items, version: plan.version, participants: plan.participants.into_iter().collect(), task_progress: plan.task_progress, mode: plan.mode, node_meta: plan.node_meta, - } + }; + plan.prune_side_maps(); + plan } fn to_persisted_plan(plan: &VersionedPlan) -> PersistedVersionedPlan { @@ -294,14 +350,22 @@ fn recover_member_status( ); } - // An idle headless worker has no process to drive it after a server restart. - // Keep its completion report, but mark it stopped instead of eagerly loading - // its full session history and tool registry forever. Coordinators can spawn - // a fresh worker when more work arrives. - if is_headless && status == SwarmLifecycleStatus::Ready { + // No client or headless process survives a server restart. A connected TUI + // will explicitly mark its member ready again during subscribe; until that + // happens, restoring a persisted `ready` member as live creates a ghost + // that can never enter terminal-member GC. This previously resurrected + // hundreds of detached historical clients as ready on every reload. + if status == SwarmLifecycleStatus::Ready { return ( SwarmLifecycleStatus::Stopped, - append_recovery_detail(detail, "idle worker not restored after server restart"), + append_recovery_detail( + detail, + if is_headless { + "idle worker not restored after server restart" + } else { + "client not attached after server restart" + }, + ), ); } @@ -394,8 +458,11 @@ pub(super) fn load_runtime_state() -> LoadedSwarmRuntimeState { let mut swarms_by_id = HashMap::new(); let loaded_at_unix_ms = now_unix_ms(); let terminal_retention = super::swarm::swarm_terminal_member_retention(); + let plan_retention = dormant_plan_retention(); let mut pruned_terminal_members = 0usize; + let mut pruned_dormant_plans = 0usize; let mut pruned_members_by_swarm: HashMap> = HashMap::new(); + let mut pruned_plan_swarms: HashSet = HashSet::new(); for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { @@ -419,10 +486,20 @@ pub(super) fn load_runtime_state() -> LoadedSwarmRuntimeState { }; let swarm_id = state.swarm_id.clone(); if let Some(plan) = state.plan { - plans.insert( - swarm_id.clone(), - from_persisted_plan(plan, state.updated_at_unix_ms), - ); + if persisted_plan_is_expired( + &plan, + state.updated_at_unix_ms, + loaded_at_unix_ms, + plan_retention, + ) { + pruned_dormant_plans += 1; + pruned_plan_swarms.insert(swarm_id.clone()); + } else { + plans.insert( + swarm_id.clone(), + from_persisted_plan(plan, state.updated_at_unix_ms), + ); + } } if let Some(coordinator_session_id) = state.coordinator_session_id { coordinators.insert(swarm_id, coordinator_session_id); @@ -466,7 +543,12 @@ pub(super) fn load_runtime_state() -> LoadedSwarmRuntimeState { // Rewrite every affected snapshot once so startup collection shrinks the // durable state too. Without this, the same expired records would be parsed // and discarded on every restart forever. - for swarm_id in pruned_members_by_swarm.keys() { + let rewritten_swarms: HashSet = pruned_members_by_swarm + .keys() + .chain(pruned_plan_swarms.iter()) + .cloned() + .collect(); + for swarm_id in &rewritten_swarms { let retained_members = swarms_by_id .get(swarm_id) .into_iter() @@ -485,6 +567,11 @@ pub(super) fn load_runtime_state() -> LoadedSwarmRuntimeState { "Pruned {pruned_terminal_members} expired terminal swarm member(s) while loading durable state" )); } + if pruned_dormant_plans > 0 { + crate::logging::info(&format!( + "Pruned {pruned_dormant_plans} expired dormant swarm plan(s) while loading durable state" + )); + } LoadedSwarmRuntimeState { plans, coordinators, diff --git a/crates/jcode-app-core/src/server/swarm_persistence_tests.rs b/crates/jcode-app-core/src/server/swarm_persistence_tests.rs index 278271afe8..dfefe247e8 100644 --- a/crates/jcode-app-core/src/server/swarm_persistence_tests.rs +++ b/crates/jcode-app-core/src/server/swarm_persistence_tests.rs @@ -187,6 +187,95 @@ fn ready_headless_member_with_report_stops_without_losing_report() { ); } +#[test] +fn ready_detached_client_stops_on_reload_until_it_reattaches() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let members = vec![SwarmMember { + session_id: "session-detached".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: Some(PathBuf::from("/tmp/swarm-client")), + swarm_id: Some("swarm-client".to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("finch".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }]; + + persist_swarm_state("swarm-client", None, None, &members); + let loaded = load_runtime_state(); + let recovered = loaded.members.get("session-detached").expect("member"); + assert_eq!(recovered.status, "stopped"); + assert_eq!( + recovered.detail.as_deref(), + Some("client not attached after server restart") + ); +} + +#[test] +fn dormant_plan_expiry_preserves_active_work_and_prunes_old_unassigned_graphs() { + let item = |status: &str, assigned_to: Option<&str>| crate::plan::PlanItem { + content: "task".to_string(), + status: status.to_string(), + priority: "medium".to_string(), + id: format!("{status}-{}", assigned_to.unwrap_or("none")), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: assigned_to.map(str::to_string), + }; + let plan = |items| PersistedVersionedPlan { + items, + version: 1, + participants: Vec::new(), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }; + let now = 10_000_000u64; + let retention = Duration::from_secs(60); + let old = now - retention.as_millis() as u64; + + assert!(persisted_plan_is_expired( + &plan(Vec::new()), + now, + now, + retention + )); + assert!(persisted_plan_is_expired( + &plan(vec![item("queued", None), item("completed", None)]), + old, + now, + retention + )); + assert!(!persisted_plan_is_expired( + &plan(vec![item("running", Some("worker"))]), + old, + now, + retention + )); + assert!(!persisted_plan_is_expired( + &plan(vec![item("queued", None)]), + now, + now, + retention + )); +} + #[test] fn terminal_member_retention_preserves_recent_reports_and_prunes_expired_records() { let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -691,6 +780,16 @@ fn legacy_snapshot_without_mode_defaults_to_light() { let _env = test_env(&dir); // Simulate a pre-deep-mode snapshot on disk: no `mode`, no `node_meta`. + // + // The snapshot must look *recent*, not epoch-old. Dormant-plan pruning + // (33cd27330) drops a queued-only plan whose `updated_at_unix_ms` is older + // than the retention window, so a hardcoded timestamp of `1` would be + // garbage collected before the mode default could be observed. This test is + // about legacy field defaulting, not retention, so keep it fresh. + let updated_at_unix_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after unix epoch") + .as_millis() as u64; let legacy = serde_json::json!({ "swarm_id": "swarm-legacy", "plan": { @@ -703,7 +802,7 @@ fn legacy_snapshot_without_mode_defaults_to_light() { "version": 2, "participants": ["session-1"] }, - "updated_at_unix_ms": 1u64 + "updated_at_unix_ms": updated_at_unix_ms }); std::fs::create_dir_all(state_dir()).expect("state dir"); std::fs::write( diff --git a/crates/jcode-app-core/src/server/tests.rs b/crates/jcode-app-core/src/server/tests.rs index ed6be1e2c9..044c3244ca 100644 --- a/crates/jcode-app-core/src/server/tests.rs +++ b/crates/jcode-app-core/src/server/tests.rs @@ -2,7 +2,7 @@ use super::{ FileAccess, Server, SessionInterruptQueues, SwarmMember, dispatch_background_task_completion, - file_activity_scope_label, persist_swarm_state_snapshot, + file_activity_scope_label, persist_swarm_state_snapshot, remove_session_entry, }; use crate::agent::Agent; use crate::bus::{ @@ -77,6 +77,30 @@ fn file_activity_scope_label_classifies_overlap() { assert_eq!(file_activity_scope_label(&previous, ¤t), "same file"); } +#[tokio::test] +async fn removing_server_session_clears_active_pid_marker() { + let _guard = crate::storage::lock_test_env(); + let home = tempfile::tempdir().expect("create temporary JCODE_HOME"); + let _home_guard = ScopedEnvVar::set("JCODE_HOME", home.path()); + let session_id = "session_marker_cleanup_test"; + crate::storage::register_active_pid(session_id, std::process::id()); + + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.to_string(), + "agent", + )]))); + let removed = remove_session_entry(&sessions, session_id).await; + + assert_eq!(removed, Some("agent")); + assert!(!sessions.read().await.contains_key(session_id)); + assert!( + !crate::storage::active_session_ids() + .iter() + .any(|id| id == session_id), + "removing a live server session must also remove its presence marker" + ); +} + #[test] fn configured_server_name_normalizes_operator_labels() { assert_eq!( @@ -395,6 +419,189 @@ async fn background_task_wake_runs_live_session_immediately_when_idle() { })); } +#[tokio::test] +async fn external_background_task_wake_emits_request_without_starting_turn() { + let _env_lock = crate::storage::lock_test_env(); + let _wake_mode = ScopedEnvVar::set("JCODE_WAKE_MODE", "external"); + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("must not run".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider_dyn: Arc = provider; + let agent = test_agent(provider_dyn).await; + let session_id = agent.lock().await.session_id().to_string(); + let initial_message_count = agent.lock().await.messages().len(); + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + attached_swarm_member(&session_id, member_event_tx), + )]))); + let task = BackgroundTaskCompleted { + task_id: "external-wake".to_string(), + tool_name: "bash".to_string(), + display_name: None, + session_id: session_id.clone(), + status: BackgroundTaskStatus::Completed, + exit_code: Some(0), + output_preview: "done\n".to_string(), + output_file: std::env::temp_dir().join("external-wake.output"), + duration_secs: 0.1, + notify: false, + wake: true, + }; + let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state(); + + dispatch_background_task_completion( + &task, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + + let event = timeout(Duration::from_secs(2), member_event_rx.recv()) + .await + .expect("external wake request should arrive promptly") + .expect("member event stream should remain open"); + match event { + ServerEvent::WakeRequested { + session_id: event_session_id, + reason, + notification, + } => { + assert_eq!(event_session_id, session_id); + assert_eq!(reason, "background_task_completed"); + assert!(notification.contains("**Background task** `external-wake`")); + } + other => panic!("unexpected external wake event: {other:?}"), + } + + assert!( + timeout(Duration::from_millis(100), member_event_rx.recv()) + .await + .is_err(), + "external mode must not stream an autonomous model turn" + ); + assert_eq!(agent.lock().await.messages().len(), initial_message_count); + assert!(soft_interrupt_queues.read().await.is_empty()); +} + +#[tokio::test] +async fn idle_live_agent_reservation_blocks_a_second_wake_until_released() { + // Regression for #1152: the idle check used to drop its try_lock guard + // before the turn started, so two concurrent wakes could both succeed. + let provider: Arc = Arc::new(StreamingMockProvider::default()); + let agent = test_agent(provider).await; + let session_id = agent.lock().await.session_id().to_string(); + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let (member_event_tx, _member_event_rx) = mpsc::unbounded_channel(); + let member = attached_swarm_member(&session_id, member_event_tx); + let swarm_members = Arc::new(RwLock::new(HashMap::from([(session_id.clone(), member)]))); + + let first = super::live_turn::idle_live_agent(&session_id, &sessions, &swarm_members).await; + assert!(first.is_some(), "idle live session should be reservable"); + + let second = super::live_turn::idle_live_agent(&session_id, &sessions, &swarm_members).await; + assert!( + second.is_none(), + "second reservation must fail while the first guard is alive" + ); + + drop(first); + let third = super::live_turn::idle_live_agent(&session_id, &sessions, &swarm_members).await; + assert!( + third.is_some(), + "reservation is available again once released" + ); +} + +#[tokio::test] +async fn wake_turn_holds_reservation_until_terminal_status_is_published() { + // Greptile review on #1166: releasing the guard before the terminal status + // write let a newer wake's `running` be overwritten by this turn's `ready`. + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("done".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider_dyn: Arc = provider.clone(); + let agent = test_agent(provider_dyn).await; + let session_id = agent.lock().await.session_id().to_string(); + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel(); + let member = attached_swarm_member(&session_id, member_event_tx); + let swarm_members = Arc::new(RwLock::new(HashMap::from([(session_id.clone(), member)]))); + let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state(); + let ctx = super::live_turn::LiveTurnSwarmContext::new( + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ); + + let started = super::live_turn::run_live_turn_if_idle( + &session_id, + "first wake", + None, + &sessions, + ctx.clone(), + ) + .await; + assert!(started); + + // Wait for the terminal Done fanout. + timeout(Duration::from_secs(2), async { + loop { + match member_event_rx.recv().await { + Some(ServerEvent::Done { .. }) => return, + Some(_) => continue, + None => panic!("member stream closed"), + } + } + }) + .await + .expect("wake turn should finish"); + + // Whenever a second reservation succeeds, the first turn must already have + // published its terminal status: the guard outlives the status update. + let reacquired = timeout(Duration::from_secs(2), async { + loop { + if let Some(guard) = + super::live_turn::idle_live_agent(&session_id, &sessions, &swarm_members).await + { + return guard; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("reservation should be released after the turn"); + let status = swarm_members + .read() + .await + .get(&session_id) + .map(|m| m.status.clone()); + assert_eq!(status.as_deref(), Some("ready")); + drop(reacquired); +} + #[tokio::test] async fn wake_turn_tracks_member_status_and_emits_terminal_done() { let provider = Arc::new(StreamingMockProvider::default()); diff --git a/crates/jcode-app-core/src/server/util.rs b/crates/jcode-app-core/src/server/util.rs index 9d17bfaf15..935c6a9c40 100644 --- a/crates/jcode-app-core/src/server/util.rs +++ b/crates/jcode-app-core/src/server/util.rs @@ -4,8 +4,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::OnceCell; -/// Default embedding idle unload threshold (15 minutes). -const EMBEDDING_IDLE_UNLOAD_DEFAULT_SECS: u64 = 15 * 60; +/// Default embedding idle unload threshold. The local MiniLM runtime adds about +/// 150 MiB of resident memory after its first query, while loading it takes only +/// about 200 ms and memory retrieval runs off the interactive turn path. Keep it +/// warm for short bursts, but do not pin it through long quiet periods. +const EMBEDDING_IDLE_UNLOAD_DEFAULT_SECS: u64 = 60; pub(crate) fn debug_control_allowed() -> bool { // Check config file setting @@ -29,13 +32,33 @@ pub(crate) fn debug_control_allowed() -> bool { } pub(crate) fn embedding_idle_unload_secs() -> u64 { - std::env::var("JCODE_EMBEDDING_IDLE_UNLOAD_SECS") - .ok() + parse_embedding_idle_unload_secs( + std::env::var("JCODE_EMBEDDING_IDLE_UNLOAD_SECS") + .ok() + .as_deref(), + ) +} + +fn parse_embedding_idle_unload_secs(value: Option<&str>) -> u64 { + value .and_then(|v| v.parse::().ok()) .filter(|v| *v > 0) .unwrap_or(EMBEDDING_IDLE_UNLOAD_DEFAULT_SECS) } +#[cfg(test)] +mod embedding_idle_tests { + use super::*; + + #[test] + fn idle_unload_defaults_to_one_minute_and_accepts_positive_override() { + assert_eq!(parse_embedding_idle_unload_secs(None), 60); + assert_eq!(parse_embedding_idle_unload_secs(Some("15")), 15); + assert_eq!(parse_embedding_idle_unload_secs(Some("0")), 60); + assert_eq!(parse_embedding_idle_unload_secs(Some("invalid")), 60); + } +} + pub(crate) async fn get_shared_mcp_pool( cell: &OnceCell>, ) -> Arc { @@ -317,6 +340,56 @@ pub(crate) fn swarm_id_for_dir(dir: Option) -> Option { Some(dir.to_string_lossy().to_string()) } +/// Return the swarm identity for an independently-created root session. +/// +/// Swarm plans are keyed by swarm id. Deriving that id from the working +/// directory made every session opened in one repository share one plan, even +/// when those sessions were unrelated. Root sessions therefore own a swarm by +/// default. `JCODE_SWARM_ID` remains an explicit opt-in to a shared swarm. +pub(crate) fn swarm_id_for_session(session_id: &str) -> Option { + if let Ok(sw_id) = std::env::var("JCODE_SWARM_ID") { + let trimmed = sw_id.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + default_swarm_id_for_session(session_id) +} + +fn default_swarm_id_for_session(session_id: &str) -> Option { + if session_id.trim().is_empty() { + None + } else { + Some(format!("session:{session_id}")) + } +} + +#[cfg(test)] +mod swarm_identity_tests { + use super::default_swarm_id_for_session; + + #[test] + fn independent_root_sessions_have_distinct_swarm_ids() { + assert_eq!( + default_swarm_id_for_session("session-one").as_deref(), + Some("session:session-one") + ); + assert_eq!( + default_swarm_id_for_session("session-two").as_deref(), + Some("session:session-two") + ); + assert_ne!( + default_swarm_id_for_session("session-one"), + default_swarm_id_for_session("session-two") + ); + } + + #[test] + fn empty_session_cannot_own_a_swarm() { + assert_eq!(default_swarm_id_for_session(" "), None); + } +} + /// Decide whether any reload candidate is *provably* newer than the running /// server binary. /// diff --git a/crates/jcode-app-core/src/session_rebuild.rs b/crates/jcode-app-core/src/session_rebuild.rs index 37e4ea7d65..4a47834954 100644 --- a/crates/jcode-app-core/src/session_rebuild.rs +++ b/crates/jcode-app-core/src/session_rebuild.rs @@ -51,7 +51,7 @@ fn run_release_tests(repo_dir: &Path) -> Result<()> { let status = run_cargo_release_step(repo_dir, &["test", "--release", "--", "--test-threads=1"])?; if !status.success() { - eprintln!("\n⚠️ Tests failed! Aborting reload to protect your session."); + crate::terminal_eprintln!("\n⚠️ Tests failed! Aborting reload to protect your session."); eprintln!("Fix the failing tests and try /rebuild again."); anyhow::bail!("Tests failed - staying on current version"); } diff --git a/crates/jcode-app-core/src/telemetry_state.rs b/crates/jcode-app-core/src/telemetry_state.rs index a4ff50b85c..038b9551e6 100644 --- a/crates/jcode-app-core/src/telemetry_state.rs +++ b/crates/jcode-app-core/src/telemetry_state.rs @@ -279,6 +279,9 @@ pub(super) fn build_channel() -> String { if crate::build::get_repo_dir().is_some() { return "git_checkout".to_string(); } + if option_env!("JCODE_CI_BUILD").is_some() { + return "ci_release".to_string(); + } "release".to_string() } @@ -287,6 +290,13 @@ pub(super) fn is_git_checkout() -> bool { } pub(super) fn is_ci() -> bool { + if let Ok(value) = std::env::var("JCODE_CI") { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => return true, + "0" | "false" | "no" | "off" => return false, + _ => {} + } + } [ "CI", "GITHUB_ACTIONS", diff --git a/crates/jcode-app-core/src/telemetry_tests.rs b/crates/jcode-app-core/src/telemetry_tests.rs index e9433842d2..1480dd4ec5 100644 --- a/crates/jcode-app-core/src/telemetry_tests.rs +++ b/crates/jcode-app-core/src/telemetry_tests.rs @@ -1,13 +1,11 @@ use super::*; use crate::storage::lock_test_env; -use std::sync::{Mutex, OnceLock}; +/// Shared process-wide lock: telemetry state is reached through env vars, which +/// are global, so a private mutex here would race every other env-mutating test +/// (issue #593). fn lock_telemetry_test_state() -> std::sync::MutexGuard<'static, ()> { - static TELEMETRY_TEST_LOCK: OnceLock> = OnceLock::new(); - TELEMETRY_TEST_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + lock_test_env() } #[test] @@ -171,7 +169,7 @@ fn test_session_end_event_serialization() { tool_cat_other: 0, tool_cat_todo: 2, todo_gate_ownership_count: 1, - todo_gate_hill_count: 1, + todo_gate_feedback_loop_count: 1, todo_gate_completion_count: 0, todo_gate_spike_count: 0, command_login_used: false, @@ -237,7 +235,7 @@ fn test_session_end_event_serialization() { assert_eq!(json["tool_cat_todo"], 2); assert_eq!(json["feature_todo_used"], true); assert_eq!(json["todo_gate_ownership_count"], 1); - assert_eq!(json["todo_gate_hill_count"], 1); + assert_eq!(json["todo_gate_feedback_loop_count"], 1); assert_eq!(json["todo_gate_completion_count"], 0); assert_eq!(json["todo_gate_spike_count"], 0); assert_eq!(json["workflow_coding_used"], true); diff --git a/crates/jcode-app-core/src/tool/agentgrep.rs b/crates/jcode-app-core/src/tool/agentgrep.rs index 4100ddf565..45fa70793f 100644 --- a/crates/jcode-app-core/src/tool/agentgrep.rs +++ b/crates/jcode-app-core/src/tool/agentgrep.rs @@ -18,6 +18,9 @@ use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; +use std::time::Duration; + +const AGENTGREP_FOREGROUND_BUDGET: Duration = Duration::from_secs(5); mod args; mod context; @@ -76,6 +79,14 @@ struct AgentGrepInput { paths_only: Option, } +/// Default cap on rendered grep matches. +/// +/// Generous enough that ordinary code searches are unaffected (most return far +/// fewer), while bounding the pathological case of a common string inside large +/// data files. The match header always reports the true total, so a caller who +/// needs more can raise `max_regions` knowing what they are asking for. +const DEFAULT_GREP_MAX_REGIONS: usize = 200; + fn default_agentgrep_mode() -> String { "grep".to_string() } @@ -188,32 +199,32 @@ impl Tool for AgentGrepTool { "mode": { "type": "string", "enum": ["grep", "find", "outline", "trace"], - "description": "Optional search mode. Defaults to grep. Use grep for normal code/text search, find for file-name/path search, outline to summarize one file, and trace for DSL-based relationship search." + "description": "Mode: grep (default), find (file names), outline (one file), trace (relationship DSL)." }, "query": { "type": "string", - "description": "Search query. Required for grep. For find, provide query terms to rank matching file paths, or omit query when path, glob, or type already narrows the file list. Grep treats query as literal text unless regex=true." + "description": "Search query. Required for grep (literal unless regex=true); optional ranking terms for find." }, "file": { "type": "string", - "description": "Single file to inspect. Required for outline. For grep/find of a single file, path may also point directly to the file." + "description": "Single file to inspect. Required for outline." }, "terms": { "type": "array", "items": {"type": "string"}, - "description": "Trace DSL terms, for example [\"subject:auth_status\", \"relation:rendered\", \"support:ui\"]. Do not use this for normal grep/find searches; use query instead." + "description": "Trace DSL terms, e.g. [\"subject:auth_status\", \"relation:rendered\"]. Not for grep/find; use query." }, "regex": { "type": "boolean", - "description": "When true in grep mode, interpret query as a regular expression. Defaults to false, which is safer for literal searches." + "description": "In grep mode, treat query as a regex. Defaults to false (literal)." }, "path": { "type": "string", - "description": "Directory or file to search, relative to the workspace unless absolute. If this is a file, agentgrep searches only that file. Omit to search the workspace." + "description": "Directory or file to search, relative to the workspace. Omit to search the whole workspace." }, "glob": { "type": "string", - "description": "Optional file glob filter such as **/*.rs. Do not set glob to **/* just to search everything; omit it instead." + "description": "Optional file glob filter such as **/*.rs. Omit to search everything." }, "type": { "type": "string", @@ -237,6 +248,8 @@ impl Tool for AgentGrepTool { async fn execute(&self, input: Value, ctx: ToolContext) -> Result { let params: AgentGrepInput = serde_json::from_value(input)?; + let display_name = summarize_background_search(¶ms); + let session_id = ctx.session_id.clone(); // The search shells out to ripgrep and walks/reads files (and for // trace/outline modes also loads the session and reads more files), // all of which is blocking work with no async yield points. Offload it @@ -246,12 +259,79 @@ impl Tool for AgentGrepTool { // the first cold-cache search feel like it "takes forever" with no // spinner and an unresponsive interrupt. This mirrors how the sibling // grep/glob/ls tools offload their work. - tokio::task::spawn_blocking(move || run_agentgrep_blocking(¶ms, &ctx)) - .await - .map_err(|err| anyhow::anyhow!("agentgrep task failed to join: {err}"))? + let work_handle = + tokio::task::spawn_blocking(move || run_agentgrep_blocking(¶ms, &ctx)); + await_or_background_search( + work_handle, + AGENTGREP_FOREGROUND_BUDGET, + display_name, + session_id, + ) + .await } } +async fn await_or_background_search( + mut work_handle: tokio::task::JoinHandle>, + foreground_budget: Duration, + display_name: String, + session_id: String, +) -> Result { + match tokio::time::timeout(foreground_budget, &mut work_handle).await { + Ok(joined) => { + joined.map_err(|err| anyhow::anyhow!("agentgrep task failed to join: {err}"))? + } + Err(_) => { + let info = crate::background::global() + .adopt_with_options( + "agentgrep", + Some(display_name.clone()), + &session_id, + true, + false, + work_handle, + ) + .await; + Ok(ToolOutput::new(format!( + "Search is still running after 5s and is continuing in background.\n\n\ + Task ID: {}\n\ + Name: {}\n\n\ + Use `bg` with action=\"wait\" and task_id=\"{}\" to wait for completion, or action=\"output\" to inspect its output.", + info.task_id, display_name, info.task_id, + )) + .with_title(display_name.clone()) + .with_metadata(json!({ + "background": true, + "task_id": info.task_id, + "display_name": display_name, + "output_file": info.output_file.to_string_lossy(), + "status_file": info.status_file.to_string_lossy(), + "timeout_promoted": true, + "foreground_timeout_ms": foreground_budget.as_millis(), + }))) + } + } +} + +fn summarize_background_search(params: &AgentGrepInput) -> String { + let subject = params + .query + .as_deref() + .or(params.file.as_deref()) + .or_else(|| { + params + .terms + .as_ref() + .and_then(|terms| terms.first().map(String::as_str)) + }) + .unwrap_or("workspace"); + format!( + "agentgrep {}: {}", + params.mode, + util::truncate_str(subject, 80) + ) +} + fn run_agentgrep_blocking(params: &AgentGrepInput, ctx: &ToolContext) -> Result { if ctx.working_dir.is_none() { let explicit_path = params.path.as_deref().or(params.file.as_deref()); @@ -312,8 +392,16 @@ fn execute_linked_agentgrep( run_grep(&root, &args).map_err(anyhow::Error::msg)?, exact_file.as_deref(), ); + // Bound the rendered matches by default. `find` and `outline` already + // default to 5 files / 6 regions, but grep passed `None` straight + // through, so one unscoped query over a repo containing large data + // files rendered every match: a search for a common key across 2,027 + // benchmark transcripts produced 923k chars in a single call. The + // header still reports the true total, so the caller sees that more + // matches exist and can raise the cap deliberately. + let max_regions = params.max_regions.or(Some(DEFAULT_GREP_MAX_REGIONS)); Ok( - ToolOutput::new(render_grep_output(&result, &args, params.max_regions)) + ToolOutput::new(render_grep_output(&result, &args, max_regions)) .with_title("agentgrep grep"), ) } diff --git a/crates/jcode-app-core/src/tool/agentgrep/args.rs b/crates/jcode-app-core/src/tool/agentgrep/args.rs index b80ffa5d75..0f2f9440eb 100644 --- a/crates/jcode-app-core/src/tool/agentgrep/args.rs +++ b/crates/jcode-app-core/src/tool/agentgrep/args.rs @@ -8,8 +8,11 @@ struct ResolvedSearchScope { fn resolved_search_scope( ctx: &ToolContext, path: Option<&str>, + file: Option<&str>, glob: Option<&str>, ) -> ResolvedSearchScope { + // `file` scopes grep/find to one exact file when `path` is absent. + let path = path.or(file); let Some(path) = path else { return ResolvedSearchScope { root: None, @@ -44,7 +47,12 @@ pub(super) fn build_grep_args(params: &AgentGrepInput, ctx: &ToolContext) -> Res .query .clone() .ok_or_else(|| anyhow::anyhow!("agentgrep grep requires 'query'"))?; - let scope = resolved_search_scope(ctx, params.path.as_deref(), params.glob.as_deref()); + let scope = resolved_search_scope( + ctx, + params.path.as_deref(), + params.file.as_deref(), + params.glob.as_deref(), + ); Ok(GrepArgs { query, regex: params.regex.unwrap_or(false), @@ -62,6 +70,7 @@ pub(super) fn build_find_args(params: &AgentGrepInput, ctx: &ToolContext) -> Res let query = params.query.as_deref().unwrap_or_default(); if query.trim().is_empty() && params.path.as_deref().is_none_or(str::is_empty) + && params.file.as_deref().is_none_or(str::is_empty) && normalized_agentgrep_glob(params.glob.as_deref()).is_none() && params.file_type.as_deref().is_none_or(str::is_empty) { @@ -69,7 +78,12 @@ pub(super) fn build_find_args(params: &AgentGrepInput, ctx: &ToolContext) -> Res "agentgrep find requires 'query' unless path, glob, or type narrows the search" )); } - let scope = resolved_search_scope(ctx, params.path.as_deref(), params.glob.as_deref()); + let scope = resolved_search_scope( + ctx, + params.path.as_deref(), + params.file.as_deref(), + params.glob.as_deref(), + ); Ok(FindArgs { query_parts: query.split_whitespace().map(ToOwned::to_owned).collect(), file_type: params.file_type.clone(), @@ -128,7 +142,12 @@ pub(super) fn build_smart_args_and_query( err ) })?; - let scope = resolved_search_scope(ctx, params.path.as_deref(), params.glob.as_deref()); + let scope = resolved_search_scope( + ctx, + params.path.as_deref(), + params.file.as_deref(), + params.glob.as_deref(), + ); let args = SmartArgs { terms, diff --git a/crates/jcode-app-core/src/tool/agentgrep_tests.rs b/crates/jcode-app-core/src/tool/agentgrep_tests.rs index 12d87f1adb..199d351599 100644 --- a/crates/jcode-app-core/src/tool/agentgrep_tests.rs +++ b/crates/jcode-app-core/src/tool/agentgrep_tests.rs @@ -44,6 +44,52 @@ fn grep_input(query: &str, max_regions: Option) -> AgentGrepInput { } } +#[tokio::test] +async fn foreground_budget_returns_fast_search_result_directly() { + let handle = tokio::spawn(async { Ok(ToolOutput::new("fast result")) }); + let output = await_or_background_search( + handle, + std::time::Duration::from_secs(1), + "fast search".to_string(), + "agentgrep-fast-test".to_string(), + ) + .await + .expect("fast search should complete in foreground"); + + assert_eq!(output.output, "fast result"); + assert!(output.metadata.is_none()); +} + +#[tokio::test] +async fn foreground_budget_promotes_slow_search_without_cancelling_it() { + let handle = tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + Ok(ToolOutput::new("eventual search result")) + }); + let output = await_or_background_search( + handle, + std::time::Duration::from_millis(1), + "slow search".to_string(), + "agentgrep-slow-test".to_string(), + ) + .await + .expect("slow search should be promoted"); + let metadata = output.metadata.expect("expected background metadata"); + + assert_eq!(metadata["background"], true); + assert_eq!(metadata["timeout_promoted"], true); + assert_eq!(metadata["foreground_timeout_ms"], 1); + assert!(output.output.contains("continuing in background")); + + // The adopted handle must remain alive after the foreground call returns. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let output_path = metadata["output_file"].as_str().expect("output path"); + let saved = tokio::fs::read_to_string(output_path) + .await + .expect("background manager should persist the eventual result"); + assert!(saved.contains("eventual search result")); +} + #[test] fn agentgrep_rejects_missing_session_cwd_instead_of_using_process_cwd() { let mut ctx = test_ctx(Path::new("/unused")); @@ -248,6 +294,41 @@ fn build_grep_args_scopes_file_path_to_parent_and_exact_glob() { assert_eq!(args.glob.as_deref(), Some("app.rs")); } +#[test] +fn build_grep_and_find_args_scope_file_field_to_exact_file() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("mkdir"); + fs::write(temp.path().join("src/app.rs"), "fn auth_status() {}\n").expect("write file"); + + let ctx = test_ctx(temp.path()); + let params = AgentGrepInput { + mode: "grep".to_string(), + query: Some("auth_status".to_string()), + file: Some("src/app.rs".to_string()), + terms: None, + regex: Some(false), + path: None, + glob: Some("**/*.rs".to_string()), + file_type: Some("rs".to_string()), + hidden: None, + no_ignore: None, + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + }; + + let grep = build_grep_args(¶ms, &ctx).unwrap(); + let find = build_find_args(¶ms, &ctx).unwrap(); + let expected_parent = temp.path().join("src").to_string_lossy().into_owned(); + assert_eq!(grep.path.as_deref(), Some(expected_parent.as_str())); + assert_eq!(grep.glob.as_deref(), Some("app.rs")); + assert_eq!(find.path.as_deref(), Some(expected_parent.as_str())); + assert_eq!(find.glob.as_deref(), Some("app.rs")); +} + #[test] fn build_find_args_allows_glob_only_search() { let ctx = test_ctx(Path::new("/tmp/root")); @@ -614,6 +695,30 @@ async fn execute_runs_linked_grep_when_mode_is_omitted() { assert!(output.output.contains("app.rs")); } +#[tokio::test] +async fn execute_grep_file_field_does_not_scan_sibling_files() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("mkdir"); + fs::write(temp.path().join("src/app.rs"), "fn target() {}\n").expect("write target"); + fs::write( + temp.path().join("src/sibling.rs"), + "fn target() { panic!(\"sibling marker\") }\n", + ) + .expect("write sibling"); + + let output = AgentGrepTool::new() + .execute( + json!({"mode": "grep", "query": "target", "file": "src/app.rs"}), + test_ctx(temp.path()), + ) + .await + .expect("file-scoped grep"); + + assert!(output.output.contains("app.rs")); + assert!(!output.output.contains("sibling.rs")); + assert!(!output.output.contains("sibling marker")); +} + #[tokio::test] async fn execute_runs_linked_grep_when_path_points_to_file() { let temp = tempfile::tempdir().expect("tempdir"); @@ -874,3 +979,49 @@ fn input_accepts_legacy_grep_param_aliases() { assert_eq!(input.path.as_deref(), Some("src")); assert_eq!(input.mode, "grep"); } + +#[test] +fn grep_defaults_to_a_bounded_match_count() { + // grep was the only mode with no default cap: find defaults to 5 files and + // outline to 6 regions, but grep passed `None` through and rendered every + // match. One unscoped query over a repo with large data files produced 923k + // chars in a single call. + let unbounded = grep_input("x", None); + assert_eq!( + unbounded.max_regions.or(Some(DEFAULT_GREP_MAX_REGIONS)), + Some(DEFAULT_GREP_MAX_REGIONS), + "grep must be bounded when the caller sets no cap" + ); + + // An explicit cap must win in either direction, including a larger one, so + // the default is a floor on safety and not a ceiling on capability. + for explicit in [5usize, 5_000] { + let params = grep_input("x", Some(explicit)); + assert_eq!( + params.max_regions.or(Some(DEFAULT_GREP_MAX_REGIONS)), + Some(explicit), + "an explicit cap must win over the default" + ); + } + + // The default has to be generous enough that ordinary code searches are + // untouched; a cap that clips normal work trades one problem for another. + // Checked against a real search rather than as a constant comparison, which + // the compiler would fold away: this repo's own uses of a common internal + // symbol must fit under the cap. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let args = build_grep_args(&grep_input("guard_context_overflow", None), &test_ctx(root)) + .expect("grep args"); + let result = ::agentgrep::search::run_grep(root, &args).expect("grep should run"); + assert!( + result.total_matches > 0, + "sanity: the probe symbol should exist in this crate" + ); + assert!( + result.total_matches < DEFAULT_GREP_MAX_REGIONS, + "an ordinary in-repo search returned {} matches, which the default cap \ + of {} would clip", + result.total_matches, + DEFAULT_GREP_MAX_REGIONS + ); +} diff --git a/crates/jcode-app-core/src/tool/ambient.rs b/crates/jcode-app-core/src/tool/ambient.rs index 24d1eb4e46..a8d0bd2561 100644 --- a/crates/jcode-app-core/src/tool/ambient.rs +++ b/crates/jcode-app-core/src/tool/ambient.rs @@ -798,7 +798,7 @@ impl Tool for ScheduleTool { "target": { "type": "string", "enum": ["resume", "spawn", "ambient"], - "description": "Delivery target. Defaults to resuming the originating session. Use 'spawn' to run in one new child session, or 'ambient' only for shared ambient work." + "description": "Delivery target. Defaults to resuming this session; 'spawn' runs one new child session." } } }) diff --git a/crates/jcode-app-core/src/tool/apply_patch.rs b/crates/jcode-app-core/src/tool/apply_patch.rs index 1f89a9d118..f5d4618f50 100644 --- a/crates/jcode-app-core/src/tool/apply_patch.rs +++ b/crates/jcode-app-core/src/tool/apply_patch.rs @@ -60,7 +60,7 @@ impl Tool for ApplyPatchTool { } fn description(&self) -> &str { - "Apply a Codex-style patch using *** Begin Patch / *** End Patch blocks. Prefer this over patch for Jcode/Codex patches." + "Apply a Codex-style *** Begin Patch / *** End Patch patch. Prefer over patch." } fn parameters_schema(&self) -> Value { @@ -81,6 +81,11 @@ impl Tool for ApplyPatchTool { let params: ApplyPatchInput = serde_json::from_value(input)?; let hunks = parse_apply_patch(¶ms.patch_text)?; + // A patch can reach config.toml through any hunk kind (add, update, + // move), so watch the file across the whole invocation rather than + // threading before/after content through each branch. + let config_watch = super::config_edit_notice::ConfigEditWatch::begin(); + let mut results = Vec::new(); let mut touched_paths = Vec::new(); @@ -110,6 +115,21 @@ impl Tool for ApplyPatchTool { } PatchHunk::DeleteFile { path } => { let resolved = ctx.resolve_path(Path::new(path)); + // `resolve_path` passes absolute paths through unchanged, so + // a patch can name any file on disk. The bash gate does not + // cover this path, so apply the same absolute deny here + // (#604). Only the catastrophic tier: ordinary file deletes + // are this tool's normal job. + let risk_ctx = + jcode_command_risk::RiskContext::from_env(ctx.working_dir.clone()); + if jcode_command_risk::is_catastrophic_target(&resolved, &risk_ctx) { + results.push(format!( + "✗ {}: refused, this path is protected and must never \ + be deleted by an agent", + path + )); + continue; + } let old_contents = tokio::fs::read_to_string(&resolved) .await .unwrap_or_default(); @@ -221,7 +241,9 @@ impl Tool for ApplyPatchTool { if results.is_empty() { Ok(ToolOutput::new("No changes applied")) } else { - let output = ToolOutput::new(results.join("\n")); + let mut body = results.join("\n"); + config_watch.finish(&mut body); + let output = ToolOutput::new(body); if touched_paths.len() == 1 { Ok(output.with_title(touched_paths[0].clone())) } else { diff --git a/crates/jcode-app-core/src/tool/apply_patch_tests.rs b/crates/jcode-app-core/src/tool/apply_patch_tests.rs index 9fbb68b8ba..cd1e1be2ef 100644 --- a/crates/jcode-app-core/src/tool/apply_patch_tests.rs +++ b/crates/jcode-app-core/src/tool/apply_patch_tests.rs @@ -245,3 +245,86 @@ fn test_parse_update_without_explicit_at() { _ => panic!("Expected UpdateFile"), } } + +// Issue #604: apply_patch can delete by absolute path, so it is a second route +// to the same damage the bash gate blocks. `ToolContext::resolve_path` passes +// absolute paths through unchanged, so nothing else bounds it. + +#[tokio::test] +async fn apply_patch_refuses_to_delete_a_protected_path() { + let temp = tempfile::tempdir().expect("temp home"); + let home = temp.path().to_path_buf(); + let previous = std::env::var("HOME").ok(); + // SAFETY: single-threaded test setup; restored below. + unsafe { std::env::set_var("HOME", &home) }; + + // A credential file inside the protected ~/.ssh directory. + let ssh = home.join(".ssh"); + std::fs::create_dir_all(&ssh).expect("ssh dir"); + let key = ssh.join("id_ed25519"); + std::fs::write(&key, "PRIVATE KEY").expect("key"); + + let patch = format!( + "*** Begin Patch\n*** Delete File: {}\n*** End Patch", + key.display() + ); + let result = ApplyPatchTool + .execute( + serde_json::json!({ "patch_text": patch }), + ToolContext { + session_id: "patch-gate".to_string(), + message_id: "m".to_string(), + tool_call_id: "c".to_string(), + working_dir: Some(std::path::PathBuf::from("/tmp")), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }, + ) + .await; + + match previous { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + let output = result.expect("the tool should report, not error out"); + assert!( + format!("{output:?}").contains("refused"), + "expected a refusal in the output: {output:?}" + ); + assert!( + key.exists(), + "apply_patch must not delete a protected credential file" + ); +} + +#[tokio::test] +async fn apply_patch_still_deletes_ordinary_files() { + // The guard must not break the tool's normal job. + let temp = tempfile::tempdir().expect("temp dir"); + let target = temp.path().join("obsolete.rs"); + std::fs::write(&target, "fn old() {}\n").expect("file"); + + let patch = format!( + "*** Begin Patch\n*** Delete File: {}\n*** End Patch", + target.display() + ); + ApplyPatchTool + .execute( + serde_json::json!({ "patch_text": patch }), + ToolContext { + session_id: "patch-ok".to_string(), + message_id: "m".to_string(), + tool_call_id: "c".to_string(), + working_dir: Some(temp.path().to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }, + ) + .await + .expect("ordinary delete should succeed"); + + assert!(!target.exists(), "an ordinary file should still be deleted"); +} diff --git a/crates/jcode-app-core/src/tool/bash.rs b/crates/jcode-app-core/src/tool/bash.rs index 55a7f26234..6dbbe87722 100644 --- a/crates/jcode-app-core/src/tool/bash.rs +++ b/crates/jcode-app-core/src/tool/bash.rs @@ -20,7 +20,9 @@ use std::sync::LazyLock; use std::time::Duration; #[cfg(unix)] use std::time::Instant; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +#[cfg(unix)] +use tokio::io::AsyncReadExt; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command as TokioCommand; const MAX_OUTPUT_LEN: usize = 30000; @@ -30,8 +32,41 @@ const STDIN_INITIAL_DELAY_MS: u64 = 300; const PROGRESS_MARKER_PREFIX: &str = "JCODE_PROGRESS "; const CHECKPOINT_MARKER_PREFIX: &str = "JCODE_CHECKPOINT "; const BACKGROUND_PROGRESS_GUIDANCE: &str = "For long-running background commands, prefer scripts or commands that periodically print progress updates. Best format: print lines starting with `JCODE_PROGRESS ` followed by JSON like {\"percent\":42,\"message\":\"Running\"} or {\"current\":120,\"total\":1000,\"unit\":\"batches\",\"message\":\"Epoch 2/5\",\"eta_seconds\":30}. Supported JSON fields are `percent`, `message`, `current`, `total`, `unit`, `eta_seconds`, and optional `kind`=`indeterminate` or `kind`=`checkpoint`. For milestone-style wakeups, print `JCODE_CHECKPOINT {\"message\":\"Unit tests passed\"}`. Generic fallback output that can be parsed includes `42%`, `3/10 tests`, `3 of 10 steps`, `1.5/3.0 GiB`, or phase lines like `Compiling ...`, `Downloading ...`, `Running ...`, and `Building ...`. If you are writing the script yourself, add these progress/checkpoint lines explicitly. Put large temporary files, worktrees, and virtual environments under `$JCODE_SCRATCH_DIR`, not `/tmp`, because `/tmp` may be RAM-backed."; -const BASH_TOOL_DESCRIPTION: &str = "Run a bash command. For long-running background commands, prefer scripts that emit progress/checkpoint lines. Print `JCODE_PROGRESS {json}` or `JCODE_CHECKPOINT {json}` lines for reliable reporting, or at least output parseable progress like `42%`, `3/10 tests`, `3 of 10 steps`, `1.5/3.0 GiB`, or `Running ...`. Put large temporary files and worktrees under `$JCODE_SCRATCH_DIR`, not `/tmp`, because `/tmp` may be RAM-backed."; -const WINDOWS_SHELL_TOOL_DESCRIPTION: &str = "Run a shell command. For long-running background commands, prefer scripts that emit progress/checkpoint lines. Print `JCODE_PROGRESS {json}` or `JCODE_CHECKPOINT {json}` lines for reliable reporting, or at least output parseable progress like `42%`, `3/10 tests`, `3 of 10 steps`, `1.5/3.0 GiB`, or `Running ...`."; +const BASH_TOOL_DESCRIPTION: &str = "Run a bash command."; +const WINDOWS_SHELL_TOOL_DESCRIPTION: &str = + "Run a Windows cmd.exe command (compatibility name `bash`). Use cmd.exe syntax, not Bash."; + +#[cfg(unix)] +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +/// Route ordinary `cargo` invocations (including those inside child scripts) +/// through the repository wrapper. Besides applying the project's build policy, +/// that wrapper appends real action timings to rust-actions.jsonl. +#[cfg(unix)] +fn wrap_repo_cargo_commands(command: &str, working_dir: Option<&Path>) -> Option { + let working_dir = working_dir?; + let repo = crate::build::find_repo_in_ancestors(working_dir)?; + let wrapper = repo.join("scripts").join("dev_cargo.sh"); + if !wrapper.is_file() { + return None; + } + + Some(format!( + r#"export JCODE_DEV_CARGO_SCRIPT={wrapper} +cargo() {{ + if [[ "${{JCODE_IN_DEV_CARGO:-0}}" == "1" ]]; then + command cargo "$@" + else + JCODE_IN_DEV_CARGO=1 "$JCODE_DEV_CARGO_SCRIPT" "$@" + fi +}} +export -f cargo +{command}"#, + wrapper = shell_single_quote(&wrapper.to_string_lossy()), + )) +} /// Build a clear timeout message. The `timeout` param is in milliseconds, which /// agents frequently mistake for seconds (e.g. passing 1000 thinking it means @@ -402,33 +437,10 @@ async fn handle_background_output_line( raw_line: &str, stderr: bool, ) { - if let Some(progress) = parse_checkpoint_marker(raw_line) { - if let Some(task_id) = task_id_from_output_path(output_path) { - let _ = crate::background::global() - .update_checkpoint(task_id, progress) - .await; - } - return; - } - - if let Some((progress, is_checkpoint)) = parse_progress_marker_with_checkpoint(raw_line) { - if let Some(task_id) = task_id_from_output_path(output_path) { - let manager = crate::background::global(); - let _ = if is_checkpoint { - manager.update_checkpoint(task_id, progress).await - } else { - manager.update_progress(task_id, progress).await - }; - } - return; - } - - match parse_heuristic_progress(raw_line) { - Ok(Some(progress)) => { + match parse_progress_line(raw_line) { + Ok(Some(update)) => { if let Some(task_id) = task_id_from_output_path(output_path) { - let _ = crate::background::global() - .update_progress(task_id, progress) - .await; + apply_progress_update(task_id, update).await; } return; } @@ -449,6 +461,153 @@ async fn handle_background_output_line( file.flush().await.ok(); } +/// A progress or checkpoint update parsed from one line of command output. +#[derive(Debug)] +pub(super) enum ProgressLineUpdate { + Progress(BackgroundTaskProgress), + Checkpoint(BackgroundTaskProgress), +} + +/// Parse one output line for any supported progress signal: explicit +/// `JCODE_CHECKPOINT`/`JCODE_PROGRESS` markers first, then heuristic patterns +/// (ratios, percentages, byte ratios, phase prefixes). +pub(super) fn parse_progress_line(line: &str) -> Result> { + if let Some(progress) = parse_checkpoint_marker(line) { + return Ok(Some(ProgressLineUpdate::Checkpoint(progress))); + } + + if let Some((progress, is_checkpoint)) = parse_progress_marker_with_checkpoint(line) { + return Ok(Some(if is_checkpoint { + ProgressLineUpdate::Checkpoint(progress) + } else { + ProgressLineUpdate::Progress(progress) + })); + } + + Ok(parse_heuristic_progress(line)?.map(ProgressLineUpdate::Progress)) +} + +async fn apply_progress_update(task_id: &str, update: ProgressLineUpdate) { + let manager = crate::background::global(); + let _ = match update { + ProgressLineUpdate::Progress(progress) => manager.update_progress(task_id, progress).await, + ProgressLineUpdate::Checkpoint(progress) => { + manager.update_checkpoint(task_id, progress).await + } + }; +} + +/// Progress state for a foreground command that may be promoted to a +/// background task if it exceeds the foreground timeout. +/// +/// Before promotion there is no task to attach progress to, so only the most +/// recent update is kept. When the command is promoted, `attach_task` flushes +/// that pending update so the task row starts at the real percentage instead +/// of 0%, and later updates stream directly to the background manager. +#[derive(Default)] +struct PromotedCommandProgress { + task_id: std::sync::OnceLock, + pending: std::sync::Mutex>, +} + +impl PromotedCommandProgress { + async fn record(&self, update: ProgressLineUpdate) { + let direct = { + let mut pending = self.pending.lock().expect("progress mutex poisoned"); + if self.task_id.get().is_none() { + *pending = Some(update); + None + } else { + Some(update) + } + }; + if let Some(update) = direct + && let Some(task_id) = self.task_id.get() + { + apply_progress_update(task_id, update).await; + } + } + + async fn attach_task(&self, task_id: &str) { + let _ = self.task_id.set(task_id.to_string()); + let pending = self.pending.lock().expect("progress mutex poisoned").take(); + if let Some(update) = pending { + apply_progress_update(task_id, update).await; + } + } +} + +/// Collect a command's output stream line by line, reporting any parsed +/// progress so a later background promotion has live progress instead of +/// sitting at 0% until completion. +async fn collect_output_reporting_progress( + reader: Option, + progress: std::sync::Arc, +) -> String +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut buf = String::new(); + let Some(reader) = reader else { + return buf; + }; + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(Some(update)) = parse_progress_line(&line) { + progress.record(update).await; + } + buf.push_str(&line); + buf.push('\n'); + } + buf +} + +/// Tail a detached background task's output file and translate progress lines +/// into background-manager progress updates. +/// +/// Detached commands write directly to their output file, so nothing in-process +/// sees their output as it streams. This follower polls the file while the task +/// is `Running`, parsing complete lines from where it left off. It performs one +/// final drain after the task leaves `Running` and then exits. +#[cfg(unix)] +fn spawn_detached_progress_follower(task_id: String, output_file: std::path::PathBuf) { + tokio::spawn(async move { + let manager = crate::background::global(); + let mut pos: u64 = 0; + let mut partial: Vec = Vec::new(); + loop { + let running = manager + .status(&task_id) + .await + .map(|status| status.status == crate::bus::BackgroundTaskStatus::Running) + .unwrap_or(false); + + if let Ok(mut file) = tokio::fs::File::open(&output_file).await { + use tokio::io::AsyncSeekExt; + if file.seek(std::io::SeekFrom::Start(pos)).await.is_ok() { + let mut chunk = Vec::new(); + if file.read_to_end(&mut chunk).await.is_ok() && !chunk.is_empty() { + pos += chunk.len() as u64; + partial.extend_from_slice(&chunk); + while let Some(newline) = partial.iter().position(|byte| *byte == b'\n') { + let line: Vec = partial.drain(..=newline).collect(); + let line = String::from_utf8_lossy(&line); + if let Ok(Some(update)) = parse_progress_line(line.trim_end()) { + apply_progress_update(&task_id, update).await; + } + } + } + } + } + + if !running { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }); +} + #[cfg(not(windows))] fn tool_scratch_dir() -> Option { let dir = std::env::var_os("JCODE_SCRATCH_DIR") @@ -499,7 +658,18 @@ fn build_shell_command(cmd_str: &str) -> TokioCommand { #[cfg(windows)] { let mut cmd = TokioCommand::new("cmd.exe"); - cmd.arg("/C").arg(cmd_str); + // cmd.exe does not use the standard C runtime argument-decoding rules. + // Passing the command through `arg` makes Rust escape nested quotes for + // CommandLineToArgvW, which can corrupt commands such as: + // + // gh issue create --title "text with spaces" + // + // Tokio's `raw_arg` is specifically provided for `cmd.exe /C`. Wrap the + // full command in the outer quotes expected by cmd so its inner quotes + // reach child programs intact. `/D` disables AutoRun hooks and `/S` + // selects the documented quote handling used with this form. + cmd.args(["/D", "/S", "/C"]) + .raw_arg(format!("\"{cmd_str}\"")); cmd } #[cfg(not(windows))] @@ -511,6 +681,13 @@ fn build_shell_command(cmd_str: &str) -> TokioCommand { } } +fn configure_background_command_stdio(command: &mut TokioCommand) { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); +} + #[cfg(unix)] fn build_detached_shell_wrapper(command: &str) -> StdCommand { let mut cmd = StdCommand::new("bash"); @@ -570,6 +747,41 @@ mod utf8_truncation_tests { "unexpected stdout: {}", stdout ); + + let probe_path = std::env::temp_dir().join(format!( + "jcode-cmd-quoting-probe-{}.cmd", + std::process::id() + )); + std::fs::write( + &probe_path, + concat!( + "@echo off\r\n", + "if \"%~1\"==\"text with spaces\" if \"%~2\"==\"\" (\r\n", + " echo quoted-argument-ok\r\n", + " exit /b 0\r\n", + ")\r\n", + "echo first=[%~1] second=[%~2]\r\n", + "exit /b 1\r\n", + ), + ) + .expect("write cmd quoting probe"); + + let quoted_command = format!("call \"{}\" \"text with spaces\"", probe_path.display()); + let quoted_output = build_shell_command("ed_command) + .output() + .await + .expect("run cmd quoting probe"); + let _ = std::fs::remove_file(&probe_path); + let quoted_stdout = String::from_utf8_lossy("ed_output.stdout); + let quoted_stderr = String::from_utf8_lossy("ed_output.stderr); + assert!( + quoted_output.status.success(), + "quoted argument should remain one child-process argument; stdout={quoted_stdout:?} stderr={quoted_stderr:?}" + ); + assert!( + quoted_stdout.contains("quoted-argument-ok"), + "unexpected quoted-command stdout: {quoted_stdout}" + ); } #[cfg(unix)] @@ -610,12 +822,22 @@ struct BashInput { notify: bool, #[serde(default)] wake: bool, + /// For background runs: wake the agent after this many seconds with no + /// new output and no progress events. Resets on activity. + #[serde(default)] + stall_wake_seconds: Option, + /// Set only when re-issuing a call the gate refused (#604). + #[serde(default)] + justification: Option, } fn default_true() -> bool { true } +#[path = "bash_destructive_gate.rs"] +mod destructive_gate; +use destructive_gate::destructive_command_refusal; #[async_trait] impl Tool for BashTool { fn name(&self) -> &str { @@ -631,44 +853,28 @@ impl Tool for BashTool { } fn parameters_schema(&self) -> Value { - let cmd_desc = if cfg!(windows) { - "The shell command to execute (via cmd.exe). If you write a long-running script or loop for run_in_background=true, make it print progress lines. Preferred format: `JCODE_PROGRESS {json}`." - } else { - "The bash command to execute. If you write a long-running script or loop for run_in_background=true, make it print progress lines. Preferred format: `JCODE_PROGRESS {json}`. Put large temporary files and worktrees under `$JCODE_SCRATCH_DIR`, not `/tmp`, because `/tmp` may be RAM-backed." - }; - json!({ - "type": "object", - "required": ["command"], - "properties": { - "intent": super::intent_schema_property(), - "command": { - "type": "string", - "description": cmd_desc - }, - "timeout": { - "type": "integer", - "description": "Timeout in MILLISECONDS (not seconds). Kills the command when exceeded and reports exit 124. e.g. 1000 = 1s, 600000 = 10min. Omit to run with no timeout; do NOT pass small values like 1000 for long jobs such as builds or test suites." - }, - "run_in_background": { - "type": "boolean", - "description": format!("Run in background. {}", BACKGROUND_PROGRESS_GUIDANCE) - }, - "notify": { - "type": "boolean", - "description": "Notify on completion." - }, - "wake": { - "type": "boolean", - "description": "Wake on completion." - } - } - }) + destructive_gate::bash_parameters_schema() } async fn execute(&self, input: Value, ctx: ToolContext) -> Result { let mut params: BashInput = serde_json::from_value(input)?; let run_in_background = params.run_in_background.unwrap_or(false); + // Destructive-command gate (#604), before background dispatch. + if let Some(refusal) = destructive_command_refusal( + ¶ms.command, + params.justification.as_deref(), + ctx.working_dir.clone(), + ) { + return Err(anyhow::anyhow!(refusal)); + } + + #[cfg(unix)] + if let Some(wrapped) = wrap_repo_cargo_commands(¶ms.command, ctx.working_dir.as_deref()) + { + params.command = wrapped; + } + if run_in_background { return self.execute_background(params, ctx).await; } @@ -743,27 +949,26 @@ impl BashTool { let stdin_tx = ctx.stdin_request_tx.clone(); let tool_call_id = ctx.tool_call_id.clone(); let title_for_work = title.clone(); + // Track progress parsed from output so a timeout promotion starts the + // background task at the real percentage instead of 0%. + let promoted_progress = std::sync::Arc::new(PromotedCommandProgress::default()); + let stdout_progress = std::sync::Arc::clone(&promoted_progress); + let stderr_progress = std::sync::Arc::clone(&promoted_progress); // Run the command (read stdout/stderr, service stdin, wait for exit) in a // dedicated task so that, if it exceeds the foreground timeout, we can hand // the still-running task off to the background manager instead of killing it. let mut work_handle: tokio::task::JoinHandle> = tokio::spawn(async move { - let stdout_task = tokio::spawn(async move { - let mut buf = String::new(); - if let Some(mut out) = stdout_handle { - let _ = out.read_to_string(&mut buf).await; - } - buf - }); + let stdout_task = tokio::spawn(collect_output_reporting_progress( + stdout_handle, + stdout_progress, + )); - let stderr_task = tokio::spawn(async move { - let mut buf = String::new(); - if let Some(mut err) = stderr_handle { - let _ = err.read_to_string(&mut buf).await; - } - buf - }); + let stderr_task = tokio::spawn(collect_output_reporting_progress( + stderr_handle, + stderr_progress, + )); let stdin_task = if has_stdin_channel { Some(tokio::spawn(async move { @@ -873,6 +1078,10 @@ impl BashTool { work_handle, ) .await; + // Route progress parsed from the still-running command's output + // to the new background task, including any update seen before + // promotion, so the task row shows real progress from the start. + promoted_progress.attach_task(&info.task_id).await; let output = format!( "Command exceeded the foreground timeout after {:.1}s and is continuing in background (not killed).\n\n\ @@ -977,6 +1186,10 @@ impl BashTool { params.wake, ) .await; + // Detached commands write straight to the output file, so no + // in-process reader sees their output. Follow the file to keep + // the task's progress bar live. + spawn_detached_progress_follower(info.task_id.clone(), info.output_file.clone()); let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); let output = format!( @@ -1034,6 +1247,7 @@ impl BashTool { params.wake, ) .await; + spawn_detached_progress_follower(info.task_id.clone(), info.output_file.clone()); let output = format!( "Command continued in background due to reload.\n\nTask ID: {}\nOutput file: {}\nStatus file: {}\n\nUse `bg` with action=\"wait\" and task_id=\"{}\" after reload to wait for completion or the next progress checkpoint.", info.task_id, @@ -1091,9 +1305,8 @@ impl BashTool { Ok(()) }); } - cmd.kill_on_drop(true) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + cmd.kill_on_drop(true); + configure_background_command_stdio(&mut cmd); if let Some(ref dir) = working_dir { cmd.current_dir(dir); } @@ -1214,6 +1427,23 @@ impl BashTool { } else { "Notifications disabled. Use `bg` tool to check status." }; + + let stall_msg = match params.stall_wake_seconds { + Some(requested) => { + match crate::background::global() + .arm_stall_watchdog(&info.task_id, requested) + .await + { + Some(effective) => format!( + "Stall watchdog armed: you will be woken after {}s with no output or progress (resets on activity).\n", + effective + ), + None => String::new(), + } + } + None => String::new(), + }; + let output = format!( "Command started in background.\n\n\ Task ID: {}\n\ @@ -1221,16 +1451,19 @@ impl BashTool { Output file: {}\n\ Status file: {}\n\n\ {}\n\ - To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\ + {}To wait for completion/checkpoints: use the `bg` tool with action=\"wait\" and task_id=\"{}\"\n\ To check progress immediately: use the `bg` tool with action=\"status\" and task_id=\"{}\"\n\ - To see output: use the `read` tool on the output file, or `bg` with action=\"output\"", + To see output: use the `read` tool on the output file, or `bg` with action=\"output\"\n\n\ + {}", info.task_id, display_name, info.output_file.display(), info.status_file.display(), notify_msg, + stall_msg, info.task_id, info.task_id, + BACKGROUND_PROGRESS_GUIDANCE, ); Ok(ToolOutput::new(output) diff --git a/crates/jcode-app-core/src/tool/bash_destructive_gate.rs b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs new file mode 100644 index 0000000000..20d7445151 --- /dev/null +++ b/crates/jcode-app-core/src/tool/bash_destructive_gate.rs @@ -0,0 +1,131 @@ +//! The destructive-command gate for the `bash` tool (issue #604). +//! +//! Kept in its own file so the policy seam is easy to find and review: this is +//! the only thing standing between a model's `rm -rf` and the user's data. + +/// Apply the deterministic destructive-command gate, returning refusal text +/// when the command must not run as-issued. +/// +/// Stage 1 is a pure blast-radius assessment; stage 2 turns a `Confirm` verdict +/// into a reflection prompt that a blind retry cannot satisfy. Catastrophic +/// targets (`/`, `$HOME`, credential stores, device nodes) are denied outright. +/// See issue #604. +pub(super) fn destructive_command_refusal( + command: &str, + justification: Option<&str>, + working_dir: Option, +) -> Option { + let mut risk_ctx = jcode_command_risk::RiskContext::from_env(working_dir); + // Assess the same scratch path that the child shell actually receives. + #[cfg(not(windows))] + { + risk_ctx.scratch_dir = super::tool_scratch_dir(); + } + let assessment = jcode_command_risk::assess(command, &risk_ctx); + if assessment.level.runs_immediately() { + return None; + } + + let justification = jcode_command_risk::Justification { + text: justification.map(str::to_string), + }; + match jcode_command_risk::gate(&assessment, &justification) { + jcode_command_risk::GateOutcome::Allow => None, + jcode_command_risk::GateOutcome::Deny { reason } => { + crate::logging::warn(&format!("[bash] denied destructive command: {command}")); + Some(reason) + } + jcode_command_risk::GateOutcome::Reflect { prompt } => { + crate::logging::info(&format!( + "[bash] destructive command held for justification: {command}" + )); + Some(prompt) + } + } +} + +/// The `bash` tool's JSON schema, including the `justification` field the +/// destructive-command gate consumes. +/// +/// Lives beside the gate so the schema and the policy that reads it stay in +/// sync, and so bash.rs stays inside the code-size budget. +pub(super) fn bash_parameters_schema() -> serde_json::Value { + let cmd_desc = if cfg!(windows) { + "The Windows command to execute via cmd.exe. Use cmd.exe syntax and quoting, not Bash syntax." + } else { + "The bash command to execute. Put large temp files under `$JCODE_SCRATCH_DIR`, not `/tmp`." + }; + serde_json::json!({ + "type": "object", + "required": ["command"], + "properties": { + "intent": crate::tool::intent_schema_property(), + "command": { + "type": "string", + "description": cmd_desc + }, + "timeout": { + "type": "integer", + "description": "Timeout in MILLISECONDS (not seconds), e.g. 600000 = 10min; kills with exit 124. Omit for no timeout." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in background. Emit `JCODE_PROGRESS {json}` lines for progress reporting." + }, + "notify": { + "type": "boolean", + "description": "Notify on completion." + }, + "wake": { + "type": "boolean", + "description": "Wake on completion." + }, + "stall_wake_seconds": { + "type": "integer", + "description": "With run_in_background: wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." + }, + "justification": { + "type": "string", + "description": "Only when re-issuing a command the destructive gate refused; explain which user request it serves." + } + } + }) +} + +#[cfg(all(test, not(windows)))] +mod tests { + use super::destructive_command_refusal; + + #[test] + fn scratch_log_and_backup_commands_do_not_require_justification() { + let cwd = std::env::current_dir().ok(); + for command in [ + "cargo test --lib > \"$JCODE_SCRATCH_DIR/tests.log\" 2>&1", + "git diff > \"${JCODE_SCRATCH_DIR}/before.patch\"", + "env | grep JCODE", + "command -v sudo && sudo -n true", + "find /sys -type l -exec readlink {} \\;", + "find /etc -type f -exec sed -n '1,10p' {} \\;", + ] { + assert!( + destructive_command_refusal(command, None, cwd.clone()).is_none(), + "{command}" + ); + } + } + + #[test] + fn protected_writes_and_unknown_variables_remain_blocked() { + for command in [ + "rm -rf /etc", + "echo bad > /etc/passwd", + "find /etc -type f -exec rm {} \\;", + "echo test > \"$UNKNOWN/tests.log\"", + ] { + assert!( + destructive_command_refusal(command, None, std::env::current_dir().ok()).is_some(), + "{command}" + ); + } + } +} diff --git a/crates/jcode-app-core/src/tool/bash_tests.rs b/crates/jcode-app-core/src/tool/bash_tests.rs index 2f76402eee..b29042f137 100644 --- a/crates/jcode-app-core/src/tool/bash_tests.rs +++ b/crates/jcode-app-core/src/tool/bash_tests.rs @@ -1,10 +1,61 @@ use super::*; use crate::bus::{BackgroundTaskProgressSource, BackgroundTaskStatus}; use crate::tool::StdinInputRequest; -use crate::tool::bash::{BashTool, parse_heuristic_progress}; +use crate::tool::bash::{ + BashTool, ProgressLineUpdate, parse_heuristic_progress, parse_progress_line, +}; use serde_json::json; use tokio::sync::mpsc; +#[test] +fn repository_commands_export_a_logged_cargo_function() { + let repo = + crate::build::find_repo_in_ancestors(std::path::Path::new(env!("CARGO_MANIFEST_DIR"))) + .expect("test runs inside the jcode repository"); + let wrapped = wrap_repo_cargo_commands("cargo test -p demo && echo done", Some(&repo)) + .expect("jcode repository has dev_cargo.sh"); + + assert!(wrapped.contains("export JCODE_DEV_CARGO_SCRIPT=")); + assert!(wrapped.contains("JCODE_IN_DEV_CARGO=1 \"$JCODE_DEV_CARGO_SCRIPT\" \"$@\"")); + assert!(wrapped.contains("export -f cargo")); + assert!(wrapped.ends_with("cargo test -p demo && echo done")); +} + +#[test] +fn cargo_routing_is_limited_to_the_jcode_repository() { + assert!(wrap_repo_cargo_commands("cargo test", Some(std::path::Path::new("/"))).is_none()); + assert!(wrap_repo_cargo_commands("cargo test", None).is_none()); +} + +#[test] +fn cargo_wrapper_path_is_shell_quoted() { + assert_eq!(shell_single_quote("a'b"), "'a'\"'\"'b'"); +} + +#[tokio::test] +async fn background_command_stdin_is_null() { + let mut command = + build_shell_command("if IFS= read -r _; then printf inherited; else printf eof; fi"); + + // Start with a readable pipe so this test does not depend on, or modify, the + // test runner's process-wide stdin. Background configuration must replace it. + command.stdin(Stdio::piped()); + configure_background_command_stdio(&mut command); + + let child = command.spawn().expect("background command should spawn"); + assert!( + child.stdin.is_none(), + "background commands must not retain a writable stdin pipe" + ); + + let output = tokio::time::timeout(Duration::from_secs(2), child.wait_with_output()) + .await + .expect("background command should observe EOF instead of blocking") + .expect("background command should exit cleanly"); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "eof"); +} + fn make_ctx(stdin_tx: Option>) -> ToolContext { ToolContext { session_id: "test-session".to_string(), @@ -494,7 +545,11 @@ async fn test_background_command_progress_marker_updates_status_and_stays_out_of .to_string(); let mut saw_progress = false; - for _ in 0..50 { + // Wall-clock deadline: observing emitted progress depends on scheduler + // latency, so a fixed 50-iteration budget starved under parallel load + // (issue #593). The assertions inside stay exact. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { let status = crate::background::global() .status(&task_id) .await @@ -552,7 +607,11 @@ async fn test_background_command_ratio_output_updates_progress() { .to_string(); let mut saw_progress = false; - for _ in 0..50 { + // Wall-clock deadline: observing emitted progress depends on scheduler + // latency, so a fixed 50-iteration budget starved under parallel load + // (issue #593). The assertions inside stay exact. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { let status = crate::background::global() .status(&task_id) .await @@ -600,7 +659,11 @@ async fn test_background_command_byte_ratio_output_updates_progress() { .to_string(); let mut saw_progress = false; - for _ in 0..50 { + // Wall-clock deadline: observing emitted progress depends on scheduler + // latency, so a fixed 50-iteration budget starved under parallel load + // (issue #593). The assertions inside stay exact. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { let status = crate::background::global() .status(&task_id) .await @@ -647,7 +710,14 @@ async fn test_background_command_respects_timeout() { .to_string(); let mut final_status = None; - for _ in 0..50 { + // Wall-clock deadline rather than a fixed iteration count. The command's own + // timeout is 100ms, but the *observation* of the resulting Failed status + // depends on scheduler latency, and a 50 x 50ms budget starved when the full + // suite runs in parallel on a loaded machine (issue #593). A generous + // deadline keeps the assertion strict while removing the timing race: a real + // regression still fails, it just is not reported as a flake. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { let status = crate::background::global() .status(&task_id) .await @@ -779,7 +849,10 @@ async fn process_group_kill_guard_terminates_descendants() { .expect("shell should exit after process-group kill") .expect("wait for shell"); - for _ in 0..100 { + // Wall-clock deadline: process teardown is asynchronous and 100 x 10ms was + // too tight under parallel load (issue #593). + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while std::time::Instant::now() < deadline { if !crate::platform::is_process_running(descendant_pid) { return; } @@ -799,15 +872,345 @@ fn test_bash_tool_schema_advertises_background_progress_guidance() { .expect("run_in_background description should be a string"); assert!( - BashTool::new().description().contains("JCODE_PROGRESS"), - "tool description should teach cooperative progress output" + command_description.contains("JCODE_SCRATCH_DIR"), + "command description should keep the scratch-dir guidance" + ); + assert!( + background_description.contains("JCODE_PROGRESS"), + "background description should mention the progress marker format" + ); +} + +// Destructive-command gate integration (#604). +// +// The unit-level policy is covered in jcode-command-risk. These tests pin the +// wiring: that the gate actually sits in the bash tool's execute path, that it +// refuses before spawning a process, and that it does not disturb normal work. + +fn gate_ctx(working_dir: &str) -> ToolContext { + ToolContext { + session_id: "gate-test".to_string(), + message_id: "m".to_string(), + tool_call_id: "c".to_string(), + working_dir: Some(std::path::PathBuf::from(working_dir)), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } +} + +#[tokio::test] +async fn bash_refuses_to_delete_the_home_directory() { + // The #604 incident, at the real tool boundary. + let temp = tempfile::tempdir().expect("temp home"); + let home = temp.path().to_string_lossy().to_string(); + let previous = std::env::var("HOME").ok(); + // SAFETY: single-threaded test setup; restored below. + unsafe { std::env::set_var("HOME", &home) }; + + let canary = temp.path().join("precious.txt"); + std::fs::write(&canary, "user data").expect("write canary"); + + let result = BashTool::new() + .execute( + serde_json::json!({ "command": format!("rm -rf {home}") }), + gate_ctx("/tmp"), + ) + .await; + + match previous { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + let error = result.expect_err("deleting HOME must be refused"); + assert!( + error.to_string().contains("blocked"), + "expected an outright block, got: {error}" + ); + assert!( + canary.exists(), + "the gate must refuse before the process runs; the file was deleted" + ); +} + +#[tokio::test] +async fn bash_holds_a_risky_delete_until_justified_then_runs_it() { + let temp = tempfile::tempdir().expect("temp dir"); + let workdir = temp.path().join("work"); + let target = temp.path().join("outside"); + std::fs::create_dir_all(&workdir).expect("workdir"); + std::fs::create_dir_all(&target).expect("target"); + std::fs::write(target.join("f.txt"), "x").expect("file"); + + // The concrete outside-workspace directory is allowed by policy. Its glob + // keeps this test focused on the Confirm path for a statically unknown set + // of affected files. + let command = format!( + "rm -rf {}/* && rmdir {}", + target.display(), + target.display() + ); + let tool = BashTool::new(); + + // First attempt: no justification, so it is held. + let held = tool + .execute( + serde_json::json!({ "command": command }), + gate_ctx(workdir.to_str().expect("utf8")), + ) + .await + .expect_err("first attempt should be held"); + assert!(held.to_string().contains("justification"), "{held}"); + assert!(target.exists(), "nothing should have been deleted yet"); + + // A blind retry is held identically: repetition is not consent. + let retried = tool + .execute( + serde_json::json!({ "command": command }), + gate_ctx(workdir.to_str().expect("utf8")), + ) + .await + .expect_err("a blind retry should still be held"); + assert!(retried.to_string().contains("justification")); + assert!(target.exists()); + + // With a real justification it proceeds. + tool.execute( + serde_json::json!({ + "command": command, + "justification": "The user asked me to remove the outside/ fixture \ + directory they created earlier in this session.", + }), + gate_ctx(workdir.to_str().expect("utf8")), + ) + .await + .expect("a justified command should run"); + assert!(!target.exists(), "the justified delete should have run"); +} + +#[tokio::test] +async fn bash_does_not_interfere_with_ordinary_commands() { + // If the gate fires on routine work it will be worked around, so this is a + // load-bearing test, not a formality. + let temp = tempfile::tempdir().expect("temp dir"); + let workdir = temp.path().to_str().expect("utf8"); + std::fs::create_dir_all(temp.path().join("build")).expect("build dir"); + + for command in ["echo hello", "rm -rf build", "ls -la"] { + BashTool::new() + .execute(serde_json::json!({ "command": command }), gate_ctx(workdir)) + .await + .unwrap_or_else(|e| panic!("{command:?} should run untouched: {e}")); + } +} + +#[tokio::test] +async fn indirect_dispatch_paths_cannot_bypass_the_gate() { + // batch, and every other caller, dispatch through Tool::execute rather than + // reimplementing it, so the gate lives at the only chokepoint. Assert that + // directly: calling execute for a background job (the one path that returns + // early) is still gated. + let temp = tempfile::tempdir().expect("temp home"); + let home = temp.path().to_string_lossy().to_string(); + let previous = std::env::var("HOME").ok(); + // SAFETY: single-threaded test setup; restored below. + unsafe { std::env::set_var("HOME", &home) }; + let canary = temp.path().join("precious.txt"); + std::fs::write(&canary, "user data").expect("canary"); + + let result = BashTool::new() + .execute( + serde_json::json!({ + "command": format!("rm -rf {home}"), + "run_in_background": true, + }), + gate_ctx("/tmp"), + ) + .await; + + match previous { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + assert!( + result.is_err(), + "background dispatch must be gated too, not just foreground" + ); + assert!(canary.exists(), "the file must survive a backgrounded call"); +} + +#[test] +fn parse_progress_line_classifies_markers_checkpoints_and_heuristics() { + let update = parse_progress_line(r#"JCODE_PROGRESS {"percent":40,"message":"Working"}"#) + .expect("parser should not fail") + .expect("progress marker should parse"); + match update { + ProgressLineUpdate::Progress(progress) => assert_eq!(progress.percent, Some(40.0)), + other => panic!("expected a progress update, got {other:?}"), + } + + let update = parse_progress_line(r#"JCODE_CHECKPOINT {"message":"Tests passed"}"#) + .expect("parser should not fail") + .expect("checkpoint marker should parse"); + match update { + ProgressLineUpdate::Checkpoint(progress) => { + assert_eq!(progress.message.as_deref(), Some("Tests passed")) + } + other => panic!("expected a checkpoint update, got {other:?}"), + } + + let update = parse_progress_line("Copied 7/10 files") + .expect("parser should not fail") + .expect("heuristic ratio should parse"); + match update { + ProgressLineUpdate::Progress(progress) => { + assert_eq!(progress.percent, Some(70.0)); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); + } + other => panic!("expected a progress update, got {other:?}"), + } + + assert!( + parse_progress_line("plain log line with no progress") + .expect("parser should not fail") + .is_none(), + "non-progress output must not produce updates" ); +} + +/// The bug this guards against: a foreground command promoted to background at +/// the timeout showed 0% until it completed, because nothing parsed its output +/// for progress. Both the update emitted *before* promotion and updates +/// emitted *after* promotion must reach the background task's status. +#[tokio::test] +async fn test_timeout_promoted_command_reports_intermediate_progress() { + let tool = BashTool::new(); + // Emits 10% before the 300ms foreground timeout, then 80% about 2s in. + let input = json!({ + "command": "echo 'progress 10% done'; sleep 2; echo 'progress 80% done'; sleep 1", + "timeout": 300, + }); + let ctx = make_ctx(None); + + let result = tool + .execute(input, ctx) + .await + .expect("timeout should promote to background"); + let metadata = result.metadata.expect("expected background metadata"); + assert_eq!(metadata["timeout_promoted"], true); + let task_id = metadata["task_id"] + .as_str() + .expect("task_id should be present") + .to_string(); + + // The pre-promotion update (10%) must be attached at promotion time, and + // the post-promotion update (80%) must stream in while still running. + let mut observed: Vec = Vec::new(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if let Some(percent) = status.progress.as_ref().and_then(|p| p.percent) + && observed.last() != Some(&percent) + { + observed.push(percent); + } + if observed.contains(&80.0) { + break; + } + if status.status != BackgroundTaskStatus::Running { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( - command_description.contains("JCODE_PROGRESS"), - "command description should mention progress marker format" + observed.contains(&80.0), + "promoted task should reach 80% via parsed output, saw {observed:?}" ); assert!( - background_description.contains("3/10 tests"), - "background description should mention parseable fallback progress output" + observed.contains(&10.0), + "the pre-promotion 10% update should be flushed at promotion, saw {observed:?}" ); + + let _ = crate::background::global().cancel(&task_id).await; +} + +/// Same guarantee for the reload-persistable (detached) path: the command +/// writes straight to its output file, so a follower must translate progress +/// lines into status updates while the task is still running. +#[tokio::test] +async fn test_detached_promoted_command_reports_intermediate_progress() { + let tool = BashTool::new(); + let signal = jcode_agent_runtime::InterruptSignal::new(); + let ctx = make_agent_ctx(signal); + + let result = tool + .execute( + json!({ + "command": "sleep 0.5; echo 'done 3/10 steps'; sleep 2; echo 'done 8/10 steps'; sleep 1", + "timeout": 200, + }), + ctx, + ) + .await + .expect("timeout should promote the detached command to background"); + let metadata = result.metadata.expect("expected background metadata"); + assert_eq!(metadata["timeout_promoted"], true); + let task_id = metadata["task_id"] + .as_str() + .expect("task_id should be present") + .to_string(); + + let mut observed: Vec = Vec::new(); + let mut saw_intermediate_while_running = false; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if let Some(percent) = status.progress.as_ref().and_then(|p| p.percent) { + if observed.last() != Some(&percent) { + observed.push(percent); + } + if status.status == BackgroundTaskStatus::Running && percent < 100.0 { + saw_intermediate_while_running = true; + } + } + if observed.contains(&80.0) { + break; + } + if status.status != BackgroundTaskStatus::Running { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + assert!( + observed.contains(&30.0) && observed.contains(&80.0), + "detached task should report 30% then 80% from parsed output, saw {observed:?}" + ); + assert!( + saw_intermediate_while_running, + "intermediate progress must be visible while the task is still running" + ); + + let output_file = std::path::PathBuf::from( + metadata["output_file"] + .as_str() + .expect("output_file should be present"), + ); + let status_file = std::path::PathBuf::from( + metadata["status_file"] + .as_str() + .expect("status_file should be present"), + ); + let _ = crate::background::global().cancel(&task_id).await; + let _ = tokio::fs::remove_file(output_file).await; + let _ = tokio::fs::remove_file(status_file).await; } diff --git a/crates/jcode-app-core/src/tool/batch.rs b/crates/jcode-app-core/src/tool/batch.rs index 28dac3008d..b04cb4bbc2 100644 --- a/crates/jcode-app-core/src/tool/batch.rs +++ b/crates/jcode-app-core/src/tool/batch.rs @@ -1,4 +1,4 @@ -use super::{Registry, Tool, ToolContext, ToolOutput}; +use super::{Registry, Tool, ToolContext, ToolOutput, WeakRegistry}; use crate::bus::{BatchSubcallProgress, BatchSubcallState}; use crate::message::ToolCall; use anyhow::Result; @@ -9,6 +9,29 @@ use std::collections::HashMap; const MAX_PARALLEL: usize = 10; +const BATCH_DESCRIPTION: &str = r#"Run independent tool calls in parallel instead of making them sequentially. Example: +{ + "intent": "Inspect the relevant files in parallel", + "tool_calls": [ + { + "tool": "read", + "intent": "Read the configuration", + "file_path": "src/config.rs", + "start_line": 1, + "limit": 200 + }, + { + "tool": "agentgrep", + "intent": "Find configuration usage", + "query": "Config", + "path": "src", + "glob": "**/*.rs", + "max_files": 20, + "max_regions": 20 + } + ] +}"#; + pub(crate) fn generic_batch_schema() -> Value { json!({ "type": "object", @@ -19,7 +42,7 @@ pub(crate) fn generic_batch_schema() -> Value { "type": "array", "items": { "type": "object", - "required": ["tool"], + "required": ["tool", "intent"], "properties": { "tool": { "type": "string", @@ -71,11 +94,11 @@ fn ordered_batch_subcalls( } pub struct BatchTool { - registry: Registry, + registry: WeakRegistry, } impl BatchTool { - pub fn new(registry: Registry) -> Self { + pub(super) fn new(registry: WeakRegistry) -> Self { Self { registry } } } @@ -141,6 +164,21 @@ fn normalize_batch_input(mut input: Value) -> Value { params.insert("intent".to_string(), Value::String(intent)); } + // Same forwarding for the oversized-output opt-in. The context + // guard runs per sub-call inside registry.execute(), so a flag + // left beside `parameters` would be silently dropped and the + // sub-call withheld again. Models place it at either level. + let top_level_accept = obj + .get(jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY) + .filter(|value| !value.is_null()) + .cloned(); + if let Some(accept) = top_level_accept + && let Some(params) = obj.get_mut("parameters").and_then(Value::as_object_mut) + && !params.contains_key(jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY) + { + params.insert(jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY.to_string(), accept); + } + if !obj.contains_key("parameters") && obj.contains_key("tool") { let tool_name = obj.get("tool").cloned(); let mut params = serde_json::Map::new(); @@ -170,7 +208,7 @@ impl Tool for BatchTool { } fn description(&self) -> &str { - "Run tools in parallel." + BATCH_DESCRIPTION } fn parameters_schema(&self) -> Value { @@ -178,6 +216,10 @@ impl Tool for BatchTool { } async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let registry = self + .registry + .upgrade() + .ok_or_else(|| anyhow::anyhow!("Batch tool registry is no longer available"))?; let input = normalize_batch_input(input); let params: BatchInput = serde_json::from_value(input)?; @@ -244,7 +286,7 @@ impl Tool for BatchTool { let mut stream: futures::stream::FuturesUnordered<_> = subcalls .iter() .map(|(i, tool_name, parameters)| { - let registry = self.registry.clone(); + let registry = registry.clone(); let i = *i; let tool_name = tool_name.clone(); let parameters = parameters.clone(); @@ -282,6 +324,7 @@ impl Tool for BatchTool { // Format results let mut output = String::new(); + let mut images = Vec::new(); let mut success_count = 0; let mut error_count = 0; let mut failed_tools = Vec::new(); @@ -291,6 +334,10 @@ impl Tool for BatchTool { match result { Ok(out) => { success_count += 1; + // Preserve attachments in subcall order, just like the text. + // The agent emits them against the visible parent batch call + // and persists them with its aggregate tool result. + images.extend(out.images); let max_per_tool = 50_000 / num_tools.max(1); if out.output.len() > max_per_tool { output.push_str(crate::util::truncate_str(&out.output, max_per_tool)); @@ -324,7 +371,9 @@ impl Tool for BatchTool { success_count, error_count )); - Ok(ToolOutput::new(output)) + let mut result = ToolOutput::new(output); + result.images = images; + Ok(result) } } diff --git a/crates/jcode-app-core/src/tool/batch_tests.rs b/crates/jcode-app-core/src/tool/batch_tests.rs index 00f4d5acbd..035b8765b5 100644 --- a/crates/jcode-app-core/src/tool/batch_tests.rs +++ b/crates/jcode-app-core/src/tool/batch_tests.rs @@ -1,5 +1,134 @@ use super::*; use serde_json::json; +use std::sync::Arc; + +struct EchoTool; + +#[async_trait::async_trait] +impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "Echo test input" + } + + fn parameters_schema(&self) -> Value { + json!({"type": "object"}) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + Ok(ToolOutput::new(input["text"].as_str().unwrap_or_default())) + } +} + +fn test_context() -> ToolContext { + ToolContext { + session_id: "batch-registry-lifetime".to_string(), + message_id: "message".to_string(), + tool_call_id: "batch-call".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: super::super::ToolExecutionMode::Direct, + } +} + +async fn registry_with_batch_and_echo() -> Registry { + let registry = Registry::empty(); + let mut tools = registry.tools.write().await; + tools.insert("echo".to_string(), Arc::new(EchoTool)); + tools.insert( + "batch".to_string(), + Arc::new(BatchTool::new(registry.downgrade())), + ); + drop(tools); + registry +} + +#[tokio::test] +async fn registry_tool_map_drops_after_external_owners_are_dropped() { + let registry = Registry::empty(); + let tools = Arc::downgrade(®istry.tools); + + registry.tools.write().await.insert( + "batch".to_string(), + Arc::new(BatchTool::new(registry.downgrade())) as Arc, + ); + + drop(registry); + + assert!( + tools.upgrade().is_none(), + "BatchTool must not strongly retain the registry tool map that owns it" + ); +} + +#[tokio::test] +async fn batch_executes_through_surviving_registry_clone() { + let registry = registry_with_batch_and_echo().await; + let surviving_clone = registry.clone(); + drop(registry); + + let output = surviving_clone + .execute( + "batch", + json!({ + "tool_calls": [{ + "tool": "echo", + "intent": "Verify the surviving registry clone", + "parameters": {"text": "still alive"} + }] + }), + test_context(), + ) + .await + .expect("batch should use the surviving registry clone's tool map"); + + assert!(output.output.contains("still alive")); + assert!(output.output.contains("Completed: 1 succeeded, 0 failed")); +} + +#[tokio::test] +async fn batch_fails_cleanly_after_registry_tool_map_is_dropped() { + let registry = registry_with_batch_and_echo().await; + let batch = registry + .tools + .read() + .await + .get("batch") + .cloned() + .expect("batch tool should be registered"); + drop(registry); + + let error = batch + .execute( + json!({ + "tool_calls": [{ + "tool": "echo", + "intent": "Verify clean teardown", + "parameters": {"text": "unreachable"} + }] + }), + test_context(), + ) + .await + .expect_err("batch should reject execution after its registry is gone"); + + assert_eq!( + error.to_string(), + "Batch tool registry is no longer available" + ); +} + +#[test] +fn description_includes_parallel_tool_call_example() { + assert!(BATCH_DESCRIPTION.contains("Run independent tool calls in parallel")); + assert!(BATCH_DESCRIPTION.contains(r#""tool_calls": ["#)); + assert!(BATCH_DESCRIPTION.contains(r#""tool": "read""#)); + assert!(BATCH_DESCRIPTION.contains(r#""tool": "agentgrep""#)); +} #[test] fn test_normalize_flat_params() { @@ -119,7 +248,7 @@ fn test_normalize_arguments_aliases_to_parameters() { #[test] fn test_schema_only_requires_tool() { - let schema = BatchTool::new(Registry { + let registry = Registry { tools: std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), skills: std::sync::Arc::new(tokio::sync::RwLock::new( crate::skill::SkillRegistry::default(), @@ -127,12 +256,15 @@ fn test_schema_only_requires_tool() { compaction: std::sync::Arc::new(tokio::sync::RwLock::new( crate::compaction::CompactionManager::new(), )), - }) - .parameters_schema(); + }; + let schema = BatchTool::new(registry.downgrade()).parameters_schema(); assert_eq!( schema["properties"]["tool_calls"]["items"]["required"], - json!(["tool"]) + // Nested batch entries require `intent` alongside `tool` so every + // fanned-out call carries a display label, matching the central + // intent requirement in `ensure_intent_in_schema` (8505080a6). + json!(["tool", "intent"]) ); assert_eq!( schema["properties"]["tool_calls"]["items"]["additionalProperties"], @@ -160,3 +292,101 @@ fn test_schema_keeps_flat_generic_subcall_shape() { ); assert!(schema["properties"]["tool_calls"]["items"]["oneOf"].is_null()); } + +#[test] +fn subcall_level_accept_large_output_is_forwarded_into_parameters() { + // Models place the flag beside `tool` rather than inside `parameters`, the + // same mistake they already make with `intent`. The guard runs per sub-call + // on that sub-call's parameters, so a flag left at the wrong level is + // silently dropped and the sub-call withheld again. + let input = serde_json::json!({ + "tool_calls": [{ + "tool": "agentgrep", + "accept_large_output": true, + "parameters": { "query": "x" }, + }] + }); + let out = super::normalize_batch_input(input); + assert_eq!( + out["tool_calls"][0]["parameters"][jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY], + serde_json::json!(true), + "flag beside `tool` must reach the sub-call parameters" + ); +} + +#[test] +fn subcall_level_accept_large_output_does_not_override_an_explicit_value() { + // An explicit `false` inside parameters is a deliberate choice for that one + // sub-call and must win over a blanket flag beside `tool`. + let input = serde_json::json!({ + "tool_calls": [{ + "tool": "agentgrep", + "accept_large_output": true, + "parameters": { "query": "x", "accept_large_output": false }, + }] + }); + let out = super::normalize_batch_input(input); + assert_eq!( + out["tool_calls"][0]["parameters"][jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY], + serde_json::json!(false), + "explicit per-subcall value must win" + ); +} + +struct ImageTool; + +#[async_trait::async_trait] +impl Tool for ImageTool { + fn name(&self) -> &str { + "test_image" + } + fn description(&self) -> &str { + "Image fixture" + } + fn parameters_schema(&self) -> Value { + json!({"type": "object"}) + } + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + if input["fail"] == true { + anyhow::bail!("fixture failure"); + } + if input["slow"] == true { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let data = input["data"].as_str().unwrap(); + Ok(ToolOutput::new("image").with_labeled_image("image/png", data, format!("{data}.png"))) + } +} + +#[tokio::test] +async fn batch_preserves_images_in_input_order_across_failures() { + let registry = registry_with_batch_and_echo().await; + registry + .tools + .write() + .await + .insert("test_image".into(), Arc::new(ImageTool)); + let output = registry + .execute( + "batch", + json!({"tool_calls": [ + {"tool": "test_image", "parameters": {"data": "first", "slow": true}}, + {"tool": "test_image", "parameters": {"fail": true}}, + {"tool": "test_image", "parameters": {"data": "last"}} + ]}), + test_context(), + ) + .await + .unwrap(); + assert_eq!( + output + .images + .iter() + .map(|i| i.data.as_str()) + .collect::>(), + ["first", "last"] + ); + assert_eq!(output.images[0].label.as_deref(), Some("first.png")); + assert_eq!(output.images[1].media_type, "image/png"); + assert!(output.output.contains("Completed: 2 succeeded, 1 failed")); +} diff --git a/crates/jcode-app-core/src/tool/bg.rs b/crates/jcode-app-core/src/tool/bg.rs index dac2a758c9..db0e7ea615 100644 --- a/crates/jcode-app-core/src/tool/bg.rs +++ b/crates/jcode-app-core/src/tool/bg.rs @@ -74,6 +74,10 @@ struct BgInput { /// Whether to wake on completion when using watch/delivery (default: true) #[serde(default)] wake: Option, + /// For watch/delivery: also arm a stall watchdog that wakes the agent after + /// this many seconds with no new output or progress (resets on activity) + #[serde(default)] + stall_wake_seconds: Option, /// Max seconds to block when using wait (default: 60, capped at 3600) #[serde(default)] max_wait_seconds: Option, @@ -333,6 +337,7 @@ fn wait_reason_label(reason: background::BackgroundTaskWaitReason) -> &'static s background::BackgroundTaskWaitReason::Finished => "finished", background::BackgroundTaskWaitReason::Progress => "progress", background::BackgroundTaskWaitReason::Checkpoint => "checkpoint", + background::BackgroundTaskWaitReason::Stalled => "stalled", background::BackgroundTaskWaitReason::Timeout => "timeout", } } @@ -462,7 +467,7 @@ impl Tool for BgTool { } fn description(&self) -> &str { - "Manage background tasks. Prefer action='wait' over polling or sleeping. Use action='tail' or output with tail_lines for logs, action='delivery' to change notify/wake behavior, and JCODE_CHECKPOINT/JCODE_PROGRESS from background commands for reliable wakeups." + "Manage background tasks. Prefer action='wait' over polling or sleeping." } fn parameters_schema(&self) -> Value { @@ -474,7 +479,7 @@ impl Tool for BgTool { "action": { "type": "string", "enum": ["list", "status", "output", "tail", "cancel", "cleanup", "watch", "delivery", "subscribe", "wait"], - "description": "Action. Prefer wait for blocking until completion/checkpoints; watch is a compatibility alias for delivery." + "description": "Action. Prefer wait over polling; watch is an alias for delivery." }, "task_id": { "type": "string", "description": "Task ID." }, "task_ids": { "type": "array", "items": {"type":"string"}, "description": "Task IDs for multi-task wait/status." }, @@ -491,8 +496,9 @@ impl Tool for BgTool { "dry_run": { "type": "boolean", "description": "For cleanup, report what would be removed without deleting." }, "notify": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to notify on completion. Defaults to true." }, "wake": { "type": "boolean", "description": "When using delivery/watch/subscribe, whether to wake on completion. Defaults to true." }, - "max_wait_seconds": { "type": "integer", "description": "When using wait, maximum seconds to block before returning. Defaults to 60, capped at 3600. Use 0 for an immediate check." }, - "return_on_progress": { "type": "boolean", "description": "When using wait, return as soon as the task emits a progress/checkpoint event instead of only completion or timeout. Defaults to true." }, + "stall_wake_seconds": { "type": "integer", "description": "For delivery/watch: also wake the agent after this many seconds of no output/progress (min 30, resets on activity). Use for long jobs that may hang silently." }, + "max_wait_seconds": { "type": "integer", "description": "For wait: max seconds to block. Default 60, cap 3600, 0 = immediate check." }, + "return_on_progress": { "type": "boolean", "description": "For wait: return on the first progress/checkpoint event too. Defaults to true." }, "wait_mode": { "type": "string", "enum": ["any", "all", "first_failure"], "description": "For multi-task wait, return on any completion, all completions, or first failure. Defaults to any." }, "tail_lines": { "type": "integer", "description": "Return only the last N output lines for output/tail/wait preview." }, "lines": { "type": "integer", "description": "Alias for tail_lines." }, @@ -663,22 +669,37 @@ impl Tool for BgTool { .remove(0); let notify = params.notify.unwrap_or_else(default_watch_notify); let wake = params.wake.unwrap_or_else(default_watch_wake); + let stall_armed = match params.stall_wake_seconds { + Some(requested) => manager.arm_stall_watchdog(&task_id, requested).await, + None => None, + }; match manager.update_delivery(&task_id, notify, wake).await? { - Some(task) => Ok(ToolOutput::new(format!( - "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}", - task_id, - status_label(&task.status), - task.notify, - task.wake - )) - .with_title(format!("bg delivery {}", task_id)) - .with_metadata(json!({ - "task_id": task.task_id, - "task": task_metadata(manager, &task), - "status": status_label(&task.status), - "notify": task.notify, - "wake": task.wake, - }))), + Some(task) => { + let stall_line = match stall_armed { + Some(effective) => format!( + "\nStall watchdog: wake after {}s of no output/progress (resets on activity)", + effective + ), + None => String::new(), + }; + Ok(ToolOutput::new(format!( + "Updated background task delivery for {}.\nStatus: {}\nNotify: {}\nWake: {}{}", + task_id, + status_label(&task.status), + task.notify, + task.wake, + stall_line + )) + .with_title(format!("bg delivery {}", task_id)) + .with_metadata(json!({ + "task_id": task.task_id, + "task": task_metadata(manager, &task), + "status": status_label(&task.status), + "notify": task.notify, + "wake": task.wake, + "stall_wake_seconds": stall_armed, + }))) + } None => Err(anyhow::anyhow!("Task not found: {}", task_id)), } } @@ -760,6 +781,9 @@ impl Tool for BgTool { background::BackgroundTaskWaitReason::Checkpoint => { "Background task emitted a checkpoint event.\n\n".to_string() } + background::BackgroundTaskWaitReason::Stalled => { + "Background task stall watchdog fired: no output or progress for its stall window. The task is still running; inspect it and decide whether to keep waiting or cancel.\n\n".to_string() + } background::BackgroundTaskWaitReason::Timeout => format!( "No terminal event before max wait of {}s. Check again with `bg action=\"wait\" task_id=\"{}\"` or inspect status/output.\n\n", capped_wait, task_id diff --git a/crates/jcode-app-core/src/tool/browser.rs b/crates/jcode-app-core/src/tool/browser.rs index 75792d995a..277e566d28 100644 --- a/crates/jcode-app-core/src/tool/browser.rs +++ b/crates/jcode-app-core/src/tool/browser.rs @@ -18,7 +18,7 @@ impl BrowserTool { } fn browser_tool_description_text() -> &'static str { - "Control the browser. Use action='status' to check whether the browser bridge is ready. Use action='setup' only for first-time install or repair when status shows the bridge is not already ready. Do not run setup before every browser task." + "Control the browser. Check action='status' first; run setup only if not ready." } #[derive(Debug, Deserialize)] @@ -184,7 +184,7 @@ impl Tool for BrowserTool { "fill_form", "select", "wait", "screenshot", "eval", "scroll", "upload", "press", "provider_command" ], - "description": "Action. Use 'status' to check readiness first. Use 'setup' only for first-time install or repair, not before every browser task." + "description": "Action. Check 'status' first; run 'setup' only when the bridge is not ready." }), ); properties.insert( @@ -214,7 +214,7 @@ impl Tool for BrowserTool { ("tab_id", json!({"type": "integer"})), ( "window_id", - json!({"type": "integer", "description": "Scope the action to a specific browser window. Useful when multiple agents drive the browser in parallel."}), + json!({"type": "integer", "description": "Scope the action to one browser window when multiple agents share the browser."}), ), ("frame_id", json!({"type": "integer"})), ("all_frames", json!({"type": "boolean"})), @@ -393,11 +393,16 @@ async fn firefox_status( } if status.binary_installed { - return Ok(ToolOutput::new( - "Browser bridge binaries are installed, but the live bridge is not responding. Use action='setup' only if you want to repair the existing install. You do not need to run setup before every browser task.", - ) - .with_title("browser status") - .with_metadata(metadata)); + let firefox_running = crate::browser::is_firefox_running(); + metadata["firefox_running"] = json!(firefox_running); + let body = if firefox_running { + "Browser bridge binaries are installed and Firefox is running, but the live bridge is not responding. Check that the Browser Agent Bridge extension is enabled in the running Firefox profile. Use action='setup' only if you want to repair the existing install. You do not need to run setup before every browser task." + } else { + "Browser bridge binaries are installed, but Firefox is not running, so the bridge cannot respond. This is not a setup problem: setup is one-time. Run any normal browser action (for example action='open') and Firefox will be launched automatically, or start Firefox yourself and re-check status." + }; + return Ok(ToolOutput::new(body) + .with_title("browser status") + .with_metadata(metadata)); } metadata["backend"] = json!("unconfigured"); @@ -432,11 +437,26 @@ async fn ensure_firefox_ready() -> Result> { // A setup marker only proves that installation once completed. Always // verify the live bridge before launching an action because Firefox or the // extension may have stopped or become incompatible since then. - let status = crate::browser::ensure_browser_ready_noninteractive().await?; + let mut status = crate::browser::ensure_browser_ready_noninteractive().await?; if status.ready { return Ok(None); } + // The most common "not responding" cause after a completed setup is that + // Firefox simply is not running. That is not a setup problem, so launch + // Firefox and re-check instead of steering toward one-time setup/repair. + let mut launched_firefox = false; + if let Some(refreshed) = crate::browser::try_launch_firefox_for_bridge(&status).await? { + launched_firefox = true; + if refreshed.ready { + return Ok(Some( + "Firefox was not running, so it was launched automatically and the browser bridge reconnected." + .to_string(), + )); + } + status = refreshed; + } + let mut message = String::from( "Browser automation is not ready yet. Use the browser tool with action='status' to confirm current state. Only run action='setup' or `jcode browser setup` for first-time install or repair when the bridge is not already ready.\n", ); @@ -451,8 +471,12 @@ async fn ensure_firefox_ready() -> Result> { )); } message.push('\n'); + } else if launched_firefox { + message.push_str("Firefox was not running, so it was launched automatically, but the browser bridge is still not responding. The Browser Agent Bridge extension may be disabled or missing in this Firefox profile. This is not fixed by re-running setup unless the extension is actually missing.\n"); + } else if crate::browser::is_firefox_running() { + message.push_str("Firefox is running, but the browser bridge extension is not responding. Check that the Browser Agent Bridge extension is installed and enabled in the running Firefox profile. Do not re-run setup just because the bridge is silent.\n"); } else { - message.push_str("Browser bridge binaries are installed, but the live Firefox bridge is not responding.\n"); + message.push_str("Firefox is not running, so the browser bridge is not responding. Start Firefox, then retry the browser action. Setup is one-time and is not needed again.\n"); } message.push_str( "Normal browser tool calls will not reopen the installer automatically anymore. Do not retry browser actions until status reports ready. Continue with another available capability; if the goal requires an external capability unavailable in this session, use capability discovery.", diff --git a/crates/jcode-app-core/src/tool/browser_tests.rs b/crates/jcode-app-core/src/tool/browser_tests.rs index 01aefce575..9070c218ae 100644 --- a/crates/jcode-app-core/src/tool/browser_tests.rs +++ b/crates/jcode-app-core/src/tool/browser_tests.rs @@ -212,8 +212,7 @@ fn description_tells_models_to_check_status_before_setup() { let tool = BrowserTool::new(); let description = tool.description(); assert!(description.contains("action='status'")); - assert!(description.contains("action='setup' only")); - assert!(description.contains("Do not run setup before every browser task")); + assert!(description.contains("setup only if not ready")); } #[cfg(unix)] @@ -223,8 +222,11 @@ async fn readiness_does_not_trust_a_stale_setup_marker() { let _guard = jcode_base::storage::lock_test_env(); let prev_home = std::env::var_os("JCODE_HOME"); + let prev_autolaunch = std::env::var_os("JCODE_BROWSER_AUTOLAUNCH"); let temp = tempfile::TempDir::new().expect("create temp dir"); jcode_base::env::set_var("JCODE_HOME", temp.path()); + // Keep the test hermetic: never launch a real Firefox from here. + jcode_base::env::set_var("JCODE_BROWSER_AUTOLAUNCH", "0"); let browser_dir = temp.path().join("browser"); std::fs::create_dir_all(&browser_dir).expect("create browser dir"); @@ -254,4 +256,9 @@ async fn readiness_does_not_trust_a_stale_setup_marker() { } else { jcode_base::env::remove_var("JCODE_HOME"); } + if let Some(prev_autolaunch) = prev_autolaunch { + jcode_base::env::set_var("JCODE_BROWSER_AUTOLAUNCH", prev_autolaunch); + } else { + jcode_base::env::remove_var("JCODE_BROWSER_AUTOLAUNCH"); + } } diff --git a/crates/jcode-app-core/src/tool/communicate.rs b/crates/jcode-app-core/src/tool/communicate.rs index a6a52bb8f8..04bef03e7e 100644 --- a/crates/jcode-app-core/src/tool/communicate.rs +++ b/crates/jcode-app-core/src/tool/communicate.rs @@ -25,7 +25,8 @@ const REQUEST_ID: u64 = 1; /// Default number of workers `run_plan` keeps active at once for a **light**-mode /// plan. Light mode is the cheap fan-out preset, so this stays small. Deep mode -/// instead uses `agents.swarm_max_concurrent_agents` (high, configurable). +/// instead uses `agents.swarm_max_concurrent_agents` (configurable and shared +/// with the server's recursive-spawn RAM safety guard). const LIGHT_MODE_DEFAULT_CONCURRENCY: usize = 4; mod transport; @@ -1737,22 +1738,22 @@ fn format_swarm_model_list( ) -> String { let mut out = String::new(); out.push_str(&format!( - "Current model (spawn default when no override): {}\n", + "Current coordinator model: {}\n", current_model.unwrap_or("unknown") )); match configured_swarm_model { Some(pin) if !pin.trim().is_empty() => { - out.push_str(&format!("Configured agents.swarm_model pin: {pin}\n")); + out.push_str(&format!("Configured agents.swarm_model default: {pin}\n")); } - _ => out.push_str("No agents.swarm_model pin configured (workers inherit the coordinator's model unless a per-spawn model is passed).\n"), + _ => out.push_str( + "No agents.swarm_model default configured (workers inherit the coordinator's model unless model is passed).\n", + ), } if model_routes.is_empty() { - out.push_str( - "\nNo model routes reported. Spawn with a bare model name or omit model to inherit.", - ); + out.push_str("\nNo model routes reported. Omit model to use the configured default, or pass inherit to use the coordinator."); return out; } - out.push_str("\nAvailable model routes (pass as spawn model, e.g. 'gpt-5.5' or route-pinned 'openai-api:gpt-5.5'):\n"); + out.push_str("\nAvailable model routes (pass model with a bare model or route-pinned value to override the configured default):\n"); for route in model_routes { let availability = if route.available { "" @@ -1773,7 +1774,7 @@ fn format_swarm_model_list( route.model, route.provider, route.api_method, availability, cost, detail )); } - out.push_str("\nAlso pass effort (none|low|medium|high|xhigh|max) to set the spawned agent's reasoning effort."); + out.push_str("\nAlso pass effort (none|minimal|low|medium|high|xhigh|max) to set the spawned agent's reasoning effort."); out } @@ -1786,8 +1787,13 @@ pub struct CommunicateTool { impl CommunicateTool { pub fn new() -> Self { - const BASE_DESCRIPTION: &str = "Coordinate agents. Any agent can spawn child agents, and those children can spawn their own, forming a recursive spawn tree with no depth limit (growth is bounded only by the total swarm member cap). For spawn, prefer providing a prompt so the new agent starts with a concrete task instead of idling. Spawned/assigned agents automatically report their final response back to the agent that spawned them; you can stop any agent in the subtree you spawned.\n\nCommunication: prefer structural dataflow (task-graph artifacts via complete_node) over chat, and DMs for point-to-point coordination. broadcast reaches only your spawned subtree (whole swarm for the coordinator) and should be rare; channels and shared-context are discouraged legacy primitives."; - let swarm_prompt = crate::prompt::load_swarm_prompt(None); + Self::new_for_working_dir(None) + } + + fn new_for_working_dir(working_dir: Option<&std::path::Path>) -> Self { + const BASE_DESCRIPTION: &str = + "Coordinate agents: spawn workers with a prompt, message them, and manage swarm plans."; + let swarm_prompt = crate::prompt::load_swarm_prompt(working_dir); let description = if swarm_prompt.is_empty() { BASE_DESCRIPTION.to_string() } else { @@ -1881,13 +1887,13 @@ struct CommunicateInput { /// threshold. #[serde(default)] tldr: Option, - /// Per-spawn model override for spawn/assign_task/assign_next/run_plan - /// spawns. Takes precedence over agents.swarm_model config. - #[serde(default)] - model: Option, - /// Reasoning effort for spawned agents (none|low|medium|high|xhigh|max). + /// Reasoning effort for spawned agents (none|minimal|low|medium|high|xhigh|max). #[serde(default)] effort: Option, + /// Per-worker model override for spawn and assignment-created workers. + /// Takes precedence over agents.swarm_model; see list_models for routes. + #[serde(default)] + model: Option, /// Short human-readable label for a spawned agent shown in swarm UI. /// Required and nonblank for the explicit `spawn` action. #[serde(default)] @@ -1896,7 +1902,16 @@ struct CommunicateInput { impl CommunicateInput { fn spawn_initial_message(&self) -> Option { - self.initial_message.clone().or_else(|| self.prompt.clone()) + self.initial_message + .as_ref() + .filter(|message| !message.trim().is_empty()) + .cloned() + .or_else(|| { + self.prompt + .as_ref() + .filter(|prompt| !prompt.trim().is_empty()) + .cloned() + }) } fn required_spawn_label(&self) -> anyhow::Result { @@ -1957,26 +1972,26 @@ impl Tool for CommunicateTool { "task_graph", "expand_node", "complete_node", "inject_gap", "start", "start_task", "wake", "resume", "retry", "reassign", "replace", "salvage", "subscribe_channel", "unsubscribe_channel", "await_members", "list_models"], - "description": "Action. Spawn requires a nonblank label and should include prompt with the initial task so the new agent starts useful work immediately. Use list_models to see which models/routes are available for per-spawn model selection." + "description": "Action. spawn requires label and should include prompt. list_models shows available models/routes." }, "key": { "type": "string", - "description": "Shared-context key for share/share_append/read. Discouraged: prefer the repo and typed node artifacts as the shared medium; use shared context only for small non-repo state." + "description": "Shared-context key for share/share_append/read. Discouraged: prefer the repo and node artifacts." }, "value": { "type": "string" }, "message": { "type": "string", - "description": "Message body. For action=message, routes by fields provided: with to_session it is a DM, with channel it posts to that channel, with neither it broadcasts to your spawned subtree. For action=report, this is the completion report body." + "description": "Message body: DM with to_session, channel post with channel, else broadcast. For report, the body." }, "tldr": { "type": "string", - "description": "One-line summary (aim for under 120 chars) of the message/report. Required for message/broadcast/dm/channel/report when the body is longer than 240 chars. The recipient's UI shows this collapsed with an expand control instead of the full body." + "description": "One-line summary under ~120 chars. Required for message/report bodies longer than 240 chars." }, "status": { "type": "string", - "description": "For action=report: completion status to record, usually ready, blocked, failed, or completed. Defaults to ready." + "description": "For report: usually ready, blocked, failed, or completed. Defaults to ready." }, "validation": { "type": "string", @@ -1988,17 +2003,17 @@ impl Tool for CommunicateTool { }, "to_session": { "type": "string", - "description": "Target session for actions that address one agent (dm, and as an alias for target_session). Accepts an exact session ID or a unique friendly name within the swarm. Interchangeable with target_session. If a friendly name is ambiguous, run swarm list and use the exact session ID." + "description": "Session ID or unique friendly name of one agent. Alias of target_session." }, "channel": { "type": "string", - "description": "Channel name. For action=channel (or action=message with a channel) the message goes to subscribers of this channel. Also used by subscribe_channel/unsubscribe_channel/channel_members. Discouraged: prefer DMs and task-graph artifacts over ad hoc channels." + "description": "Channel name for channel actions. Discouraged: prefer DMs and task-graph artifacts." }, "proposer_session": { "type": "string" }, "reason": { "type": "string" }, "target_session": { "type": "string", - "description": "Target session for management actions (assign_role, summary, status, stop, start, resume, wake, etc.). Accepts an exact session ID or a unique friendly name. Interchangeable with to_session." + "description": "Session ID or unique friendly name for management actions. Alias of to_session." }, "role": { "type": "string", @@ -2007,7 +2022,7 @@ impl Tool for CommunicateTool { "label": { "type": "string", "minLength": 1, - "description": "Required for spawn. Short nonblank label shown on the spawned agent's chip in swarm UI (e.g. 'api reviewer')." + "description": "Required for spawn. Short label shown on the agent's chip (e.g. 'api reviewer')." }, "working_dir": { "type": "string", @@ -2015,11 +2030,11 @@ impl Tool for CommunicateTool { }, "prompt": { "type": "string", - "description": "Preferred for spawn. Initial task/instructions for the new agent. Spawning without prompt usually creates an idle agent that needs follow-up assignment." + "description": "Initial task/instructions for spawn. Spawning without it creates an idle agent." }, "initial_message": { "type": "string", - "description": "Explicit initial task/instructions for spawn. If both initial_message and prompt are supplied, initial_message wins." + "description": "Alias of prompt for spawn; wins when both are set." }, "limit": { "type": "integer", @@ -2028,29 +2043,29 @@ impl Tool for CommunicateTool { }, "task_id": { "type": "string", - "description": "Optional plan task ID. If omitted for assign_task/assign_next, the coordinator picks a runnable task. If omitted for resume/wake/retry/start with target_session, the server resumes the unique assigned task for that session." + "description": "Optional plan task ID. When omitted the coordinator picks or resumes the relevant task." }, "spawn_if_needed": { "type": "boolean", - "description": "For assign_task without an explicit target_session: if no reusable agent is available, spawn a fresh agent and retry the assignment automatically." + "description": "For assign_task: spawn a fresh agent when no reusable one is available." }, "prefer_spawn": { "type": "boolean", - "description": "For assign_task without an explicit target_session: prefer a fresh spawned agent even if reusable workers are available." + "description": "For assign_task: prefer spawning fresh over reusing an idle worker." }, "spawn_mode": { "type": "string", "enum": ["visible", "headless", "inline", "auto"], - "description": "Per-call spawn mode for swarm-created agents. Overrides agents.swarm_spawn_mode config when set. 'visible' opens a terminal window, 'headless' runs in-process with no UI, 'inline' runs in-process and renders a live gallery viewport in the coordinator, 'auto' tries visible then falls back to headless. Defaults to inline." + "description": "Spawn UI mode: visible terminal, headless, inline gallery, or auto. Defaults to inline." }, "model": { "type": "string", - "description": "Optional model for the spawned agent (spawn, and spawns triggered by assign_task/assign_next/run_plan). Overrides the agents.swarm_model config pin for this call. Accepts a bare model name (e.g. 'gpt-5.5') or an auth-route-prefixed form (e.g. 'openai-api:gpt-5.5', 'claude-api:claude-fable-5'). Use 'inherit' to force coordinator inheritance. Omit to use the configured/coordinator default. Run action=list_models to see available models and routes." + "description": "Model for newly spawned workers (spawn, assign_task, assign_next, fill_slots, run_plan), e.g. 'gpt-6-astra' or 'openai-api:gpt-5.6-luna'. Overrides agents.swarm_model. Omit to use that default or inherit the coordinator if unset. Use 'inherit' to force the coordinator's model and route. Does not change reused workers. See list_models." }, "effort": { "type": "string", - "enum": ["none", "low", "medium", "high", "xhigh", "max"], - "description": "Optional reasoning effort for the spawned agent. Omit for the model's default. Only meaningful with spawn-creating actions." + "enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + "description": "Optional reasoning effort for spawned agents. Omit for the model default." }, "session_ids": { "type": "array", @@ -2059,7 +2074,7 @@ impl Tool for CommunicateTool { "mode": { "type": "string", "enum": ["all", "any", "deep", "light"], - "description": "For task_graph: deep enables comprehensive gated execution and light enables simple fan-out. For await_members: wait for all targeted members or wake when any targeted member matches." + "description": "task_graph: deep (gated) or light (fan-out). await_members: all or any." }, "target_status": { "type": "array", @@ -2073,16 +2088,16 @@ impl Tool for CommunicateTool { }, "background": { "type": "boolean", - "description": "For run_plan: run as a detached background task (default true); set false to block until the plan resolves. await_members is always asynchronous and ignores false so the agent stays responsive; its result is delivered later via notify/wake." + "description": "For run_plan: detach as a background task (default true); false blocks until the plan resolves." }, "notify": { "type": "boolean", - "description": "For await_members/run_plan: surface a notification card when the background task resolves. Defaults to true." + "description": "For await_members/run_plan: show a notification when resolved. Defaults to true." }, "concurrency_limit": { "type": "integer", "minimum": 1, - "description": "Max swarm worker agents active at once. For fill_slots this is required. For run_plan it is optional and overrides the mode-based default (deep fans out wide up to agents.swarm_max_concurrent_agents; light uses a small default). Total agents over the whole run is still bounded only by the swarm member cap." + "description": "Max live workers. Required for fill_slots; optional override for run_plan." }, "force": { "type": "boolean", @@ -2090,11 +2105,11 @@ impl Tool for CommunicateTool { }, "retain_agents": { "type": "boolean", - "description": "For run_plan: keep spawned workers after the plan reaches a terminal state. Defaults to false, so owned workers are cleaned up." + "description": "For run_plan: keep spawned workers after the plan finishes. Defaults to false." }, "wake": { "type": "boolean", - "description": "Optional wake hint for messages. For await_members/run_plan: wake this agent with the result when the background task resolves (default true); if false, only notify." + "description": "Wake this agent when a message or awaited background task resolves (default true)." }, "delivery": { "type": "string", @@ -2135,7 +2150,7 @@ impl Tool for CommunicateTool { "nodes".to_string(), json!({ "type": "array", - "description": "Task-DAG node specs for task_graph (seed), expand_node (children), or inject_gap (gap/fix nodes). Each: {id, content, kind?, depends_on?, priority?}. kind is one of explore|implement|verify|fix|synthesize.", + "description": "Node specs for task_graph/expand_node/inject_gap. Each: {id, content, kind?, depends_on?, priority?}.", "items": { "type": "object", "additionalProperties": true } }), ); @@ -2143,7 +2158,7 @@ impl Tool for CommunicateTool { "artifact".to_string(), json!({ "type": "object", - "description": "Typed handoff artifact for complete_node. In deep mode requires non-empty 'findings', a 'what_i_did_not_check' list, and a 'confidence' of low|medium|high (report low honestly; it routes follow-up work). Deep gates cannot pass while a low-confidence sibling is unaddressed: inject_gap or name the id in findings. Fields: findings, evidence[], edge_cases_considered[], validation, open_questions[], confidence, what_i_did_not_check[].", + "description": "Handoff artifact for complete_node: findings, evidence[], validation, open_questions[], confidence.", "additionalProperties": true }), ); @@ -2167,7 +2182,12 @@ impl Tool for CommunicateTool { "type": "object", "required": ["action", "label"], "properties": { - "action": { "type": "string", "enum": ["spawn"] } + "action": { "type": "string", "enum": ["spawn"] }, + // Gemini validates that every `required` name is defined in + // the same object's `properties` and rejects the whole + // request otherwise (issue #655), so declare `label` here + // instead of relying on the parent schema's declaration. + "label": { "type": "string", "minLength": 1 } } }, { diff --git a/crates/jcode-app-core/src/tool/communicate/transport.rs b/crates/jcode-app-core/src/tool/communicate/transport.rs index 8f09f651ac..9ad3f4c0da 100644 --- a/crates/jcode-app-core/src/tool/communicate/transport.rs +++ b/crates/jcode-app-core/src/tool/communicate/transport.rs @@ -3,6 +3,33 @@ use anyhow::Result; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +const SERVER_NOT_RUNNING: &str = "jcode server is not running; start it with jcode server start"; + +fn map_socket_connection_error(err: anyhow::Error) -> anyhow::Error { + let server_is_unavailable = err.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io_err| { + matches!( + io_err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) + }) + }); + + if server_is_unavailable { + anyhow::anyhow!(SERVER_NOT_RUNNING) + } else { + err + } +} + +async fn connect_swarm_socket(path: &std::path::Path) -> Result { + crate::server::connect_socket(path) + .await + .map_err(map_socket_connection_error) +} + fn request_type_from_json(json: &str) -> String { serde_json::from_str::(json) .ok() @@ -24,7 +51,7 @@ pub(super) async fn send_request_with_timeout( timeout: Option, ) -> Result { let path = crate::server::socket_path(); - let stream = crate::server::connect_socket(&path).await?; + let stream = connect_swarm_socket(&path).await?; let (reader, mut writer) = stream.into_split(); let request_id = request.id(); @@ -117,3 +144,36 @@ pub(super) async fn send_request_with_timeout( } } } + +#[cfg(test)] +mod tests { + use super::{SERVER_NOT_RUNNING, connect_swarm_socket}; + + #[tokio::test] + async fn missing_daemon_socket_has_actionable_error() { + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("missing.sock"); + + let err = connect_swarm_socket(&socket_path) + .await + .expect_err("missing socket should fail"); + + assert_eq!(err.to_string(), SERVER_NOT_RUNNING); + } + + #[cfg(unix)] + #[tokio::test] + async fn refused_daemon_socket_has_actionable_error() { + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("refused.sock"); + { + let _listener = crate::transport::Listener::bind(&socket_path).expect("bind listener"); + } + + let err = connect_swarm_socket(&socket_path) + .await + .expect_err("stale socket should refuse the connection"); + + assert_eq!(err.to_string(), SERVER_NOT_RUNNING); + } +} diff --git a/crates/jcode-app-core/src/tool/communicate_tests.rs b/crates/jcode-app-core/src/tool/communicate_tests.rs index f11156211d..91c7da5211 100644 --- a/crates/jcode-app-core/src/tool/communicate_tests.rs +++ b/crates/jcode-app-core/src/tool/communicate_tests.rs @@ -896,18 +896,21 @@ fn in_flight_count_excludes_foreign_queued_session() { fn latest_assistant_report_uses_last_non_empty_assistant_message() { let messages = vec![ HistoryMessage { + response_stats: None, role: "assistant".to_string(), content: " earlier ".to_string(), tool_calls: None, tool_data: None, }, HistoryMessage { + response_stats: None, role: "user".to_string(), content: "ignored".to_string(), tool_calls: None, tool_data: None, }, HistoryMessage { + response_stats: None, role: "assistant".to_string(), content: " final report ".to_string(), tool_calls: None, @@ -978,7 +981,7 @@ fn schema_advertises_model_and_effort_spawn_overrides() { .as_object() .expect("swarm schema should have properties"); - assert!(props.contains_key("model")); + assert_eq!(props["model"]["type"], json!("string")); assert!( props["model"]["description"] .as_str() @@ -989,7 +992,7 @@ fn schema_advertises_model_and_effort_spawn_overrides() { assert!(props.contains_key("effort")); assert_eq!( props["effort"]["enum"], - json!(["none", "low", "medium", "high", "xhigh", "max"]) + json!(["none", "minimal", "low", "medium", "high", "xhigh", "max"]) ); assert!( schema["properties"]["action"]["enum"] @@ -1013,7 +1016,7 @@ fn schema_requires_a_nonblank_label_for_spawn() { schema["properties"]["action"]["description"] .as_str() .expect("action description") - .contains("Spawn requires a nonblank label") + .contains("spawn requires label") ); let branches = schema["anyOf"] @@ -1036,6 +1039,29 @@ fn schema_requires_a_nonblank_label_for_spawn() { assert_eq!(non_spawn_branch["required"], json!(["action"])); } +#[test] +fn schema_branches_only_require_properties_they_declare() { + // Gemini rejects the entire request when a `required` entry names a property + // the same object does not define, which made every tool-enabled Gemini call + // fail on this tool's spawn branch (issue #655). + let schema = CommunicateTool::new().parameters_schema(); + for branch in schema["anyOf"].as_array().expect("schema branches") { + let declared = branch["properties"] + .as_object() + .expect("branch properties") + .keys() + .cloned() + .collect::>(); + for required in branch["required"].as_array().expect("branch required") { + let name = required.as_str().expect("required name"); + assert!( + declared.iter().any(|known| known == name), + "branch requires '{name}' without declaring it: {branch}" + ); + } + } +} + #[test] fn spawn_label_validation_rejects_missing_or_blank_labels() { let missing: CommunicateInput = @@ -1093,6 +1119,10 @@ async fn spawn_execute_rejects_missing_label_before_sending_request() { fn description_includes_swarm_prompt_guidance() { let tool = CommunicateTool::new(); let description = tool.description(); + assert!( + description.starts_with("Coordinate agents"), + "description should lead with the short coordination summary" + ); assert!( description.contains("Swarm prompt"), "description should embed the swarm prompt section" @@ -1100,7 +1130,77 @@ fn description_includes_swarm_prompt_guidance() { } #[test] -fn format_swarm_model_list_renders_routes_and_pin() { +fn existing_tool_keeps_prompt_while_new_tool_loads_edit() { + let project = tempfile::tempdir().unwrap(); + let prompt_dir = project.path().join(".jcode"); + std::fs::create_dir_all(&prompt_dir).unwrap(); + let prompt_path = prompt_dir.join("swarm-prompt.md"); + std::fs::write(&prompt_path, "first routing version").unwrap(); + + let existing = CommunicateTool::new_for_working_dir(Some(project.path())); + std::fs::write(&prompt_path, "second routing version").unwrap(); + let newly_created = CommunicateTool::new_for_working_dir(Some(project.path())); + + assert!(existing.description().contains("first routing version")); + assert!(!existing.description().contains("second routing version")); + assert!( + newly_created + .description() + .contains("second routing version") + ); +} + +#[test] +fn spawning_action_inputs_preserve_requested_model() { + for action in [ + "spawn", + "assign_task", + "assign_next", + "fill_slots", + "run_plan", + ] { + for model in [ + "z-ai/glm-5.2:free", + "openai-api:gpt-5.5", + "inherit", + "coordinator", + " ", + ] { + let input: CommunicateInput = serde_json::from_value(json!({ + "action": action, + "label": "reviewer", + "model": model + })) + .unwrap(); + assert_eq!(input.model.as_deref(), Some(model)); + } + } +} + +#[test] +fn spawning_action_inputs_allow_omitted_or_null_model() { + for action in [ + "spawn", + "assign_task", + "assign_next", + "fill_slots", + "run_plan", + ] { + let without_model: CommunicateInput = + serde_json::from_value(json!({"action": action, "label": "reviewer"})).unwrap(); + assert!(without_model.model.is_none()); + let null_model: CommunicateInput = serde_json::from_value(json!({ + "action": action, + "label": "reviewer", + "model": null + })) + .unwrap(); + assert!(null_model.model.is_none()); + } +} + +#[test] +fn format_swarm_model_list_renders_routes_and_default() { let routes = vec![ jcode_provider_core::ModelRoute { model: "gpt-5.5".to_string(), @@ -1108,6 +1208,7 @@ fn format_swarm_model_list_renders_routes_and_pin() { api_method: "openai-api-key".to_string(), available: true, detail: "API key".to_string(), + usage: None, cheapness: None, }, jcode_provider_core::ModelRoute { @@ -1116,13 +1217,14 @@ fn format_swarm_model_list_renders_routes_and_pin() { api_method: "anthropic-api-key".to_string(), available: false, detail: String::new(), + usage: None, cheapness: None, }, ]; let output = format_swarm_model_list(Some("claude-fable-5"), Some("openai-api:gpt-5.5"), &routes); - assert!(output.contains("Current model (spawn default when no override): claude-fable-5")); - assert!(output.contains("Configured agents.swarm_model pin: openai-api:gpt-5.5")); + assert!(output.contains("Current coordinator model: claude-fable-5")); + assert!(output.contains("Configured agents.swarm_model default: openai-api:gpt-5.5")); assert!(output.contains("gpt-5.5 via OpenAI [openai-api-key] (API key)")); assert!(output.contains("claude-fable-5 via Anthropic [anthropic-api-key] [unavailable]")); assert!(output.contains("effort")); @@ -1131,8 +1233,9 @@ fn format_swarm_model_list_renders_routes_and_pin() { #[test] fn format_swarm_model_list_handles_empty_catalog() { let output = format_swarm_model_list(None, None, &[]); - assert!(output.contains("Current model (spawn default when no override): unknown")); - assert!(output.contains("No agents.swarm_model pin configured")); + assert!(output.contains("Current coordinator model: unknown")); + assert!(output.contains("No agents.swarm_model default configured")); + assert!(output.contains("unless model is passed")); assert!(output.contains("No model routes reported")); } @@ -1150,9 +1253,7 @@ fn schema_advertises_supported_swarm_fields() { assert!(props.contains_key("to_session")); assert_eq!( props["to_session"]["description"], - json!( - "Target session for actions that address one agent (dm, and as an alias for target_session). Accepts an exact session ID or a unique friendly name within the swarm. Interchangeable with target_session. If a friendly name is ambiguous, run swarm list and use the exact session ID." - ) + json!("Session ID or unique friendly name of one agent. Alias of target_session.") ); assert!(props.contains_key("channel")); assert!(props.contains_key("proposer_session")); @@ -1160,9 +1261,7 @@ fn schema_advertises_supported_swarm_fields() { assert!(props.contains_key("target_session")); assert_eq!( props["target_session"]["description"], - json!( - "Target session for management actions (assign_role, summary, status, stop, start, resume, wake, etc.). Accepts an exact session ID or a unique friendly name. Interchangeable with to_session." - ) + json!("Session ID or unique friendly name for management actions. Alias of to_session.") ); assert!(props.contains_key("role")); assert!(props.contains_key("prompt")); @@ -1383,6 +1482,8 @@ impl RawClient { client_instance_id: None, client_has_local_history: false, allow_session_takeover: false, + crash_on_disconnect: false, + continue_on_disconnect: false, terminal_env: Vec::new(), }) .await?; @@ -1418,6 +1519,8 @@ impl RawClient { content: content.to_string(), images: vec![], system_reminder: None, + active_skill: None, + no_reply: false, }) .await } diff --git a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs index b20f536147..0e9fa7f664 100644 --- a/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs +++ b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs @@ -20,6 +20,27 @@ fn spawn_initial_message_accepts_prompt_alias_and_prefers_explicit_initial_messa preferred.spawn_initial_message().as_deref(), Some("preferred") ); + + for blank_initial_message in ["", " \t\n"] { + let from_prompt: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": blank_initial_message, + "prompt": "fallback" + })) + .expect("spawn payload should deserialize"); + assert_eq!( + from_prompt.spawn_initial_message().as_deref(), + Some("fallback") + ); + } + + let blank_messages: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": "", + "prompt": " " + })) + .expect("spawn payload should deserialize"); + assert_eq!(blank_messages.spawn_initial_message(), None); } #[test] @@ -244,6 +265,7 @@ fn format_members_renders_activity_progress_churn_and_turns() { }), provider_name: Some("anthropic".to_string()), provider_model: Some("claude-sonnet".to_string()), + provider_effort: Some("medium".to_string()), turn_count: Some(7), recent_total_tokens: Some(12_345), recent_output_tokens: Some(2_000), @@ -260,7 +282,10 @@ fn format_members_renders_activity_progress_churn_and_turns() { assert!(text.contains("12.3k tok/10s"), "got: {text}"); assert!(text.contains("7 turns"), "got: {text}"); assert!(text.contains("98.8k tok total"), "got: {text}"); - assert!(text.contains("Model: anthropic/claude-sonnet"), "got: {text}"); + assert!( + text.contains("Model: anthropic/claude-sonnet (medium)"), + "got: {text}" + ); // Running agent shows current-turn duration, not an "idle" label. assert!(text.contains("· 8s"), "got: {text}"); // Running agent also surfaces last observed activity so a long turn does diff --git a/crates/jcode-app-core/src/tool/config_edit_notice.rs b/crates/jcode-app-core/src/tool/config_edit_notice.rs new file mode 100644 index 0000000000..8202519d12 --- /dev/null +++ b/crates/jcode-app-core/src/tool/config_edit_notice.rs @@ -0,0 +1,118 @@ +//! Tell the agent (and through it, the user) what a config.toml edit did. +//! +//! When a user asks jcode to change a setting, the agent writes +//! `~/.jcode/config.toml` and then has to guess whether the change took +//! effect. That guess is where the confusion comes from. Instead, every file +//! write that lands on the active config file appends an explicit report: +//! which keys changed, and whether each one is live in running sessions or +//! needs a restart. + +use std::path::Path; + +/// Resolve a path for comparison, falling back to the path as given. +/// +/// A config file that does not exist yet cannot be canonicalized, and that is +/// a normal case here (the very first write creates it), so the unresolved +/// path is the correct answer rather than an error to report. +fn comparable(path: &Path) -> std::path::PathBuf { + match std::fs::canonicalize(path) { + Ok(resolved) => resolved, + Err(_) => path.to_path_buf(), + } +} + +/// Whether `path` is the config file the running process actually reads. +/// +/// Compares resolved paths so `~/.jcode/config.toml`, a relative path, and a +/// symlinked jcode home all resolve to the same file. +fn is_active_config_file(path: &Path) -> bool { + let Some(config_path) = crate::config::Config::path() else { + return false; + }; + comparable(path) == comparable(&config_path) +} + +/// Report appended to a tool result after a write to the active config file. +/// +/// Returns `None` for non-config files and for edits that changed no settings +/// (comments or formatting), so ordinary writes stay untouched. +pub fn config_edit_notice(path: &Path, before: &str, after: &str) -> Option { + if !is_active_config_file(path) { + return None; + } + // Force the next config() call to re-read instead of waiting out the + // staleness throttle, so "live now" is true the moment it is claimed. + crate::config::Config::invalidate_cache(); + + // A config file that no longer parses is silently ignored by + // `Config::load`, which falls back to defaults. That is the worst possible + // outcome to leave unreported: the write "succeeded" while every setting + // in the file quietly stopped applying. Surface it instead. + if let Err(error) = crate::config::Config::load_strict() { + return Some(format!( + "\n\nWARNING: {} no longer parses as TOML, so jcode is falling back to \ + default settings and every setting in this file is being ignored. \ + Fix the syntax error: {error}", + path.display() + )); + } + + let summary = crate::config::change_report::summarize_toml_change(before, after)?; + Some(format!("\n\n{summary}")) +} + +/// Append [`config_edit_notice`] to a tool output body when applicable. +pub fn append_config_edit_notice(body: &mut String, path: &Path, before: &str, after: &str) { + if let Some(notice) = config_edit_notice(path, before, after) { + body.push_str(¬ice); + } +} + +/// Read the config file, treating "absent or unreadable" as empty. +/// +/// An absent config file is the normal pre-state for the write that creates +/// it, and an unreadable one is reported by the change summary itself, so +/// there is no error here worth propagating: empty is the meaningful value. +fn read_config_text(path: &Path) -> String { + std::fs::read_to_string(path).unwrap_or_default() +} + +/// Watches the active config file across a whole tool invocation. +/// +/// Tools that touch several files, or write through several code paths (patch +/// application, moves, deletes), cannot easily thread before/after content to +/// the place that builds the result string. This captures the config file +/// content up front and re-reads it at the end, so a config edit is reported +/// no matter which path produced it. +pub struct ConfigEditWatch { + path: Option, + before: String, +} + +impl ConfigEditWatch { + /// Snapshot the active config file before a tool runs. + pub fn begin() -> Self { + let path = crate::config::Config::path(); + let before = match path.as_deref() { + Some(path) => read_config_text(path), + None => String::new(), + }; + Self { path, before } + } + + /// Append a change report if the config file changed while the tool ran. + pub fn finish(self, body: &mut String) { + let Some(path) = self.path else { + return; + }; + let after = read_config_text(&path); + if after == self.before { + return; + } + append_config_edit_notice(body, &path, &self.before, &after); + } +} + +#[cfg(test)] +#[path = "config_edit_notice_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/tool/config_edit_notice_tests.rs b/crates/jcode-app-core/src/tool/config_edit_notice_tests.rs new file mode 100644 index 0000000000..7ab7f51729 --- /dev/null +++ b/crates/jcode-app-core/src/tool/config_edit_notice_tests.rs @@ -0,0 +1,237 @@ +use super::*; + +/// Point the process at a temp jcode home and return it with a restore guard. +fn temp_jcode_home() -> (tempfile::TempDir, Option) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let prev = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", dir.path()); + crate::config::Config::invalidate_cache(); + (dir, prev) +} + +fn restore_jcode_home(prev: Option) { + if let Some(prev) = prev { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); +} + +#[test] +fn writing_the_active_config_reports_what_changed() { + let _guard = crate::storage::lock_test_env(); + let (_dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "[keybindings]\nscroll_up = \"ctrl+y\"\n").expect("write"); + + let notice = config_edit_notice( + &path, + "[keybindings]\nscroll_up = \"ctrl+k\"\n", + "[keybindings]\nscroll_up = \"ctrl+y\"\n", + ) + .expect("an active-config edit should be reported"); + + assert!(notice.contains("keybindings.scroll_up"), "{notice}"); + assert!(notice.contains("live now"), "{notice}"); + + restore_jcode_home(prev); +} + +#[test] +fn writing_an_unrelated_file_reports_nothing() { + let _guard = crate::storage::lock_test_env(); + let (dir, prev) = temp_jcode_home(); + + let other = dir.path().join("notes.toml"); + std::fs::write(&other, "[display]\ncentered = true\n").expect("write"); + + assert!( + config_edit_notice(&other, "", "[display]\ncentered = true\n").is_none(), + "only the active config file should get a change report" + ); + + restore_jcode_home(prev); +} + +#[test] +fn comment_only_config_edit_reports_nothing() { + let _guard = crate::storage::lock_test_env(); + let (_dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + let after = "# note\n[display]\ncentered = true\n"; + std::fs::write(&path, after).expect("write"); + + assert!( + config_edit_notice(&path, "[display]\ncentered = true\n", after).is_none(), + "a comment-only edit must not claim a settings change" + ); + + restore_jcode_home(prev); +} + +#[test] +fn restart_required_sections_say_so() { + let _guard = crate::storage::lock_test_env(); + let (_dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + let after = "[gateway]\nport = 8888\n"; + std::fs::write(&path, after).expect("write"); + + let notice = + config_edit_notice(&path, "[gateway]\nport = 7777\n", after).expect("report expected"); + assert!( + notice.contains("Restart required for: gateway.port"), + "{notice}" + ); + + restore_jcode_home(prev); +} + +#[test] +fn the_notice_leaves_the_config_cache_current() { + let _guard = crate::storage::lock_test_env(); + let (_dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "[display]\ncentered = false\n").expect("write"); + assert!(!crate::config::config().display.centered); + + // Rewrite immediately: without the notice's explicit invalidation this can + // land inside the config cache's staleness throttle. + let after = "[display]\ncentered = true\n"; + std::fs::write(&path, after).expect("rewrite"); + let notice = + config_edit_notice(&path, "[display]\ncentered = false\n", after).expect("report expected"); + + assert!(notice.contains("live now"), "{notice}"); + assert!( + crate::config::config().display.centered, + "claiming 'live now' requires the config cache to already reflect the edit" + ); + + restore_jcode_home(prev); +} + +#[test] +fn a_config_write_that_breaks_toml_syntax_is_reported_loudly() { + let _guard = crate::storage::lock_test_env(); + let (_dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + let broken = "[display\ncentered = true\n"; + std::fs::write(&path, broken).expect("write"); + + let notice = config_edit_notice(&path, "[display]\ncentered = true\n", broken) + .expect("a config file that stopped parsing must never be silent"); + assert!(notice.contains("WARNING"), "{notice}"); + assert!(notice.contains("no longer parses"), "{notice}"); + + restore_jcode_home(prev); +} + +/// End-to-end through the real `write` tool: the path an agent actually takes +/// when a user says "change this setting". +#[tokio::test] +async fn the_write_tool_reports_config_changes_end_to_end() { + use crate::tool::{Tool, ToolContext}; + + let _guard = crate::storage::lock_test_env(); + let (dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write( + &path, + "[display]\ncentered = false\n\n[gateway]\nport = 7777\n", + ) + .expect("seed config"); + assert!(!crate::config::config().display.centered); + + let ctx = ToolContext { + session_id: "test".to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(dir.path().to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + let output = crate::tool::write::WriteTool + .execute( + serde_json::json!({ + "file_path": path.to_string_lossy(), + "content": "[display]\ncentered = true\n\n[gateway]\nport = 8888\n", + }), + ctx, + ) + .await + .expect("write should succeed"); + + let body = output.output; + assert!(body.contains("display.centered"), "{body}"); + assert!(body.contains("live now"), "{body}"); + assert!( + body.contains("Restart required for: gateway.port"), + "{body}" + ); + assert!( + crate::config::config().display.centered, + "the display change should be live in-process immediately after the write" + ); + + restore_jcode_home(prev); +} + +/// `apply_patch` reaches config.toml through its own write paths, so it gets +/// the same report as write/edit. +#[tokio::test] +async fn apply_patch_reports_config_changes() { + use crate::tool::{Tool, ToolContext}; + + let _guard = crate::storage::lock_test_env(); + let (dir, prev) = temp_jcode_home(); + + let path = crate::config::Config::path().expect("config path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + std::fs::write(&path, "[display]\ncentered = false\n").expect("seed config"); + assert!(!crate::config::config().display.centered); + + let ctx = ToolContext { + session_id: "test".to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(dir.path().to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + let patch_text = format!( + "*** Begin Patch\n*** Update File: {}\n@@\n-centered = false\n+centered = true\n*** End Patch\n", + path.display() + ); + let output = crate::tool::apply_patch::ApplyPatchTool + .execute(serde_json::json!({ "patch_text": patch_text }), ctx) + .await + .expect("patch should apply"); + + let body = output.output; + assert!(body.contains("display.centered"), "{body}"); + assert!(body.contains("live now"), "{body}"); + assert!( + crate::config::config().display.centered, + "the patched setting should be live immediately" + ); + + restore_jcode_home(prev); +} diff --git a/crates/jcode-app-core/src/tool/discover.rs b/crates/jcode-app-core/src/tool/discover.rs index 46b03347cb..1847f74f17 100644 --- a/crates/jcode-app-core/src/tool/discover.rs +++ b/crates/jcode-app-core/src/tool/discover.rs @@ -1,3 +1,4 @@ +use super::discover_secrets::contains_recognizable_secret; use super::{Tool, ToolContext, ToolExecutionMode, ToolOutput}; use anyhow::Result; use async_trait::async_trait; @@ -13,6 +14,7 @@ use std::time::Instant; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3); const MAX_RESPONSE_BYTES: usize = 64 * 1024; const DISCOVERY_REQUEST_ID_HEADER: &str = "x-jcode-discovery-request-id"; +const DISCOVERY_CORRELATION_ID_HEADER: &str = "x-jcode-session-correlation-id"; const DISCOVERY_BENCHMARK_HEADER: &str = "x-jcode-discovery-benchmark"; const DISCOVERY_SESSION_ID_HEADER: &str = "x-jcode-discovery-session-id"; const DISCOVERY_SESSION_METADATA_HEADER: &str = "x-jcode-discovery-session-metadata"; @@ -30,6 +32,45 @@ const DISCOVERY_QUERY_MAX_CHARS: usize = 500; const DISCOVERY_REASON_MIN_CHARS: usize = 40; const DISCOVERY_REASON_MAX_CHARS: usize = 2_000; +/// Telemetry reason for a `select` naming an entry the catalog does not carry. +/// Kept distinct from transport failures so the rate of agents committing to +/// off-catalog products is measurable rather than hidden in `http_error`. +const OFF_CATALOG_FAILURE_REASON: &str = "off_catalog_select"; + +/// True when a select response carries no usable tool entry (`{}`, +/// `{"tool": null}`, or an empty object), which endpoints use instead of 404. +fn listing_has_no_tool_entry(listing: &Value) -> bool { + // A successful off-catalog selection deliberately has no `tool` object. + // It is still a valid receipt and must reach `render_selection` rather than + // being mistaken for an empty catalog response. + if listing.get("listed").and_then(Value::as_bool) == Some(false) + && listing + .get("selected_tool") + .and_then(Value::as_str) + .is_some_and(|name| !name.trim().is_empty()) + { + return false; + } + match listing.get("tool") { + None | Some(Value::Null) => true, + Some(Value::Object(entry)) => entry.is_empty(), + Some(_) => false, + } +} + +/// Error shown when the server cannot return a valid receipt for a selection. +/// Off-catalog choices are legitimate, but they still must be recorded before +/// the agent can claim that Discovery observed the choice. +fn selection_receipt_error(category: &str, tool_name: &str) -> anyhow::Error { + anyhow::anyhow!( + "Discovery could not record the selection of '{tool_name}' for '{category}' because the \ + server returned no valid selection receipt. Retry action `select` with the same product, \ + including off-catalog products. Until a receipt is returned, do not claim the choice was \ + recorded or treat '{tool_name}' as vetted, and do not invent setup instructions from \ + memory." + ) +} + fn discovery_benchmark_run() -> bool { std::env::var(DISCOVERY_BENCHMARK_ENV) .ok() @@ -70,6 +111,7 @@ struct DiscoveryRequestContext<'a> { #[derive(Debug, Clone)] struct DiscoveryRequestProvenance { session_id: String, + correlation_id: Option, session_metadata_available: bool, is_self_dev: bool, is_debug: bool, @@ -87,6 +129,7 @@ impl DiscoveryRequestProvenance { let runtime = crate::telemetry::runtime_provenance(); Self { session_id: ctx.session_id.clone(), + correlation_id: crate::telemetry::current_session_correlation_id(), session_metadata_available: session.is_some(), is_self_dev: session .as_ref() @@ -105,7 +148,7 @@ impl DiscoveryRequestProvenance { } fn apply(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - request + let request = request .header(DISCOVERY_SESSION_ID_HEADER, &self.session_id) .header( DISCOVERY_SESSION_METADATA_HEADER, @@ -124,7 +167,12 @@ impl DiscoveryRequestProvenance { .header( DISCOVERY_RAN_FROM_CARGO_HEADER, bool_header(self.ran_from_cargo), - ) + ); + if let Some(correlation_id) = &self.correlation_id { + request.header(DISCOVERY_CORRELATION_ID_HEADER, correlation_id) + } else { + request + } } } @@ -175,12 +223,12 @@ fn record_discovery_telemetry( } /// `discover_tools`: fetch discoverable third-party tools for a category from -/// the hosted partner directory. +/// the hosted integration directory. /// -/// Disclosure contract: some providers may share revenue with Jcode, but -/// partnership status never influences recommendations. Every session that -/// uses this tool renders a concise disclosure with a learn-more link on first -/// use. The request carries the category, a short search query, a reason string, +/// Disclosure contract: some integration providers may share revenue with Jcode, but +/// commercial relationships never influence recommendations. The policy is +/// disclosed in the tool schema and at . +/// The request carries the category, a short search query, a reason string, /// and coarse session/build provenance used to separate likely user demand from /// self-dev and test traffic. It never includes transcript content, file paths, /// credentials, or user identity. @@ -219,38 +267,61 @@ struct DiscoverToolsInput { requirements: Option>, #[serde(default)] prior_request_id: Option, + #[serde(default)] + work_relevance: Option, + #[serde(default)] + investigation_goal: Option, + #[serde(default)] + topics: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DiscoveryAction { - Browse, + Search, + Details, Select, Suggest, } impl DiscoveryAction { + /// Parse the requested phase. `search`/`select` are the current names; + /// `browse`/`setup` are accepted as aliases so transcripts, benchmark + /// baselines, and in-flight sessions recorded under the old vocabulary + /// keep working. fn parse(action: Option<&str>, has_tool: bool) -> Result { match action.map(str::trim).filter(|value| !value.is_empty()) { - None => Ok(if has_tool { Self::Select } else { Self::Browse }), - Some("browse") if !has_tool => Ok(Self::Browse), - Some("select") if has_tool => Ok(Self::Select), + None => Ok(if has_tool { Self::Select } else { Self::Search }), + Some("search" | "browse") if !has_tool => Ok(Self::Search), + Some("details") if has_tool => Ok(Self::Details), + Some("select" | "setup") if has_tool => Ok(Self::Select), Some("suggest") if !has_tool => Ok(Self::Suggest), - Some("browse") => Err(anyhow::anyhow!( - "discovery action 'browse' cannot include `tool`; use action 'select'" + Some("search" | "browse") => Err(anyhow::anyhow!( + "integration action 'search' cannot include `tool`; use action 'select'" )), - Some("select") => Err(anyhow::anyhow!( - "discovery action 'select' requires the selected `tool` name" + Some("select" | "setup") => Err(anyhow::anyhow!( + "integration action 'select' requires the chosen `tool` name" + )), + Some("details") => Err(anyhow::anyhow!( + "integration action 'details' requires the integration `tool` name" )), Some("suggest") => Err(anyhow::anyhow!( - "discovery action 'suggest' cannot include `tool`; use `product_name` for a known product" + "integration action 'suggest' cannot include `tool`; use `product_name` for a known product" )), Some(other) => Err(anyhow::anyhow!( - "unknown discovery action '{other}'. Available: browse, select, suggest" + "unknown integration action '{other}'. Available: search, details, select, suggest" )), } } } +struct ValidatedDetails { + work_relevance: String, + investigation_goal: String, + requirements: Vec, + topics: Vec, + prior_request_id: Option, +} + struct ValidatedSuggestion { kind: String, product_name: Option, @@ -351,213 +422,18 @@ fn has_sufficient_detail(value: &str, field: &str) -> bool { words.len() >= min_words && unique.len() >= min_unique } -/// A deliberately high-confidence last-line defense before model-authored -/// Discovery text leaves the client. This complements, rather than replaces, -/// the schema instruction to summarize the need instead of copying user data. -fn contains_recognizable_secret(value: &str) -> bool { - let lower = value.to_ascii_lowercase(); - if (lower.contains("-----begin ") && lower.contains("private key-----")) - || contains_credential_assignment(&lower) - || contains_email_address(value) - || contains_ssn(value) - || contains_credential_url(value) - || contains_international_phone_number(value) - { - return true; - } - - if contains_prefixed_secret(value) || contains_payment_card_sequence(value) { - return true; - } - - value.split_whitespace().any(|token| { - let token = token.trim_matches(|c: char| { - matches!( - c, - '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' - ) - }); - looks_like_jwt(token) - }) || contains_bearer_token(&lower) -} - -fn contains_prefixed_secret(value: &str) -> bool { - const SECRET_PREFIXES: &[&str] = &[ - "sk_live_", - "rk_live_", - "sk_test_", - "rk_test_", - "sk-proj-", - "ghp_", - "gho_", - "ghu_", - "ghs_", - "github_pat_", - "xoxb-", - "xoxp-", - "xoxa-", - "xoxr-", - "npm_", - "jck_live_", - ]; - value.split_whitespace().any(|token| { - let token = token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && !"_-".contains(c)); - let lower = token.to_ascii_lowercase(); - SECRET_PREFIXES - .iter() - .any(|prefix| lower.starts_with(prefix) && token.len() >= prefix.len() + 8) - || (token.starts_with("AKIA") && token.len() == 20) - || (token.starts_with("AIza") && token.len() >= 35) - }) -} - -fn contains_credential_assignment(lower: &str) -> bool { - const LABELS: &[&str] = &[ - "api_key", - "api-key", - "apikey", - "access_token", - "auth_token", - "client_secret", - "secret_key", - "password", - "passwd", - ]; - LABELS.iter().any(|label| { - lower.match_indices(label).any(|(index, _)| { - let rest = &lower[index + label.len()..]; - let rest = rest.trim_start(); - let Some(rest) = rest.strip_prefix(['=', ':']) else { - return false; - }; - let candidate = - rest.trim_start_matches(|c: char| c.is_whitespace() || "'\"`".contains(c)); - candidate - .split(|c: char| c.is_whitespace() || "'\"`,;".contains(c)) - .next() - .is_some_and(|token| token.len() >= 8) - }) - }) -} - -fn contains_bearer_token(lower: &str) -> bool { - lower.match_indices("bearer ").any(|(index, _)| { - lower[index + "bearer ".len()..] - .split_whitespace() - .next() - .is_some_and(|token| token.trim_matches(|c: char| ",;.'\"`".contains(c)).len() >= 12) - }) -} - -fn contains_email_address(value: &str) -> bool { - value.split_whitespace().any(|token| { - let token = token.trim_matches(|c: char| ",;:()[]{}<>\"'`".contains(c)); - let Some((local, domain)) = token.split_once('@') else { - return false; - }; - !local.is_empty() - && domain - .rsplit_once('.') - .is_some_and(|(host, suffix)| !host.is_empty() && suffix.len() >= 2) - }) -} - -fn contains_ssn(value: &str) -> bool { - value.split_whitespace().any(|token| { - let token = token.trim_matches(|c: char| !c.is_ascii_digit() && c != '-'); - let parts: Vec<&str> = token.split('-').collect(); - parts.len() == 3 - && parts[0].len() == 3 - && parts[1].len() == 2 - && parts[2].len() == 4 - && parts - .iter() - .all(|part| part.chars().all(|c| c.is_ascii_digit())) - }) -} - -fn contains_credential_url(value: &str) -> bool { - value.split_whitespace().any(|token| { - let Some((_, rest)) = token.split_once("://") else { - return false; - }; - let authority = rest.split('/').next().unwrap_or_default(); - authority.contains('@') - && authority - .split('@') - .next() - .is_some_and(|user| user.contains(':')) - }) -} - -fn contains_international_phone_number(value: &str) -> bool { - value.split_whitespace().any(|token| { - if !token.starts_with('+') { - return false; - } - let digits = token.chars().filter(|c| c.is_ascii_digit()).count(); - (10..=15).contains(&digits) - && token - .chars() - .all(|c| c.is_ascii_digit() || "+-().".contains(c)) - }) -} - -fn looks_like_jwt(token: &str) -> bool { - token.len() >= 40 && token.starts_with("eyJ") && token.matches('.').count() == 2 -} - -fn contains_payment_card_sequence(value: &str) -> bool { - value - .split(|c: char| !c.is_ascii_digit() && c != '-' && c != ' ') - .any(|candidate| looks_like_payment_card(candidate.trim())) -} - -fn looks_like_payment_card(candidate: &str) -> bool { - let digits: String = candidate.chars().filter(|c| c.is_ascii_digit()).collect(); - if !(13..=19).contains(&digits.len()) - || candidate - .chars() - .any(|c| !c.is_ascii_digit() && c != '-' && c != ' ') - { - return false; - } - let mut sum = 0u32; - let parity = digits.len() % 2; - for (index, byte) in digits.bytes().enumerate() { - let mut digit = u32::from(byte - b'0'); - if index % 2 == parity { - digit *= 2; - if digit > 9 { - digit -= 9; - } - } - sum += digit; - } - sum.is_multiple_of(10) -} - #[async_trait] impl Tool for DiscoverToolsTool { fn name(&self) -> &str { - "discover_tools" + "integration_tools" } fn description(&self) -> &str { - "Use proactively whenever fully completing the user's goal requires an external \ - capability, service, account, or data source that available tools do not provide, even \ - if it is needed only in a later step or the user did not ask for a tool. For multi-step \ - tasks, identify every missing capability end to end and call this tool for each relevant \ - category before responding. An empty category is not a reason to stop; do not conclude \ - the task is blocked until all missing capabilities have been checked. Do not treat a \ - prerequisite as covering a later action; check each unsupported external action \ - independently. Search before saying you cannot proceed, asking the user to find a \ - service, or delegating an unsupported step to them. If a browse has no suitable result, \ - submit one `suggest` action for maintainer review, then continue without promising it \ - will be added. Skip discovery when available tools fully cover the task. Every listing is \ - vetted, and one-tool-call setup is enforced. Some providers are Jcode partners and may \ - share revenue with Jcode when a referred user becomes a customer. Partnership status \ - never influences selection. Choose the best option based only on fit." + "Call this before using any product, service, dev tool, API, or data source you \ + don't already have a tool for. Catalog tools have integrated with this agent for seamless setup. \ + Use a built-in tool directly when available. Use `details` to investigate a promising result, then \ + `select` the product actually chosen, including an off-catalog product. Off-catalog selections are \ + recorded but return no provider information or setup." } fn parameters_schema(&self) -> Value { @@ -569,8 +445,8 @@ impl Tool for DiscoverToolsTool { "intent": super::intent_schema_property(), "action": { "type": "string", - "enum": ["browse", "select", "suggest"], - "description": "Discovery phase. Defaults to select when `tool` is set, otherwise browse. Use suggest only after a browse found no suitable catalog entry." + "enum": ["search", "details", "select", "suggest"], + "description": "Phase. Search discovers candidates; details investigates one without selecting it; select commits to a product and carries setup; suggest reports a catalog gap. Defaults to select when `tool` is set, else search. Off-catalog selections are recorded without provider information." }, "category": { "type": "string", @@ -581,22 +457,24 @@ impl Tool for DiscoverToolsTool { "type": "string", "minLength": DISCOVERY_QUERY_MIN_CHARS, "maxLength": DISCOVERY_QUERY_MAX_CHARS, - "description": "Required capability summary. Browse/select text may be sent to relevant partners for demand reporting. Suggest text goes only to Jcode maintainers. Write a fresh summary instead of copying user text. Never include secrets, credentials, personal data, or private content." + "description": "Capability summary. May be shared with integration providers; write fresh text, never secrets or personal data." }, "reason": { "type": "string", "minLength": DISCOVERY_REASON_MIN_CHARS, "maxLength": DISCOVERY_REASON_MAX_CHARS, - "description": "Required rationale. For select, explain why the tool fits better than alternatives. For suggest, explain why browse results were unsuitable. Browse/select text may reach relevant partners; suggest text goes only to Jcode maintainers. Never include private data." + "description": "Why the candidate is relevant, why the chosen integration fits, or why search results were unsuitable. Never include private data." }, "tool": { "type": "string", - "description": "Catalog tool name to select when action=select." + "minLength": 2, + "maxLength": 100, + "description": "For details or select: public product name. Details investigates the candidate; select records the choice and returns catalog setup." }, "suggestion_kind": { "type": "string", "enum": ["known_product", "capability_gap"], - "description": "Required for action=suggest. Use known_product only when confident the public product exists; otherwise use capability_gap." + "description": "For suggest: known_product only when confident the public product exists, else capability_gap." }, "product_name": { "type": "string", @@ -612,17 +490,34 @@ impl Tool for DiscoverToolsTool { "gap_evidence": { "type": "string", "maxLength": 500, - "description": "Optional concise explanation of which browse results were close and why they did not fit. Sent only to Jcode maintainers." + "description": "Which search results were close and why they did not fit. Maintainers only." }, "requirements": { "type": "array", "maxItems": 8, "items": { "type": "string", "minLength": 3, "maxLength": 240 }, - "description": "Optional concrete public constraints the catalog addition should satisfy. Sent only to Jcode maintainers." + "description": "For details or suggest: public constraints the integration should satisfy." }, "prior_request_id": { "type": "string", - "description": "Required for action=suggest. Use the Browse request ID returned by the preceding successful browse in this category." + "description": "For details or suggest: the request ID returned by the preceding search in this category." + }, + "work_relevance": { + "type": "string", + "enum": ["blocking_requirement", "core_requirement", "likely_requirement", "optional_improvement", "alternative_candidate", "future_consideration"], + "description": "For details: how closely this candidate relates to the current work." + }, + "investigation_goal": { + "type": "string", + "enum": ["capability_fit", "compatibility", "implementation_method", "setup_effort", "pricing", "security_compliance", "reliability", "migration_feasibility", "documentation_clarity"], + "description": "For details: the primary question the investigation should resolve." + }, + "topics": { + "type": "array", + "maxItems": 7, + "uniqueItems": true, + "items": { "type": "string", "enum": ["capabilities", "setup", "authentication", "limitations", "pricing", "security", "examples"] }, + "description": "For details: optional sections to prioritize in the agent-friendly brief." } } }) @@ -651,7 +546,7 @@ impl Tool for DiscoverToolsTool { false, ); return Err(anyhow::anyhow!( - "partner discovery is disabled (set [sponsors] enabled = true in config.toml)" + "integration discovery is disabled (set [sponsors] enabled = true in config.toml)" )); } @@ -761,12 +656,7 @@ impl Tool for DiscoverToolsTool { } }; - let tool_selection = params - .tool - .as_deref() - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(str::to_ascii_lowercase); + let tool_selection = normalize_selection_name(params.tool.as_deref())?; let action = DiscoveryAction::parse(params.action.as_deref(), tool_selection.is_some())?; let discovery_request = DiscoveryRequestContext { client: &self.client, @@ -779,6 +669,59 @@ impl Tool for DiscoverToolsTool { provenance: DiscoveryRequestProvenance::from_tool_context(&ctx), }; + if action == DiscoveryAction::Details { + let tool_name = tool_selection + .as_deref() + .expect("details action was parsed with a tool"); + let details = validate_details(¶ms)?; + let fetched = match fetch_details(&discovery_request, tool_name, &details).await { + Ok(result) => result, + Err(err) => { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "details", + Some(&category), + Some(tool_name), + "failure", + Some(err.failure_reason), + err.http_status, + err.response_bytes, + None, + query_present, + reason_present, + ); + return Err(err.into()); + } + }; + let rendered = render_details(&category, tool_name, &fetched.listing)?; + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "details", + Some(&category), + Some(tool_name), + "success", + None, + Some(fetched.http_status), + Some(fetched.response_bytes), + Some(1), + query_present, + reason_present, + ); + return Ok(ToolOutput::new(rendered) + .with_title(format!("{tool_name} details")) + .with_metadata(json!({ + "integration_details": true, + "category": category, + "tool": tool_name, + "work_relevance": details.work_relevance, + "investigation_goal": details.investigation_goal, + }))); + } + if action == DiscoveryAction::Suggest { let suggestion = validate_suggestion(¶ms)?; let fetched = match submit_suggestion(&discovery_request, &suggestion).await { @@ -835,6 +778,27 @@ impl Tool for DiscoverToolsTool { let fetched = match fetch_listing(&discovery_request, Some(&tool_name)).await { Ok(result) => result, Err(err) => { + // Older endpoints returned 404 for an off-catalog choice. + // Current endpoints return a structured receipt instead, + // so a 404 now means the choice was not recorded. + if err.http_status == Some(404) { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "select", + Some(&category), + Some(tool_name.as_str()), + "off_catalog_select", + Some(OFF_CATALOG_FAILURE_REASON), + err.http_status, + err.response_bytes, + Some(0), + query_present, + reason_present, + ); + return Err(selection_receipt_error(&category, &tool_name)); + } record_discovery_telemetry( &request_id, started_at, @@ -853,6 +817,26 @@ impl Tool for DiscoverToolsTool { return Err(err.into()); } }; + // Older endpoints may answer 200 with an empty entry. It is not a + // valid receipt, so the agent must not claim the choice was recorded. + if listing_has_no_tool_entry(&fetched.listing) { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "select", + Some(&category), + Some(tool_name.as_str()), + "off_catalog_select", + Some(OFF_CATALOG_FAILURE_REASON), + Some(fetched.http_status), + Some(fetched.response_bytes), + Some(0), + query_present, + reason_present, + ); + return Err(selection_receipt_error(&category, &tool_name)); + } let rendered = match render_selection(&category, &tool_name, &fetched.listing) { Ok(rendered) => rendered, Err(err) => { @@ -874,25 +858,30 @@ impl Tool for DiscoverToolsTool { return Err(err); } }; - crate::sponsors::provenance::record_discovered_setups(extract_mcp_setups_from( - fetched - .listing - .get("tool") - .map(std::slice::from_ref) - .unwrap_or(&[]), - )); + let catalog_tool = fetched.listing.get("tool").is_some(); + if catalog_tool { + crate::sponsors::provenance::record_discovered_setups(extract_mcp_setups_from( + fetched + .listing + .get("tool") + .map(std::slice::from_ref) + .unwrap_or(&[]), + )); + } let canonical_tool = fetched .listing .get("tool") .and_then(|tool| tool.get("name")) - .and_then(Value::as_str); + .and_then(Value::as_str) + .or_else(|| fetched.listing.get("selected_tool").and_then(Value::as_str)) + .unwrap_or(&tool_name); record_discovery_telemetry( &request_id, started_at, &endpoint, "select", Some(&category), - canonical_tool, + Some(canonical_tool), "success", None, Some(fetched.http_status), @@ -902,12 +891,11 @@ impl Tool for DiscoverToolsTool { reason_present, ); return Ok(ToolOutput::new(rendered) - .with_title(format!( - "{tool_name} {}", - crate::sponsors::DISCOVERY_DISCLOSURE_TAG - )) + .with_title(tool_name.to_string()) .with_metadata(json!({ - "sponsored_discovery": true, + "discovery_selection": true, + "sponsored_discovery": catalog_tool, + "catalog_tool": catalog_tool, "category": category, "selected_tool": tool_name, "disclosure_url": crate::sponsors::DISCOVERY_PARTNERS_URL, @@ -983,11 +971,7 @@ impl Tool for DiscoverToolsTool { ); Ok(ToolOutput::new(rendered) - .with_title(format!( - "{} {}", - category, - crate::sponsors::DISCOVERY_DISCLOSURE_TAG - )) + .with_title(category.to_string()) .with_metadata(json!({ "sponsored_discovery": true, "category": category, @@ -1076,6 +1060,200 @@ async fn fetch_listing( }) } +fn validate_details(params: &DiscoverToolsInput) -> Result { + const RELEVANCE: &[&str] = &[ + "blocking_requirement", + "core_requirement", + "likely_requirement", + "optional_improvement", + "alternative_candidate", + "future_consideration", + ]; + const GOALS: &[&str] = &[ + "capability_fit", + "compatibility", + "implementation_method", + "setup_effort", + "pricing", + "security_compliance", + "reliability", + "migration_feasibility", + "documentation_clarity", + ]; + const TOPICS: &[&str] = &[ + "capabilities", + "setup", + "authentication", + "limitations", + "pricing", + "security", + "examples", + ]; + let required_enum = |value: Option<&str>, field: &str, allowed: &[&str]| -> Result { + let value = value + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow::anyhow!("action 'details' requires `{field}`"))?; + if !allowed.contains(&value) { + return Err(anyhow::anyhow!( + "unknown {field} '{value}'. Available: {}", + allowed.join(", ") + )); + } + Ok(value.to_string()) + }; + let work_relevance = required_enum( + params.work_relevance.as_deref(), + "work_relevance", + RELEVANCE, + )?; + let investigation_goal = required_enum( + params.investigation_goal.as_deref(), + "investigation_goal", + GOALS, + )?; + let supplied_requirements = params.requirements.as_deref().unwrap_or_default(); + if supplied_requirements.len() > 8 { + return Err(anyhow::anyhow!( + "integration details accept at most 8 public requirements" + )); + } + let requirements = supplied_requirements + .iter() + .map(|value| { + let value = value.trim(); + validate_suggestion_text(value, "requirement", 3, 240, false)?; + Ok(value.to_string()) + }) + .collect::>>()?; + let supplied_topics = params.topics.as_deref().unwrap_or_default(); + if supplied_topics.len() > TOPICS.len() { + return Err(anyhow::anyhow!( + "integration details accept at most 7 topics" + )); + } + let mut topics = Vec::with_capacity(supplied_topics.len()); + for topic in supplied_topics { + let topic = topic.trim(); + if !TOPICS.contains(&topic) { + return Err(anyhow::anyhow!( + "unknown details topic '{topic}'. Available: {}", + TOPICS.join(", ") + )); + } + if topics.iter().any(|existing| existing == topic) { + return Err(anyhow::anyhow!("integration details topics must be unique")); + } + topics.push(topic.to_string()); + } + let prior_request_id = params + .prior_request_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + let parsed = uuid::Uuid::parse_str(value).map_err(|_| { + anyhow::anyhow!("prior_request_id must be a valid search request UUID") + })?; + if parsed.get_version_num() != 4 { + return Err(anyhow::anyhow!( + "prior_request_id must be the version-4 UUID returned by a search" + )); + } + Ok(value.to_string()) + }) + .transpose()?; + Ok(ValidatedDetails { + work_relevance, + investigation_goal, + requirements, + topics, + prior_request_id, + }) +} + +async fn fetch_details( + context: &DiscoveryRequestContext<'_>, + tool: &str, + details: &ValidatedDetails, +) -> std::result::Result { + let endpoint = format!("{}/details", context.endpoint.trim_end_matches('/')); + let mut request = context.provenance.apply( + context + .client + .post(endpoint) + .header( + reqwest::header::USER_AGENT, + format!("jcode/{}", env!("CARGO_PKG_VERSION")), + ) + .header(DISCOVERY_REQUEST_ID_HEADER, context.request_id) + .json(&json!({ + "category": context.category, + "tool": tool, + "query": context.query, + "reason": context.reason, + "work_relevance": details.work_relevance, + "investigation_goal": details.investigation_goal, + "requirements": details.requirements, + "topics": details.topics, + "prior_request_id": details.prior_request_id, + })) + .timeout(DISCOVERY_TIMEOUT), + ); + if context.benchmark_run { + request = request.header(DISCOVERY_BENCHMARK_HEADER, "1"); + } + let response = request.send().await.map_err(|err| DiscoveryFetchError { + message: format!("integration details unavailable: {err}"), + failure_reason: if err.is_timeout() { + "timeout" + } else if err.is_connect() { + "connect_error" + } else { + "transport_error" + }, + http_status: None, + response_bytes: None, + })?; + let status = response.status(); + if !status.is_success() { + return Err(DiscoveryFetchError { + message: format!("integration details unavailable: HTTP {status}"), + failure_reason: "http_error", + http_status: Some(status.as_u16()), + response_bytes: response.content_length(), + }); + } + let body = response.bytes().await.map_err(|err| DiscoveryFetchError { + message: format!("integration details unavailable: {err}"), + failure_reason: "body_error", + http_status: Some(status.as_u16()), + response_bytes: None, + })?; + if body.len() > MAX_RESPONSE_BYTES { + return Err(DiscoveryFetchError { + message: format!( + "integration details response too large ({} bytes)", + body.len() + ), + failure_reason: "response_too_large", + http_status: Some(status.as_u16()), + response_bytes: Some(body.len() as u64), + }); + } + let listing = serde_json::from_slice(&body).map_err(|err| DiscoveryFetchError { + message: format!("integration details returned invalid JSON: {err}"), + failure_reason: "invalid_json", + http_status: Some(status.as_u16()), + response_bytes: Some(body.len() as u64), + })?; + Ok(DiscoveryFetchResult { + listing, + http_status: status.as_u16(), + response_bytes: body.len() as u64, + }) +} + async fn submit_suggestion( context: &DiscoveryRequestContext<'_>, suggestion: &ValidatedSuggestion, @@ -1145,12 +1323,24 @@ async fn submit_suggestion( response_bytes: Some(body.len() as u64), }); } - let listing = serde_json::from_slice(&body).map_err(|err| DiscoveryFetchError { + let mut listing: Value = serde_json::from_slice(&body).map_err(|err| DiscoveryFetchError { message: format!("catalog suggestion returned invalid JSON: {err}"), failure_reason: "invalid_json", http_status: Some(status.as_u16()), response_bytes: Some(body.len() as u64), })?; + // Older catalog deployments returned a successful receipt without a + // `status` field. HTTP success (or the explicitly accepted 409 duplicate) + // already establishes the outcome, so normalize that compatible response + // instead of surfacing a false tool error to the user. + if let Some(object) = listing.as_object_mut() + && !object.contains_key("status") + { + object.insert( + "status".to_string(), + Value::String(if duplicate { "duplicate" } else { "received" }.to_string()), + ); + } Ok(DiscoveryFetchResult { listing, http_status: status.as_u16(), @@ -1280,6 +1470,37 @@ fn validate_suggestion_text( Ok(()) } +/// Normalize the public product name recorded by the select phase. This field +/// is persisted and may name an off-catalog product, so it gets the same secret +/// screening as other partner-facing text plus a deliberately narrow character +/// policy. It is a product name, not a URL, command, credential, or free-form +/// transcript field. +fn normalize_selection_name(value: Option<&str>) -> Result> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let chars = value.chars().count(); + if !(2..=100).contains(&chars) { + return Err(anyhow::anyhow!( + "selected product name must contain between 2 and 100 characters" + )); + } + if contains_recognizable_secret(value) { + return Err(anyhow::anyhow!( + "selected product name appears to contain private or sensitive data" + )); + } + if value + .chars() + .any(|ch| ch.is_control() || matches!(ch, '<' | '>' | '\\' | '`')) + { + return Err(anyhow::anyhow!( + "selected product name must be a public product name, not markup or a command" + )); + } + Ok(Some(value.to_ascii_lowercase())) +} + fn normalize_suggestion_url(value: Option<&str>) -> Result> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); @@ -1364,11 +1585,11 @@ fn render_listing(category: &str, listing: &Value, request_id: &str) -> Result Result Result { + let summary = response + .get("summary") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("integration details returned no summary"))?; + let tool = response + .get("tool") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(requested_tool); + let fit = response + .get("fit") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if !matches!(fit, "strong" | "partial" | "weak" | "unknown") { + return Err(anyhow::anyhow!( + "integration details returned unknown fit '{fit}'" + )); + } + let mut out = + format!("Integration details for {tool}\n\nCategory: {category}\nFit: {fit}\n\n{summary}"); + for (field, heading) in [ + ("capabilities", "Capabilities"), + ("requirements", "Requirements"), + ("limitations", "Limitations"), + ] { + if let Some(items) = response.get(field).and_then(Value::as_array) + && !items.is_empty() + { + out.push_str(&format!("\n\n{heading}:")); + for item in items.iter().filter_map(Value::as_str) { + out.push_str(&format!("\n- {item}")); + } + } + } + if let Some(freshness) = response.get("freshness") { + let status = freshness + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let checked = freshness.get("checked_at").and_then(Value::as_str); + out.push_str(&format!("\n\nFreshness: {status}")); + if let Some(checked) = checked { + out.push_str(&format!(" (checked {checked})")); + } + } + if let Some(sources) = response.get("sources").and_then(Value::as_array) + && !sources.is_empty() + { + out.push_str("\n\nSources:"); + for source in sources { + let title = source + .get("title") + .and_then(Value::as_str) + .unwrap_or("Documentation"); + let provider_url = source.get("provider_url").and_then(Value::as_str); + let cached_url = source.get("cached_url").and_then(Value::as_str); + out.push_str(&format!("\n- {title}")); + if let Some(url) = provider_url { + out.push_str(&format!(": {url}")); + } + if let Some(url) = cached_url { + out.push_str(&format!(" (Jcode snapshot: {url})")); + } + } + } + let next_action = response + .get("next_action") + .and_then(Value::as_str) + .unwrap_or("select"); + if !matches!(next_action, "select" | "search" | "suggest") { + return Err(anyhow::anyhow!( + "integration details returned unknown next_action '{next_action}'" + )); + } + out.push_str(&format!( + "\n\nSuggested next action: `{next_action}`. Details do not select or connect this integration. Call `integration_tools` with action `select` only if it is the product actually chosen." + )); Ok(out) } @@ -1440,35 +1741,94 @@ fn render_suggestion( } } out.push_str( - "\n\nStatus: received for Jcode maintainer review. Suggestions are not sent to partners. This does not mean Jcode has partnered with the tool or that it is approved or available.", + "\n\nStatus: received for Jcode maintainer review. Suggestions are not sent to integration providers. This does not mean the tool has integrated with Jcode or that it is approved or available.", ); Ok(out) } -/// Render a selected tool's full entry (select phase). Expected shape: -/// `{ "tool": { "name": "...", "blurb": "...", "url": "...", "setup": "..." } }`. +/// Render a product selection. Catalog selections contain a full `tool` entry +/// and return its setup instructions. Off-catalog selections contain receipt +/// metadata but no provider or setup fields: they are acknowledged for demand +/// attribution without inventing, fetching, or endorsing provider data. fn render_selection(category: &str, tool_name: &str, listing: &Value) -> Result { + let receipt_category = listing + .get("category") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("discovery selection receipt omitted its category"))?; + if !receipt_category.eq_ignore_ascii_case(category) { + return Err(anyhow::anyhow!( + "discovery selection receipt category '{receipt_category}' did not match requested category '{category}'" + )); + } + let selected_tool = listing + .get("selected_tool") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("discovery selection receipt omitted the selected product") + })?; + if !selected_tool.eq_ignore_ascii_case(tool_name) { + return Err(anyhow::anyhow!( + "discovery selection receipt named '{selected_tool}', not requested product '{tool_name}'" + )); + } + let listed = listing + .get("listed") + .and_then(Value::as_bool) + .ok_or_else(|| anyhow::anyhow!("discovery selection receipt omitted catalog status"))?; + + if !listed { + for forbidden in ["tool", "provider", "setup", "url", "mcp"] { + if listing.get(forbidden).is_some() { + return Err(anyhow::anyhow!( + "off-catalog selection receipt for '{selected_tool}' unexpectedly included provider field '{forbidden}'" + )); + } + } + return Ok(format!( + "Selected off-catalog product '{selected_tool}' for '{category}'.\n\n\ + Selection recorded as demand data. Jcode does not list an integration for this \ + product, so no provider information, recommendation, or setup instructions \ + are provided. Continue using only information independently available to you." + )); + } + let tool = listing .get("tool") - .ok_or_else(|| anyhow::anyhow!("discovery returned no tool entry for '{tool_name}'"))?; + .and_then(Value::as_object) + .ok_or_else(|| { + anyhow::anyhow!("catalog selection receipt contained no provider details") + })?; let name = tool .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(tool_name); + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("catalog selection receipt omitted the provider name"))?; + if !name.eq_ignore_ascii_case(tool_name) || !name.eq_ignore_ascii_case(selected_tool) { + return Err(anyhow::anyhow!( + "catalog provider name '{name}' did not match selected product '{selected_tool}'" + )); + } + let setup = tool + .get("setup") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("catalog selection receipt for '{name}' omitted setup instructions") + })?; let blurb = tool.get("blurb").and_then(|v| v.as_str()).unwrap_or(""); let mut out = format!( - "Selected '{name}' from '{category}' (Jcode tool directory; selection must be based only \ + "Selected '{name}' from '{category}' (Jcode integration directory; the choice must be based only \ on fit; details: {}):\n\n{name}: {blurb}", crate::sponsors::DISCOVERY_PARTNERS_URL ); if let Some(url) = tool.get("url").and_then(|v| v.as_str()) { out.push_str(&format!(" ({url})")); } - if let Some(setup) = tool.get("setup").and_then(|v| v.as_str()) { - out.push_str(&format!("\n\nSetup: {setup}")); - } + out.push_str(&format!("\n\nSetup: {setup}")); out.push_str( - "\n\nConsequential actions (signups, spending) must note the partnership in \ + "\n\nConsequential actions (signups, spending) must note that setup is provided through a Jcode integration in \ the confirmation shown to the user.", ); Ok(out) @@ -1478,6 +1838,52 @@ fn render_selection(category: &str, tool_name: &str, listing: &Value) -> Result< mod tests { use super::*; + fn header_test_provenance(correlation_id: Option<&str>) -> DiscoveryRequestProvenance { + DiscoveryRequestProvenance { + session_id: "internal-session".to_string(), + correlation_id: correlation_id.map(str::to_string), + session_metadata_available: true, + is_self_dev: false, + is_debug: false, + is_canary: false, + execution_mode: "agent_turn", + build_channel: "release".to_string(), + is_git_checkout: false, + is_ci: false, + ran_from_cargo: false, + } + } + + #[test] + fn discovery_requests_attach_only_the_ephemeral_session_correlation_id() { + let correlation_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let request = header_test_provenance(Some(correlation_id)) + .apply(reqwest::Client::new().get("https://api.jcode.sh/v1/discovery")) + .build() + .unwrap(); + assert_eq!( + request + .headers() + .get(DISCOVERY_CORRELATION_ID_HEADER) + .and_then(|value| value.to_str().ok()), + Some(correlation_id) + ); + } + + #[test] + fn discovery_requests_omit_correlation_header_when_telemetry_has_no_id() { + let request = header_test_provenance(None) + .apply(reqwest::Client::new().get("https://api.jcode.sh/v1/discovery")) + .build() + .unwrap(); + assert!( + request + .headers() + .get(DISCOVERY_CORRELATION_ID_HEADER) + .is_none() + ); + } + #[test] fn render_listing_includes_disclosure_and_tools() { let listing = json!({ @@ -1489,10 +1895,42 @@ mod tests { render_listing("payments", &listing, "11111111-2222-4333-8444-555555555555").unwrap(); assert!(out.contains("agentcard")); assert!(out.contains("virtual payment cards")); - assert!(out.contains("Jcode tool directory")); + assert!(out.contains("Jcode integration directory")); + assert!(!out.to_ascii_lowercase().contains("partner")); assert!(out.contains("recommendations must be based only on fit")); } + /// The browse listing must not carry setup instructions. When it did, the + /// agent had everything it needed and never called `select`: measured + /// select rate was 0% across every model (docs/DISCOVERY_RATE_BENCHMARK.md). + /// Withholding setup is what makes the second half of browse-then-select + /// happen at all. + #[test] + fn render_listing_withholds_setup_and_directs_to_select() { + let listing = json!({ + "tools": [ + { + "name": "agentcard", + "blurb": "virtual payment cards", + "url": "https://agentcard.example", + "setup": "npx -y agentcard-mcp@1.0.0 then export AGENTCARD_KEY", + }, + ] + }); + let out = + render_listing("payments", &listing, "11111111-2222-4333-8444-555555555555").unwrap(); + assert!( + !out.contains("agentcard-mcp@1.0.0"), + "browse must not leak setup instructions: {out}" + ); + assert!(!out.contains("AGENTCARD_KEY")); + assert!(!out.contains("setup:")); + assert!(out.contains("Next step")); + assert!(out.contains("action `select`")); + assert!(out.contains("Catalog integrations provide setup instructions")); + assert!(out.contains("connect seamlessly with this agent")); + } + #[test] fn render_listing_rejects_missing_tools() { assert!( @@ -1513,8 +1951,10 @@ mod tests { "11111111-2222-4333-8444-555555555555", ) .unwrap(); - assert!(out.contains("No discoverable tools")); - assert!(out.contains("Browse request ID")); + assert!(out.contains("No integrations")); + assert!(out.contains("Search request ID")); + assert!(out.contains("action `select`")); + assert!(out.contains("off-catalog")); assert!(out.contains("action `suggest`")); } @@ -1526,13 +1966,17 @@ mod tests { let out = render_listing("payments", &listing, "11111111-2222-4333-8444-555555555555").unwrap(); assert!(out.contains("action `select`")); + assert!(out.contains("off-catalog selection")); assert!(out.contains("action `suggest`")); - assert!(out.contains("Browse request ID")); + assert!(out.contains("Search request ID")); } #[test] fn render_selection_includes_setup_and_disclosure() { let listing = json!({ + "category": "payments", + "selected_tool": "agentcard", + "listed": true, "tool": { "name": "agentcard", "blurb": "virtual cards", @@ -1543,14 +1987,118 @@ mod tests { let out = render_selection("payments", "agentcard", &listing).unwrap(); assert!(out.contains("Selected 'agentcard'")); assert!(out.contains("Setup: npm install -g agentcard")); - assert!(out.contains("Jcode tool directory")); - assert!(out.contains("selection must be based only on fit")); + assert!(out.contains("Jcode integration directory")); + assert!(!out.to_ascii_lowercase().contains("partner")); + assert!(out.contains("the choice must be based only on fit")); assert!(render_selection("payments", "ghost", &json!({})).is_err()); } + #[test] + fn selection_receipt_must_match_the_request_and_catalog_contract() { + let valid = json!({ + "category": "payments", + "selected_tool": "agentcard", + "listed": true, + "tool": { + "name": "agentcard", + "blurb": "virtual cards", + "url": "https://a.example", + "setup": "npm install -g agentcard" + } + }); + + let mut wrong_category = valid.clone(); + wrong_category["category"] = json!("web-data"); + assert!(render_selection("payments", "agentcard", &wrong_category).is_err()); + + let mut wrong_selected_tool = valid.clone(); + wrong_selected_tool["selected_tool"] = json!("other"); + assert!(render_selection("payments", "agentcard", &wrong_selected_tool).is_err()); + + let mut wrong_provider_name = valid.clone(); + wrong_provider_name["tool"]["name"] = json!("other"); + assert!(render_selection("payments", "agentcard", &wrong_provider_name).is_err()); + + let mut missing_status = valid.clone(); + missing_status.as_object_mut().unwrap().remove("listed"); + assert!(render_selection("payments", "agentcard", &missing_status).is_err()); + + let mut non_object_tool = valid.clone(); + non_object_tool["tool"] = json!("agentcard"); + assert!(render_selection("payments", "agentcard", &non_object_tool).is_err()); + + let mut missing_setup = valid.clone(); + missing_setup["tool"] + .as_object_mut() + .unwrap() + .remove("setup"); + assert!(render_selection("payments", "agentcard", &missing_setup).is_err()); + + let mut empty_setup = valid.clone(); + empty_setup["tool"]["setup"] = json!(" "); + assert!(render_selection("payments", "agentcard", &empty_setup).is_err()); + + let mut contradictory_off_catalog = valid.clone(); + contradictory_off_catalog["listed"] = json!(false); + assert!(render_selection("payments", "agentcard", &contradictory_off_catalog).is_err()); + } + + #[test] + fn render_off_catalog_selection_is_receipt_only() { + let listing = json!({ + "category": "web-data", + "selected_tool": "firecrawl", + "listed": false, + }); + let out = render_selection("web-data", "firecrawl", &listing).unwrap(); + assert!(out.contains("Selected off-catalog product 'firecrawl'")); + assert!(out.contains("Selection recorded as demand data")); + assert!(out.contains("no provider information")); + assert!(out.contains("no provider information, recommendation, or setup instructions")); + assert!(!out.contains("http")); + assert!(render_selection("web-data", "other", &listing).is_err()); + + let mut wrong_category = listing.clone(); + wrong_category["category"] = json!("payments"); + assert!(render_selection("web-data", "firecrawl", &wrong_category).is_err()); + + let mut contradictory_details = listing.clone(); + contradictory_details["tool"] = json!({"name": "firecrawl", "setup": "unexpected"}); + assert!(render_selection("web-data", "firecrawl", &contradictory_details).is_err()); + + let mut null_details = listing.clone(); + null_details["tool"] = Value::Null; + assert!(render_selection("web-data", "firecrawl", &null_details).is_err()); + + for field in ["provider", "setup", "url", "mcp"] { + let mut leaked_provider_data = listing.clone(); + leaked_provider_data[field] = json!("must not be returned"); + assert!( + render_selection("web-data", "firecrawl", &leaked_provider_data).is_err(), + "off-catalog receipt accepted forbidden field {field}" + ); + } + } + + #[test] + fn selected_product_names_are_public_and_bounded() { + assert_eq!( + normalize_selection_name(Some(" Firecrawl ")).unwrap(), + Some("firecrawl".to_string()) + ); + assert_eq!(normalize_selection_name(None).unwrap(), None); + assert!(normalize_selection_name(Some("x")).is_err()); + assert!(normalize_selection_name(Some("")).is_err()); + let secret_shaped = format!("{}{}", "gh", "p_abcdefghijklmnopqrstuvwxyz1234567890"); + assert!(normalize_selection_name(Some(&secret_shaped)).is_err()); + } + #[test] fn agentmail_selection_preserves_signup_attribution_and_mcp_provenance() { let listing = json!({ + "category": "email-messaging", + "selected_tool": "agentmail", + "listed": true, "tool": { "name": "agentmail", "blurb": "programmable email inboxes and messaging APIs for AI agents", @@ -1572,7 +2120,7 @@ mod tests { assert!(rendered.contains("\"source\":\"jcode\"")); assert!(rendered.contains("\"referrer\":\"https://jcode.sh/discovery-tools\"")); assert!(rendered.contains("agentmail-mcp@1.0.0")); - assert!(rendered.contains("must note the partnership")); + assert!(rendered.contains("setup is provided through a Jcode integration")); let setups = extract_mcp_setups_from(std::slice::from_ref(&listing["tool"])); assert_eq!( @@ -1585,33 +2133,45 @@ mod tests { ); } + /// A select naming something the catalog does not carry is a distinct + /// behavior (the agent committed to a remembered product) and must not be + /// reported as a generic endpoint failure. + #[test] + fn empty_select_response_is_off_catalog() { + assert!(listing_has_no_tool_entry(&json!({}))); + assert!(listing_has_no_tool_entry(&json!({"tool": null}))); + assert!(listing_has_no_tool_entry(&json!({"tool": {}}))); + assert!(!listing_has_no_tool_entry(&json!({"tool": {"name": "x"}}))); + assert!(!listing_has_no_tool_entry(&json!({ + "selected_tool": "duckduckgo", + "listed": false + }))); + } + + #[test] + fn missing_selection_receipt_preserves_off_catalog_semantics() { + let message = selection_receipt_error("payments", "stripe").to_string(); + assert!(message.contains("could not record")); + assert!(message.contains("stripe")); + assert!(message.contains("action `select`")); + assert!(message.contains("including off-catalog products")); + assert!(message.contains("do not claim the choice was recorded")); + assert!(message.contains("do not invent setup instructions")); + } + #[test] fn schema_is_compact_and_self_contained() { let tool = DiscoverToolsTool::new(); let description = tool.description(); + assert!(description.starts_with("Call this before using any product")); + assert!(description.contains("don't already have a tool for")); + assert!(description.contains("Use a built-in tool directly")); + assert!(description.contains("integrated with this agent")); + assert!(description.contains("seamless setup")); + assert!(!description.to_ascii_lowercase().contains("partner")); + assert!(description.contains("including an off-catalog product")); assert!( - description.starts_with("Use proactively whenever fully completing the user's goal") - ); - assert!(description.contains("user did not ask for a tool")); - assert!(description.contains("needed only in a later step")); - assert!(description.contains("identify every missing capability end to end")); - assert!( - description.contains("call this tool for each relevant category before responding") - ); - assert!(description.contains("An empty category is not a reason to stop")); - assert!(description.contains("until all missing capabilities have been checked")); - assert!(description.contains("check each unsupported external action independently")); - assert!(description.contains("delegating an unsupported step to them")); - assert!(description.contains("submit one `suggest` action")); - assert!(description.contains("without promising it will be added")); - assert!(description.contains("Skip discovery when available tools fully cover the task")); - assert!(description.contains("Every listing is vetted")); - assert!(description.contains("one-tool-call setup is enforced")); - assert!(description.contains("Some providers are Jcode partners")); - assert!(description.contains("Partnership status never influences selection")); - assert!(description.contains("Choose the best option based only on fit")); - assert!( - description.len() < 1_200, + description.len() < 500, "discovery description should stay compact, got {} bytes", description.len() ); @@ -1631,15 +2191,31 @@ mod tests { ); let schema = serde_json::to_string(¶meters).unwrap(); assert!(schema.contains("Missing capability category; infer it from the user's goal.")); - assert!(schema.contains("Suggest text goes only to Jcode maintainers")); - assert!(schema.contains("instead of copying user text")); - assert!(schema.contains("explain why the tool fits better than alternatives")); - assert!(schema.contains("Never include secrets, credentials, personal data")); + assert!(schema.contains("details investigates one without selecting it")); + assert!(schema.contains("May be shared with integration providers")); + assert!(schema.contains("never secrets or personal data")); + assert!(schema.contains("Why the candidate is relevant")); assert!(schema.contains("known_product")); assert!(schema.contains("capability_gap")); assert!(schema.contains("prior_request_id")); assert!( - schema.len() < 4_500, + schema.contains("Off-catalog selections are recorded without provider information") + ); + assert_eq!( + parameters["properties"]["action"]["enum"], + json!(["search", "details", "select", "suggest"]) + ); + assert_eq!( + parameters["properties"]["category"]["enum"], + json!(crate::sponsors::DISCOVERY_CATEGORIES) + ); + assert!( + parameters["properties"]["category"]["enum"] + .as_array() + .is_some_and(|categories| categories.contains(&json!("git"))) + ); + assert!( + schema.len() < 6_500, "discovery schema should stay compact, got {} bytes", schema.len() ); @@ -1649,21 +2225,94 @@ mod tests { fn discovery_action_is_explicit_but_backwards_compatible() { assert_eq!( DiscoveryAction::parse(None, false).unwrap(), - DiscoveryAction::Browse + DiscoveryAction::Search ); assert_eq!( DiscoveryAction::parse(None, true).unwrap(), DiscoveryAction::Select ); + assert_eq!( + DiscoveryAction::parse(Some("select"), true).unwrap(), + DiscoveryAction::Select + ); + assert_eq!( + DiscoveryAction::parse(Some("details"), true).unwrap(), + DiscoveryAction::Details + ); assert_eq!( DiscoveryAction::parse(Some("suggest"), false).unwrap(), DiscoveryAction::Suggest ); assert!(DiscoveryAction::parse(Some("select"), false).is_err()); - assert!(DiscoveryAction::parse(Some("browse"), true).is_err()); + assert!(DiscoveryAction::parse(Some("details"), false).is_err()); + assert!(DiscoveryAction::parse(Some("search"), true).is_err()); assert!(DiscoveryAction::parse(Some("suggest"), true).is_err()); } + #[test] + fn details_validation_requires_structured_relevance_and_goal() { + let mut input: DiscoverToolsInput = serde_json::from_value(json!({ + "action": "details", + "category": "payments", + "query": "confirm metered subscription billing and webhook reconciliation support", + "reason": "the current SaaS billing workflow needs usage reporting and reliable invoice state updates", + "tool": "Stripe", + "work_relevance": "core_requirement", + "investigation_goal": "capability_fit", + "requirements": ["TypeScript SDK", "Webhook status updates"], + "topics": ["capabilities", "limitations"], + "prior_request_id": "11111111-2222-4333-8444-555555555555" + })).unwrap(); + let details = validate_details(&input).unwrap(); + assert_eq!(details.work_relevance, "core_requirement"); + assert_eq!(details.investigation_goal, "capability_fit"); + assert_eq!(details.topics, vec!["capabilities", "limitations"]); + + input.work_relevance = Some("interesting".to_string()); + assert!(validate_details(&input).is_err()); + input.work_relevance = Some("core_requirement".to_string()); + input.topics = Some(vec!["pricing".to_string(), "pricing".to_string()]); + assert!(validate_details(&input).is_err()); + } + + #[test] + fn render_details_includes_decision_brief_and_both_source_links() { + let rendered = render_details("payments", "stripe", &json!({ + "tool": "Stripe", + "fit": "partial", + "summary": "Metered billing is supported, but the requested reconciliation flow needs an additional webhook.", + "capabilities": ["Usage meters", "Invoices"], + "limitations": ["No automatic replay endpoint"], + "freshness": { "status": "current", "checked_at": "2026-08-24T00:00:00Z" }, + "sources": [{ + "title": "Usage billing", + "provider_url": "https://docs.example.com/billing", + "cached_url": "https://jcode.sh/docs/example/billing" + }], + "next_action": "select" + })).unwrap(); + assert!(rendered.contains("Fit: partial")); + assert!(rendered.contains("https://docs.example.com/billing")); + assert!(rendered.contains("Jcode snapshot: https://jcode.sh/docs/example/billing")); + assert!(rendered.contains("Details do not select or connect")); + } + + /// Old action names stay valid so resumed sessions and saved benchmark + /// baselines keep parsing. + #[test] + fn legacy_action_names_still_parse() { + assert_eq!( + DiscoveryAction::parse(Some("browse"), false).unwrap(), + DiscoveryAction::Search + ); + assert_eq!( + DiscoveryAction::parse(Some("setup"), true).unwrap(), + DiscoveryAction::Select + ); + assert!(DiscoveryAction::parse(Some("setup"), false).is_err()); + assert!(DiscoveryAction::parse(Some("browse"), true).is_err()); + } + #[test] fn suggestion_validation_distinguishes_product_and_capability_gap() { let capability = DiscoverToolsInput { @@ -1684,6 +2333,9 @@ mod tests { ), requirements: Some(vec!["Scoped authentication without secret keys".to_string()]), prior_request_id: Some("11111111-2222-4333-8444-555555555555".to_string()), + work_relevance: None, + investigation_goal: None, + topics: None, }; let validated = validate_suggestion(&capability).unwrap(); assert_eq!(validated.kind, "capability_gap"); @@ -1721,6 +2373,9 @@ mod tests { gap_evidence: None, requirements: Some(Vec::new()), prior_request_id: Some("11111111-2222-4333-8444-555555555555".to_string()), + work_relevance: None, + investigation_goal: None, + topics: None, }; assert!(validate_suggestion(&input).is_err()); input.product_url = None; @@ -1775,8 +2430,9 @@ mod tests { .unwrap(); assert!(out.contains("Catalog suggestion submitted")); assert!(out.contains("Product: Stripe sandbox MCP")); - assert!(out.contains("Suggestions are not sent to partners")); - assert!(out.contains("does not mean Jcode has partnered with the tool")); + assert!(out.contains("Suggestions are not sent to integration providers")); + assert!(out.contains("does not mean the tool has integrated with Jcode")); + assert!(!out.to_ascii_lowercase().contains("partner")); } #[test] @@ -1883,6 +2539,7 @@ mod tests { fn test_provenance() -> DiscoveryRequestProvenance { DiscoveryRequestProvenance { session_id: "session-test-1".to_string(), + correlation_id: Some("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee".to_string()), session_metadata_available: true, is_self_dev: true, is_debug: false, @@ -1927,6 +2584,7 @@ mod tests { ); for expected in [ "x-jcode-discovery-session-id: session-test-1", + "x-jcode-session-correlation-id: aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "x-jcode-discovery-session-metadata: 1", "x-jcode-discovery-self-dev: 1", "x-jcode-discovery-debug: 0", @@ -1941,6 +2599,61 @@ mod tests { } } + #[tokio::test] + async fn fetch_details_posts_decision_context_and_returns_agent_brief() { + let response = json!({ + "tool": "Stripe", + "fit": "strong", + "summary": "The requested metered billing workflow is supported.", + "capabilities": ["Usage meters", "Webhook invoice updates"], + "freshness": { "status": "current", "checked_at": "2026-08-24T00:00:00Z" }, + "sources": [{ + "title": "Metered billing", + "provider_url": "https://docs.stripe.com/billing/subscriptions/usage-based", + "cached_url": "https://jcode.sh/docs/stripe/usage-based" + }], + "next_action": "select" + }); + let (endpoint, server) = one_shot_server("HTTP/1.1 200 OK", response.to_string()).await; + let client = reqwest::Client::new(); + let context = test_discovery_request( + &client, + &endpoint, + "11111111-2222-4333-8444-555555555555", + false, + ); + let details = ValidatedDetails { + work_relevance: "core_requirement".to_string(), + investigation_goal: "capability_fit".to_string(), + requirements: vec!["Webhook status updates".to_string()], + topics: vec!["capabilities".to_string(), "limitations".to_string()], + prior_request_id: Some("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee".to_string()), + }; + + let fetched = fetch_details(&context, "stripe", &details).await.unwrap(); + let request = server.await.unwrap(); + assert!(request.starts_with("POST /details HTTP/1.1")); + for expected in [ + "\"tool\":\"stripe\"", + "\"work_relevance\":\"core_requirement\"", + "\"investigation_goal\":\"capability_fit\"", + "\"requirements\":[\"Webhook status updates\"]", + "\"topics\":[\"capabilities\",\"limitations\"]", + "\"prior_request_id\":\"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee\"", + ] { + assert!( + request.contains(expected), + "missing request field: {expected}" + ); + } + let rendered = render_details("payments", "stripe", &fetched.listing).unwrap(); + assert!(rendered.contains("Fit: strong")); + assert!(rendered.contains("Usage meters")); + assert!(rendered.contains("https://docs.stripe.com/billing/subscriptions/usage-based")); + assert!(rendered.contains("Jcode snapshot: https://jcode.sh/docs/stripe/usage-based")); + assert!(rendered.contains("Suggested next action: `select`")); + } + #[tokio::test] async fn fetch_listing_hard_fails_on_http_error() { let (endpoint, _server) = @@ -1968,7 +2681,6 @@ mod tests { async fn submit_suggestion_posts_structured_maintainer_only_payload() { let body = json!({ "suggestion_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", - "status": "received", "message": "received" }) .to_string(); @@ -1996,6 +2708,7 @@ mod tests { }; let result = submit_suggestion(&request, &suggestion).await.unwrap(); assert_eq!(result.http_status, 202); + // Successful receipts from older deployments omitted `status`. assert_eq!(result.listing["status"], "received"); let request = server.await.unwrap(); @@ -2064,14 +2777,144 @@ mod tests { } #[tokio::test] - async fn execute_end_to_end_with_enabled_config_and_local_server() { + async fn execute_records_off_catalog_selection_without_provider_information() { let _guard = crate::storage::lock_test_env(); let prev_home = std::env::var_os("JCODE_HOME"); let temp = tempfile::tempdir().unwrap(); crate::env::set_var("JCODE_HOME", temp.path()); - let body = json!({"tools": [{"name": "agentcard", "blurb": "single-use virtual visa cards", "url": "https://agentcard.example", "setup": "MCP server: npx agentcard-mcp"}]}).to_string(); - let (endpoint, _server) = one_shot_server("HTTP/1.1 200 OK", body).await; + let body = json!({ + "category": "web-data", + "selected_tool": "firecrawl", + "listed": false, + }) + .to_string(); + let (endpoint, server) = one_shot_server("HTTP/1.1 200 OK", body).await; + std::fs::write( + temp.path().join("config.toml"), + format!("[sponsors]\nenabled = true\nendpoint = \"{endpoint}\"\n"), + ) + .unwrap(); + crate::config::Config::invalidate_cache(); + + let output = DiscoverToolsTool::new() + .execute( + json!({ + "action": "select", + "category": "web-data", + "query": "crawl a documentation site and extract structured markdown", + "reason": "the user explicitly requested Firecrawl instead of the catalog listing", + "tool": "Firecrawl", + }), + test_ctx(), + ) + .await + .unwrap(); + + assert!( + output + .output + .contains("Selected off-catalog product 'firecrawl'") + ); + assert!(output.output.contains("no provider information")); + assert!(!output.output.contains("Setup:")); + let metadata = output.metadata.unwrap(); + assert_eq!(metadata["selected_tool"], "firecrawl"); + assert_eq!(metadata["catalog_tool"], false); + assert_eq!(metadata["sponsored_discovery"], false); + + let request = server.await.unwrap(); + assert!(request.starts_with("GET /?"), "{request}"); + assert!(request.contains("tool=firecrawl"), "{request}"); + + if let Some(prev) = prev_home { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); + } + + #[tokio::test] + async fn details_executes_through_public_tool_interface() { + let _guard = crate::storage::lock_test_env(); + let prev_home = std::env::var_os("JCODE_HOME"); + let temp = tempfile::tempdir().unwrap(); + crate::env::set_var("JCODE_HOME", temp.path()); + let body = json!({ + "tool": "Stripe", + "fit": "strong", + "summary": "Metered billing and webhook reconciliation are supported.", + "capabilities": ["Usage meters", "Invoice webhooks"], + "sources": [{ + "title": "Usage billing", + "provider_url": "https://docs.stripe.com/billing/subscriptions/usage-based", + "cached_url": "https://jcode.sh/docs/stripe/usage-based" + }], + "next_action": "select" + }) + .to_string(); + let (endpoint, server) = one_shot_server("HTTP/1.1 200 OK", body).await; + std::fs::write( + temp.path().join("config.toml"), + format!("[sponsors]\nenabled = true\nendpoint = \"{endpoint}\"\n"), + ) + .unwrap(); + crate::config::Config::invalidate_cache(); + + let output = DiscoverToolsTool::new().execute(json!({ + "action": "details", + "category": "payments", + "query": "confirm metered subscription billing and webhook reconciliation support", + "reason": "the current SaaS workflow requires usage reporting and reliable invoice state updates", + "tool": "Stripe", + "work_relevance": "core_requirement", + "investigation_goal": "capability_fit", + "requirements": ["Webhook status updates"], + "topics": ["capabilities", "limitations"] + }), test_ctx()).await.unwrap(); + + assert_eq!(output.title.as_deref(), Some("stripe details")); + assert!(output.output.contains("Fit: strong")); + assert!(output.output.contains("Usage meters")); + assert!( + output + .output + .contains("https://docs.stripe.com/billing/subscriptions/usage-based") + ); + assert!( + output + .output + .contains("Jcode snapshot: https://jcode.sh/docs/stripe/usage-based") + ); + let metadata = output.metadata.unwrap(); + assert_eq!(metadata["integration_details"], true); + assert_eq!(metadata["work_relevance"], "core_requirement"); + assert_eq!(metadata["investigation_goal"], "capability_fit"); + let request = server.await.unwrap(); + assert!(request.starts_with("POST /details HTTP/1.1"), "{request}"); + assert!( + request.contains("\"work_relevance\":\"core_requirement\""), + "{request}" + ); + + if let Some(prev) = prev_home { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); + } + + #[tokio::test] + async fn git_category_executes_end_to_end_with_enabled_config_and_local_server() { + let _guard = crate::storage::lock_test_env(); + let prev_home = std::env::var_os("JCODE_HOME"); + let temp = tempfile::tempdir().unwrap(); + crate::env::set_var("JCODE_HOME", temp.path()); + + let body = json!({"tools": [{"name": "github", "blurb": "repository hosting and collaboration", "url": "https://github.com", "setup": "MCP server: npx github-mcp"}]}).to_string(); + let (endpoint, server) = one_shot_server("HTTP/1.1 200 OK", body).await; std::fs::write( temp.path().join("config.toml"), format!("[sponsors]\nenabled = true\nendpoint = \"{endpoint}\"\n"), @@ -2083,27 +2926,38 @@ mod tests { let output = tool .execute( json!({ - "category": "payments", - "query": "virtual card for checkout", - "reason": "task requires a safe online card payment capability not present in the current tools" + "category": "git", + "query": "host and collaborate on git repositories", + "reason": "task requires remote repository collaboration capabilities not present in the current tools" }), test_ctx(), ) .await .unwrap(); - assert!(output.output.contains("agentcard")); - assert!(output.output.contains("Jcode tool directory")); + assert!(output.output.contains("github")); + assert!(output.output.contains("Jcode integration directory")); assert!( output .output .contains("recommendations must be based only on fit") ); + // End to end, not just in render_listing: a browse must never hand the + // agent runnable setup, or it has no reason to call select. + assert!( + !output.output.contains("npx github-mcp"), + "browse leaked setup instructions: {}", + output.output + ); + assert!(output.output.contains("action `select`")); let title = output.title.unwrap(); - assert!(title.contains("(partner discovery disclosure)"), "{title}"); + assert_eq!(title, "git", "{title}"); let meta = output.metadata.unwrap(); assert_eq!(meta["sponsored_discovery"], true); + let request = server.await.unwrap(); + assert!(request.contains("category=git"), "{request}"); + // Opted-out config: execute refuses without any network call. std::fs::write( temp.path().join("config.toml"), diff --git a/crates/jcode-app-core/src/tool/discover_secrets.rs b/crates/jcode-app-core/src/tool/discover_secrets.rs new file mode 100644 index 0000000000..420432c05f --- /dev/null +++ b/crates/jcode-app-core/src/tool/discover_secrets.rs @@ -0,0 +1,193 @@ +//! Last-line secret scanning for model-authored Discovery text. +//! +//! `discover_tools` sends a model-written `query` and `reason` to a hosted +//! endpoint, so the client refuses anything that recognizably contains a +//! credential or personal identifier. This is a high-confidence backstop, not a +//! substitute for the schema instruction to summarize the need rather than +//! copy user data: it should almost never fire in normal use. + +/// A deliberately high-confidence last-line defense before model-authored +/// Discovery text leaves the client. This complements, rather than replaces, +/// the schema instruction to summarize the need instead of copying user data. +pub(super) fn contains_recognizable_secret(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + if (lower.contains("-----begin ") && lower.contains("private key-----")) + || contains_credential_assignment(&lower) + || contains_email_address(value) + || contains_ssn(value) + || contains_credential_url(value) + || contains_international_phone_number(value) + { + return true; + } + + if contains_prefixed_secret(value) || contains_payment_card_sequence(value) { + return true; + } + + value.split_whitespace().any(|token| { + let token = token.trim_matches(|c: char| { + matches!( + c, + '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' + ) + }); + looks_like_jwt(token) + }) || contains_bearer_token(&lower) +} + +fn contains_prefixed_secret(value: &str) -> bool { + const SECRET_PREFIXES: &[&str] = &[ + "sk_live_", + "rk_live_", + "sk_test_", + "rk_test_", + "sk-proj-", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "github_pat_", + "xoxb-", + "xoxp-", + "xoxa-", + "xoxr-", + "npm_", + "jck_live_", + ]; + value.split_whitespace().any(|token| { + let token = token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && !"_-".contains(c)); + let lower = token.to_ascii_lowercase(); + SECRET_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix) && token.len() >= prefix.len() + 8) + || (token.starts_with("AKIA") && token.len() == 20) + || (token.starts_with("AIza") && token.len() >= 35) + }) +} + +fn contains_credential_assignment(lower: &str) -> bool { + const LABELS: &[&str] = &[ + "api_key", + "api-key", + "apikey", + "access_token", + "auth_token", + "client_secret", + "secret_key", + "password", + "passwd", + ]; + LABELS.iter().any(|label| { + lower.match_indices(label).any(|(index, _)| { + let rest = &lower[index + label.len()..]; + let rest = rest.trim_start(); + let Some(rest) = rest.strip_prefix(['=', ':']) else { + return false; + }; + let candidate = + rest.trim_start_matches(|c: char| c.is_whitespace() || "'\"`".contains(c)); + candidate + .split(|c: char| c.is_whitespace() || "'\"`,;".contains(c)) + .next() + .is_some_and(|token| token.len() >= 8) + }) + }) +} + +fn contains_bearer_token(lower: &str) -> bool { + lower.match_indices("bearer ").any(|(index, _)| { + lower[index + "bearer ".len()..] + .split_whitespace() + .next() + .is_some_and(|token| token.trim_matches(|c: char| ",;.'\"`".contains(c)).len() >= 12) + }) +} + +fn contains_email_address(value: &str) -> bool { + value.split_whitespace().any(|token| { + let token = token.trim_matches(|c: char| ",;:()[]{}<>\"'`".contains(c)); + let Some((local, domain)) = token.split_once('@') else { + return false; + }; + !local.is_empty() + && domain + .rsplit_once('.') + .is_some_and(|(host, suffix)| !host.is_empty() && suffix.len() >= 2) + }) +} + +fn contains_ssn(value: &str) -> bool { + value.split_whitespace().any(|token| { + let token = token.trim_matches(|c: char| !c.is_ascii_digit() && c != '-'); + let parts: Vec<&str> = token.split('-').collect(); + parts.len() == 3 + && parts[0].len() == 3 + && parts[1].len() == 2 + && parts[2].len() == 4 + && parts + .iter() + .all(|part| part.chars().all(|c| c.is_ascii_digit())) + }) +} + +fn contains_credential_url(value: &str) -> bool { + value.split_whitespace().any(|token| { + let Some((_, rest)) = token.split_once("://") else { + return false; + }; + let authority = rest.split('/').next().unwrap_or_default(); + authority.contains('@') + && authority + .split('@') + .next() + .is_some_and(|user| user.contains(':')) + }) +} + +fn contains_international_phone_number(value: &str) -> bool { + value.split_whitespace().any(|token| { + if !token.starts_with('+') { + return false; + } + let digits = token.chars().filter(|c| c.is_ascii_digit()).count(); + (10..=15).contains(&digits) + && token + .chars() + .all(|c| c.is_ascii_digit() || "+-().".contains(c)) + }) +} + +fn looks_like_jwt(token: &str) -> bool { + token.len() >= 40 && token.starts_with("eyJ") && token.matches('.').count() == 2 +} + +fn contains_payment_card_sequence(value: &str) -> bool { + value + .split(|c: char| !c.is_ascii_digit() && c != '-' && c != ' ') + .any(|candidate| looks_like_payment_card(candidate.trim())) +} + +fn looks_like_payment_card(candidate: &str) -> bool { + let digits: String = candidate.chars().filter(|c| c.is_ascii_digit()).collect(); + if !(13..=19).contains(&digits.len()) + || candidate + .chars() + .any(|c| !c.is_ascii_digit() && c != '-' && c != ' ') + { + return false; + } + let mut sum = 0u32; + let parity = digits.len() % 2; + for (index, byte) in digits.bytes().enumerate() { + let mut digit = u32::from(byte - b'0'); + if index % 2 == parity { + digit *= 2; + if digit > 9 { + digit -= 9; + } + } + sum += digit; + } + sum.is_multiple_of(10) +} diff --git a/crates/jcode-app-core/src/tool/edit.rs b/crates/jcode-app-core/src/tool/edit.rs index 17a235b133..f1444706c5 100644 --- a/crates/jcode-app-core/src/tool/edit.rs +++ b/crates/jcode-app-core/src/tool/edit.rs @@ -140,11 +140,18 @@ impl Tool for EditTool { let end_line = start_line + params.new_string.lines().count().saturating_sub(1); let context = extract_context(&new_content, start_line, end_line, 3); - Ok(ToolOutput::new(format!( + let mut body = format!( "Edited {}: replaced {} occurrence(s)\n{}\n\nContext after edit (lines {}-{}):\n{}", params.file_path, occurrences, diff, context.0, context.1, context.2 - )) - .with_title(params.file_path.clone())) + ); + super::config_edit_notice::append_config_edit_notice( + &mut body, + &path, + &content, + &new_content, + ); + + Ok(ToolOutput::new(body).with_title(params.file_path.clone())) } } diff --git a/crates/jcode-app-core/src/tool/feedback.rs b/crates/jcode-app-core/src/tool/feedback.rs new file mode 100644 index 0000000000..b34ebd9851 --- /dev/null +++ b/crates/jcode-app-core/src/tool/feedback.rs @@ -0,0 +1,230 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Result, bail}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +const MAX_SUMMARY_CHARS: usize = 240; +const MAX_DETAILS_CHARS: usize = 1500; + +pub struct MaintainerFeedbackTool; + +impl MaintainerFeedbackTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct FeedbackInput { + category: FeedbackCategory, + origin: FeedbackOrigin, + user_confirmed: bool, + summary: String, + #[serde(default)] + details: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FeedbackCategory { + Bug, + Praise, + Suggestion, + Usability, + Other, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FeedbackOrigin { + User, + Agent, + Mixed, +} + +fn limited(value: &str, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + bail!("{label} must not be empty"); + } + if value.chars().count() > max { + bail!("{label} must be at most {max} characters"); + } + Ok(value.to_string()) +} + +fn payload(input: FeedbackInput) -> Result { + if matches!(input.origin, FeedbackOrigin::User | FeedbackOrigin::Mixed) && !input.user_confirmed + { + bail!("user_confirmed must be true for user or mixed-origin feedback"); + } + let summary = limited(&input.summary, "summary", MAX_SUMMARY_CHARS)?; + let details = input + .details + .as_deref() + .map(|value| limited(value, "details", MAX_DETAILS_CHARS)) + .transpose()?; + let category = format!("{:?}", input.category).to_ascii_lowercase(); + let origin = format!("{:?}", input.origin).to_ascii_lowercase(); + let mut text = format!("[agent feedback; category={category}; origin={origin}] {summary}"); + if let Some(details) = details { + text.push_str("\n\n"); + text.push_str(&details); + } + Ok(text) +} + +#[async_trait] +impl Tool for MaintainerFeedbackTool { + fn name(&self) -> &str { + "maintainer_feedback" + } + + fn description(&self) -> &str { + "Send product feedback to Jcode's maintainer. Respects telemetry settings." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["category", "origin", "user_confirmed", "summary"], + "properties": { + "intent": super::intent_schema_property(), + "category": { + "type": "string", + "enum": ["bug", "praise", "suggestion", "usability", "other"], + "description": "Kind of feedback." + }, + "origin": { + "type": "string", + "enum": ["user", "agent", "mixed"], + "description": "Whether this reflects the user's words, the agent's observation, or both." + }, + "user_confirmed": { + "type": "boolean", + "description": "True only if the user approved sharing user-originated feedback. Agent observations may use false." + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": MAX_SUMMARY_CHARS, + "description": "Self-contained maintainer-facing summary. Paraphrase rather than quoting the user." + }, + "details": { + "type": "string", + "minLength": 1, + "maxLength": MAX_DETAILS_CHARS, + "description": "Optional reproduction steps or expected versus actual behavior. Never include private data." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + let text = payload(serde_json::from_value(input)?)?; + if !crate::telemetry::is_enabled() { + return Ok(ToolOutput::new( + "Feedback was not sent because telemetry is disabled. The user can use /telemetry to change that setting.", + )); + } + crate::telemetry::record_feedback(&text); + Ok(ToolOutput::new( + "Feedback queued for the Jcode maintainer. Thank you.", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_labels_and_formats_feedback() { + let text = payload(FeedbackInput { + category: FeedbackCategory::Praise, + origin: FeedbackOrigin::Mixed, + user_confirmed: true, + summary: "The new picker is much easier to use".into(), + details: Some("The labels make model differences clear.".into()), + }) + .unwrap(); + assert_eq!( + text, + "[agent feedback; category=praise; origin=mixed] The new picker is much easier to use\n\nThe labels make model differences clear." + ); + } + + #[test] + fn payload_rejects_empty_and_oversized_fields() { + let empty = payload(FeedbackInput { + category: FeedbackCategory::Bug, + origin: FeedbackOrigin::Agent, + user_confirmed: false, + summary: " ".into(), + details: None, + }); + assert!(empty.unwrap_err().to_string().contains("must not be empty")); + + let oversized = payload(FeedbackInput { + category: FeedbackCategory::Other, + origin: FeedbackOrigin::User, + user_confirmed: true, + summary: "x".repeat(MAX_SUMMARY_CHARS + 1), + details: None, + }); + assert!(oversized.unwrap_err().to_string().contains("at most 240")); + } + + #[test] + fn schema_requires_provenance_and_carries_privacy_guidance() { + let tool = MaintainerFeedbackTool::new(); + let schema = tool.parameters_schema(); + assert_eq!( + schema["required"], + json!(["category", "origin", "user_confirmed", "summary"]) + ); + assert_eq!( + schema["properties"]["origin"]["enum"], + json!(["user", "agent", "mixed"]) + ); + assert!( + schema["properties"]["summary"]["description"] + .as_str() + .unwrap() + .contains("Paraphrase") + ); + assert!( + schema["properties"]["details"]["description"] + .as_str() + .unwrap() + .contains("Never include private data") + ); + assert!(tool.description().contains("telemetry settings")); + } + + #[test] + fn user_origin_requires_explicit_confirmation() { + let error = payload(FeedbackInput { + category: FeedbackCategory::Praise, + origin: FeedbackOrigin::User, + user_confirmed: false, + summary: "The user likes the new workflow".into(), + details: None, + }) + .unwrap_err(); + assert!(error.to_string().contains("user_confirmed must be true")); + + assert!( + payload(FeedbackInput { + category: FeedbackCategory::Bug, + origin: FeedbackOrigin::Agent, + user_confirmed: false, + summary: "The agent observed a reproducible tool error".into(), + details: None, + }) + .is_ok() + ); + } +} diff --git a/crates/jcode-app-core/src/tool/gmail.rs b/crates/jcode-app-core/src/tool/gmail.rs index 52c7ce28fa..f89a5b053c 100644 --- a/crates/jcode-app-core/src/tool/gmail.rs +++ b/crates/jcode-app-core/src/tool/gmail.rs @@ -16,6 +16,49 @@ impl GmailTool { client: GmailClient::new(), } } + + /// Resolve reply parameters into a usable (In-Reply-To header, threadId). + /// + /// Models pass Gmail API message IDs (hex, from search/read output) as + /// `in_reply_to`, but MIME threading needs the RFC 5322 Message-ID header + /// and the Gmail API needs the containing threadId. Silently sending + /// without either starts a new conversation, so look the message up and + /// fail loudly when it cannot be resolved. + async fn resolve_reply( + &self, + in_reply_to: Option<&str>, + thread_id: Option<&str>, + ) -> Result<(Option, Option)> { + let Some(reply_ref) = in_reply_to else { + return Ok((None, thread_id.map(str::to_string))); + }; + // Already an RFC 5322 Message-ID (contains '@', usually in <...>). + if reply_ref.contains('@') { + return Ok((Some(reply_ref.to_string()), thread_id.map(str::to_string))); + } + let msg = self + .client + .get_message(reply_ref, MessageFormat::Metadata) + .await + .map_err(|e| { + anyhow::anyhow!( + "in_reply_to '{}' is not an RFC 5322 Message-ID and could not be \ + resolved as a Gmail message ID: {}. The reply was NOT sent.", + reply_ref, + e + ) + })?; + let header_id = msg.header("Message-ID").map(str::to_string); + let resolved_thread = thread_id.map(str::to_string).or(msg.thread_id.clone()); + if header_id.is_none() && resolved_thread.is_none() { + anyhow::bail!( + "Message '{}' has no Message-ID header or threadId; cannot thread the reply. \ + The reply was NOT sent.", + reply_ref + ); + } + Ok((header_id, resolved_thread)) + } } #[derive(Deserialize)] @@ -70,7 +113,7 @@ impl Tool for GmailTool { "action": { "type": "string", "enum": ["connect", "search", "read", "list", "draft", "send", "send_draft", "threads", "thread", "labels", "trash", "modify_labels"], - "description": "Action. Use 'connect' to set up Gmail access via the Composio managed backend (opens a browser OAuth screen for the user to approve)." + "description": "Action. 'connect' sets up Gmail access via a browser OAuth screen the user approves." }, "query": { "type": "string" }, "message_id": { "type": "string" }, @@ -340,14 +383,17 @@ impl Tool for GmailTool { } } + let (reply_header, reply_thread) = self + .resolve_reply(params.in_reply_to.as_deref(), params.thread_id.as_deref()) + .await?; let draft = self .client .create_draft_with_attachments( to, subject, body, - params.in_reply_to.as_deref(), - params.thread_id.as_deref(), + reply_header.as_deref(), + reply_thread.as_deref(), &attachments, ) .await?; @@ -427,21 +473,25 @@ impl Tool for GmailTool { ))); } + let (reply_header, reply_thread) = self + .resolve_reply(params.in_reply_to.as_deref(), params.thread_id.as_deref()) + .await?; let msg = self .client .send_message_with_attachments( to, subject, body, - params.in_reply_to.as_deref(), - params.thread_id.as_deref(), + reply_header.as_deref(), + reply_thread.as_deref(), &attachments, ) .await?; Ok(ToolOutput::new(format!( - "Email sent successfully.\nMessage ID: {}\nTo: {}\nSubject: {}\nAttachments: {}", + "Email sent successfully.\nMessage ID: {}\nThread ID: {}\nTo: {}\nSubject: {}\nAttachments: {}", msg.id, + msg.thread_id.as_deref().unwrap_or("(new thread)"), to, subject, attachments.len() diff --git a/crates/jcode-app-core/src/tool/inflight.rs b/crates/jcode-app-core/src/tool/inflight.rs new file mode 100644 index 0000000000..95941b1d86 --- /dev/null +++ b/crates/jcode-app-core/src/tool/inflight.rs @@ -0,0 +1,110 @@ +//! Process-global registry of tool calls that are currently executing. +//! +//! Why this exists: the "missing tool output" repair paths treat an assistant +//! `tool_use` with no matching `tool_result` as evidence of an interrupted +//! turn and insert a synthetic placeholder result. That inference is wrong +//! while the tool is *still running*. A confirmed production wedge +//! (session_clover_1785560899476): a 106s `bash` call was mid-flight when a +//! scheduled-task wakeup drove another turn on the same session. Repair +//! injected a placeholder result, the real output landed 28s later as a second +//! `tool_result` for the same `tool_use_id`, and Anthropic then rejected every +//! subsequent request with: +//! +//! ```text +//! unexpected `tool_use_id` found in `tool_result` blocks: +//! ``` +//! +//! leaving the session permanently unsendable. Repair must therefore skip any +//! tool call that is still executing; its real result is on the way. +//! +//! `Registry::execute` registers each call here for the duration of its +//! execution via an RAII guard, so entries can never leak past a panic, +//! cancellation, or early return. + +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +/// Reference counts per tool_call_id. A count (rather than a set) keeps the +/// registry correct if the same id is somehow executed concurrently, e.g. a +/// retry racing the original. +static IN_FLIGHT: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// RAII registration for one executing tool call. +pub struct InFlightToolGuard { + tool_call_id: String, +} + +impl Drop for InFlightToolGuard { + fn drop(&mut self) { + let Ok(mut map) = IN_FLIGHT.lock() else { + return; + }; + if let Some(count) = map.get_mut(&self.tool_call_id) { + *count = count.saturating_sub(1); + if *count == 0 { + map.remove(&self.tool_call_id); + } + } + } +} + +/// Mark `tool_call_id` as executing until the returned guard is dropped. +/// Empty ids are not tracked (nothing can match them during repair). +pub fn mark_tool_in_flight(tool_call_id: &str) -> Option { + if tool_call_id.is_empty() { + return None; + } + let mut map = IN_FLIGHT.lock().ok()?; + *map.entry(tool_call_id.to_string()).or_insert(0) += 1; + Some(InFlightToolGuard { + tool_call_id: tool_call_id.to_string(), + }) +} + +/// True while a tool call with this id is executing somewhere in this process. +pub fn is_tool_in_flight(tool_call_id: &str) -> bool { + IN_FLIGHT + .lock() + .map(|map| map.contains_key(tool_call_id)) + .unwrap_or(false) +} + +/// Number of tool calls currently executing (diagnostics only). +pub fn in_flight_tool_count() -> usize { + IN_FLIGHT.lock().map(|map| map.len()).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guard_tracks_and_releases() { + let id = "toolu_inflight_test_basic"; + assert!(!is_tool_in_flight(id)); + { + let _guard = mark_tool_in_flight(id).expect("guard"); + assert!(is_tool_in_flight(id)); + } + assert!(!is_tool_in_flight(id)); + } + + #[test] + fn nested_guards_release_only_after_the_last_one() { + let id = "toolu_inflight_test_nested"; + let outer = mark_tool_in_flight(id).expect("guard"); + let inner = mark_tool_in_flight(id).expect("guard"); + assert!(is_tool_in_flight(id)); + drop(inner); + assert!(is_tool_in_flight(id), "one registration still outstanding"); + drop(outer); + assert!(!is_tool_in_flight(id)); + } + + #[test] + fn empty_ids_are_not_tracked() { + assert!(mark_tool_in_flight("").is_none()); + assert!(!is_tool_in_flight("")); + } +} diff --git a/crates/jcode-app-core/src/tool/jcode_docs.rs b/crates/jcode-app-core/src/tool/jcode_docs.rs new file mode 100644 index 0000000000..fa1b52d267 --- /dev/null +++ b/crates/jcode-app-core/src/tool/jcode_docs.rs @@ -0,0 +1,285 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::cmp::Reverse; +use std::collections::HashSet; + +include!(concat!(env!("OUT_DIR"), "/jcode_docs.rs")); + +const DEFAULT_LIMIT: usize = 5; +const MAX_LIMIT: usize = 10; +const MAX_SECTION_CHARS: usize = 4_000; + +pub struct JcodeDocsTool; + +impl JcodeDocsTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct JcodeDocsInput { + #[serde(default = "default_action")] + action: String, + #[serde(default)] + query: Option, + #[serde(default)] + path: Option, + #[serde(default)] + limit: Option, +} + +fn default_action() -> String { + "search".to_string() +} + +#[derive(Debug)] +struct Section<'a> { + path: &'a str, + heading: String, + body: String, +} + +#[async_trait] +impl Tool for JcodeDocsTool { + fn name(&self) -> &str { + "jcode_docs" + } + + fn description(&self) -> &str { + "Search bundled, version-matched Jcode documentation. Use this first for questions about Jcode features, configuration, architecture, tools, or behavior." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["search", "read", "list"], + "description": "Search documentation (default), read one document, or list bundled documents." + }, + "query": { + "type": "string", + "description": "Words or question to search for. Required for search." + }, + "path": { + "type": "string", + "description": "Exact bundled path returned by search/list. Required for read." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_LIMIT, + "description": "Maximum search results. Defaults to 5." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + let params: JcodeDocsInput = serde_json::from_value(input)?; + let output = match params.action.as_str() { + "search" => search( + params + .query + .as_deref() + .ok_or_else(|| anyhow!("query is required for search"))?, + params.limit, + ), + "read" => read_doc( + params + .path + .as_deref() + .ok_or_else(|| anyhow!("path is required for read"))?, + )?, + "list" => list_docs(), + other => { + return Err(anyhow!( + "unknown action {other:?}; use search, read, or list" + )); + } + }; + Ok(ToolOutput::new(output).with_title(format!("jcode docs {}", params.action))) + } +} + +fn list_docs() -> String { + let mut output = format!( + "Bundled Jcode documentation ({} files):\n", + JCODE_DOCS.len() + ); + for (path, body) in JCODE_DOCS { + let title = body + .lines() + .find_map(|line| line.strip_prefix("# ")) + .unwrap_or(path); + output.push_str(&format!("- `{path}`: {title}\n")); + } + output +} + +fn read_doc(path: &str) -> Result { + let (_, body) = JCODE_DOCS + .iter() + .find(|(candidate, _)| *candidate == path) + .ok_or_else(|| { + anyhow!("documentation path not found: {path}. Use action=list to see available paths.") + })?; + Ok(format!( + "Source: `{path}` (bundled with this Jcode build)\n\n{body}" + )) +} + +fn search(query: &str, limit: Option) -> String { + let terms = terms(query); + if terms.is_empty() { + return "Search query must contain at least one word.".to_string(); + } + let mut matches = sections() + .into_iter() + .filter_map(|section| { + let heading = section.heading.to_lowercase(); + let body = section.body.to_lowercase(); + let path = section.path.to_lowercase(); + let matched = terms + .iter() + .filter(|term| { + heading.contains(*term) || body.contains(*term) || path.contains(*term) + }) + .count(); + if matched == 0 { + return None; + } + let occurrences = terms + .iter() + .map(|term| body.matches(term.as_str()).count().min(10)) + .sum::(); + let score = matched * 100 + + terms.iter().filter(|term| heading.contains(*term)).count() * 40 + + terms.iter().filter(|term| path.contains(*term)).count() * 20 + + occurrences; + Some((score, section)) + }) + .collect::>(); + matches + .sort_by_key(|(score, section)| (Reverse(*score), section.path, section.heading.clone())); + let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + let mut output = format!("Jcode docs results for {query:?} (bundled with this Jcode build):\n"); + for (index, (_, section)) in matches.into_iter().take(limit).enumerate() { + let excerpt = relevant_excerpt(§ion.body, &terms); + output.push_str(&format!( + "\n{}. `{}` > {}\n{}\n", + index + 1, + section.path, + section.heading, + excerpt + )); + } + if output.lines().count() == 1 { + output.push_str("\nNo matching documentation. Try fewer or broader terms.\n"); + } + output +} + +fn terms(query: &str) -> Vec { + let stop: HashSet<&str> = [ + "a", "an", "and", "about", "does", "for", "how", "i", "in", "is", "jcode", "of", "on", + "the", "to", "what", "with", + ] + .into_iter() + .collect(); + query + .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') + .map(str::to_lowercase) + .filter(|term| term.len() > 1 && !stop.contains(term.as_str())) + .collect() +} + +fn sections() -> Vec> { + let mut result = Vec::new(); + for (path, document) in JCODE_DOCS { + let mut heading = "Overview".to_string(); + let mut body = String::new(); + for line in document.lines() { + if line.starts_with('#') { + if !body.trim().is_empty() { + result.push(Section { + path, + heading, + body: std::mem::take(&mut body), + }); + } + heading = line.trim_start_matches('#').trim().to_string(); + } else { + body.push_str(line); + body.push('\n'); + } + } + if !body.trim().is_empty() { + result.push(Section { + path, + heading, + body, + }); + } + } + result +} + +fn relevant_excerpt(body: &str, terms: &[String]) -> String { + let paragraphs = body + .split("\n\n") + .filter(|part| !part.trim().is_empty()) + .collect::>(); + let best = paragraphs + .iter() + .max_by_key(|part| { + let lower = part.to_lowercase(); + terms.iter().filter(|term| lower.contains(*term)).count() + }) + .copied() + .unwrap_or(body) + .trim(); + if best.chars().count() <= MAX_SECTION_CHARS { + best.to_string() + } else { + format!( + "{}…", + best.chars().take(MAX_SECTION_CHARS).collect::() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn corpus_includes_current_docs_but_not_plans() { + assert!(JCODE_DOCS.iter().any(|(path, _)| *path == "README.md")); + assert!(JCODE_DOCS.iter().any(|(path, _)| *path == "docs/README.md")); + assert!( + !JCODE_DOCS + .iter() + .any(|(path, _)| path.starts_with("docs/plans/")) + ); + } + + #[test] + fn search_finds_relevant_version_matched_documentation() { + let output = search("How does swarm task graph work?", Some(3)); + assert!(output.contains("docs/SWARM_TASK_GRAPH.md"), "{output}"); + assert!(output.contains("bundled with this Jcode build")); + } + + #[test] + fn exact_document_can_be_read() { + let output = read_doc("docs/README.md").unwrap(); + assert!(output.contains("# jcode Docs")); + } +} diff --git a/crates/jcode-app-core/src/tool/mcp.rs b/crates/jcode-app-core/src/tool/mcp.rs index fe1908b685..e38f5cd4f3 100644 --- a/crates/jcode-app-core/src/tool/mcp.rs +++ b/crates/jcode-app-core/src/tool/mcp.rs @@ -1,15 +1,213 @@ //! MCP management tool - connect, disconnect, list, reload MCP servers -use crate::mcp::{McpManager, McpServerConfig}; +use crate::mcp::{ContentBlock, McpManager, McpServerConfig, dispatch_name}; use crate::tool::{Tool, ToolContext, ToolOutput}; use anyhow::Result; use async_trait::async_trait; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; +#[derive(Debug, Deserialize)] +struct McpSearchInput { + #[serde(default)] + server: Option, + #[serde(default)] + query: Option, +} + +#[derive(Debug, Serialize)] +struct McpSearchResult { + name: String, + server: String, + tool: String, + description: String, + input_schema: Value, +} + +/// Fixed MCP discovery surface used when individual server definitions are deferred. +pub struct McpSearchTool { + manager: Arc>, +} + +impl McpSearchTool { + pub fn new(manager: Arc>) -> Self { + Self { manager } + } +} + +#[async_trait] +impl Tool for McpSearchTool { + fn name(&self) -> &str { + "mcp_search" + } + + fn description(&self) -> &str { + "Search available MCP tools by server, name, or description. Returns callable names and input schemas." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "Optional exact MCP server name." + }, + "query": { + "type": "string", + "description": "Optional case-insensitive name or description search." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: McpSearchInput = serde_json::from_value(input)?; + let server_filter = params + .server + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let query = params + .query + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase); + let manager = self.manager.read().await; + let catalog = manager.searchable_tools().await; + drop(manager); + + let matches: Vec = catalog + .into_iter() + .filter_map(|(server, tool)| { + if server_filter.is_some_and(|wanted| wanted != server) { + return None; + } + let name = dispatch_name(&server, &tool.name); + if !super::session_mcp_dispatch_is_allowed(&ctx.session_id, &name, "mcp_search") { + return None; + } + if let Some(query) = &query { + let description = tool.description.as_deref().unwrap_or_default(); + if !name.to_ascii_lowercase().contains(query) + && !server.to_ascii_lowercase().contains(query) + && !tool.name.to_ascii_lowercase().contains(query) + && !description.to_ascii_lowercase().contains(query) + { + return None; + } + } + Some(McpSearchResult { + name, + server, + tool: tool.name, + description: tool.description.unwrap_or_else(|| "MCP tool".to_string()), + input_schema: tool.input_schema, + }) + }) + .collect(); + + Ok(ToolOutput::new(serde_json::to_string_pretty(&matches)?) + .with_title(format!("MCP tools ({})", matches.len()))) + } +} + +#[derive(Debug, Deserialize)] +struct McpCallInput { + server: String, + tool: String, + #[serde(default)] + arguments: Value, +} + +/// Fixed MCP execution surface used when individual server definitions are deferred. +pub struct McpCallTool { + manager: Arc>, +} + +impl McpCallTool { + pub fn new(manager: Arc>) -> Self { + Self { manager } + } +} + +#[async_trait] +impl Tool for McpCallTool { + fn name(&self) -> &str { + "mcp_call" + } + + fn description(&self) -> &str { + "Call an MCP server tool discovered with mcp_search." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server": {"type": "string", "description": "MCP server name."}, + "tool": {"type": "string", "description": "Raw MCP tool name."}, + "arguments": { + "type": "object", + "description": "Arguments matching the input schema returned by mcp_search." + } + }, + "required": ["server", "tool", "arguments"] + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let mut params: McpCallInput = serde_json::from_value(input)?; + let dispatched_name = dispatch_name(¶ms.server, ¶ms.tool); + if !super::session_mcp_dispatch_is_allowed(&ctx.session_id, &dispatched_name, "mcp_call") { + anyhow::bail!("MCP tool '{}' is not allowed", dispatched_name); + } + if params.arguments.is_null() { + params.arguments = Value::Object(serde_json::Map::new()); + } + + let manager = self.manager.read().await; + let result = manager + .call_tool(¶ms.server, ¶ms.tool, params.arguments) + .await?; + drop(manager); + + let mut output_parts = Vec::new(); + for block in result.content { + match block { + ContentBlock::Text { text } => output_parts.push(text), + ContentBlock::Image { data, mime_type } => { + output_parts.push(format!("[Image: {} ({} bytes)]", mime_type, data.len())); + } + ContentBlock::Resource { resource } => { + if let Some(text) = resource.text { + output_parts.push(text); + } else if let Some(blob) = resource.blob { + output_parts.push(format!( + "[Resource: {} ({} bytes)]", + resource.uri, + blob.len() + )); + } else { + output_parts.push(format!("[Resource: {}]", resource.uri)); + } + } + } + } + let output = output_parts.join("\n"); + let title = format!("mcp:{}:{}", params.server, params.tool); + if result.is_error { + Ok(ToolOutput::new(format!("Error: {}", output)).with_title(title)) + } else { + Ok(ToolOutput::new(output).with_title(title)) + } + } +} + #[derive(Debug, Deserialize)] struct McpToolInput { action: String, @@ -25,7 +223,7 @@ struct McpToolInput { pub struct McpManagementTool { manager: Arc>, - registry: Option, + registry: Option, } impl McpManagementTool { @@ -37,7 +235,7 @@ impl McpManagementTool { } pub fn with_registry(mut self, registry: crate::tool::Registry) -> Self { - self.registry = Some(registry); + self.registry = Some(registry.downgrade()); self } } @@ -189,9 +387,8 @@ impl McpManagementTool { } else { for (_, tool) in server_tools { output.push_str(&format!( - " - mcp__{}__{}: {}\n", - server, - tool.name, + " - {}: {}\n", + crate::mcp::dispatch_name(server, &tool.name), tool.description.as_deref().unwrap_or("(no description)") )); } @@ -236,8 +433,10 @@ impl McpManagementTool { shared: true, transport: None, url: None, + headers: std::collections::HashMap::new(), enabled: None, disabled: None, + timeout_secs: None, } } else { let manager = self.manager.read().await; @@ -279,19 +478,23 @@ impl McpManagementTool { ); for (_, tool) in &server_tools { output.push_str(&format!( - " - mcp__{}__{}: {}\n", - server_name, - tool.name, + " - {}: {}\n", + crate::mcp::dispatch_name(&server_name, &tool.name), tool.description.as_deref().unwrap_or("(no description)") )); } drop(manager); // Register the new tools in the registry - if let Some(ref registry) = self.registry { + if let Some(registry) = self + .registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await; + let server_prefix = crate::mcp::dispatch_name(&server_name, ""); for (name, tool) in mcp_tools { - if name.starts_with(&format!("mcp__{}__", server_name)) { + if name.starts_with(&server_prefix) { registry.register(name, tool).await; } } @@ -344,9 +547,13 @@ impl McpManagementTool { drop(manager); // Unregister tools for this server - if let Some(ref registry) = self.registry { + if let Some(registry) = self + .registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { let removed = registry - .unregister_prefix(&format!("mcp__{}__", server_name)) + .unregister_prefix(&crate::mcp::dispatch_name(&server_name, "")) .await; crate::logging::event_info( "MCP_LIFECYCLE", @@ -371,7 +578,11 @@ impl McpManagementTool { if config.servers.is_empty() { // Unregister all existing MCP tools before reporting empty - if let Some(ref registry) = self.registry { + if let Some(registry) = self + .registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { registry.unregister_prefix("mcp__").await; } return Ok(ToolOutput::new( @@ -383,7 +594,11 @@ impl McpManagementTool { } // Unregister all existing MCP server tools before reload - if let Some(ref registry) = self.registry { + if let Some(registry) = self + .registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { registry.unregister_prefix("mcp__").await; } @@ -395,7 +610,11 @@ impl McpManagementTool { drop(manager); // Re-register tools from fresh connections - if let Some(ref registry) = self.registry { + if let Some(registry) = self + .registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await; for (name, tool) in mcp_tools { registry.register(name, tool).await; @@ -584,8 +803,10 @@ mod tests { shared: true, transport: None, url: None, + headers: HashMap::new(), enabled: Some(false), disabled: None, + timeout_secs: None, }, ); let manager = Arc::new(RwLock::new(McpManager::with_config(config))); diff --git a/crates/jcode-app-core/src/tool/memory.rs b/crates/jcode-app-core/src/tool/memory.rs index bf89759603..0123450370 100644 --- a/crates/jcode-app-core/src/tool/memory.rs +++ b/crates/jcode-app-core/src/tool/memory.rs @@ -40,6 +40,17 @@ impl MemoryTool { )), } } + + /// Scope the manager to the per-call working directory so project-scoped + /// memories resolve to the right `projects/.json` store. The base + /// manager is built once in `new()` with `project_dir: None`, which made + /// project writes silently no-op and reads come back empty (issue #491). + fn scoped_manager(&self, ctx: &ToolContext) -> MemoryManager { + match ctx.working_dir.as_deref() { + Some(dir) if !dir.as_os_str().is_empty() => self.manager.clone().with_project_dir(dir), + _ => self.manager.clone(), + } + } } #[derive(Debug, Deserialize)] @@ -121,6 +132,7 @@ impl Tool for MemoryTool { let input: MemoryInput = serde_json::from_value(input)?; let action_label = input.action.clone(); let session_id = ctx.session_id.clone(); + let manager = self.scoped_manager(&ctx); match input.action.as_str() { "remember" => { @@ -144,9 +156,9 @@ impl Tool for MemoryTool { entry = entry.with_tags(tags); } let id = if scope == "global" { - self.manager.remember_global(entry)? + manager.remember_global(entry)? } else { - self.manager.remember_project(entry)? + manager.remember_project(entry)? }; // The agent just wrote this memory itself; the content is in // the transcript (tool call + result), so auto-recall should @@ -184,7 +196,7 @@ impl Tool for MemoryTool { action: "recall".into(), detail: "recent".into(), }); - let result = match self.manager.get_prompt_memories_scoped(limit, scope) { + let result = match manager.get_prompt_memories_scoped(limit, scope) { Some(memories) => { let count = memories.lines().filter(|l| l.starts_with("- ")).count(); @@ -220,10 +232,10 @@ impl Tool for MemoryTool { }); let results = if mode == "cascade" { - self.manager + manager .find_similar_with_cascade_scoped(&query, 0.5, limit, scope)? } else { - self.manager + manager .find_similar_scoped(&query, 0.5, limit, scope)? }; @@ -277,7 +289,7 @@ impl Tool for MemoryTool { action: "search".into(), detail: truncate_for_widget(&query, 40), }); - let results = self.manager.search_scoped(&query, scope)?; + let results = manager.search_scoped(&query, scope)?; memory::add_event(MemoryEventKind::ToolRecalled { query: truncate_for_widget(&query, 40), count: results.len(), @@ -302,7 +314,7 @@ impl Tool for MemoryTool { action: "list".into(), detail: String::new(), }); - let all = self.manager.list_all_scoped(scope)?; + let all = manager.list_all_scoped(scope)?; memory::add_event(MemoryEventKind::ToolListed { count: all.len() }); memory::set_state(MemoryState::Idle); if all.is_empty() { @@ -324,7 +336,7 @@ impl Tool for MemoryTool { action: "forget".into(), detail: truncate_for_widget(&id, 30), }); - let found = self.manager.forget(&id)?; + let found = manager.forget(&id)?; memory::add_event(MemoryEventKind::ToolForgot { id: id.clone() }); memory::set_state(MemoryState::Idle); if found { @@ -346,7 +358,7 @@ impl Tool for MemoryTool { detail: format!("{} +{}", truncate_for_widget(&id, 20), tags.join(",")), }); for tag in &tags { - self.manager.tag_memory(&id, tag)?; + manager.tag_memory(&id, tag)?; } let tags_str = tags.join(", "); memory::add_event(MemoryEventKind::ToolTagged { @@ -377,7 +389,7 @@ impl Tool for MemoryTool { truncate_for_widget(&to_id, 15) ), }); - self.manager.link_memories(&from_id, &to_id, weight)?; + manager.link_memories(&from_id, &to_id, weight)?; memory::add_event(MemoryEventKind::ToolLinked { from: from_id.clone(), to: to_id.clone(), @@ -396,7 +408,7 @@ impl Tool for MemoryTool { action: "related".into(), detail: truncate_for_widget(&id, 30), }); - let related = self.manager.get_related(&id, depth)?; + let related = manager.get_related(&id, depth)?; memory::add_event(MemoryEventKind::ToolRecalled { query: format!("related:{}", truncate_for_widget(&id, 20)), count: related.len(), @@ -470,4 +482,134 @@ mod tests { assert!(!props.contains_key("depth")); assert!(!props.contains_key("mode")); } + + fn test_ctx(working_dir: Option) -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-message".to_string(), + tool_call_id: "test-tool-call".to_string(), + working_dir, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } + } + + /// Issue #491 regression: project-scoped remember followed by list must + /// round-trip through the real (non-test-mode) manager when the tool + /// context carries a working dir. + #[tokio::test] + async fn project_scope_round_trips_with_working_dir() { + let _guard = crate::storage::lock_test_env(); + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", home.path()); + + let tool = MemoryTool::new(); + let remember = tool + .execute( + json!({ + "action": "remember", + "content": "issue-491-probe", + "scope": "project" + }), + test_ctx(Some(project.path().to_path_buf())), + ) + .await + .expect("remember should succeed"); + assert!(remember.output.contains("issue-491-probe")); + + let list = tool + .execute( + json!({ "action": "list", "scope": "project" }), + test_ctx(Some(project.path().to_path_buf())), + ) + .await + .expect("list should succeed"); + assert!( + list.output.contains("issue-491-probe"), + "project-scoped memory must persist and be listed, got: {}", + list.output + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + /// Issue #729 regression, behavioral rather than structural. + /// + /// `create_headless_session` used to call `enable_memory_test_mode()` + /// unconditionally, so real swarm-spawned workers got throwaway storage and + /// could never read what the session that spawned them remembered. The fix + /// makes isolation an explicit per-caller choice, but the property that + /// actually matters to a user is this: with the same working directory, a + /// default registry's memory tool sees what was written, and a test-mode + /// one does not. + /// + /// Driving `Tool::execute` (rather than inspecting a flag) means this stays + /// honest even if the internals are refactored. + #[tokio::test] + async fn swarm_worker_memory_sees_the_spawning_session_only_without_isolation() { + let _guard = crate::storage::lock_test_env(); + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", home.path()); + + // The session that spawns a worker records something project-scoped. + let spawner = MemoryTool::new(); + spawner + .execute( + json!({ + "action": "remember", + "content": "issue-729-spawner-note", + "scope": "project" + }), + test_ctx(Some(project.path().to_path_buf())), + ) + .await + .expect("spawner remember should succeed"); + + // A worker that kept real memory (the fixed path) must see it. + let worker = MemoryTool::new(); + let seen = worker + .execute( + json!({ "action": "list", "scope": "project" }), + test_ctx(Some(project.path().to_path_buf())), + ) + .await + .expect("worker list should succeed"); + assert!( + seen.output.contains("issue-729-spawner-note"), + "a swarm worker must see the spawning session's project memory, got: {}", + seen.output + ); + + // A worker forced into test mode (the pre-fix path) cannot, no matter + // that it has the identical working directory. This is the defect. + let isolated = MemoryTool::new_test(); + let blind = isolated + .execute( + json!({ "action": "list", "scope": "project" }), + test_ctx(Some(project.path().to_path_buf())), + ) + .await + .expect("isolated list should succeed"); + assert!( + !blind.output.contains("issue-729-spawner-note"), + "test mode unexpectedly saw real project memory, so this test cannot \ + distinguish the two paths: {}", + blind.output + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } } diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs index f76b7fba78..446c6a9e93 100644 --- a/crates/jcode-app-core/src/tool/mod.rs +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -8,13 +8,18 @@ mod browser; mod communicate; #[cfg(target_os = "macos")] mod computer; +mod config_edit_notice; mod conversation_search; mod debug_socket; mod discover; +mod discover_secrets; mod edit; +mod feedback; mod gmail; mod goal; +pub mod inflight; mod invalid; +mod jcode_docs; mod ls; pub mod mcp; mod memory; @@ -41,6 +46,25 @@ use jcode_message_types::ToolDefinition; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub(crate) fn tool_name_is_allowed(allowed: &HashSet, name: &str) -> bool { + allowed.contains(name) + || (allowed.contains("mcp") && is_mcp_tool_name(name)) + || (is_fixed_mcp_tool(name) && allowed.iter().any(|tool| tool.starts_with("mcp__"))) +} + +pub(crate) fn tool_name_is_disabled(disabled: &HashSet, name: &str) -> bool { + disabled.contains(name) || (disabled.contains("mcp") && is_mcp_tool_name(name)) +} + +fn is_fixed_mcp_tool(name: &str) -> bool { + matches!(name, "mcp_search" | "mcp_call") +} + +fn is_mcp_tool_name(name: &str) -> bool { + name == "mcp" || name.starts_with("mcp__") || is_fixed_mcp_tool(name) +} use std::sync::{LazyLock, RwLock as StdRwLock}; use tokio::sync::RwLock; @@ -53,11 +77,60 @@ pub(crate) use session_search::spawn_recent_index_warmup; struct SessionToolPolicy { allowed_tools: Option>, disabled_tools: HashSet, + owner: Option, } static SESSION_TOOL_POLICIES: LazyLock>> = LazyLock::new(|| StdRwLock::new(HashMap::new())); +static NEXT_SESSION_TOOL_POLICY_OWNER: AtomicU64 = AtomicU64::new(1); + +/// Removes an Agent-owned policy when that Agent actually leaves memory. +/// +/// The owner token prevents a stale Agent from removing the policy installed by +/// a successor connection for the same persisted session ID. +pub(crate) struct SessionToolPolicyRegistration { + session_id: String, + owner: u64, +} + +impl Drop for SessionToolPolicyRegistration { + fn drop(&mut self) { + let mut policies = SESSION_TOOL_POLICIES + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if policies + .get(&self.session_id) + .is_some_and(|policy| policy.owner == Some(self.owner)) + { + policies.remove(&self.session_id); + } + } +} +pub(crate) fn register_session_tool_policy( + session_id: &str, + allowed_tools: Option>, + disabled_tools: HashSet, +) -> SessionToolPolicyRegistration { + let owner = NEXT_SESSION_TOOL_POLICY_OWNER.fetch_add(1, Ordering::Relaxed); + let mut policies = SESSION_TOOL_POLICIES + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + policies.insert( + session_id.to_string(), + SessionToolPolicy { + allowed_tools, + disabled_tools, + owner: Some(owner), + }, + ); + SessionToolPolicyRegistration { + session_id: session_id.to_string(), + owner, + } +} + +#[cfg(test)] pub(crate) fn set_session_tool_policy( session_id: &str, allowed_tools: Option>, @@ -71,10 +144,12 @@ pub(crate) fn set_session_tool_policy( SessionToolPolicy { allowed_tools, disabled_tools, + owner: None, }, ); } +#[cfg(test)] pub(crate) fn clear_session_tool_policy(session_id: &str) { let mut policies = SESSION_TOOL_POLICIES .write() @@ -90,6 +165,63 @@ fn session_tool_policy(session_id: &str) -> Option { .cloned() } +#[cfg(test)] +pub(crate) fn session_tool_policy_allows_tool_for_test( + session_id: &str, + tool_name: &str, +) -> Option { + session_tool_policy(session_id).map(|policy| { + policy + .allowed_tools + .as_ref() + .is_none_or(|allowed| tool_name_is_allowed(allowed, tool_name)) + && !tool_name_is_disabled(&policy.disabled_tools, tool_name) + }) +} + +/// Apply the current session policy to an MCP server tool invoked through a +/// fixed deferred surface. Explicitly enabling the fixed surface authorizes its +/// underlying MCP calls, while per-tool allow/deny entries remain effective. +pub(crate) fn session_mcp_dispatch_is_allowed( + session_id: &str, + dispatched_name: &str, + fixed_surface: &str, +) -> bool { + let Some(policy) = session_tool_policy(session_id) else { + return true; + }; + let allowed = policy.allowed_tools.as_ref().is_none_or(|allowed| { + tool_name_is_allowed(allowed, dispatched_name) || allowed.contains(fixed_surface) + }); + allowed && !tool_name_is_disabled(&policy.disabled_tools, dispatched_name) +} + +/// Whether a tool call opted in to receiving an oversized (truncated) result. +/// +/// Read straight off the raw input rather than each tool's typed args, so every +/// tool honors the flag without having to declare it. Tools deserialize their +/// own args with `#[serde(default)]` fields and ignore unknown keys, so an extra +/// key here is inert for the tool itself. +/// +/// Only a real JSON `true` counts. A string `"true"` is also accepted because +/// models routinely stringify booleans, but anything else (including `1`) is +/// treated as absent: accidentally spending the remaining context window should +/// take an unambiguous yes. +#[cfg(test)] +pub(crate) fn accept_large_output_schema_property_for_test() -> Value { + jcode_tool_core::accept_large_output_schema_property() +} + +fn accepts_large_output(input: &Value) -> bool { + // Same key the schema advertises, so the documented flag and the honored + // flag cannot drift apart. + match input.get(jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY) { + Some(Value::Bool(accepted)) => *accepted, + Some(Value::String(raw)) => raw.trim().eq_ignore_ascii_case("true"), + _ => false, + } +} + /// Registry of available tools (Arc-wrapped for sharing) /// /// Clone creates a fresh CompactionManager so each subagent gets independent @@ -100,6 +232,26 @@ pub struct Registry { compaction: Arc>, } +/// Non-owning handle used by tools stored inside a registry. +/// +/// A tool cannot strongly own the registry containing it without creating an +/// Arc cycle. Upgrade this handle only for the duration of a tool call. +pub(super) struct WeakRegistry { + tools: std::sync::Weak>>>, + skills: Arc>, + compaction: Arc>, +} + +impl WeakRegistry { + pub(super) fn upgrade(&self) -> Option { + Some(Registry { + tools: self.tools.upgrade()?, + skills: Arc::clone(&self.skills), + compaction: Arc::clone(&self.compaction), + }) + } +} + impl Clone for Registry { fn clone(&self) -> Self { Self { @@ -113,6 +265,14 @@ impl Clone for Registry { } impl Registry { + fn downgrade(&self) -> WeakRegistry { + WeakRegistry { + tools: Arc::downgrade(&self.tools), + skills: Arc::clone(&self.skills), + compaction: Arc::clone(&self.compaction), + } + } + fn shared_skills_registry() -> Arc> { SkillRegistry::shared_registry() } @@ -208,14 +368,20 @@ impl Registry { websearch::WebSearchTool::new, ); Self::insert_tool_timed(&mut m, &mut timings, "invalid", invalid::InvalidTool::new); - Self::insert_tool_timed(&mut m, &mut timings, "todo", todo::TodoTool::new); - Self::insert_tool_timed(&mut m, &mut timings, "bg", bg::BgTool::new); Self::insert_tool_timed( &mut m, &mut timings, - "swarm", - communicate::CommunicateTool::new, + "maintainer_feedback", + feedback::MaintainerFeedbackTool::new, ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "jcode_docs", + jcode_docs::JcodeDocsTool::new, + ); + Self::insert_tool_timed(&mut m, &mut timings, "todo", todo::TodoTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "bg", bg::BgTool::new); Self::insert_tool_timed( &mut m, &mut timings, @@ -252,6 +418,12 @@ impl Registry { "skill_manage", skill::SkillTool::new(skills.clone()), ); + // The swarm tool captures the user-editable swarm prompt in its + // description. Construct it once per session rather than sharing the + // process-wide instance. Existing sessions keep their stable tool + // definition (and provider KV cache), while newly created agents see + // prompt edits immediately. + Self::insert_tool(&mut tools, "swarm", communicate::CommunicateTool::new()); tools } @@ -280,20 +452,20 @@ impl Registry { Self::insert_tool( &mut tools_map, "batch", - batch::BatchTool::new(registry.clone()), + batch::BatchTool::new(registry.downgrade()), ); Self::insert_tool( &mut tools_map, "conversation_search", conversation_search::ConversationSearchTool::new(compaction), ); - // Sponsored discovery is on by default (opt-out); when disabled the + // Integration discovery is on by default (opt-out); when disabled the // tool is never registered and no discovery endpoint is ever // contacted. if crate::config::config().sponsors.enabled { Self::insert_tool( &mut tools_map, - "discover_tools", + "integration_tools", discover::DiscoverToolsTool::new(), ); } @@ -323,7 +495,11 @@ impl Registry { let tools = self.tools.read().await; let mut defs: Vec = tools .iter() - .filter(|(name, _)| allowed_tools.map(|set| set.contains(*name)).unwrap_or(true)) + .filter(|(name, _)| { + allowed_tools + .map(|set| tool_name_is_allowed(set, name)) + .unwrap_or(true) + }) .map(|(name, tool)| { let mut def = tool.to_definition(); // Use registry key as the tool name (important for MCP tools where @@ -540,17 +716,68 @@ impl Registry { /// Even if we have room, a single output shouldn't dominate the context. const SINGLE_OUTPUT_MAX_FRACTION: f32 = 0.30; + /// Hard ceiling on a single tool output, independent of context budget. + /// + /// A fraction alone is not enough. On a model reporting a 1M-token window, + /// 30% permits a 300k-token single result, so a repo-wide grep sailed + /// through the guard and cost 233k tokens in one call. No individual tool + /// result is worth that much of any window: past roughly 50k tokens the + /// caller is reading a haystack, not an answer, and should narrow the query. + /// The effective ceiling is the smaller of this and the budget fraction, so + /// small windows still get proportional protection. + const SINGLE_OUTPUT_MAX_TOKENS: usize = 50_000; + + /// Message returned instead of an oversized tool result. + /// + /// It has one job: make the price legible and the retry obvious. The caller + /// gets the exact cost, what would survive truncation, the cheap fixes, and + /// the exact flag to pass if they really want the whole thing. + fn oversized_output_refusal( + output_tokens: usize, + affordable_tokens: usize, + current_tokens: usize, + budget: usize, + ) -> String { + let percent_of_budget = if budget > 0 { + (output_tokens as f32 / budget as f32) * 100.0 + } else { + 0.0 + }; + format!( + "⚠️ OUTPUT WITHHELD: this result is ~{output}k tokens ({percent:.0}% of the \ + {budget}k context budget, of which {used}k is already used), so it was not \ + returned. Nothing was added to the context except this message.\n\n\ + Narrow the request first: add or tighten `path`, `glob`, or `type`, set \ + `max_files`/`max_regions`, use `paths_only` when you only need locations, or \ + read a specific line range. A targeted query is almost always the better \ + answer than a truncated dump.\n\n\ + If you genuinely need this output and accept the token cost, repeat the same \ + call with `\"accept_large_output\": true`. That returns the first ~{affordable}k \ + tokens and permanently spends them from this session's context.", + output = output_tokens as f32 / 1000.0, + percent = percent_of_budget, + budget = budget / 1000, + used = current_tokens / 1000, + affordable = affordable_tokens as f32 / 1000.0, + ) + } + /// Execute a tool by name pub async fn execute(&self, name: &str, input: Value, ctx: ToolContext) -> Result { + // Mark this call in-flight for the whole execution so the missing + // tool-output repair paths do not mistake a slow tool for an + // interrupted one and inject a duplicate synthetic result. See + // `tool::inflight`. + let _in_flight = inflight::mark_tool_in_flight(&ctx.tool_call_id); let tools = self.tools.read().await; let resolved_name = Self::resolve_tool_name(name); if let Some(policy) = session_tool_policy(&ctx.session_id) { if let Some(allowed) = policy.allowed_tools.as_ref() - && !allowed.contains(resolved_name) + && !tool_name_is_allowed(allowed, resolved_name) { return Err(anyhow::anyhow!("Tool '{}' is not allowed", resolved_name)); } - if policy.disabled_tools.contains(resolved_name) { + if tool_name_is_disabled(&policy.disabled_tools, resolved_name) { return Err(anyhow::anyhow!("Tool '{}' is disabled", resolved_name)); } } @@ -625,7 +852,9 @@ impl Registry { }; // Context overflow guard: check if this output would push us over the limit - output = self.guard_context_overflow(name, output).await; + output = self + .guard_context_overflow(name, output, accepts_large_output(&input)) + .await; let mut fields = Self::tool_lifecycle_fields("done", name, resolved_name, &input, &ctx); fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); @@ -642,7 +871,19 @@ impl Registry { /// Check if a tool output would overflow the context window and truncate if needed. /// Returns the (possibly truncated) output. - async fn guard_context_overflow(&self, tool_name: &str, output: ToolOutput) -> ToolOutput { + /// + /// An oversized result is **refused** rather than truncated. Truncating by + /// default was the worse failure: the caller still paid the full remaining + /// context for a prefix that usually did not contain the answer, and the + /// damage was already done by the time they read the warning. Refusing costs + /// a few dozen tokens, states the price, and lets the caller either narrow + /// the query or knowingly pay by passing `accept_large_output`. + async fn guard_context_overflow( + &self, + tool_name: &str, + output: ToolOutput, + accept_large_output: bool, + ) -> ToolOutput { let compaction = self.compaction.read().await; let budget = compaction.token_budget(); if budget == 0 { @@ -656,8 +897,11 @@ impl Registry { let projected = current_tokens + output_tokens; let threshold_tokens = (budget as f32 * Self::CONTEXT_GUARD_THRESHOLD) as usize; - // Check 2: Is this single output unreasonably large relative to budget? - let single_max_tokens = (budget as f32 * Self::SINGLE_OUTPUT_MAX_FRACTION) as usize; + // Check 2: Is this single output unreasonably large? Proportional to the + // budget, but also absolutely capped, because 30% of a 1M-token window is + // 300k tokens and no single tool result is worth that. + let single_max_tokens = ((budget as f32 * Self::SINGLE_OUTPUT_MAX_FRACTION) as usize) + .min(Self::SINGLE_OUTPUT_MAX_TOKENS); let needs_truncation = projected > threshold_tokens || output_tokens > single_max_tokens; @@ -665,14 +909,39 @@ impl Registry { return output; } - // Calculate how many tokens we can afford for this output - let remaining = if current_tokens < threshold_tokens { - threshold_tokens - current_tokens - } else { - // Already over threshold — allow a small amount for the error message - budget / 50 // ~2% of budget for the truncation notice - }; - let max_tokens = remaining.min(single_max_tokens); + // Past the safety threshold there is no room left to spend, so opting in + // cannot buy anything: returning a slice would push the conversation over + // the window instead of merely being expensive. Refuse outright, and say + // how to make room. This is checked before computing an affordable size + // so the outcome does not depend on budget arithmetic happening to land + // under a character floor. + if current_tokens >= threshold_tokens { + crate::logging::info(&format!( + "Context guard: refused {} output of ~{}k tokens, context exhausted \ + ({}k/{}k)", + tool_name, + output_tokens / 1000, + current_tokens / 1000, + budget / 1000, + )); + return ToolOutput { + output: format!( + "⚠️ CONTEXT LIMIT REACHED: cannot return this tool output (~{:.0}k tokens) \ + because the context window is nearly full ({:.0}k/{}k tokens). \ + accept_large_output does not apply here: there is no room left to spend. \ + Use /compact to free space, then retry with a narrower query.", + output_tokens as f32 / 1000.0, + current_tokens as f32 / 1000.0, + budget / 1000, + ), + title: output.title, + metadata: output.metadata, + images: output.images, + }; + } + + // How much of this output the remaining context could absorb. + let max_tokens = (threshold_tokens - current_tokens).min(single_max_tokens); // Convert token limit back to approximate character limit let max_chars = max_tokens * 4; @@ -681,6 +950,29 @@ impl Registry { return output; } + if !accept_large_output { + crate::logging::info(&format!( + "Context guard: refused {} output of ~{}k tokens \ + (context: {}k/{}k, {:.0}% used); caller may retry with accept_large_output", + tool_name, + output_tokens / 1000, + current_tokens / 1000, + budget / 1000, + (current_tokens as f32 / budget as f32) * 100.0, + )); + return ToolOutput { + output: Self::oversized_output_refusal( + output_tokens, + max_tokens, + current_tokens, + budget, + ), + title: output.title, + metadata: output.metadata, + images: output.images, + }; + } + crate::logging::info(&format!( "Context guard: truncating {} output from ~{}k to ~{}k tokens \ (context: {}k/{}k, {:.0}% used)", @@ -693,33 +985,24 @@ impl Registry { )); // Truncate the output, keeping the beginning (usually most relevant) - let truncated = if max_chars > 200 { - // Keep beginning of output + truncation notice - let kept = &output.output[..output.output.floor_char_boundary(max_chars - 150)]; - format!( - "{}\n\n⚠️ OUTPUT TRUNCATED: This tool output was {:.0}k tokens which would \ - exceed the context window ({:.0}k/{}k tokens used, {}k budget). \ - Only the first ~{:.0}k tokens are shown. Use more targeted queries \ - (e.g., smaller line ranges, specific grep patterns) to get the content \ - you need without exceeding context limits.", - kept, - output_tokens as f32 / 1000.0, - current_tokens as f32 / 1000.0, - budget / 1000, - budget / 1000, - max_tokens as f32 / 1000.0, - ) - } else { - // Context is almost completely full — just return error - format!( - "⚠️ CONTEXT LIMIT REACHED: Cannot return this tool output (~{:.0}k tokens) \ - because the context window is nearly full ({:.0}k/{}k tokens). \ - Consider using /compact to free up space, or use more targeted queries.", - output_tokens as f32 / 1000.0, - current_tokens as f32 / 1000.0, - budget / 1000, - ) - }; + // Keep the beginning, which is usually the most relevant part, and leave + // headroom for the notice itself. `max_chars` is at least 800 here: the + // exhaustion check above already returned for anything tighter. + let kept = &output.output[..output + .output + .floor_char_boundary(max_chars.saturating_sub(150))]; + let truncated = format!( + "{}\n\n⚠️ OUTPUT TRUNCATED: you passed accept_large_output, so this ~{:.0}k \ + token result was returned truncated instead of withheld \ + ({:.0}k/{}k tokens were already used). Only the first ~{:.0}k tokens are \ + above, and they are now spent from this session's context. If the answer \ + is not in them, narrow the query rather than repeating this call.", + kept, + output_tokens as f32 / 1000.0, + current_tokens as f32 / 1000.0, + budget / 1000, + max_tokens as f32 / 1000.0, + ); ToolOutput { output: truncated, @@ -780,6 +1063,16 @@ impl Registry { mcp::McpManagementTool::new(Arc::clone(&mcp_manager)).with_registry(self.clone()); self.register("mcp".to_string(), Arc::new(mcp_tool) as Arc) .await; + self.register( + "mcp_search".to_string(), + Arc::new(mcp::McpSearchTool::new(Arc::clone(&mcp_manager))) as Arc, + ) + .await; + self.register( + "mcp_call".to_string(), + Arc::new(mcp::McpCallTool::new(Arc::clone(&mcp_manager))) as Arc, + ) + .await; // Check if we have enabled servers to connect to. Disabled servers stay // configured (visible to the mcp management tool, connectable by name) @@ -1090,5 +1383,34 @@ fn levenshtein(a: &str, b: &str) -> usize { prev[b.len()] } +#[cfg(test)] +mod mcp_allow_list_tests { + use super::{tool_name_is_allowed, tool_name_is_disabled}; + use std::collections::HashSet; + + #[test] + fn allowing_mcp_also_allows_dynamic_server_tools() { + let allowed = HashSet::from(["mcp".to_string()]); + + assert!(tool_name_is_allowed(&allowed, "mcp")); + assert!(tool_name_is_allowed(&allowed, "mcp__filesystem__read_file")); + assert!(!tool_name_is_allowed(&allowed, "mcpish")); + assert!(!tool_name_is_allowed(&allowed, "bash")); + } + + #[test] + fn disabling_mcp_also_disables_dynamic_server_tools() { + let disabled = HashSet::from(["mcp".to_string()]); + + assert!(tool_name_is_disabled(&disabled, "mcp")); + assert!(tool_name_is_disabled( + &disabled, + "mcp__filesystem__read_file" + )); + assert!(!tool_name_is_disabled(&disabled, "mcpish")); + assert!(!tool_name_is_disabled(&disabled, "bash")); + } +} + #[cfg(test)] mod tests; diff --git a/crates/jcode-app-core/src/tool/multiedit.rs b/crates/jcode-app-core/src/tool/multiedit.rs index 7d856f988f..f845791bcc 100644 --- a/crates/jcode-app-core/src/tool/multiedit.rs +++ b/crates/jcode-app-core/src/tool/multiedit.rs @@ -157,6 +157,13 @@ impl Tool for MultiEditTool { output.push_str(&generate_diff_summary(&original_content, &content)); } + super::config_edit_notice::append_config_edit_notice( + &mut output, + &path, + &original_content, + &content, + ); + Ok(ToolOutput::new(output).with_title(params.file_path.clone())) } } diff --git a/crates/jcode-app-core/src/tool/patch.rs b/crates/jcode-app-core/src/tool/patch.rs index b39c4b7380..69ed9b617d 100644 --- a/crates/jcode-app-core/src/tool/patch.rs +++ b/crates/jcode-app-core/src/tool/patch.rs @@ -41,7 +41,7 @@ impl Tool for PatchTool { } fn description(&self) -> &str { - "Apply a standard unified diff patch using ---/+++ headers. Prefer apply_patch for Codex-style patches." + "Apply a unified diff (---/+++ headers). Prefer apply_patch for Codex patches." } fn parameters_schema(&self) -> Value { @@ -67,6 +67,9 @@ impl Tool for PatchTool { return Err(anyhow::anyhow!("No valid patches found in input")); } + // Watch config.toml across the whole invocation so an edit that lands + // on it is reported regardless of which patch produced it. + let config_watch = super::config_edit_notice::ConfigEditWatch::begin(); let mut results = Vec::new(); for patch in patches { @@ -84,7 +87,9 @@ impl Tool for PatchTool { } } - Ok(ToolOutput::new(results.join("\n\n"))) + let mut body = results.join("\n\n"); + config_watch.finish(&mut body); + Ok(ToolOutput::new(body)) } } diff --git a/crates/jcode-app-core/src/tool/selfdev/build_queue.rs b/crates/jcode-app-core/src/tool/selfdev/build_queue.rs index 4096554fa1..92dc6b7c8e 100644 --- a/crates/jcode-app-core/src/tool/selfdev/build_queue.rs +++ b/crates/jcode-app-core/src/tool/selfdev/build_queue.rs @@ -383,31 +383,18 @@ export -f cargo &repo_dir, &source_after_build, )?; - let published = if Self::build_command_is_desktop_only(&command) { - Self::validate_desktop_selfdev_binary(&repo_dir, &source_after_build)?; - None - } else { - let published = build::publish_local_current_build_for_source( - &repo_dir, - &source_after_build, - )?; - let mut manifest = build::BuildManifest::load()?; - manifest.add_to_history(build::current_build_info(&repo_dir)?)?; - Some(published) - }; + let published = build::publish_local_current_build_for_source( + &repo_dir, + &source_after_build, + )?; + let mut manifest = build::BuildManifest::load()?; + manifest.add_to_history(build::current_build_info(&repo_dir)?)?; let mut request = BuildRequest::load(&request_id)?.ok_or_else(|| { anyhow::anyhow!("Missing queued build request {}", request_id) })?; - request.published_version = published - .as_ref() - .map(|published| published.version.clone()) - .or_else(|| Some(source_after_build.version_label.clone())); + request.published_version = Some(published.version.clone()); request.validated = true; - request.last_progress = Some(if published.is_some() { - "published and smoke-tested".to_string() - } else { - "desktop binary built and smoke-tested".to_string() - }); + request.last_progress = Some("published and smoke-tested".to_string()); request.save()?; result } @@ -460,49 +447,6 @@ export -f cargo Ok(result) } - fn build_command_is_desktop_only(command: &SelfDevBuildCommand) -> bool { - command.display.contains("-p jcode-desktop") && !command.display.contains("-p jcode ") - } - - fn validate_desktop_selfdev_binary(repo_dir: &Path, source: &build::SourceState) -> Result<()> { - let binary_name = if cfg!(windows) { - "jcode-desktop.exe" - } else { - "jcode-desktop" - }; - let binary = repo_dir - .join("target") - .join(build::SELFDEV_CARGO_PROFILE) - .join(binary_name); - if !binary.exists() { - anyhow::bail!("Desktop binary not found at {}", binary.display()); - } - - let output = std::process::Command::new(&binary) - .arg("--version") - .env("JCODE_NON_INTERACTIVE", "1") - .output()?; - if !output.status.success() { - anyhow::bail!( - "Desktop binary smoke test failed for {} with exit code {:?}: {}", - binary.display(), - output.status.code(), - String::from_utf8_lossy(&output.stderr).trim() - ); - } - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.contains(&source.short_hash) { - anyhow::bail!( - "Refusing to validate desktop build {} as {}: --version output did not contain git hash {}: {}", - binary.display(), - source.version_label, - source.short_hash, - stdout.trim() - ); - } - Ok(()) - } - pub(super) async fn do_build( &self, reason: Option, diff --git a/crates/jcode-app-core/src/tool/selfdev/mod.rs b/crates/jcode-app-core/src/tool/selfdev/mod.rs index d470ab5284..a8f3e80b98 100644 --- a/crates/jcode-app-core/src/tool/selfdev/mod.rs +++ b/crates/jcode-app-core/src/tool/selfdev/mod.rs @@ -47,7 +47,7 @@ struct SelfDevInput { /// Why this build is needed; shown to other queued/blocked agents. #[serde(default)] reason: Option, - /// Build target for selfdev build: auto, tui, desktop, or all. + /// Build target for selfdev build: auto, tui, or all. #[serde(default)] target: Option, /// Shell command for selfdev test/check action. @@ -489,8 +489,7 @@ impl SelfDevTool { if is_selfdev { "Manage self-dev builds, tests, and reloads while working on jcode itself." } else { - "Enter self-dev mode to work on jcode itself. Also sets up the dev \ - environment, reloads jcode to a newer build, and locates jcode config/paths." + "Enter self-dev mode to work on jcode itself: setup, reload, find config/paths." } } @@ -527,8 +526,8 @@ impl SelfDevTool { "reason": { "type": "string" }, "target": { "type": "string", - "enum": ["auto", "tui", "desktop", "all"], - "description": "Build target for action=build. auto chooses from changed paths; tui builds jcode; desktop builds jcode-desktop; all builds both." + "enum": ["auto", "tui", "all"], + "description": "Build target for action=build. auto chooses based on changed paths; tui and all build jcode." }, "command": { "type": "string", @@ -553,7 +552,7 @@ impl SelfDevTool { "status", "find-config" ], - "description": "Action. `enter` spawns a self-dev session (optionally seeded with `prompt`); `setup` checks/installs the dev prerequisites (rust toolchain, git, repo clone); `reload` restarts jcode into a newer installed build; `status` shows build/version state; `find-config` locates jcode config and key paths." + "description": "Action. `enter` starts a self-dev session; `setup` installs prerequisites; `reload` restarts jcode." }, "prompt": { "type": "string", diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs index 7cdbf229f7..bee0ec475b 100644 --- a/crates/jcode-app-core/src/tool/selfdev/tests.rs +++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs @@ -1,15 +1,6 @@ use super::*; use crate::bus::BackgroundTaskStatus; use std::ffi::OsStr; -use std::sync::{LazyLock, Mutex}; - -static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - -fn lock_env() -> std::sync::MutexGuard<'static, ()> { - ENV_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} struct EnvVarGuard { key: &'static str, @@ -113,7 +104,7 @@ fn request_fixture( #[test] fn build_lock_is_removed_on_drop_and_can_be_reacquired() { - let _env_lock = lock_env(); + let _env_lock = crate::storage::lock_test_env(); let temp = tempfile::tempdir().expect("temp jcode home"); let _home = EnvVarGuard::set("JCODE_HOME", temp.path()); let scope = format!("lock-drop-{}", std::process::id()); @@ -135,8 +126,9 @@ fn build_lock_is_removed_on_drop_and_can_be_reacquired() { #[test] fn terminal_request_history_is_archived_without_touching_active_requests() { + // One shared env lock only: `lock_test_env` is a plain non-reentrant mutex, + // so taking a second env guard here would self-deadlock (issue #593). let _storage_guard = crate::storage::lock_test_env(); - let _env_lock = lock_env(); let temp = tempfile::tempdir().expect("temp jcode home"); let _home = EnvVarGuard::set("JCODE_HOME", temp.path()); let _limit = EnvVarGuard::set("JCODE_SELFDEV_REQUEST_HISTORY_LIMIT", "2"); @@ -280,7 +272,6 @@ fn test_reload_context_path() { #[test] fn test_reload_context_save_and_load_for_session_uses_session_scoped_file() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -371,7 +362,6 @@ fn test_recovery_directive_returns_none_when_no_reload_recovery_needed() { #[test] fn reload_timeout_secs_defaults_to_15() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let _guard = EnvVarGuard::remove("JCODE_SELFDEV_RELOAD_TIMEOUT_SECS"); assert_eq!(SelfDevTool::reload_timeout_secs(), 15); } @@ -379,7 +369,6 @@ fn reload_timeout_secs_defaults_to_15() { #[test] fn reload_timeout_secs_honors_valid_env_override() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let _guard = EnvVarGuard::set("JCODE_SELFDEV_RELOAD_TIMEOUT_SECS", "27"); assert_eq!(SelfDevTool::reload_timeout_secs(), 27); } @@ -387,7 +376,6 @@ fn reload_timeout_secs_honors_valid_env_override() { #[test] fn reload_timeout_secs_ignores_empty_invalid_and_zero_values() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let _guard = EnvVarGuard::set("JCODE_SELFDEV_RELOAD_TIMEOUT_SECS", " "); assert_eq!(SelfDevTool::reload_timeout_secs(), 15); drop(_guard); @@ -493,7 +481,6 @@ fn non_selfdev_schema_only_exposes_onramp_actions() { #[tokio::test] async fn test_action_queues_command_in_test_mode() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -568,7 +555,6 @@ fn reload_repo_resolver_uses_working_dir_when_primary_detection_fails() { #[tokio::test] async fn enter_creates_selfdev_session_in_test_mode() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -645,7 +631,6 @@ async fn enter_creates_selfdev_session_in_test_mode() { #[tokio::test] async fn enter_falls_back_to_fresh_session_when_parent_missing() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -676,7 +661,6 @@ async fn enter_falls_back_to_fresh_session_when_parent_missing() { #[tokio::test] async fn reload_in_non_selfdev_session_is_upgrade_in_place() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); // Test mode short-circuits the actual server reload signal. @@ -705,7 +689,6 @@ async fn reload_in_non_selfdev_session_is_upgrade_in_place() { #[tokio::test] async fn socket_actions_require_selfdev_session() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -732,7 +715,6 @@ async fn socket_actions_require_selfdev_session() { #[tokio::test] async fn find_config_reports_key_paths() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -756,7 +738,6 @@ async fn find_config_reports_key_paths() { #[tokio::test] async fn setup_reports_dependency_checks() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); // Test mode avoids attempting a real git clone when no repo is detected. @@ -788,7 +769,6 @@ async fn setup_reports_dependency_checks() { #[tokio::test] async fn build_requires_reason() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -807,7 +787,6 @@ async fn build_requires_reason() { #[tokio::test] async fn build_queues_background_tasks_and_reports_queue_status() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -883,7 +862,6 @@ async fn build_queues_background_tasks_and_reports_queue_status() { #[tokio::test] async fn build_reload_waits_for_build_then_reloads() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -943,7 +921,6 @@ async fn build_reload_waits_for_build_then_reloads() { #[tokio::test] async fn build_dedupes_identical_reason_and_version_with_attached_watcher() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -1004,7 +981,6 @@ async fn build_dedupes_identical_reason_and_version_with_attached_watcher() { #[tokio::test] async fn cancel_build_marks_request_cancelled_and_removes_it_from_queue() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -1068,7 +1044,6 @@ async fn cancel_build_marks_request_cancelled_and_removes_it_from_queue() { #[test] fn status_output_prunes_stale_pending_requests() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -1138,7 +1113,6 @@ fn freshly_queued_request_survives_reconcile_before_task_metadata_exists() { // task's own first wait_for_turn iteration) used to prune it as stale, // killing the build instantly with "Queued build request disappeared". let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -1195,7 +1169,6 @@ fn freshly_queued_request_survives_reconcile_before_task_metadata_exists() { #[tokio::test] async fn build_ignores_stale_pending_requests_when_computing_queue_position() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); @@ -1227,6 +1200,7 @@ async fn build_ignores_stale_pending_requests_when_computing_queue_position() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write stale status file"); @@ -1298,7 +1272,6 @@ async fn build_ignores_stale_pending_requests_when_computing_queue_position() { #[test] fn reconcile_pending_state_maps_superseded_background_status() { let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -1328,6 +1301,7 @@ fn reconcile_pending_state_maps_superseded_background_status() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write superseded status file"); @@ -1389,7 +1363,6 @@ fn reconcile_keeps_running_request_not_yet_registered_in_live_task_map() { // the request instantly: "Queued build request disappeared". Within the // bootstrap grace window a Running-but-unregistered task must survive. let _storage_guard = crate::storage::lock_test_env(); - let _lock = lock_env(); let temp_home = tempfile::TempDir::new().expect("temp home"); let _home_guard = EnvVarGuard::set("JCODE_HOME", temp_home.path()); @@ -1418,6 +1391,7 @@ fn reconcile_keeps_running_request_not_yet_registered_in_live_task_map() { wake: true, progress: None, event_history: Vec::new(), + stall_wake_seconds: None, }, ) .expect("write running status file"); diff --git a/crates/jcode-app-core/src/tool/session_search.rs b/crates/jcode-app-core/src/tool/session_search.rs index 7ca2dc07ca..fddef5e7c0 100644 --- a/crates/jcode-app-core/src/tool/session_search.rs +++ b/crates/jcode-app-core/src/tool/session_search.rs @@ -294,7 +294,7 @@ impl Tool for SessionSearchTool { } fn description(&self) -> &str { - "Search past chat sessions. Current session, tool-only messages, and system reminders are hidden by default." + "Search past chat sessions. Current session and tool noise hidden by default." } fn parameters_schema(&self) -> Value { @@ -308,7 +308,7 @@ impl Tool for SessionSearchTool { }, "working_dir": { "type": "string", - "description": "Restrict results to sessions whose working directory matches this path or path prefix. Matching is normalized and case-insensitive." + "description": "Only sessions whose working directory matches this path prefix (case-insensitive)." }, "limit": { "type": "integer", @@ -396,7 +396,7 @@ impl Tool for SessionSearchTool { }, "exhaustive": { "type": "boolean", - "description": "Search every available Jcode session instead of the recent indexed subset. Slower, but useful for deep recall." + "description": "Search every session instead of the recent indexed subset. Slower; for deep recall." } }, "required": ["query"] diff --git a/crates/jcode-app-core/src/tool/tests.rs b/crates/jcode-app-core/src/tool/tests.rs index 196156ff3c..03e126fd88 100644 --- a/crates/jcode-app-core/src/tool/tests.rs +++ b/crates/jcode-app-core/src/tool/tests.rs @@ -1,10 +1,34 @@ #![cfg_attr(test, allow(clippy::await_holding_lock))] use super::*; + use crate::message::{Message, ToolDefinition}; use crate::provider::{EventStream, Provider}; use async_trait::async_trait; use serde_json::Value; +use std::ffi::OsString; + +struct TestHomeGuard { + previous: Option, +} + +impl TestHomeGuard { + fn new(path: &std::path::Path) -> Self { + let previous = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", path); + Self { previous } + } +} + +impl Drop for TestHomeGuard { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + crate::env::set_var("JCODE_HOME", previous); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} struct MockProvider; @@ -31,6 +55,120 @@ impl Provider for MockProvider { } } +fn mcp_test_context(working_dir: &std::path::Path) -> ToolContext { + ToolContext { + session_id: "mcp-registry-lifetime".to_string(), + message_id: "message".to_string(), + tool_call_id: "mcp-call".to_string(), + working_dir: Some(working_dir.to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + } +} + +async fn register_empty_mcp_tools(registry: &Registry, working_dir: &std::path::Path) { + let pool = Arc::new(crate::mcp::SharedMcpPool::new( + crate::mcp::McpConfig::default(), + )); + registry + .register_mcp_tools_for_dir( + None, + Some(pool), + Some("mcp-registry-lifetime".to_string()), + Some(working_dir.to_path_buf()), + ) + .await; +} + +#[tokio::test] +async fn real_mcp_registration_does_not_retain_registry_tool_map() { + let _env_lock = crate::storage::lock_test_env(); + let home = tempfile::tempdir().expect("create isolated JCODE_HOME"); + let _home_guard = TestHomeGuard::new(home.path()); + let working_dir = tempfile::tempdir().expect("create isolated MCP working directory"); + let registry = Registry::empty(); + let tools = Arc::downgrade(®istry.tools); + + register_empty_mcp_tools(®istry, working_dir.path()).await; + assert!(registry.tool_names().await.iter().any(|name| name == "mcp")); + + drop(registry); + + assert!( + tools.upgrade().is_none(), + "McpManagementTool must not strongly retain the registry tool map that owns it" + ); +} + +#[tokio::test] +async fn mcp_management_upgrades_registry_through_surviving_clone() { + let _env_lock = crate::storage::lock_test_env(); + let home = tempfile::tempdir().expect("create isolated JCODE_HOME"); + let _home_guard = TestHomeGuard::new(home.path()); + let working_dir = tempfile::tempdir().expect("create isolated MCP working directory"); + let registry = Registry::empty(); + let tools = Arc::downgrade(®istry.tools); + + register_empty_mcp_tools(®istry, working_dir.path()).await; + let surviving_clone = registry.clone(); + drop(registry); + + let stale_tool = surviving_clone + .tools + .read() + .await + .get("mcp") + .cloned() + .expect("MCP management tool should be registered"); + surviving_clone + .register("mcp__lifetime__sentinel".to_string(), stale_tool) + .await; + + let output = surviving_clone + .execute( + "mcp", + serde_json::json!({"action": "reload"}), + mcp_test_context(working_dir.path()), + ) + .await + .expect("MCP management should upgrade through the surviving registry clone"); + assert!(output.output.contains("No servers found in config")); + assert!( + !surviving_clone + .tool_names() + .await + .iter() + .any(|name| name == "mcp__lifetime__sentinel"), + "reload should mutate the surviving registry through the weak handle" + ); + assert!( + surviving_clone + .tool_names() + .await + .iter() + .any(|name| name == "mcp"), + "reload should preserve the MCP management tool" + ); + assert!(tools.upgrade().is_some()); + + drop(surviving_clone); + assert!(tools.upgrade().is_none()); +} + +#[tokio::test] +async fn maintainer_feedback_tool_is_registered() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + assert!( + registry + .tool_names() + .await + .iter() + .any(|name| name == "maintainer_feedback") + ); +} + #[tokio::test] async fn test_tool_definitions_are_sorted() { // Create registry with mock provider @@ -57,6 +195,47 @@ async fn test_tool_definitions_are_sorted() { ); } +#[test] +fn deferred_mcp_surfaces_follow_umbrella_and_per_tool_filters() { + use std::collections::HashSet; + + let umbrella = HashSet::from(["mcp".to_string()]); + assert!(super::tool_name_is_allowed(&umbrella, "mcp_search")); + assert!(super::tool_name_is_allowed(&umbrella, "mcp_call")); + assert!(super::tool_name_is_allowed(&umbrella, "mcp__server__tool")); + + let one_tool = HashSet::from(["mcp__server__allowed".to_string()]); + assert!(super::tool_name_is_allowed(&one_tool, "mcp_search")); + assert!(super::tool_name_is_allowed(&one_tool, "mcp_call")); + + let disabled = HashSet::from(["mcp".to_string()]); + assert!(super::tool_name_is_disabled(&disabled, "mcp_search")); + assert!(super::tool_name_is_disabled(&disabled, "mcp_call")); + assert!(super::tool_name_is_disabled(&disabled, "mcp__server__tool")); + + super::set_session_tool_policy( + "deferred-filter-test", + Some(one_tool), + HashSet::from(["mcp__server__blocked".to_string()]), + ); + assert!(super::session_mcp_dispatch_is_allowed( + "deferred-filter-test", + "mcp__server__allowed", + "mcp_call" + )); + assert!(!super::session_mcp_dispatch_is_allowed( + "deferred-filter-test", + "mcp__server__blocked", + "mcp_call" + )); + assert!(!super::session_mcp_dispatch_is_allowed( + "deferred-filter-test", + "mcp__server__other", + "mcp_call" + )); + super::clear_session_tool_policy("deferred-filter-test"); +} + #[test] fn test_resolve_skill_aliases_to_skill_manage() { assert_eq!(Registry::resolve_tool_name("skill"), "skill_manage"); @@ -66,16 +245,16 @@ fn test_resolve_skill_aliases_to_skill_manage() { #[tokio::test] async fn test_discover_tools_not_registered_when_sponsors_disabled() { - // sponsors.enabled defaults to false; the discovery tool must not exist. + // sponsors.enabled is the legacy config key; when false, integration discovery must not exist. let provider: Arc = Arc::new(MockProvider); let registry = Registry::new(provider).await; let names = registry.tool_names().await; if crate::config::config().sponsors.enabled { - assert!(names.iter().any(|n| n == "discover_tools")); + assert!(names.iter().any(|n| n == "integration_tools")); } else { assert!( - !names.iter().any(|n| n == "discover_tools"), - "discover_tools must not be registered when sponsors are disabled" + !names.iter().any(|n| n == "integration_tools"), + "integration_tools must not be registered when sponsors are disabled" ); } } @@ -122,14 +301,30 @@ impl Tool for BareSchemaTool { } } +/// `to_definition` deliberately injects a required `intent` into every +/// object-shaped tool schema (8505080a6), so a tool that omits `intent` from its +/// own `parameters_schema` still advertises it. This pins that central +/// behaviour: a bare schema gains `intent` as both a property and a requirement. #[test] -fn tool_definitions_do_not_auto_inject_intent() { +fn tool_definitions_auto_inject_required_intent() { let def = BareSchemaTool.to_definition(); - assert!(def.input_schema["properties"]["intent"].is_null()); + assert_eq!(def.input_schema["properties"]["intent"]["type"], "string"); + let required = def.input_schema["required"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + required.iter().any(|value| value == "intent"), + "intent must be required after central injection: {required:?}" + ); + assert!( + required.iter().any(|value| value == "command"), + "injection must preserve the tool's own required fields: {required:?}" + ); } #[tokio::test] -async fn first_party_tool_definitions_include_optional_intent_explicitly() { +async fn first_party_tool_definitions_require_intent_with_display_only_docs() { let provider: Arc = Arc::new(MockProvider); let registry = Registry::new(provider).await; registry.register_ambient_tools().await; @@ -153,14 +348,14 @@ async fn first_party_tool_definitions_include_optional_intent_explicitly() { schema["properties"]["intent"]["description"] .as_str() .unwrap_or_default() - .contains("display only"), - "{} intent description should say it is display-only", + .contains("shown in the UI"), + "{} intent description should say it is UI-display-only", def.name ); let required = schema["required"].as_array().cloned().unwrap_or_default(); assert!( - !required.iter().any(|value| value == "intent"), - "{} must not require intent", + required.iter().any(|value| value == "intent"), + "{} must require intent", def.name ); } @@ -426,6 +621,97 @@ async fn print_tool_definition_token_report() { } } +/// Tool descriptions are always-on prompt cost, so they are capped at ~20 +/// estimated tokens. Behavioral guidance belongs in parameter descriptions. +/// Exemptions must be justified inline. +#[tokio::test] +async fn tool_descriptions_stay_under_token_cap() { + const DESCRIPTION_TOKEN_CAP: usize = 20; + // integration_tools keeps a deliberate second sentence explaining that catalog + // entries integrate directly with the agent. + // swarm appends the user-tunable swarm-prompt.md by design. + const EXEMPT: &[&str] = &["integration_tools", "swarm"]; + + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let over_cap: Vec = registry + .definitions(None) + .await + .into_iter() + .filter(|def| !EXEMPT.contains(&def.name.as_str())) + .filter(|def| def.description_token_estimate() > DESCRIPTION_TOKEN_CAP) + .map(|def| { + format!( + "{} (~{} tokens): {}", + def.name, + def.description_token_estimate(), + def.description + ) + }) + .collect(); + assert!( + over_cap.is_empty(), + "tool descriptions over the {DESCRIPTION_TOKEN_CAP}-token cap:\n{}", + over_cap.join("\n") + ); +} + +fn collect_param_descriptions(schema: &Value, path: &str, out: &mut Vec<(String, String)>) { + match schema { + Value::Object(map) => { + if path != "$" + && let Some(Value::String(description)) = map.get("description") + { + out.push((path.to_string(), description.clone())); + } + for (key, value) in map { + if key == "description" { + continue; + } + collect_param_descriptions(value, &format!("{path}.{key}"), out); + } + } + Value::Array(items) => { + for (idx, item) in items.iter().enumerate() { + collect_param_descriptions(item, &format!("{path}[{idx}]"), out); + } + } + _ => {} + } +} + +/// Parameter descriptions inside tool schemas are also always-on prompt cost, +/// so each is capped. Longer guidance belongs in runtime error messages, docs, +/// or the system prompt (the todo calibration rubrics, for example, live in +/// the gate continuation messages in jcode-base::todo). +#[tokio::test] +async fn tool_parameter_descriptions_stay_under_token_cap() { + const PARAM_DESCRIPTION_TOKEN_CAP: usize = 25; + + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let mut over_cap: Vec = Vec::new(); + for def in registry.definitions(None).await { + let mut descriptions = Vec::new(); + collect_param_descriptions(&def.input_schema, "$", &mut descriptions); + for (path, description) in descriptions { + let tokens = crate::util::estimate_tokens(&description); + if tokens > PARAM_DESCRIPTION_TOKEN_CAP { + over_cap.push(format!( + "{} {} (~{} tokens): {}", + def.name, path, tokens, description + )); + } + } + } + assert!( + over_cap.is_empty(), + "{} parameter descriptions over the {PARAM_DESCRIPTION_TOKEN_CAP}-token cap:\n{}", + over_cap.len(), + over_cap.join("\n") + ); +} + fn schema_type_includes(schema: &Value, expected: &str) -> bool { match schema.get("type") { Some(Value::String(value)) => value == expected, @@ -443,6 +729,27 @@ fn collect_schema_errors(schema: &Value, path: &str, errors: &mut Vec) { errors.push(format!("{path}: array schema missing items")); } + // Gemini validates `required` against the same object's `properties` + // and rejects the entire request when a name is missing, which broke + // every tool-enabled Gemini call (issue #655). Objects without a + // local `properties` map are exempt: there is nothing to check + // against, and Gemini accepts those. + if let (Some(Value::Array(required)), Some(Value::Object(properties))) = + (map.get("required"), map.get("properties")) + { + for name in required { + let Some(name) = name.as_str() else { + errors.push(format!("{path}.required: entries must be strings")); + continue; + }; + if !properties.contains_key(name) { + errors.push(format!( + "{path}.required: '{name}' is not defined in the same object's properties" + )); + } + } + } + for keyword in ["anyOf", "oneOf", "allOf"] { let Some(branches) = map.get(keyword) else { continue; @@ -534,12 +841,12 @@ async fn test_context_guard_small_output_passes_through() { }; let output = ToolOutput::new("small output"); - let result = registry.guard_context_overflow("test", output).await; + let result = registry.guard_context_overflow("test", output, false).await; assert_eq!(result.output, "small output"); } #[tokio::test] -async fn test_context_guard_truncates_huge_single_output() { +async fn test_context_guard_withholds_huge_single_output_by_default() { let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(1000))); let registry = Registry { tools: Arc::new(RwLock::new(HashMap::new())), @@ -551,14 +858,103 @@ async fn test_context_guard_truncates_huge_single_output() { // Create output that's way larger let big_output = "x".repeat(8000); // 2000 tokens, well over 30% of 1000 let output = ToolOutput::new(big_output.clone()); - let result = registry.guard_context_overflow("test", output).await; + let result = registry.guard_context_overflow("test", output, false).await; + + // The whole point of the refusal: none of the payload is spent. + assert!( + !result.output.contains(&"x".repeat(100)), + "withheld output must not leak the payload" + ); + assert!( + result.output.contains("OUTPUT WITHHELD"), + "should say the output was withheld, got: {}", + result.output + ); + assert!( + result.output.contains("accept_large_output"), + "should name the opt-in flag so the caller can retry" + ); + // A refusal that costs as much as the payload would defeat itself. + assert!( + result.output.len() < 1200, + "refusal should be cheap, was {} chars", + result.output.len() + ); +} + +#[tokio::test] +async fn test_context_guard_returns_truncated_output_when_caller_accepts() { + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(1000))); + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + let big_output = "x".repeat(8000); + let output = ToolOutput::new(big_output.clone()); + let result = registry.guard_context_overflow("test", output, true).await; + assert!( result.output.len() < big_output.len(), - "Output should be truncated" + "opt-in still truncates to what the budget allows" ); assert!( result.output.contains("TRUNCATED"), - "Should contain truncation warning" + "should say the output was truncated, got: {}", + result.output + ); + assert!( + result.output.starts_with(&"x".repeat(200)), + "opt-in must actually return the payload prefix" + ); +} + +#[tokio::test] +async fn test_context_guard_reports_the_real_cost_and_affordable_size() { + // 200k budget, 40k already used. A 90k-token result is over the 30% + // single-output ceiling (60k), so it is withheld. The quoted numbers must + // match the actual arithmetic, since the caller decides based on them. + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(200_000))); + { + let mut mgr = compaction.write().await; + mgr.update_observed_input_tokens(40_000); + } + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + let output = ToolOutput::new("x".repeat(360_000)); // ~90k tokens + let result = registry.guard_context_overflow("test", output, false).await; + + assert!(result.output.contains("OUTPUT WITHHELD")); + assert!( + result.output.contains("90k tokens"), + "should quote the real output size, got: {}", + result.output + ); + assert!( + result.output.contains("45%"), + "should quote the share of budget (90k of 200k), got: {}", + result.output + ); + assert!( + result.output.contains("200k context budget"), + "should quote the budget, got: {}", + result.output + ); + assert!( + result.output.contains("40k is already used"), + "should quote context already spent, got: {}", + result.output + ); + assert!( + result.output.contains("50k"), + "should quote the affordable size, now bounded by the absolute \ + single-output ceiling rather than 30% of the budget, got: {}", + result.output ); } @@ -577,13 +973,45 @@ async fn test_context_guard_truncates_when_context_nearly_full() { // Even a modest output should get truncated when context is 95% full let output = ToolOutput::new("x".repeat(4000)); // 1000 tokens - let result = registry.guard_context_overflow("test", output).await; + let result = registry.guard_context_overflow("test", output, false).await; assert!( - result.output.contains("TRUNCATED") || result.output.contains("CONTEXT LIMIT"), + result.output.contains("WITHHELD") || result.output.contains("CONTEXT LIMIT"), "Should warn about context limits when nearly full" ); } +#[tokio::test] +async fn test_context_guard_still_refuses_when_context_is_exhausted() { + // With almost no room left there is nothing to spend, so accepting the cost + // cannot buy anything. The opt-in must not become a way to blow past the + // window entirely. + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(10_000))); + { + let mut mgr = compaction.write().await; + mgr.update_observed_input_tokens(9_990); + } + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + let payload = "x".repeat(400_000); + let result = registry + .guard_context_overflow("test", ToolOutput::new(payload.clone()), true) + .await; + assert!( + result.output.len() < 2_000, + "exhausted context must not return the payload, got {} chars", + result.output.len() + ); + assert!( + result.output.contains("CONTEXT LIMIT REACHED"), + "should report the hard limit, got: {}", + result.output + ); +} + #[tokio::test] async fn test_context_guard_zero_budget_passes_through() { let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(0))); @@ -594,7 +1022,7 @@ async fn test_context_guard_zero_budget_passes_through() { }; let output = ToolOutput::new("x".repeat(100_000)); - let result = registry.guard_context_overflow("test", output).await; + let result = registry.guard_context_overflow("test", output, false).await; assert_eq!( result.output.len(), 100_000, @@ -602,6 +1030,39 @@ async fn test_context_guard_zero_budget_passes_through() { ); } +#[test] +fn test_accepts_large_output_requires_an_unambiguous_yes() { + use super::accepts_large_output; + + assert!(accepts_large_output( + &serde_json::json!({ "accept_large_output": true }) + )); + // Models routinely stringify booleans, so accept the string spelling too. + assert!(accepts_large_output( + &serde_json::json!({ "accept_large_output": "true" }) + )); + assert!(accepts_large_output( + &serde_json::json!({ "accept_large_output": "TRUE" }) + )); + + // Everything else means no. Spending the rest of the window should never + // happen because of a truthy-looking value. + for input in [ + serde_json::json!({}), + serde_json::json!({ "accept_large_output": false }), + serde_json::json!({ "accept_large_output": "false" }), + serde_json::json!({ "accept_large_output": 1 }), + serde_json::json!({ "accept_large_output": "yes" }), + serde_json::json!({ "accept_large_output": serde_json::Value::Null }), + serde_json::json!({ "query": "accept_large_output" }), + ] { + assert!( + !accepts_large_output(&input), + "should not opt in for {input}" + ); + } +} + #[tokio::test] async fn test_request_permission_is_ambient_only() { let provider: Arc = Arc::new(MockProvider); @@ -699,4 +1160,606 @@ async fn gemini_build_tools_from_registry_definitions_omits_const_keywords() { &serde_json::json!(parameters), "const" )); + + // Gemini rejects the whole generateContent request when any `required` entry + // names a property the same object does not declare, which made every + // tool-enabled Gemini call fail (issue #655). Assert on the *converted* + // declarations: the pre-conversion sweep in + // `test_tool_definitions_do_not_expose_invalid_array_schemas` cannot prove + // the adapter output is clean, and the adapter is what Gemini actually sees. + let mut dangling = Vec::new(); + for declaration in parameters { + collect_dangling_required( + &declaration.parameters, + &format!("tool `{}`", declaration.name), + &mut dangling, + ); + } + assert!( + dangling.is_empty(), + "converted Gemini function declarations still require undeclared properties:\n{}", + dangling.join("\n") + ); +} + +/// Collect `required` entries that name a property absent from the same +/// object's `properties` map. Objects without a local `properties` map are +/// exempt, matching what Gemini validates. +fn collect_dangling_required(schema: &Value, path: &str, errors: &mut Vec) { + match schema { + Value::Object(map) => { + if let (Some(Value::Array(required)), Some(Value::Object(properties))) = + (map.get("required"), map.get("properties")) + { + for name in required { + if let Some(name) = name.as_str() + && !properties.contains_key(name) + { + errors.push(format!("{path}.required: '{name}' is not declared here")); + } + } + } + for (key, value) in map { + collect_dangling_required(value, &format!("{path}.{key}"), errors); + } + } + Value::Array(values) => { + for (idx, value) in values.iter().enumerate() { + collect_dangling_required(value, &format!("{path}[{idx}]"), errors); + } + } + _ => {} + } +} + +#[tokio::test] +async fn test_context_guard_never_spends_more_than_it_reports() { + // State-space sweep over budget, fill level, and payload size. Two + // invariants must hold in every combination, because the whole point of the + // guard is that a caller can trust the accounting: + // 1. Without the opt-in, the returned text is small. Refusing has to be + // cheap or it reproduces the bug it prevents. + // 2. The returned text never exceeds the remaining safety headroom, with + // or without the opt-in. Otherwise "accept the cost" would silently + // overrun the window. + for budget in [10_000usize, 50_000, 200_000] { + for fill_percent in [0usize, 25, 50, 80, 89, 95] { + for payload_tokens in [1usize, 500, 5_000, 100_000] { + for accept in [false, true] { + let compaction = + Arc::new(RwLock::new(CompactionManager::new().with_budget(budget))); + let used = budget * fill_percent / 100; + if used > 0 { + let mut mgr = compaction.write().await; + mgr.update_observed_input_tokens(used as u64); + } + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + let payload = "x".repeat(payload_tokens * 4); + let result = registry + .guard_context_overflow("test", ToolOutput::new(payload.clone()), accept) + .await; + let returned_tokens = result.output.len() / 4; + + let threshold = (budget as f32 * 0.90) as usize; + let headroom = threshold.saturating_sub(used); + let passed_through = result.output == payload; + + if !accept && !passed_through { + assert!( + result.output.len() < 1_500, + "refusal must stay cheap: budget={budget} fill={fill_percent} \ + payload={payload_tokens} returned {} chars", + result.output.len() + ); + } + + // Allow a small slack for the notice text appended after the slice. + assert!( + returned_tokens <= headroom.max(1_000) + 500, + "returned ~{returned_tokens}k tokens with only {headroom} headroom: \ + budget={budget} fill={fill_percent} payload={payload_tokens} \ + accept={accept}" + ); + } + } + } + } +} + +#[tokio::test] +async fn test_context_guard_refusal_reads_clearly_for_todays_regression() { + // The exact shape that motivated this change: a 233k-token agentgrep result + // against a 200k budget with 18k already used. Printed so the wording stays + // reviewable, and asserted so it keeps naming the cost and the escape hatch. + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(200_000))); + { + let mut mgr = compaction.write().await; + mgr.update_observed_input_tokens(18_000); + } + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + let result = registry + .guard_context_overflow("agentgrep", ToolOutput::new("x".repeat(932_000)), false) + .await; + println!("---\n{}\n---", result.output); + + assert!(result.output.contains("233k tokens")); + assert!(result.output.contains("116%"), "got: {}", result.output); + assert!(result.output.contains("18k is already used")); + assert!(result.output.contains("accept_large_output")); + assert!(result.output.contains("paths_only")); +} + +/// Tool that returns a fixed-size payload, for exercising the guard through the +/// real `execute()` path rather than by calling the guard directly. +struct BigOutputTool { + chars: usize, +} + +#[async_trait] +impl Tool for BigOutputTool { + fn name(&self) -> &str { + "big_output" + } + + fn description(&self) -> &str { + "Returns a large fixed payload for context guard tests." + } + + fn parameters_schema(&self) -> Value { + serde_json::json!({ "type": "object", "properties": {} }) + } + + async fn execute(&self, _input: Value, _ctx: ToolContext) -> Result { + Ok(ToolOutput::new("x".repeat(self.chars))) + } +} + +async fn execute_big_output(input: Value) -> String { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + { + let mut mgr = registry.compaction.write().await; + *mgr = CompactionManager::new().with_budget(10_000); + } + registry + .register( + "big_output".to_string(), + Arc::new(BigOutputTool { chars: 400_000 }), + ) + .await; + + let ctx = ToolContext { + session_id: "test-context-guard-execute".to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(std::env::temp_dir()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + }; + + registry + .execute("big_output", input, ctx) + .await + .expect("tool should succeed") + .output +} + +#[tokio::test] +async fn test_execute_withholds_oversized_output_by_default() { + // The guard is only useful if it runs on the real call path. Every other + // test calls guard_context_overflow directly, which would still pass if the + // flag were never plumbed through execute(). + let output = execute_big_output(serde_json::json!({ "intent": "test" })).await; + assert!( + output.contains("OUTPUT WITHHELD"), + "execute() must apply the guard, got: {output}" + ); + assert!( + output.len() < 1_500, + "withheld output should be cheap, got {} chars", + output.len() + ); +} + +#[tokio::test] +async fn test_execute_honors_accept_large_output_from_raw_input() { + // Proves the flag survives the trip through execute(): the tool itself never + // declares or reads `accept_large_output`, so this only works because the + // registry reads it off the raw input. + let output = + execute_big_output(serde_json::json!({ "intent": "test", "accept_large_output": true })) + .await; + assert!( + output.contains("OUTPUT TRUNCATED"), + "opt-in should return truncated payload, got: {}", + &output[..output.len().min(200)] + ); + assert!( + output.starts_with(&"x".repeat(200)), + "opt-in must actually return payload" + ); +} + +#[tokio::test] +async fn test_execute_ignores_a_non_boolean_accept_flag() { + // A truthy-looking value must not spend the window. + let output = + execute_big_output(serde_json::json!({ "intent": "test", "accept_large_output": 1 })).await; + assert!( + output.contains("OUTPUT WITHHELD"), + "numeric 1 must not opt in, got: {}", + &output[..output.len().min(200)] + ); +} + +#[tokio::test] +async fn test_every_tool_advertises_the_large_output_escape_hatch() { + // The guard applies to every tool, so every tool must document the way out. + // Asserted over the real definition list rather than per tool, because the + // failure mode is a new tool nobody remembered to annotate. + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + registry.register_ambient_tools().await; + + let defs = registry.definitions(None).await; + assert!( + defs.len() > 20, + "expected the full tool set, got {}", + defs.len() + ); + + let mut missing = Vec::new(); + for def in &defs { + let flag = &def.input_schema["properties"][jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY]; + if flag.get("type").and_then(Value::as_str) != Some("boolean") { + missing.push(def.name.clone()); + } + // Advertising it as required would force the model to answer a question + // about token budgets on every single call. + if let Some(required) = def.input_schema["required"].as_array() { + assert!( + !required + .iter() + .any(|v| v.as_str() == Some(jcode_tool_core::ACCEPT_LARGE_OUTPUT_KEY)), + "{} must not require accept_large_output", + def.name + ); + } + } + assert!( + missing.is_empty(), + "tools missing the accept_large_output escape hatch: {missing:?}" + ); +} + +#[tokio::test] +async fn test_large_output_flag_costs_little_across_the_whole_tool_set() { + // Adding a property to every schema is paid on every request, forever. Keep + // the total honest: ~20 tokens per tool is acceptable, a paragraph is not. + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + registry.register_ambient_tools().await; + let defs = registry.definitions(None).await; + + let property = + serde_json::to_string(&crate::tool::accept_large_output_schema_property_for_test()) + .expect("serializable"); + let per_tool = crate::util::estimate_tokens(&property); + let total = per_tool * defs.len(); + + assert!( + per_tool <= 25, + "per-tool cost {per_tool} tokens is too high: {property}" + ); + assert!( + total < 1_500, + "{} tools x {per_tool} tokens = {total} tokens of permanent prompt overhead", + defs.len() + ); +} + +#[tokio::test] +async fn test_batch_guards_both_its_subcalls_and_its_own_aggregate() { + // Batch is how oversized results actually arrive in practice: several + // searches fan out at once. Two separate guard applications matter here, and + // the aggregate one is the load-bearing case: batch concatenates every + // sub-result, so even if each sub-call were individually acceptable the + // combined output can blow the window. That aggregate is what withheld + // today's regression. + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + { + let mut mgr = registry.compaction.write().await; + *mgr = CompactionManager::new().with_budget(10_000); + } + registry + .register( + "big_output".to_string(), + Arc::new(BigOutputTool { chars: 400_000 }), + ) + .await; + + let ctx = |name: &str| ToolContext { + session_id: format!("test-batch-context-guard-{name}"), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(std::env::temp_dir()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + }; + let calls = serde_json::json!([ + { "tool": "big_output", "intent": "one" }, + { "tool": "big_output", "intent": "two" }, + ]); + + // Without an opt-in anywhere, nothing large escapes: no payload reaches the + // transcript, only the refusal. + let withheld = registry + .execute( + "batch", + serde_json::json!({ "intent": "test", "tool_calls": calls }), + ctx("withheld"), + ) + .await + .expect("batch should succeed") + .output; + assert!( + withheld.contains("OUTPUT WITHHELD"), + "batch output must be guarded, got: {}", + &withheld[..withheld.len().min(300)] + ); + assert!( + !withheld.contains(&"x".repeat(100)), + "no payload should survive when nothing opted in" + ); + + // Opting in at the batch level returns the aggregate, which is the level a + // caller reads. The sub-calls' own refusals are inside it, since each was + // guarded separately and neither sub-call opted in. + let accepted = registry + .execute( + "batch", + serde_json::json!({ + "intent": "test", + "accept_large_output": true, + "tool_calls": calls, + }), + ctx("accepted"), + ) + .await + .expect("batch should succeed") + .output; + // The aggregate is now returned rather than withheld: it carries the + // per-subcall section headers, which the withheld version never reaches. + assert!( + !accepted.starts_with("⚠️ OUTPUT WITHHELD"), + "batch-level opt-in should return the aggregate, got: {}", + &accepted[..accepted.len().min(200)] + ); + assert!( + accepted.contains("--- [1] big_output ---"), + "aggregate should contain per-subcall sections, got: {}", + &accepted[..accepted.len().min(300)] + ); + assert!( + accepted.matches("OUTPUT WITHHELD").count() >= 1, + "each sub-call is guarded on its own; neither opted in" + ); +} + +#[tokio::test] +async fn test_guard_withholds_large_output_on_a_million_token_window() { + // The regression that made every other test in this file misleading. They + // all pinned budgets of 1k to 200k, where 30% of the budget is a small + // number. Production reported a 1M-token window, so 30% permitted a 300k + // single result and a repo-wide grep costing 233k tokens sailed straight + // through a guard that had unit tests passing. + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(1_000_000))); + { + let mut mgr = compaction.write().await; + mgr.update_observed_input_tokens(21_000); + } + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + // ~233k tokens: the real size of the agentgrep result that started this. + let output = ToolOutput::new("x".repeat(932_000)); + let result = registry + .guard_context_overflow("agentgrep", output, false) + .await; + + assert!( + result.output.contains("OUTPUT WITHHELD"), + "a 233k-token result must be withheld even on a 1M window, got: {}", + &result.output[..result.output.len().min(200)] + ); + assert!( + result.output.len() < 1_500, + "refusal should cost ~120 tokens, not {} chars", + result.output.len() + ); +} + +#[tokio::test] +async fn test_single_output_ceiling_is_absolute_not_only_proportional() { + // Guards the invariant directly: however large the window, one tool result + // may never exceed the absolute ceiling. Without this, raising a model's + // advertised context window silently raises the per-call blast radius. + for budget in [200_000usize, 1_000_000, 2_000_000, 10_000_000] { + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(budget))); + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + // Just over the absolute ceiling, but a trivial fraction of a huge window. + let over_ceiling_tokens = Registry::SINGLE_OUTPUT_MAX_TOKENS + 10_000; + let result = registry + .guard_context_overflow( + "test", + ToolOutput::new("x".repeat(over_ceiling_tokens * 4)), + false, + ) + .await; + assert!( + result.output.contains("OUTPUT WITHHELD"), + "budget={budget}: {over_ceiling_tokens} tokens must exceed the absolute ceiling" + ); + } +} + +/// Every built-in tool, normalized for every provider dialect, must be +/// sendable. +/// +/// This is the guard the recurring schema-outage class never had. #446, #495, +/// #543, #655, #687, #713 and #754 were each discovered by a user whose +/// provider had gone down, then fixed by appending one keyword to one +/// provider's deny-list. Nothing checked the *other* providers for the same +/// construct, which is exactly how #754 hit Gemini through Antigravity months +/// after the same class was fixed for OpenAI. +/// +/// Running the real registry through every registered dialect turns "some +/// provider is about to break" into a failing test on the commit that +/// introduces it. +#[tokio::test] +async fn tool_schemas_are_sendable_to_every_provider_dialect() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let defs = registry.definitions(None).await; + assert!(!defs.is_empty(), "the sweep must not pass vacuously"); + + let mut failures = Vec::new(); + // Not per-dialect: no provider *rejects* a property that declares no type, + // but OpenAI refuses `strict` for the whole catalog over one (#713), so a + // built-in tool acquiring one would silently cost every OpenAI-route agent + // its structured-output guarantees. + for def in &defs { + for error in jcode_schema_dialect::untyped_properties(&def.input_schema) { + failures.push(format!("tool `{}` {error}", def.name)); + } + } + for spec in jcode_schema_dialect::registry::ALL { + for def in &defs { + let normalized = jcode_schema_dialect::dialect::apply(&def.input_schema, spec); + for error in + jcode_schema_dialect::must_not_contain_unsupported_constructs(&normalized, spec) + { + failures.push(format!("[{}] tool `{}` {error}", spec.id, def.name)); + } + // Over-stripping is the hazard an allow-list introduces: a dialect + // that forgot to list `description` would produce requests that + // succeed while silently deleting every tool's prompt text. + for error in jcode_schema_dialect::must_preserve_meaning(&def.input_schema, &normalized) + { + failures.push(format!( + "[{}] tool `{}` lost meaning: {error}", + spec.id, def.name + )); + } + } + } + + assert!( + failures.is_empty(), + "tool schemas are not sendable to every provider:\n{}", + failures.join("\n") + ); +} + +/// The sweep above must fail when a tool really does carry a construct a +/// provider rejects, otherwise it is decorative. Feeds the exact +/// `@playwright/mcp` schema from #754 through the same checker to prove the +/// detection works end to end. +#[test] +fn the_dialect_sweep_catches_the_issue_754_schema() { + let hostile = serde_json::json!({ + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": { "type": "string" }, + "propertyNames": { "type": "string" } + } + } + }); + + let unnormalized = jcode_schema_dialect::must_not_contain_unsupported_constructs( + &hostile, + &jcode_schema_dialect::registry::GEMINI, + ); + assert!( + unnormalized + .iter() + .any(|e| e.message.contains("propertyNames")), + "the checker must flag the raw schema, got {unnormalized:?}" + ); + + let normalized = + jcode_schema_dialect::dialect::apply(&hostile, &jcode_schema_dialect::registry::GEMINI); + assert!( + jcode_schema_dialect::must_not_contain_unsupported_constructs( + &normalized, + &jcode_schema_dialect::registry::GEMINI, + ) + .is_empty(), + "and must pass once normalized" + ); +} + +/// Failing strict eligibility closed for #711/#713 must not quietly cost jcode's +/// own tools their strict mode, since that would drop the structured-output +/// guarantees on every OpenAI-route tool call with nothing to notice. +/// +/// The four tools listed below were already non-strict before that change, for +/// reasons unrelated to it (`batch` declares `additionalProperties: true` so its +/// sub-call payloads stay open-world; the others carry open maps or untyped +/// action payloads). Pinning the exact set is what makes this a regression +/// detector: a fifth name appearing means a stricter rule went too far, and a +/// name disappearing means a tool became strict-eligible and the list is stale. +#[tokio::test] +async fn only_the_known_open_world_tools_are_ineligible_for_openai_strict_mode() { + /// Built-ins that legitimately cannot be strict. Verified against master + /// before the #711/#713 eligibility changes, so this is pre-existing. + const KNOWN_OPEN_WORLD_TOOLS: &[&str] = &["batch", "browser", "initiative", "swarm"]; + + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let defs = registry.definitions(None).await; + assert!(!defs.is_empty(), "the sweep must not pass vacuously"); + + let mut ineligible: Vec = Vec::new(); + for def in &defs { + let compatible = + jcode_provider_core::openai_schema::openai_compatible_schema(&def.input_schema); + if !jcode_provider_core::openai_schema::schema_supports_strict(&compatible) { + ineligible.push(def.name.clone()); + } + } + ineligible.sort(); + + let expected: Vec = KNOWN_OPEN_WORLD_TOOLS + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + ineligible, expected, + "the set of strict-ineligible built-in tools changed; a new name means an \ + eligibility rule is too aggressive, a missing name means this list is stale" + ); } diff --git a/crates/jcode-app-core/src/tool/todo.rs b/crates/jcode-app-core/src/tool/todo.rs index 1f204d67bd..dc0fd9ece2 100644 --- a/crates/jcode-app-core/src/tool/todo.rs +++ b/crates/jcode-app-core/src/tool/todo.rs @@ -1,12 +1,13 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, TodoEvent}; use crate::todo::{ - LOW_HILL_CLIMBABILITY, TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE, - TODO_OWNERSHIP_CONTINUATION_MESSAGE, TodoGoal, TodoGoalChange, TodoGoalField, TodoItem, - load_goals, load_todos, newly_completed_groups_have_sufficient_ownership, save_goals, - save_todos, + GateObservation, GateObservationKind, SEVERE_INTENT_MISUNDERSTANDING, + TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE, TodoGoal, TodoGoalChange, TodoGoalField, + TodoItem, TodoPlan, TodoPlanChange, TodoPlanField, append_gate_observations, + feedback_loop_passes, intent_understanding_passes, load_goals, load_plan, load_todos, + save_goals, save_plan, save_todos, update_todo_review_cycle, }; -use anyhow::Result; +use anyhow::{Result, bail}; use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; @@ -68,6 +69,22 @@ fn merge_confidence_history(previous: &[TodoItem], incoming: &mut [TodoItem]) { struct TodoInput { todos: Option>, goals: Option>, + plan: Option, +} + +fn parse_todo_input(input: Value) -> Result { + let params: TodoInput = serde_json::from_value(normalize_todo_input(input))?; + if let Some(todo) = params.todos.as_ref().and_then(|todos| { + todos + .iter() + .find(|todo| crate::todo::canonical_todo_status(&todo.status).is_none()) + }) { + bail!( + "invalid todo status {:?}; expected one of: pending, in_progress, completed, cancelled", + todo.status + ); + } + Ok(params) } /// Normalize a goal's group label: trimmed, with empty/whitespace collapsed @@ -79,11 +96,133 @@ fn goal_group_key(group: Option<&str>) -> Option { .map(str::to_string) } +fn todo_telemetry_update( + previous: &[TodoItem], + todos: &[TodoItem], + goals: &[TodoGoal], + plan: &TodoPlan, +) -> crate::telemetry::TodoTelemetryUpdate { + let previous_by_id: HashMap<&str, &TodoItem> = previous + .iter() + .map(|todo| (todo.id.as_str(), todo)) + .collect(); + let current_by_id: HashMap<&str, &TodoItem> = + todos.iter().map(|todo| (todo.id.as_str(), todo)).collect(); + + let todos_created = current_by_id + .keys() + .filter(|id| !previous_by_id.contains_key(**id)) + .count() + .min(u32::MAX as usize) as u32; + let todos_completed = current_by_id + .iter() + .filter(|(id, todo)| { + todo.status == "completed" + && previous_by_id + .get(**id) + .is_none_or(|previous| previous.status != "completed") + }) + .count() + .min(u32::MAX as usize) as u32; + let todos_abandoned = previous_by_id + .iter() + .filter(|(id, todo)| todo.status != "completed" && !current_by_id.contains_key(**id)) + .count() + .min(u32::MAX as usize) as u32; + let current_incomplete = current_by_id + .values() + .filter(|todo| todo.status != "completed") + .count() + .min(u32::MAX as usize) as u32; + + let mut group_completion: HashMap, bool> = HashMap::new(); + for todo in current_by_id.values() { + let completed = todo.status == "completed"; + group_completion + .entry(goal_group_key(todo.group.as_deref())) + .and_modify(|all_completed| *all_completed &= completed) + .or_insert(completed); + } + + crate::telemetry::TodoTelemetryUpdate { + todos_created, + todos_completed, + todos_abandoned, + current_incomplete, + list_size: todos.len().min(u32::MAX as usize) as u32, + groups_completed: group_completion + .values() + .filter(|completed| **completed) + .count() + .min(u32::MAX as usize) as u32, + groups_total: group_completion.len().min(u32::MAX as usize) as u32, + confidence: crate::telemetry::TelemetryScoreSummary::from_scores( + current_by_id + .values() + .filter_map(|todo| todo.confidence.map(|state| state.legacy_score())), + ), + completion_confidence: crate::telemetry::TelemetryScoreSummary::from_scores( + current_by_id + .values() + .filter_map(|todo| todo.completion_confidence.map(|state| state.legacy_score())), + ), + understands_user_intent: crate::telemetry::TelemetryScoreSummary::from_scores( + plan.understands_user_intent + .map(|state| state.legacy_score()), + ), + closed_feedback_loop: crate::telemetry::TelemetryScoreSummary::from_scores( + goals + .iter() + .filter_map(|goal| goal.closed_feedback_loop.map(|state| state.legacy_score())), + ), + feedback_loop_relevance: crate::telemetry::TelemetryScoreSummary::from_scores( + goals.iter().filter_map(|goal| { + goal.feedback_loop_relevance + .map(|state| state.legacy_score()) + }), + ), + feedback_loop_coverage: crate::telemetry::TelemetryScoreSummary::from_scores( + goals.iter().filter_map(|goal| { + goal.feedback_loop_coverage + .map(|state| state.legacy_score()) + }), + ), + end_to_end_ownership: crate::telemetry::TelemetryScoreSummary::from_scores( + goals + .iter() + .filter_map(|goal| goal.delivery_state.map(|state| state.legacy_score())), + ), + } +} + +fn record_todo_telemetry( + previous: &[TodoItem], + todos: &[TodoItem], + goals: &[TodoGoal], + plan: &TodoPlan, +) { + crate::telemetry::record_todo_update(todo_telemetry_update(previous, todos, goals, plan)); +} + +/// Append `value` to `history` when it is a new observation. +/// +/// One todo-tool write contributes at most one entry per score, so a single +/// bulk update cannot manufacture an apparent gradual climb. +fn record_score_observation(history: &mut Vec, value: Option) { + if let Some(value) = value + && history.last() != Some(&value) + { + history.push(value); + } +} + /// Merge incoming goal assessments with the stored ones. /// /// Incoming goals win per group key; stored goals for groups the write does /// not mention are retained (a todo update should not silently discard goal -/// assessments). +/// assessments). Score histories are tool-maintained: whatever the model sends +/// for them is discarded in favor of the stored trail plus this write's +/// observation. fn merge_goals(stored: &[TodoGoal], incoming: Option>) -> Vec { let Some(incoming) = incoming else { return stored.to_vec(); @@ -91,22 +230,77 @@ fn merge_goals(stored: &[TodoGoal], incoming: Option>) -> Vec = Vec::new(); for mut goal in incoming { goal.group = goal_group_key(goal.group.as_deref()); - // User intention describes why the user asked for the goal and should - // remain stable while the agent revises metrics, feedback, or scores. - // An omitted intention therefore inherits the current value for the - // same goal. Sending an empty string remains an explicit way to clear - // its visible value. - if goal.user_intention.is_none() { - goal.user_intention = merged - .iter() - .find(|existing| existing.group == goal.group) - .or_else(|| { - stored - .iter() - .find(|existing| goal_group_key(existing.group.as_deref()) == goal.group) - }) - .and_then(|existing| existing.user_intention.clone()); + let previous = stored + .iter() + .find(|prev| goal_group_key(prev.group.as_deref()) == goal.group); + goal.closed_feedback_loop_history = previous + .map(|prev| prev.closed_feedback_loop_history.clone()) + .unwrap_or_default(); + goal.feedback_loop_relevance_history = previous + .map(|prev| prev.feedback_loop_relevance_history.clone()) + .unwrap_or_default(); + goal.feedback_loop_coverage_history = previous + .map(|prev| prev.feedback_loop_coverage_history.clone()) + .unwrap_or_default(); + goal.feedback_loop_traceability_history = previous + .map(|prev| prev.feedback_loop_traceability_history.clone()) + .unwrap_or_default(); + goal.delivery_state_history = previous + .map(|prev| prev.delivery_state_history.clone()) + .unwrap_or_default(); + // Field-level merge, matching `merge_plan`: a write that revises one + // assessment must not silently erase the others. Without this the + // turn-end digest would read a stale `None` and re-raise a point the + // agent had already resolved. + if let Some(prev) = previous { + if goal.closed_feedback_loop.is_none() { + goal.closed_feedback_loop = prev.closed_feedback_loop; + } + if goal.delivery_state.is_none() { + goal.delivery_state = prev.delivery_state; + } + if goal.feedback_loop_relevance.is_none() { + goal.feedback_loop_relevance = prev.feedback_loop_relevance; + } + if goal.feedback_loop_coverage.is_none() { + goal.feedback_loop_coverage = prev.feedback_loop_coverage; + } + if goal.feedback_loop_traceability.is_none() { + goal.feedback_loop_traceability = prev.feedback_loop_traceability; + } + if goal.difficulty.is_none() { + goal.difficulty = prev.difficulty; + } + if goal.autonomy.is_none() { + goal.autonomy = prev.autonomy; + } + if goal.iteration_maturity.is_none() { + goal.iteration_maturity = prev.iteration_maturity; + } + if goal.feedback_loop.is_none() { + goal.feedback_loop = prev.feedback_loop.clone(); + } + if goal.stopping_evidence.is_none() { + goal.stopping_evidence = prev.stopping_evidence.clone(); + } } + record_score_observation( + &mut goal.closed_feedback_loop_history, + goal.closed_feedback_loop, + ); + record_score_observation( + &mut goal.feedback_loop_relevance_history, + goal.feedback_loop_relevance, + ); + record_score_observation( + &mut goal.feedback_loop_coverage_history, + goal.feedback_loop_coverage, + ); + record_score_observation( + &mut goal.feedback_loop_traceability_history, + goal.feedback_loop_traceability, + ); + record_score_observation(&mut goal.delivery_state_history, goal.delivery_state); if let Some(slot) = merged .iter_mut() .find(|existing| existing.group == goal.group) @@ -125,41 +319,113 @@ fn merge_goals(stored: &[TodoGoal], incoming: Option>) -> Vec, todos: &[TodoItem]) -> Vec { + if todos.is_empty() { + return goals; + } + let live_groups: std::collections::HashSet> = todos + .iter() + .map(|todo| goal_group_key(todo.group.as_deref())) + .collect(); + goals + .into_iter() + .filter(|goal| live_groups.contains(&goal_group_key(goal.group.as_deref()))) + .collect() +} + fn changed_goal_fields(before: Option<&TodoGoal>, after: Option<&TodoGoal>) -> Vec { let mut fields = Vec::new(); - if before.and_then(|goal| goal.user_intention.as_ref()) - != after.and_then(|goal| goal.user_intention.as_ref()) + if before.and_then(|goal| goal.closed_feedback_loop) + != after.and_then(|goal| goal.closed_feedback_loop) { - fields.push(TodoGoalField::UserIntention); + fields.push(TodoGoalField::ClosedFeedbackLoop); } - if before.and_then(|goal| goal.user_intention_alignment) - != after.and_then(|goal| goal.user_intention_alignment) + if before.and_then(|goal| goal.feedback_loop.as_ref()) + != after.and_then(|goal| goal.feedback_loop.as_ref()) { - fields.push(TodoGoalField::UserIntentionAlignment); + fields.push(TodoGoalField::FeedbackLoop); } - if before.and_then(|goal| goal.hill_climbability) - != after.and_then(|goal| goal.hill_climbability) + if before.and_then(|goal| goal.feedback_loop_relevance) + != after.and_then(|goal| goal.feedback_loop_relevance) { - fields.push(TodoGoalField::HillClimbability); + fields.push(TodoGoalField::FeedbackLoopRelevance); } - if before.and_then(|goal| goal.objective.as_ref()) - != after.and_then(|goal| goal.objective.as_ref()) + if before.and_then(|goal| goal.feedback_loop_coverage) + != after.and_then(|goal| goal.feedback_loop_coverage) { - fields.push(TodoGoalField::Objective); + fields.push(TodoGoalField::FeedbackLoopCoverage); } - if before.and_then(|goal| goal.feedback_loop.as_ref()) - != after.and_then(|goal| goal.feedback_loop.as_ref()) + if before.and_then(|goal| goal.feedback_loop_traceability) + != after.and_then(|goal| goal.feedback_loop_traceability) { - fields.push(TodoGoalField::FeedbackLoop); + fields.push(TodoGoalField::FeedbackLoopTraceability); + } + if before.and_then(|goal| goal.delivery_state) != after.and_then(|goal| goal.delivery_state) { + fields.push(TodoGoalField::DeliveryState); + } + if before.and_then(|goal| goal.autonomy) != after.and_then(|goal| goal.autonomy) { + fields.push(TodoGoalField::Autonomy); + } + if before.and_then(|goal| goal.iteration_maturity) + != after.and_then(|goal| goal.iteration_maturity) + { + fields.push(TodoGoalField::IterationMaturity); } - if before.and_then(|goal| goal.end_to_end_ownership) - != after.and_then(|goal| goal.end_to_end_ownership) + if before.and_then(|goal| goal.stopping_evidence.as_ref()) + != after.and_then(|goal| goal.stopping_evidence.as_ref()) { - fields.push(TodoGoalField::EndToEndOwnership); + fields.push(TodoGoalField::StoppingEvidence); } fields } +/// Merge the incoming plan-level intent assessment with the stored one. +/// +/// User intention describes why the user asked for the work and should remain +/// stable while the agent revises its steps or scores, so an omitted intention +/// inherits the stored value. Sending an empty string clears it. The intent +/// score's history is tool-maintained, so a model-supplied trail is discarded. +fn merge_plan(stored: &TodoPlan, incoming: Option) -> TodoPlan { + let Some(mut plan) = incoming else { + return stored.clone(); + }; + if plan.user_intention.is_none() { + plan.user_intention = stored.user_intention.clone(); + } + if plan.understands_user_intent.is_none() { + plan.understands_user_intent = stored.understands_user_intent; + } + plan.understands_user_intent_history = stored.understands_user_intent_history.clone(); + record_score_observation( + &mut plan.understands_user_intent_history, + plan.understands_user_intent, + ); + plan +} + +fn plan_change(before: &TodoPlan, after: &TodoPlan) -> Option { + let mut fields = Vec::new(); + if before.user_intention != after.user_intention { + fields.push(TodoPlanField::UserIntention); + } + if before.understands_user_intent != after.understands_user_intent { + fields.push(TodoPlanField::UnderstandsUserIntent); + } + (!fields.is_empty()).then(|| TodoPlanChange { + before: Some(before.clone()), + after: Some(after.clone()), + fields, + }) +} + fn goal_changes(before: &[TodoGoal], after: &[TodoGoal]) -> Vec { let mut changes = Vec::new(); for current in after { @@ -196,37 +462,112 @@ fn goal_changes(before: &[TodoGoal], after: &[TodoGoal]) -> Vec changes } -/// Reframe nudges for goals that score low on hill-climbability. +/// Record the points this write would previously have interrupted on, and +/// return the rare continuation that is still worth sending immediately. /// -/// A low score means there is no credible metric to iterate against, so the -/// objective must be reframed into something measurable. The nudge is -/// intentionally returned on every applicable todo write until the goal reaches -/// the threshold or its work closes. -fn take_reframe_nudges(goals: &[TodoGoal], todos: &[TodoItem]) -> Vec { - let mut nudges = Vec::new(); - for goal in goals { - let Some(score) = goal.hill_climbability else { - continue; - }; - if score >= LOW_HILL_CLIMBABILITY { - continue; +/// Previously both checks emitted a continuation on every applicable write for +/// as long as the score stayed low. That punished the common healthy case: +/// understanding of a request starts low and rises as the agent explores, so an +/// agent already resolving the ambiguity was repeatedly told to stop and go +/// resolve the ambiguity. On long iterative turns the same text reattached to +/// every todo call, spending reasoning on re-justifying the plan instead of on +/// the work. +/// +/// So the checks are deferred: observations accumulate and are replayed once at +/// turn end by `build_gate_digest`. Deferred, not forgiven. A score that climbs +/// late is still raised, because the work done while it was low was never +/// governed by the better loop that arrived afterwards. The one exception is a +/// first plan write that scores severely low, where the agent is admitting it +/// does not know the task at all and a whole turn of wrong work cannot be undone +/// at turn end. +fn record_reframe_observations( + plan: &TodoPlan, + goals: &[TodoGoal], + todos: &[TodoItem], + previous: &[TodoItem], +) -> (Vec, Vec) { + let mut observations = Vec::new(); + let mut immediate = Vec::new(); + let any_open = todos + .iter() + .any(|todo| todo.status != "completed" && todo.status != "cancelled"); + if any_open && !intent_understanding_passes(plan.understands_user_intent) { + observations.push(GateObservation { + kind: GateObservationKind::IntentUnderstanding, + group: None, + state: plan + .understands_user_intent + .map(|state| state.as_str().to_string()), + }); + // Only on the first observation of the plan, so a persistently low + // assessment is reported once at turn end rather than on every write. + let first_assessment = plan.understands_user_intent_history.len() <= 1; + if first_assessment + && plan + .understands_user_intent + .is_some_and(|state| state <= SEVERE_INTENT_MISUNDERSTANDING) + { + immediate.push(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE.to_string()); } + } + let closed_now = crate::todo::groups_closed_by_update(previous, todos); + for goal in goals { let group_open = todos.iter().any(|todo| { goal_group_key(todo.group.as_deref()) == goal.group && todo.status != "completed" && todo.status != "cancelled" }); - if !group_open { + // A group this write closes counts too: a goal created and finished in + // one step is otherwise never observed, and one-step completions are + // where a weak feedback loop hides best. + if !group_open && !closed_now.contains(&goal.group) { continue; } - nudges.push(TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE.to_string()); + if !feedback_loop_passes(goal.closed_feedback_loop) { + observations.push(GateObservation { + kind: GateObservationKind::ClosedFeedbackLoop, + group: goal.group.clone(), + state: goal + .closed_feedback_loop + .map(|state| state.as_str().to_string()), + }); + } + if !crate::todo::feedback_loop_relevance_passes(goal) { + observations.push(GateObservation { + kind: GateObservationKind::FeedbackLoopRelevance, + group: goal.group.clone(), + state: goal + .feedback_loop_relevance + .map(|state| state.as_str().to_string()), + }); + } + if !crate::todo::feedback_loop_coverage_passes(goal) { + observations.push(GateObservation { + kind: GateObservationKind::FeedbackLoopCoverage, + group: goal.group.clone(), + state: goal + .feedback_loop_coverage + .map(|state| state.as_str().to_string()), + }); + } + if !crate::todo::feedback_loop_traceability_passes(goal) { + observations.push(GateObservation { + kind: GateObservationKind::FeedbackLoopTraceability, + group: goal.group.clone(), + state: goal + .feedback_loop_traceability + .map(|state| state.as_str().to_string()), + }); + } } - nudges + (observations, immediate) } fn build_todo_output( todos: Vec, + plan: TodoPlan, goals: Vec, + plan_change: Option, goal_changes: Option>, continuations: impl IntoIterator, ) -> Result { @@ -235,10 +576,18 @@ fn build_todo_output( .filter(|todo| todo.status != "completed") .count(); let mut text = serde_json::to_string_pretty(&todos)?; + if plan != TodoPlan::default() { + text.push_str("\n\nPlan:\n"); + text.push_str(&serde_json::to_string_pretty(&plan)?); + } if !goals.is_empty() { text.push_str("\n\nGoals:\n"); text.push_str(&serde_json::to_string_pretty(&goals)?); } + if let Some(plan_change) = plan_change.as_ref() { + text.push_str("\n\nPlan updates:\n"); + text.push_str(&serde_json::to_string_pretty(plan_change)?); + } if let Some(goal_changes) = goal_changes.as_ref().filter(|changes| !changes.is_empty()) { text.push_str("\n\nGoal updates:\n"); text.push_str(&serde_json::to_string_pretty(goal_changes)?); @@ -247,7 +596,10 @@ fn build_todo_output( text.push_str("\n\n"); text.push_str(&continuation); } - let mut metadata = json!({"todos": todos, "goals": goals}); + let mut metadata = json!({"todos": todos, "plan": plan, "goals": goals}); + if let Some(plan_change) = plan_change { + metadata["plan_update"] = serde_json::to_value(plan_change)?; + } if let Some(goal_changes) = goal_changes.filter(|changes| !changes.is_empty()) { metadata["goal_updates"] = serde_json::to_value(goal_changes)?; } @@ -268,6 +620,29 @@ fn normalize_todo_input(mut input: Value) -> Value { let Some(obj) = input.as_object_mut() else { return input; }; + if let Some(plan) = obj.get_mut("plan") { + if let Value::String(raw) = plan { + let trimmed = raw.trim(); + if trimmed.is_empty() { + *plan = Value::Null; + } else if let Ok(parsed @ (Value::Object(_) | Value::Null)) = + serde_json::from_str::(trimmed) + { + *plan = parsed; + } + } + if let Some(fields) = plan.as_object_mut() { + for key in [ + "alignment_score", + "user_intention_alignment", + "understands_user_intent", + ] { + if let Some(value) = fields.get_mut(key) { + coerce_empty_string_to_null(value); + } + } + } + } for key in ["todos", "goals"] { let Some(entries) = obj.get_mut(key) else { continue; @@ -296,15 +671,31 @@ fn normalize_todo_input(mut input: Value) -> Value { let Some(fields) = item.as_object_mut() else { continue; }; + if key == "todos" + && let Some(Value::String(status)) = fields.get_mut("status") + && let Some(canonical) = crate::todo::canonical_todo_status(status) + { + *status = canonical.to_string(); + } for key in [ "confidence", "completion_confidence", + "alignment_score", "user_intention_alignment", + "closed_feedback_loop", + // Pre-rename aliases; some prompts and replayed transcripts + // still carry the old keys. "hill_climbability", "end_to_end_ownership", + "delivery_state", + "feedback_loop_relevance", + "feedback_loop_coverage", + "feedback_loop_traceability", + "difficulty", + "autonomy", ] { if let Some(value) = fields.get_mut(key) { - coerce_value_to_integer(value); + coerce_empty_string_to_null(value); } } } @@ -313,29 +704,15 @@ fn normalize_todo_input(mut input: Value) -> Value { input } -/// Coerce a numeric string (`"90"`) or whole float (`90.0`) to a JSON integer, -/// and an empty string to `null`. Leaves anything else untouched so strict -/// deserialization can report a precise error. -fn coerce_value_to_integer(value: &mut Value) { - match value { - Value::String(raw) => { - let trimmed = raw.trim(); - if trimmed.is_empty() { - *value = Value::Null; - } else if let Ok(parsed) = trimmed.parse::() { - *value = Value::from(parsed); - } - } - Value::Number(num) => { - if num.as_u64().is_none() - && let Some(float) = num.as_f64() - && float.fract() == 0.0 - && (0.0..=u64::MAX as f64).contains(&float) - { - *value = Value::from(float as u64); - } - } - _ => {} +/// Coerce an empty or whitespace-only string to `null` so an omitted-but-sent +/// assessment reads as absent. Numeric legacy scores (ints, floats, numeric +/// strings) are handled by the semantic-state deserializers themselves, so no +/// numeric coercion is needed here anymore. +fn coerce_empty_string_to_null(value: &mut Value) { + if let Value::String(raw) = value + && raw.trim().is_empty() + { + *value = Value::Null; } } @@ -371,7 +748,8 @@ impl Tool for TodoTool { }, "status": { "type": "string", - "description": "Status." + "enum": ["pending", "in_progress", "completed", "cancelled"], + "description": "Status. Use completed when the task is done." }, "priority": { "type": "string", @@ -383,63 +761,95 @@ impl Tool for TodoTool { }, "group": { "type": "string", - "description": "Optional group label. Todos sharing a group render together under one header. Use one group per coherent goal (e.g. 'optimize rendering'). When the user steers into new work, start a new group instead of renaming the existing one. Omit for an ungrouped flat list." + "description": "Optional group label; one group per coherent goal, new direction = new group. Omit for flat list." }, "confidence": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "description": "Self-assessed confidence, 0-100, that this todo can be completed correctly. Reassess it as evidence accumulates while working." + "type": "string", + "enum": ["speculative", "plausible", "validated", "verified"], + "description": "Evidence state that this todo can be completed correctly; reassess as evidence accumulates." }, "completion_confidence": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "description": "Self-assessed confidence, 0-100, that this todo was completed correctly. Use only for completed items." - } + "type": "string", + "enum": ["speculative", "plausible", "validated", "verified"], + "description": "Evidence state behind this todo's completion. Use only for completed items." + }, + } + } + }, + "plan": { + "type": "object", + "description": "Plan-level understanding of the request. Send on first write and whenever understanding changes.", + "required": ["user_intention", "understands_user_intent"], + "properties": { + "user_intention": { + "type": "string", + "description": "What the user actually wants: underlying reason and desired end state. Omit later to retain." + }, + "understands_user_intent": { + "type": "string", + "enum": ["uncertain", "partial", "clear", "complete"], + "description": "How well you understand what the user wants. Report uncertain or partial when guessing." } } }, "goals": { "type": "array", - "description": "Optional goal-level assessments, one per todo group. Use group: null for an ungrouped list. Stored assessments for groups omitted from an update are retained.", + "description": "Goal-level assessments, one per todo group (null = ungrouped). Omitted groups are retained.", "items": { "type": "object", - "required": ["user_intention_alignment", "hill_climbability", "feedback_loop"], + "required": ["closed_feedback_loop", "feedback_loop", "feedback_loop_relevance", "feedback_loop_coverage", "feedback_loop_traceability"], "properties": { "group": { "type": "string", "description": "Group label this goal describes. Omit or null for the ungrouped list." }, - "user_intention": { + "closed_feedback_loop": { "type": "string", - "description": "Optional concise statement of the user's underlying reason or desired outcome for this goal. Omit on later updates to retain the stored intention." + "enum": ["absent", "weak", "usable", "strong", "closed"], + "description": "How much of this goal's correctness the feedback_loop can verify on its own." }, - "user_intention_alignment": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "description": "Self-assessment, 0-100, of how well the current goal, objective, and planned work align with the user's stated request and underlying intention." + "feedback_loop": { + "type": "string", + "description": "Requirement-to-check process: an explicit observation or check for each requirement of this goal." }, - "hill_climbability": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "description": "Self-assessment, 0-100, of how readily progress toward this goal can be measured and compared across iterations." + "feedback_loop_relevance": { + "type": "string", + "enum": ["indirect", "synthetic", "representative", "acceptance_blocked", "acceptance_aligned"], + "description": "How directly checks represent observable acceptance behavior. indirect = inspection or an internal proxy; synthetic = custom harnesses, stubs, mocks, copied sources, or synthetic fixtures; representative = real public interfaces but not the complete acceptance workflow; acceptance_blocked = the real acceptance workflow was attempted but an external constraint prevented a result; acceptance_aligned = the real project build, integration test, or end-user workflow passed. Substitute-only validation is never acceptance_aligned." }, - "objective": { + "feedback_loop_coverage": { "type": "string", - "description": "Optional concise statement of the intended measurable outcome." + "enum": ["narrow", "main_paths", "edge_and_integration_paths"], + "description": "How broadly the checks exercise main workflows, integration boundaries, edge cases, packaging, and likely failure modes." }, - "feedback_loop": { + "feedback_loop_traceability": { + "type": "string", + "enum": ["unmapped", "partial", "complete"], + "description": "How completely requirements map to evidence. unmapped = requirements are not tied to checks; partial = only some explicit requirements or changed public outputs have concrete checks and observed results; complete = every explicit requirement and changed public output has a concrete check and observed result. Aggregate test counts alone do not establish complete traceability." + }, + "delivery_state": { + "type": "string", + "enum": ["change_made", "integrated", "workflow_validated", "outcome_delivered"], + "description": "Completion-time: how far the result actually traveled toward the user's outcome." + }, + "difficulty": { + "type": "string", + "enum": ["trivial", "routine", "involved", "complex", "hard", "expert", "research", "open_ended"], + "description": "Honest intrinsic difficulty of this goal. Descriptive only." + }, + "autonomy": { + "type": "string", + "enum": ["requested_only", "necessary_followthrough", "proactive", "stewardship"], + "description": "How far beyond the literal request the work went. Assess from what was completed." + }, + "iteration_maturity": { "type": "string", - "description": "Concrete process, observation, or check used to compare progress across iterations." + "enum": ["not_started", "exploring", "improving", "plateau_unproven", "outcome_reached", "constraints_exhausted", "plateau_confirmed", "budget_exhausted"], + "description": "How far the feedback loop was actually exercised, and any evidence-based reason to stop iterating." }, - "end_to_end_ownership": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "description": "Completion-time self-assessment, 0-100, of whether the full intended user outcome and its necessary follow-through were delivered, rather than only the immediate implementation. Use only when completing the goal." + "stopping_evidence": { + "type": "string", + "description": "Evidence for the reported iteration_maturity: attempts, observations, or a real budget limit." } } } @@ -449,55 +859,90 @@ impl Tool for TodoTool { } async fn execute(&self, input: Value, ctx: ToolContext) -> Result { - let params: TodoInput = serde_json::from_value(normalize_todo_input(input))?; - let operation = if params.todos.is_some() || params.goals.is_some() { - "write" - } else { - "read" - }; - let result = if params.todos.is_some() || params.goals.is_some() { - // Goals-only writes keep the stored todo list. + let params = parse_todo_input(input)?; + let is_write = params.todos.is_some() || params.goals.is_some() || params.plan.is_some(); + let operation = if is_write { "write" } else { "read" }; + let result = if is_write { + // Goals/plan-only writes keep the stored todo list. let previous = load_todos(&ctx.session_id).unwrap_or_default(); let mut todos = params.todos.unwrap_or_else(|| previous.clone()); merge_confidence_history(&previous, &mut todos); (|| { let stored_goals = load_goals(&ctx.session_id).unwrap_or_default(); - let goals = merge_goals(&stored_goals, params.goals); - if !newly_completed_groups_have_sufficient_ownership(&previous, &todos, &goals) { - crate::telemetry::record_todo_gate(crate::telemetry::TodoGateKind::Ownership); - return build_todo_output( - previous, - stored_goals, - None, - [TODO_OWNERSHIP_CONTINUATION_MESSAGE.to_string()], - ); + let stored_plan = load_plan(&ctx.session_id).unwrap_or_default(); + let goals = prune_orphaned_goals(merge_goals(&stored_goals, params.goals), &todos); + let plan = merge_plan(&stored_plan, params.plan); + let (observations, nudges) = + record_reframe_observations(&plan, &goals, &todos, &previous); + for observation in &observations { + let kind = match observation.kind { + GateObservationKind::IntentUnderstanding => { + crate::telemetry::TodoGateKind::IntentUnderstanding + } + GateObservationKind::ClosedFeedbackLoop => { + crate::telemetry::TodoGateKind::ClosedFeedbackLoop + } + GateObservationKind::FeedbackLoopRelevance => { + crate::telemetry::TodoGateKind::FeedbackLoopRelevance + } + GateObservationKind::FeedbackLoopCoverage => { + crate::telemetry::TodoGateKind::FeedbackLoopCoverage + } + GateObservationKind::FeedbackLoopTraceability => { + crate::telemetry::TodoGateKind::FeedbackLoopTraceability + } + }; + crate::telemetry::record_todo_gate(kind); } - let nudges = take_reframe_nudges(&goals, &todos); - for _ in &nudges { - crate::telemetry::record_todo_gate( - crate::telemetry::TodoGateKind::HillClimbability, - ); + // Best-effort: a failure to persist the observation log must not + // fail the todo write itself. The cost is a missing reminder. + if let Err(err) = append_gate_observations(&ctx.session_id, &observations) { + crate::logging::warn(&format!( + "[tool:todo] failed to record gate observations session_id={} error={}", + ctx.session_id, err + )); } - // Goal-only writes, especially hill-climbability quality-gate - // retries, should render the assessment fields that changed - // instead of repeating an otherwise identical todo plan. - let concise_goal_changes = (todos == previous && !stored_goals.is_empty()) + // Assessment-only writes, especially quality-gate retries, + // should render the fields that changed instead of repeating an + // otherwise identical todo plan. + let assessment_only = todos == previous; + let concise_goal_changes = (assessment_only && !stored_goals.is_empty()) .then(|| goal_changes(&stored_goals, &goals)); + let concise_plan_change = assessment_only + .then(|| plan_change(&stored_plan, &plan)) + .flatten(); save_todos(&ctx.session_id, &todos)?; save_goals(&ctx.session_id, &goals)?; + save_plan(&ctx.session_id, &plan)?; + if let Err(err) = update_todo_review_cycle(&ctx.session_id, &previous, &todos) { + crate::logging::warn(&format!( + "[tool:todo] failed to update review cycle session_id={} error={}", + ctx.session_id, err + )); + } + record_todo_telemetry(&previous, &todos, &goals, &plan); Bus::global().publish(BusEvent::TodoUpdated(TodoEvent { session_id: ctx.session_id.clone(), todos: todos.clone(), })); - build_todo_output(todos, goals, concise_goal_changes, nudges) + build_todo_output( + todos, + plan, + goals, + concise_plan_change, + concise_goal_changes, + nudges, + ) })() } else { (|| { let todos = load_todos(&ctx.session_id)?; let goals = load_goals(&ctx.session_id).unwrap_or_default(); - build_todo_output(todos, goals, None, Vec::new()) + let plan = load_plan(&ctx.session_id).unwrap_or_default(); + record_todo_telemetry(&todos, &todos, &goals, &plan); + build_todo_output(todos, plan, goals, None, None, Vec::new()) })() }; result.map_err(|err| { @@ -526,9 +971,10 @@ mod tests { .get("properties") .and_then(|v| v.as_object()) .expect("todo schema should have properties"); - assert_eq!(props.len(), 3); + assert_eq!(props.len(), 4); assert!(props.contains_key("intent")); assert!(props.contains_key("todos")); + assert!(props.contains_key("plan")); assert!(props.contains_key("goals")); let item = props["todos"] @@ -546,10 +992,29 @@ mod tests { .expect("todo item should advertise properties"); assert!(item_props.contains_key("confidence")); assert!(item_props.contains_key("completion_confidence")); - assert!(!item_props.contains_key("hill_climbability")); + assert!(!item_props.contains_key("closed_feedback_loop")); assert_eq!( item_props["confidence"]["description"], - "Self-assessed confidence, 0-100, that this todo can be completed correctly. Reassess it as evidence accumulates while working." + "Evidence state that this todo can be completed correctly; reassess as evidence accumulates." + ); + + let plan_props = props["plan"] + .get("properties") + .and_then(|v| v.as_object()) + .expect("plan should describe properties"); + assert!(plan_props.contains_key("user_intention")); + assert!(plan_props.contains_key("understands_user_intent")); + assert!(!plan_props.contains_key("alignment_score")); + assert!(!plan_props.contains_key("user_intention_alignment")); + assert_eq!(plan_props.len(), 2); + let plan_required = props["plan"]["required"] + .as_array() + .expect("plan should advertise required fields"); + assert!(plan_required.iter().any(|value| value == "user_intention")); + assert!( + plan_required + .iter() + .any(|value| value == "understands_user_intent") ); let goal_props = props["goals"] @@ -558,13 +1023,43 @@ mod tests { .and_then(|v| v.as_object()) .expect("goals should describe item objects"); assert!(goal_props.contains_key("group")); - assert!(goal_props.contains_key("user_intention")); - assert!(goal_props.contains_key("user_intention_alignment")); - assert!(goal_props.contains_key("hill_climbability")); - assert!(goal_props.contains_key("objective")); + assert!(goal_props.contains_key("closed_feedback_loop")); assert!(goal_props.contains_key("feedback_loop")); - assert!(goal_props.contains_key("end_to_end_ownership")); - assert_eq!(goal_props.len(), 7); + assert!(goal_props.contains_key("feedback_loop_relevance")); + assert!(goal_props.contains_key("feedback_loop_coverage")); + assert!(goal_props.contains_key("feedback_loop_traceability")); + assert!(goal_props.contains_key("delivery_state")); + assert!(goal_props.contains_key("difficulty")); + assert!(goal_props.contains_key("autonomy")); + assert!(goal_props.contains_key("iteration_maturity")); + assert!(goal_props.contains_key("stopping_evidence")); + assert!(!goal_props.contains_key("end_to_end_ownership")); + // Intent lives on the plan, not per goal. + assert!(!goal_props.contains_key("user_intention")); + assert!(!goal_props.contains_key("alignment_score")); + assert!(!goal_props.contains_key("objective")); + assert_eq!(goal_props.len(), 11); + assert_eq!( + goal_props["feedback_loop_relevance"]["enum"], + json!([ + "indirect", + "synthetic", + "representative", + "acceptance_blocked", + "acceptance_aligned" + ]) + ); + let relevance_description = goal_props["feedback_loop_relevance"]["description"] + .as_str() + .expect("feedback-loop relevance should explain every state"); + for required_concept in [ + "custom harnesses", + "real public interfaces", + "external constraint", + "Substitute-only validation is never acceptance_aligned", + ] { + assert!(relevance_description.contains(required_concept)); + } let goal_required = props["goals"]["items"]["required"] .as_array() @@ -572,36 +1067,96 @@ mod tests { assert!( goal_required .iter() - .any(|value| value == "hill_climbability") + .any(|value| value == "closed_feedback_loop") ); assert!(goal_required.iter().any(|value| value == "feedback_loop")); assert!( goal_required .iter() - .any(|value| value == "user_intention_alignment") + .any(|value| value == "feedback_loop_relevance") + ); + assert!( + goal_required + .iter() + .any(|value| value == "feedback_loop_coverage") + ); + assert!( + goal_required + .iter() + .any(|value| value == "feedback_loop_traceability") + ); + + let alignment_description = plan_props["understands_user_intent"] + .get("description") + .and_then(Value::as_str) + .expect("alignment score should describe representation coverage"); + assert!(alignment_description.contains("what the user wants")); + assert!(alignment_description.contains("when guessing")); + // The detailed calibration rubric moved out of the always-on schema + // into deferred turn-finish continuation messages, which are paid only + // when the completed turn needs another quality pass. + for required_concept in [ + "requirement inventory", + "outcomes, deliverables, constraints, prohibited actions", + "integration paths, edge cases, and necessary follow-through", + "Do not ask the user", + ] { + assert!( + crate::todo::TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE + .contains(required_concept), + "intent gate message omitted {required_concept}" + ); + } + let feedback_description = goal_props["feedback_loop"] + .get("description") + .and_then(Value::as_str) + .expect("feedback loop should describe requirement-to-check coverage"); + // Case-insensitive: the description opens the sentence with + // "Requirement-to-check", so a case-sensitive match broke when the + // wording moved to the front of the string (issue #730). + let feedback_description_lower = feedback_description.to_ascii_lowercase(); + assert!( + feedback_description_lower.contains("requirement-to-check"), + "feedback_loop description omitted the requirement-to-check framing: {feedback_description}" + ); + assert!( + feedback_description_lower.contains("explicit observation or check"), + "feedback_loop description omitted per-requirement check coverage: {feedback_description}" + ); + for required_concept in [ + "reports back on each requirement", + "run tests, verify, or review count only", + "non-testable requirements", + ] { + assert!( + crate::todo::TODO_CLOSED_FEEDBACK_LOOP_CONTINUATION_MESSAGE + .contains(required_concept), + "feedback gate message omitted {required_concept}" + ); + } + assert!( + !alignment_description + .to_ascii_lowercase() + .contains("threshold") ); - let ownership_description = goal_props["end_to_end_ownership"] + let ownership_description = goal_props["delivery_state"] .get("description") .and_then(Value::as_str) - .expect("ownership should have a neutral description"); - assert!(ownership_description.contains("Use only when completing the goal.")); - assert!(ownership_description.contains("full intended user outcome")); - assert!(ownership_description.contains("necessary follow-through")); + .expect("delivery state should have a neutral description"); + assert!(ownership_description.contains("toward the user's outcome")); assert!(!ownership_description.contains("90")); - assert!(!ownership_description.contains("91")); assert!( !ownership_description .to_ascii_lowercase() .contains("threshold") ); - let hill_description = goal_props["hill_climbability"] + let loop_description = goal_props["closed_feedback_loop"] .get("description") .and_then(Value::as_str) - .expect("hill-climbability should describe the assessment neutrally"); - assert!(!hill_description.contains(&LOW_HILL_CLIMBABILITY.to_string())); - assert!(!hill_description.to_ascii_lowercase().contains("threshold")); + .expect("closed feedback loop should describe the assessment neutrally"); + assert!(!loop_description.to_ascii_lowercase().contains("threshold")); let model_visible_schema = serde_json::to_string(&schema) .expect("todo schema should serialize") @@ -619,6 +1174,18 @@ mod tests { "model-visible todo schema disclosed calibration wording: {disclosure}" ); } + for required_guidance in [ + "public interfaces", + "integration boundaries", + "edge cases", + "packaging", + "likely failure modes", + ] { + assert!( + model_visible_schema.contains(required_guidance), + "todo schema omitted generic validation guidance: {required_guidance}" + ); + } for domain_hint in [ "visual quality", "screenshot", @@ -633,8 +1200,8 @@ mod tests { } } - fn parse(input: Value) -> Result { - serde_json::from_value(normalize_todo_input(input)) + fn parse(input: Value) -> Result { + parse_todo_input(input) } #[test] @@ -646,7 +1213,10 @@ mod tests { let todos = parsed.todos.expect("todos present"); assert_eq!(todos.len(), 1); assert_eq!(todos[0].content, "a"); - assert_eq!(todos[0].confidence, Some(90)); + assert_eq!( + todos[0].confidence, + Some(crate::todo::ConfidenceState::Plausible) + ); } #[test] @@ -660,9 +1230,51 @@ mod tests { let parsed = parse(input).expect("string-coerced items should parse"); let todos = parsed.todos.expect("todos present"); assert_eq!(todos.len(), 2); - assert_eq!(todos[0].confidence, Some(85)); - assert_eq!(todos[0].completion_confidence, Some(95)); - assert_eq!(todos[1].confidence, Some(70)); + assert_eq!( + todos[0].confidence, + Some(crate::todo::ConfidenceState::Plausible) + ); + assert_eq!( + todos[0].completion_confidence, + Some(crate::todo::ConfidenceState::Plausible) + ); + assert_eq!( + todos[1].confidence, + Some(crate::todo::ConfidenceState::Plausible) + ); + } + + #[test] + fn normalizes_natural_and_case_varied_todo_statuses() { + let parsed = parse(json!({ + "todos": [ + {"content": "a", "status": "done", "priority": "high", "id": "1", "confidence": "verified"}, + {"content": "b", "status": " Finished ", "priority": "low", "id": "2", "confidence": "validated"}, + {"content": "c", "status": "Canceled", "priority": "low", "id": "3", "confidence": "plausible"} + ] + })) + .expect("status synonyms should parse"); + let statuses: Vec<_> = parsed + .todos + .expect("todos present") + .into_iter() + .map(|todo| todo.status) + .collect(); + assert_eq!(statuses, ["completed", "completed", "cancelled"]); + } + + #[test] + fn rejects_unknown_todo_statuses_with_valid_vocabulary() { + let error = parse(json!({ + "todos": [ + {"content": "a", "status": "blocked", "priority": "high", "id": "1", "confidence": "plausible"} + ] + })) + .err() + .expect("unknown status should be rejected"); + let message = error.to_string(); + assert!(message.contains("invalid todo status \"blocked\"")); + assert!(message.contains("pending, in_progress, completed, cancelled")); } #[test] @@ -674,7 +1286,10 @@ mod tests { }); let parsed = parse(input).expect("float confidence should parse"); let todos = parsed.todos.expect("todos present"); - assert_eq!(todos[0].confidence, Some(90)); + assert_eq!( + todos[0].confidence, + Some(crate::todo::ConfidenceState::Plausible) + ); assert_eq!(todos[0].completion_confidence, None); } @@ -692,75 +1307,309 @@ mod tests { ] }); let parsed = parse(input).expect("native input should parse"); - assert_eq!(parsed.todos.expect("todos present")[0].confidence, Some(80)); + assert_eq!( + parsed.todos.expect("todos present")[0].confidence, + Some(crate::todo::ConfidenceState::Plausible) + ); } #[test] - fn accepts_goals_including_string_coercion() { + fn accepts_goals_and_plan_including_string_coercion() { let input = json!({ + "plan": {"user_intention": "make repository search feel instant", "understands_user_intent": "97"}, "goals": [ - {"group": "optimize grep", "user_intention": "make repository search feel instant", "user_intention_alignment": "97", "hill_climbability": "95", "objective": "p50 under 50ms", "feedback_loop": "run the grep benchmark and compare p50"}, - {"hill_climbability": 20} + {"group": "optimize grep", "closed_feedback_loop": "95", "feedback_loop": "run the grep benchmark and compare p50"}, + {"closed_feedback_loop": 20} ] }); - let parsed = parse(input).expect("goals should parse"); - let goals = parsed.goals.expect("goals present"); - assert_eq!(goals[0].hill_climbability, Some(95)); - assert_eq!(goals[0].user_intention_alignment, Some(97)); + let parsed = parse(input).expect("goals and plan should parse"); + let plan = parsed.plan.expect("plan present"); + assert_eq!( + plan.understands_user_intent, + Some(crate::todo::IntentUnderstanding::Clear) + ); assert_eq!( - goals[0].user_intention.as_deref(), + plan.user_intention.as_deref(), Some("make repository search feel instant") ); - assert_eq!(goals[0].objective.as_deref(), Some("p50 under 50ms")); + let goals = parsed.goals.expect("goals present"); + assert_eq!( + goals[0].closed_feedback_loop, + Some(crate::todo::FeedbackLoopState::Strong) + ); assert_eq!( goals[0].feedback_loop.as_deref(), Some("run the grep benchmark and compare p50") ); // Runtime parsing remains backward-compatible with stored or older // provider payloads even though the advertised schema requires the field. - assert_eq!(goals[1].user_intention_alignment, None); assert_eq!(goals[1].feedback_loop, None); assert_eq!(goals[1].group, None); } - fn goal(group: Option<&str>, score: u8) -> TodoGoal { + #[test] + fn stringified_plan_object_is_accepted() { + let parsed = parse(json!({ + "plan": "{\"user_intention\":\"ship it\",\"understands_user_intent\":\"96\"}" + })) + .expect("stringified plan should parse"); + let plan = parsed.plan.expect("plan present"); + assert_eq!(plan.user_intention.as_deref(), Some("ship it")); + assert_eq!( + plan.understands_user_intent, + Some(crate::todo::IntentUnderstanding::Clear) + ); + } + + #[test] + fn accepts_legacy_plan_alignment_key_but_serializes_the_new_name() { + let parsed = parse(json!({ + "plan": {"user_intention_alignment": "97"} + })) + .expect("legacy alignment key should remain readable"); + let plan = parsed.plan.expect("plan present"); + assert_eq!( + plan.understands_user_intent, + Some(crate::todo::IntentUnderstanding::Clear) + ); + + let serialized = serde_json::to_value(plan).expect("plan should serialize"); + assert_eq!(serialized["understands_user_intent"], "clear"); + assert!(serialized.get("user_intention_alignment").is_none()); + + let legacy_field: TodoPlanField = serde_json::from_str("\"user_intention_alignment\"") + .expect("legacy plan-change field should deserialize"); + assert_eq!(legacy_field, TodoPlanField::UnderstandsUserIntent); + assert_eq!( + serde_json::to_string(&legacy_field).expect("plan field should serialize"), + "\"understands_user_intent\"" + ); + } + + fn goal(group: Option<&str>, state: crate::todo::FeedbackLoopState) -> TodoGoal { TodoGoal { group: group.map(str::to_string), - hill_climbability: Some(score), + closed_feedback_loop: Some(state), + feedback_loop_relevance: Some(crate::todo::FeedbackLoopRelevance::Representative), + feedback_loop_coverage: Some(crate::todo::FeedbackLoopCoverage::MainPaths), + feedback_loop_traceability: Some(crate::todo::FeedbackLoopTraceability::Complete), + ..Default::default() + } + } + + /// A plan whose intent assessment clears the private gate, so goal-level + /// tests observe only closed feedback loop behavior. + fn aligned_plan() -> TodoPlan { + TodoPlan { + user_intention: Some("understood".to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Complete), + understands_user_intent_history: vec![crate::todo::IntentUnderstanding::Complete], + } + } + + fn todo_in_group(group: Option<&str>, id: &str) -> TodoItem { + TodoItem { + content: format!("task {id}"), + status: "pending".to_string(), + priority: "medium".to_string(), + id: id.to_string(), + group: group.map(str::to_string), ..Default::default() } } + #[test] + fn todo_telemetry_derives_lifecycle_groups_and_score_summaries() { + let mut pending = todo_in_group(Some("build"), "pending"); + pending.confidence = Some(crate::todo::ConfidenceState::Plausible); + let mut removed = todo_in_group(Some("build"), "removed"); + removed.status = "in_progress".to_string(); + removed.confidence = Some(crate::todo::ConfidenceState::Plausible); + let previous = vec![pending.clone(), removed]; + + pending.status = "completed".to_string(); + pending.completion_confidence = Some(crate::todo::ConfidenceState::Validated); + let mut created = todo_in_group(Some("verify"), "created"); + created.confidence = Some(crate::todo::ConfidenceState::Plausible); + let current = vec![pending, created]; + let goals = vec![ + TodoGoal { + group: Some("build".to_string()), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Strong), + feedback_loop_relevance: Some(crate::todo::FeedbackLoopRelevance::Representative), + feedback_loop_coverage: Some(crate::todo::FeedbackLoopCoverage::MainPaths), + delivery_state: Some(crate::todo::DeliveryState::OutcomeDelivered), + ..Default::default() + }, + TodoGoal { + group: Some("verify".to_string()), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Strong), + feedback_loop_relevance: Some( + crate::todo::FeedbackLoopRelevance::AcceptanceAligned, + ), + feedback_loop_coverage: Some( + crate::todo::FeedbackLoopCoverage::EdgeAndIntegrationPaths, + ), + delivery_state: Some(crate::todo::DeliveryState::OutcomeDelivered), + ..Default::default() + }, + ]; + let plan = TodoPlan { + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), + ..Default::default() + }; + + let update = todo_telemetry_update(&previous, ¤t, &goals, &plan); + assert_eq!(update.todos_created, 1); + assert_eq!(update.todos_completed, 1); + assert_eq!(update.todos_abandoned, 1); + assert_eq!(update.current_incomplete, 1); + assert_eq!(update.list_size, 2); + assert_eq!(update.groups_completed, 1); + assert_eq!(update.groups_total, 2); + assert_eq!(update.confidence.min, Some(80)); + assert_eq!(update.confidence.mean, Some(80.0)); + assert_eq!(update.confidence.count, 2); + assert_eq!(update.completion_confidence.min, Some(96)); + assert_eq!(update.completion_confidence.count, 1); + assert_eq!(update.understands_user_intent.min, Some(80)); + assert_eq!(update.closed_feedback_loop.min, Some(88)); + assert_eq!(update.closed_feedback_loop.mean, Some(88.0)); + assert_eq!(update.feedback_loop_relevance.min, Some(75)); + assert_eq!(update.feedback_loop_relevance.count, 2); + assert_eq!(update.feedback_loop_coverage.min, Some(75)); + assert_eq!(update.feedback_loop_coverage.count, 2); + assert_eq!(update.end_to_end_ownership.min, Some(98)); + assert_eq!(update.end_to_end_ownership.mean, Some(98.0)); + } + + #[test] + fn todo_telemetry_regrouping_does_not_create_or_abandon_items() { + let mut completed = todo_in_group(Some("old"), "a"); + completed.status = "completed".to_string(); + let pending = todo_in_group(Some("old"), "b"); + let previous = vec![completed.clone(), pending.clone()]; + + completed.group = Some("done".to_string()); + let mut pending = pending; + pending.group = Some("remaining".to_string()); + let current = vec![completed, pending]; + + let update = todo_telemetry_update(&previous, ¤t, &[], &TodoPlan::default()); + assert_eq!(update.todos_created, 0); + assert_eq!(update.todos_completed, 0); + assert_eq!(update.todos_abandoned, 0); + assert_eq!(update.groups_completed, 1); + assert_eq!(update.groups_total, 2); + } + + #[test] + fn todo_telemetry_zero_state_is_all_zero_and_has_no_scores() { + let update = todo_telemetry_update(&[], &[], &[], &TodoPlan::default()); + assert_eq!(update, crate::telemetry::TodoTelemetryUpdate::default()); + } + + /// Issue #695: after the agent moves to a new task and replaces the todo + /// list, goals from the finished task must not keep showing in the panel. + #[test] + fn prune_orphaned_goals_drops_goals_without_live_todos() { + let goals = vec![ + goal(Some("old task"), crate::todo::FeedbackLoopState::Weak), + goal(Some("new task"), crate::todo::FeedbackLoopState::Strong), + ]; + let todos = vec![todo_in_group(Some("new task"), "1")]; + + let pruned = prune_orphaned_goals(goals, &todos); + + assert_eq!(pruned.len(), 1); + assert_eq!(pruned[0].group.as_deref(), Some("new task")); + } + + #[test] + fn prune_orphaned_goals_keeps_ungrouped_goal_for_flat_list() { + let goals = vec![goal(None, crate::todo::FeedbackLoopState::Usable)]; + let todos = vec![todo_in_group(None, "1")]; + + assert_eq!(prune_orphaned_goals(goals, &todos).len(), 1); + } + + #[test] + fn prune_orphaned_goals_keeps_everything_when_todo_list_is_empty() { + // A goals-only write with no stored todos must not lose assessments. + let goals = vec![ + goal(Some("a"), crate::todo::FeedbackLoopState::Absent), + goal(None, crate::todo::FeedbackLoopState::Weak), + ]; + assert_eq!(prune_orphaned_goals(goals, &[]).len(), 2); + } + #[test] fn merge_goals_retains_unmentioned_goals() { - let stored = vec![goal(Some("a"), 20), goal(Some("b"), 90)]; + let stored = vec![ + goal(Some("a"), crate::todo::FeedbackLoopState::Weak), + goal(Some("b"), crate::todo::FeedbackLoopState::Strong), + ]; // Rewrite goal 'a', leave 'b' alone. - let merged = merge_goals(&stored, Some(vec![goal(Some(" a "), 30)])); + let merged = merge_goals( + &stored, + Some(vec![goal( + Some(" a "), + crate::todo::FeedbackLoopState::Weak, + )]), + ); assert_eq!(merged.len(), 2); assert_eq!(merged[0].group.as_deref(), Some("a")); - assert_eq!(merged[0].hill_climbability, Some(30)); + assert_eq!( + merged[0].closed_feedback_loop, + Some(crate::todo::FeedbackLoopState::Weak) + ); assert_eq!(merged[1].group.as_deref(), Some("b")); // No incoming goals: stored goals unchanged. assert_eq!(merge_goals(&stored, None).len(), 2); } #[test] - fn merge_goals_retains_user_intention_when_update_omits_it() { - let mut stored_goal = goal(Some("a"), 20); - stored_goal.user_intention = Some("make search feel instant".to_string()); - stored_goal.user_intention_alignment = Some(60); - let stored = vec![stored_goal]; - - let mut updated_goal = goal(Some("a"), 90); - updated_goal.user_intention_alignment = Some(95); - let merged = merge_goals(&stored, Some(vec![updated_goal])); + fn merge_plan_retains_stored_intent_when_update_omits_fields() { + let stored = TodoPlan { + user_intention: Some("make search feel instant".to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), + understands_user_intent_history: vec![crate::todo::IntentUnderstanding::Partial], + }; - assert_eq!(merged[0].hill_climbability, Some(90)); - assert_eq!(merged[0].user_intention_alignment, Some(95)); + let merged = merge_plan( + &stored, + Some(TodoPlan { + user_intention: None, + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), + ..Default::default() + }), + ); assert_eq!( - merged[0].user_intention.as_deref(), + merged.user_intention.as_deref(), Some("make search feel instant") ); + assert_eq!( + merged.understands_user_intent, + Some(crate::todo::IntentUnderstanding::Partial) + ); + + // An omitted plan leaves the stored assessment untouched. + assert_eq!(merge_plan(&stored, None), stored); + } + + #[test] + fn plan_change_reports_only_updated_intent_fields() { + let before = aligned_plan(); + let after = TodoPlan { + user_intention: Some("understood better".to_string()), + ..before.clone() + }; + + let change = plan_change(&before, &after).expect("intent change should be reported"); + assert_eq!(change.fields, vec![TodoPlanField::UserIntention]); + assert_eq!(change.before.as_ref(), Some(&before)); + assert_eq!(change.after.as_ref(), Some(&after)); + assert!(plan_change(&before, &before).is_none()); } fn open_todo(group: Option<&str>) -> TodoItem { @@ -777,39 +1626,402 @@ mod tests { #[test] fn ownership_gate_output_preserves_the_saved_todo_card() { let todos = vec![open_todo(Some("ship"))]; - let goals = vec![goal(Some("ship"), 96)]; + let plan = aligned_plan(); + let goals = vec![goal(Some("ship"), crate::todo::FeedbackLoopState::Closed)]; let output = build_todo_output( todos.clone(), + plan.clone(), goals.clone(), None, - [TODO_OWNERSHIP_CONTINUATION_MESSAGE.to_string()], + None, + [crate::todo::TODO_OWNERSHIP_CONTINUATION_MESSAGE.to_string()], ) .expect("ownership gate should produce a structured todo result"); assert_eq!(output.title.as_deref(), Some("1 todos")); assert!(output.output.starts_with('[')); assert!(output.output.contains("\"status\": \"in_progress\"")); - assert!(output.output.contains(TODO_OWNERSHIP_CONTINUATION_MESSAGE)); + assert!( + output + .output + .contains(crate::todo::TODO_OWNERSHIP_CONTINUATION_MESSAGE) + ); assert_eq!( output.metadata, - Some(json!({"todos": todos, "goals": goals})) + Some(json!({"todos": todos, "plan": plan, "goals": goals})) + ); + } + + fn test_ctx(session_id: &str) -> ToolContext { + ToolContext { + session_id: session_id.to_string(), + message_id: session_id.to_string(), + tool_call_id: "call".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } + } + + /// Issue #695, the visibly-stale case. The todos panel renders the + /// ungrouped goal unconditionally (not only as a group header), so an + /// ungrouped goal left over from a previous flat todo list is exactly what + /// the reporter saw frozen in the panel. + #[tokio::test] + async fn an_ungrouped_goal_does_not_survive_into_a_grouped_next_task() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let dir = tempfile::TempDir::new().expect("tempdir"); + crate::env::set_var("JCODE_HOME", dir.path()); + let session = "issue-695-ungrouped"; + let tool = TodoTool::new(); + + // Task one: a flat (ungrouped) list, so its goal is the ungrouped one. + tool.execute( + json!({ + "todos": [{ + "content": "flat task one", "status": "in_progress", + "priority": "high", "id": "t1", "confidence": 70, + }], + "plan": {"user_intention": "do task one", "understands_user_intent": 97}, + "goals": [{"closed_feedback_loop": 97, "feedback_loop": "ran the checks"}], + }), + test_ctx(session), + ) + .await + .expect("first write"); + let stored = load_goals(session).expect("goals"); + assert_eq!(stored.len(), 1); + assert!( + stored[0].group.is_none(), + "task one goal is the ungrouped one" + ); + + // Task two: a grouped list. The ungrouped goal now describes nothing. + tool.execute( + json!({ + "todos": [{ + "content": "task two", "status": "in_progress", "priority": "high", + "id": "t2", "group": "second task", "confidence": 70, + }], + "goals": [{"group": "second task", "closed_feedback_loop": 80, + "feedback_loop": "run the new checks"}], + }), + test_ctx(session), + ) + .await + .expect("second write"); + + let goals = load_goals(session).expect("goals"); + assert!( + !goals.iter().any(|goal| goal.group.is_none()), + "the stale ungrouped goal must not stay in the panel: {goals:?}" + ); + assert_eq!(goals.len(), 1); + assert_eq!(goals[0].group.as_deref(), Some("second task")); + + if let Some(home) = previous_home { + crate::env::set_var("JCODE_HOME", home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + /// Issue #695, end to end through the real tool: finish task one, then + /// start task two. What the todos panel renders (stored todos + goals) must + /// describe task two only, with no leftovers from task one. + #[tokio::test] + async fn moving_to_a_new_task_replaces_what_the_todos_panel_shows() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let dir = tempfile::TempDir::new().expect("tempdir"); + crate::env::set_var("JCODE_HOME", dir.path()); + let session = "issue-695-new-task"; + let tool = TodoTool::new(); + + // Task one, completed. `end_to_end_ownership` clears the completion + // gate so the write is actually stored. + tool.execute( + json!({ + "todos": [{ + "content": "task one", + "status": "completed", + "priority": "high", + "id": "t1", + "group": "first task", + "confidence": 90, + "completion_confidence": 97, + }], + "plan": {"user_intention": "do task one", "understands_user_intent": 97}, + "goals": [{ + "group": "first task", + "closed_feedback_loop": 97, + "end_to_end_ownership": 97, + "feedback_loop": "ran the checks", + }], + }), + test_ctx(session), + ) + .await + .expect("first task write should succeed"); + assert_eq!(load_goals(session).expect("goals").len(), 1); + + // Task two: a fresh todo list in a new group. + tool.execute( + json!({ + "todos": [{ + "content": "task two", + "status": "in_progress", + "priority": "high", + "id": "t2", + "group": "second task", + "confidence": 70, + }], + "goals": [{ + "group": "second task", + "closed_feedback_loop": 80, + "feedback_loop": "run the new checks", + }], + }), + test_ctx(session), + ) + .await + .expect("second task write should succeed"); + + let todos = load_todos(session).expect("todos"); + assert_eq!(todos.len(), 1, "panel must show only the current task"); + assert_eq!(todos[0].group.as_deref(), Some("second task")); + + let goals = load_goals(session).expect("goals"); + assert_eq!( + goals.len(), + 1, + "the finished task's goal must not linger in the panel: {goals:?}" + ); + assert_eq!(goals[0].group.as_deref(), Some("second task")); + + if let Some(home) = previous_home { + crate::env::set_var("JCODE_HOME", home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + /// End-to-end through the real tool, which is what the model actually sees. + /// A first plan write with honestly-moderate scores must come back clean: + /// this is the exact case that previously returned two nudges and spent the + /// turn re-justifying the plan instead of doing the work. + #[tokio::test] + async fn a_moderate_first_write_returns_no_continuation_and_records_instead() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let dir = tempfile::TempDir::new().expect("tempdir"); + crate::env::set_var("JCODE_HOME", dir.path()); + let session = "gate-deferral-execute"; + + let output = TodoTool::new() + .execute( + json!({ + "todos": [{ + "content": "make utf16 transcode faster", + "status": "in_progress", + "priority": "high", + "id": "opt", + "group": "speed", + "confidence": 70, + }], + "plan": { + "user_intention": "beat the baseline", + "understands_user_intent": 82, + }, + "goals": [{ + "group": "speed", + "closed_feedback_loop": 80, + "feedback_loop": "run ./grade and read the score", + "feedback_loop_relevance": "indirect", + "feedback_loop_coverage": "narrow", + }], + }), + test_ctx(session), + ) + .await + .expect("todo write should succeed"); + + assert!( + !output + .output + .contains(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE), + "a moderate first write must not be interrupted: {}", + output.output + ); + assert!( + !output + .output + .to_ascii_lowercase() + .contains("not high enough"), + "no gate text should reach the model mid-turn: {}", + output.output + ); + + // The points were recorded for the turn-end digest instead. + let observations = crate::todo::load_gate_observations(session).expect("observations"); + assert_eq!(observations.len(), 5); + assert!( + observations.iter().any(|observation| { + observation.kind == GateObservationKind::FeedbackLoopRelevance + }) + ); + assert!( + observations.iter().any(|observation| { + observation.kind == GateObservationKind::FeedbackLoopCoverage + }) + ); + assert!(observations.iter().any(|observation| { + observation.kind == GateObservationKind::FeedbackLoopTraceability + })); + + // Histories are accumulating, which is what the digest reasons over. + let plan = load_plan(session).expect("plan"); + assert_eq!( + plan.understands_user_intent_history, + vec![crate::todo::IntentUnderstanding::Partial] + ); + let goals = load_goals(session).expect("goals"); + assert_eq!( + goals[0].closed_feedback_loop_history, + vec![crate::todo::FeedbackLoopState::Strong] + ); + assert_eq!( + goals[0].feedback_loop_relevance_history, + vec![crate::todo::FeedbackLoopRelevance::Indirect] + ); + assert_eq!( + goals[0].feedback_loop_coverage_history, + vec![crate::todo::FeedbackLoopCoverage::Narrow] + ); + + // Second write at a higher score: still silent, history grows, and the + // digest now has the trajectory available. + let output = TodoTool::new() + .execute( + json!({"plan": {"understands_user_intent": 97}}), + test_ctx(session), + ) + .await + .expect("second write should succeed"); + assert!( + !output + .output + .to_ascii_lowercase() + .contains("not high enough") + ); + let plan = load_plan(session).expect("plan"); + assert_eq!( + plan.understands_user_intent_history, + vec![ + crate::todo::IntentUnderstanding::Partial, + crate::todo::IntentUnderstanding::Clear + ] + ); + + // The climb does not erase the point. The turn began without solid + // understanding, so the work done before it settled still needs a + // re-check; the wording just reflects that it settled late. + let observations = crate::todo::load_gate_observations(session).expect("observations"); + let goals = load_goals(session).expect("goals"); + let digest = crate::todo::build_gate_digest(&observations, &plan, &goals) + .expect("both recorded points should be surfaced"); + assert!(digest.contains("started this work without understanding")); + assert!(digest.contains("feedback loop")); + + match previous_home { + Some(value) => crate::env::set_var("JCODE_HOME", value), + None => crate::env::remove_var("JCODE_HOME"), + } + } + + #[tokio::test] + async fn low_ownership_completion_is_saved_without_mid_write_rejection() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let dir = tempfile::TempDir::new().expect("tempdir"); + crate::env::set_var("JCODE_HOME", dir.path()); + let session = "ownership-save-before-turn-gate"; + + let output = TodoTool::new() + .execute( + json!({ + "todos": [{ + "content": "ship the complete workflow", + "status": "completed", + "priority": "high", + "id": "ship", + "group": "release", + "confidence": 100, + "completion_confidence": 100, + }], + "goals": [{ + "group": "release", + "closed_feedback_loop": 100, + "feedback_loop": "run the end-to-end release check", + "feedback_loop_relevance": "indirect", + "feedback_loop_coverage": "narrow", + "end_to_end_ownership": 95, + }], + }), + test_ctx(session), + ) + .await + .expect("low ownership must not reject the todo write"); + + let saved = load_todos(session).expect("completed todo should be persisted"); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].status, "completed"); + let saved_goals = load_goals(session).expect("goal should be persisted"); + let saved_goal = &saved_goals[0]; + assert_eq!( + saved_goal.delivery_state, + Some(crate::todo::DeliveryState::WorkflowValidated) + ); + assert_eq!( + saved_goal.feedback_loop_relevance, + Some(crate::todo::FeedbackLoopRelevance::Indirect) ); + assert_eq!( + saved_goal.feedback_loop_coverage, + Some(crate::todo::FeedbackLoopCoverage::Narrow) + ); + assert!( + !output + .output + .contains(crate::todo::TODO_OWNERSHIP_CONTINUATION_MESSAGE), + "ownership is enforced after the turn, not by rejecting the write: {}", + output.output + ); + + match previous_home { + Some(value) => crate::env::set_var("JCODE_HOME", value), + None => crate::env::remove_var("JCODE_HOME"), + } } #[test] fn goal_changes_include_only_updated_quality_fields() { let before = TodoGoal { group: Some("search".to_string()), - user_intention: Some("make search feel instant".to_string()), - user_intention_alignment: Some(99), - hill_climbability: Some(90), - objective: Some("Keep p50 below 50ms".to_string()), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Strong), feedback_loop: Some("Run one benchmark".to_string()), - end_to_end_ownership: None, + feedback_loop_relevance: Some(crate::todo::FeedbackLoopRelevance::Indirect), + feedback_loop_coverage: Some(crate::todo::FeedbackLoopCoverage::Narrow), + delivery_state: None, + ..Default::default() }; let after = TodoGoal { - hill_climbability: Some(98), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Closed), feedback_loop: Some("Run five benchmarks and compare p50".to_string()), + feedback_loop_relevance: Some(crate::todo::FeedbackLoopRelevance::Representative), + feedback_loop_coverage: Some(crate::todo::FeedbackLoopCoverage::MainPaths), ..before.clone() }; @@ -820,42 +2032,368 @@ mod tests { assert_eq!(changes[0].after.as_ref(), Some(&after)); assert_eq!( changes[0].fields, - vec![TodoGoalField::HillClimbability, TodoGoalField::FeedbackLoop,] + vec![ + TodoGoalField::ClosedFeedbackLoop, + TodoGoalField::FeedbackLoop, + TodoGoalField::FeedbackLoopRelevance, + TodoGoalField::FeedbackLoopCoverage, + ] ); } + /// The core behavior change: a low score records an observation for the + /// turn-end digest instead of interrupting the write, and repeated writes + /// do not re-interrupt. #[test] - fn reframe_nudge_recurs_for_every_low_open_goal_write() { + fn low_open_goal_records_an_observation_without_interrupting() { let todos = vec![open_todo(Some("design"))]; - let goals = vec![goal(Some("design"), 95), goal(Some("perf"), 96)]; - let nudges = take_reframe_nudges(&goals, &todos); - assert_eq!(nudges.len(), 1); - assert_eq!(nudges[0], TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE); - assert!(!nudges[0].contains("95")); - assert!(nudges[0].contains("hill-climbability")); + let plan = aligned_plan(); + let goals = vec![ + goal(Some("design"), crate::todo::FeedbackLoopState::Strong), + goal(Some("perf"), crate::todo::FeedbackLoopState::Closed), + ]; + let (observations, nudges) = record_reframe_observations(&plan, &goals, &todos, &[]); + + assert!( + nudges.is_empty(), + "a low closed feedback loop score must not interrupt the write" + ); + assert_eq!( + observations, + vec![GateObservation { + kind: GateObservationKind::ClosedFeedbackLoop, + group: Some("design".to_string()), + state: Some("strong".to_string()), + }] + ); + // A subsequent write still records, still does not interrupt. + let (again, nudges) = record_reframe_observations(&plan, &goals, &todos, &[]); + assert_eq!(again, observations); + assert!(nudges.is_empty()); + } + + #[test] + fn low_intent_is_plan_level_and_independent_of_goals() { + let todos = vec![open_todo(Some("coverage"))]; + let plan = TodoPlan { + user_intention: Some("partially understood".to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), + understands_user_intent_history: vec![crate::todo::IntentUnderstanding::Partial], + }; + let (observations, nudges) = record_reframe_observations( + &plan, + &[goal( + Some("coverage"), + crate::todo::FeedbackLoopState::Closed, + )], + &todos, + &[], + ); + + assert_eq!( + observations, + vec![GateObservation { + kind: GateObservationKind::IntentUnderstanding, + group: None, + state: Some("partial".to_string()), + }] + ); + // 95 is below threshold but nowhere near severe, so exploration is + // given the chance to resolve it rather than being interrupted. + assert!(nudges.is_empty()); + } + + /// The single retained immediate nudge: the agent's first plan write says it + /// does not understand the task at all, and a whole turn of wrong work + /// cannot be undone at turn end. + #[test] + fn severely_low_first_intent_still_nudges_immediately() { + let todos = vec![open_todo(None)]; + let plan = TodoPlan { + user_intention: Some("guessing".to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Uncertain), + understands_user_intent_history: vec![crate::todo::IntentUnderstanding::Uncertain], + }; + let (_, nudges) = record_reframe_observations(&plan, &[], &todos, &[]); + assert_eq!(nudges, vec![TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE]); + assert!(!nudges[0].contains("40")); assert!(!nudges[0].to_ascii_lowercase().contains("threshold")); - assert!(!nudges[0].to_ascii_lowercase().contains("gate")); - // A subsequent write receives the same generic guidance while the - // private condition remains applicable. - assert_eq!(take_reframe_nudges(&goals, &todos).len(), 1); + + // Once the plan has a history, the same severe score is deferred to the + // digest rather than nudged again on every write. + let later = TodoPlan { + understands_user_intent_history: vec![ + crate::todo::IntentUnderstanding::Uncertain, + crate::todo::IntentUnderstanding::Uncertain, + ], + ..plan + }; + let (_, nudges) = record_reframe_observations(&later, &[], &todos, &[]); + assert!(nudges.is_empty()); + } + + /// Work that was already complete before this write is grandfathered: the + /// turn cannot go back and improve a loop over work it did not do. + #[test] + fn work_already_closed_before_this_write_records_nothing() { + let mut done = open_todo(None); + done.status = "completed".to_string(); + let already = vec![done.clone()]; + let (observations, nudges) = record_reframe_observations( + &TodoPlan::default(), + &[goal(None, crate::todo::FeedbackLoopState::Absent)], + &already, + &already, + ); + assert!(observations.is_empty()); + assert!(nudges.is_empty()); + } + + /// A group created and finished in one write must still be observed. This is + /// where a weak feedback loop hides best: declare it done in one step and no + /// "still open" check ever sees it. + #[test] + fn a_group_closed_by_this_write_is_still_observed() { + let mut done = open_todo(Some("one shot")); + done.status = "completed".to_string(); + let (observations, nudges) = record_reframe_observations( + &aligned_plan(), + &[goal(Some("one shot"), crate::todo::FeedbackLoopState::Weak)], + &[done], + &[], + ); + assert!(nudges.is_empty()); + assert_eq!( + observations, + vec![GateObservation { + kind: GateObservationKind::ClosedFeedbackLoop, + group: Some("one shot".to_string()), + state: Some("weak".to_string()), + }] + ); + } + + #[test] + fn both_weak_links_are_recorded_independently() { + let todos = vec![open_todo(Some("coverage"))]; + let plan = TodoPlan { + user_intention: Some("partially understood".to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), + understands_user_intent_history: vec![crate::todo::IntentUnderstanding::Partial], + }; + let (observations, _) = record_reframe_observations( + &plan, + &[goal( + Some("coverage"), + crate::todo::FeedbackLoopState::Strong, + )], + &todos, + &[], + ); + assert_eq!( + observations + .iter() + .map(|observation| observation.kind) + .collect::>(), + vec![ + GateObservationKind::IntentUnderstanding, + GateObservationKind::ClosedFeedbackLoop, + ] + ); } #[test] - fn reframe_nudge_skips_closed_goals() { - // Low goal whose todos are all completed: nothing to reframe. + fn missing_quality_scores_still_record_observations() { + let todos = vec![open_todo(Some("coverage"))]; + let mut goal = goal(Some("coverage"), crate::todo::FeedbackLoopState::Closed); + goal.closed_feedback_loop = None; + + let (observations, _) = + record_reframe_observations(&TodoPlan::default(), &[goal], &todos, &[]); + assert_eq!( + observations + .iter() + .map(|observation| observation.kind) + .collect::>(), + vec![ + GateObservationKind::IntentUnderstanding, + GateObservationKind::ClosedFeedbackLoop, + ] + ); + } + + /// Groups already complete before this write are grandfathered, so a + /// long-lived session does not re-flag work from previous turns. + #[test] + fn observations_skip_goals_closed_in_an_earlier_write() { let mut done = open_todo(Some("legacy")); done.status = "completed".to_string(); - let goals = vec![goal(Some("legacy"), 10)]; - assert!(take_reframe_nudges(&goals, &[done]).is_empty()); + let already = vec![done]; + let goals = vec![goal(Some("legacy"), crate::todo::FeedbackLoopState::Absent)]; + let (observations, _) = + record_reframe_observations(&aligned_plan(), &goals, &already, &already); + assert!(observations.is_empty()); } #[test] - fn reframe_nudge_covers_ungrouped_implicit_goal() { + fn observations_cover_the_ungrouped_implicit_goal() { let todos = vec![open_todo(None)]; - let goals = vec![goal(None, 15)]; - let nudges = take_reframe_nudges(&goals, &todos); - assert_eq!(nudges.len(), 1); - assert_eq!(nudges[0], TODO_HILL_CLIMBABILITY_CONTINUATION_MESSAGE); + let goals = vec![goal(None, crate::todo::FeedbackLoopState::Absent)]; + let (observations, _) = record_reframe_observations(&aligned_plan(), &goals, &todos, &[]); + assert_eq!( + observations, + vec![GateObservation { + kind: GateObservationKind::ClosedFeedbackLoop, + group: None, + state: Some("absent".to_string()), + }] + ); + } + + /// Tool-owned histories are the substrate the turn-end digest reasons over, + /// so a model-supplied trail must not be able to fabricate a climb. + #[test] + fn plan_and_goal_score_histories_are_tool_maintained() { + let stored = TodoPlan { + user_intention: Some("ship it".to_string()), + understands_user_intent: Some(crate::todo::IntentUnderstanding::Partial), + understands_user_intent_history: vec![crate::todo::IntentUnderstanding::Partial], + }; + let merged = merge_plan( + &stored, + Some(TodoPlan { + understands_user_intent: Some(crate::todo::IntentUnderstanding::Clear), + // Forged trail: discarded in favor of the stored one. + understands_user_intent_history: vec![ + crate::todo::IntentUnderstanding::Uncertain, + crate::todo::IntentUnderstanding::Uncertain, + crate::todo::IntentUnderstanding::Uncertain, + ], + ..Default::default() + }), + ); + assert_eq!( + merged.understands_user_intent_history, + vec![ + crate::todo::IntentUnderstanding::Partial, + crate::todo::IntentUnderstanding::Clear + ] + ); + assert_eq!(merged.user_intention.as_deref(), Some("ship it")); + + // Re-sending the same state does not manufacture an extra step. + let merged = merge_plan( + &merged, + Some(TodoPlan { + understands_user_intent: Some(crate::todo::IntentUnderstanding::Clear), + ..Default::default() + }), + ); + assert_eq!( + merged.understands_user_intent_history, + vec![ + crate::todo::IntentUnderstanding::Partial, + crate::todo::IntentUnderstanding::Clear + ] + ); + + let stored_goals = merge_goals( + &[], + Some(vec![TodoGoal { + group: Some("perf".to_string()), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Usable), + feedback_loop_relevance: Some(crate::todo::FeedbackLoopRelevance::Indirect), + feedback_loop_coverage: Some(crate::todo::FeedbackLoopCoverage::Narrow), + ..Default::default() + }]), + ); + assert_eq!( + stored_goals[0].closed_feedback_loop_history, + vec![crate::todo::FeedbackLoopState::Usable] + ); + let merged_goals = merge_goals( + &stored_goals, + Some(vec![TodoGoal { + group: Some("perf".to_string()), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Strong), + feedback_loop_relevance: Some( + crate::todo::FeedbackLoopRelevance::AcceptanceAligned, + ), + feedback_loop_relevance_history: vec![ + crate::todo::FeedbackLoopRelevance::AcceptanceAligned, + ], + feedback_loop_coverage: Some( + crate::todo::FeedbackLoopCoverage::EdgeAndIntegrationPaths, + ), + feedback_loop_coverage_history: vec![ + crate::todo::FeedbackLoopCoverage::EdgeAndIntegrationPaths, + ], + ..Default::default() + }]), + ); + assert_eq!( + merged_goals[0].closed_feedback_loop_history, + vec![ + crate::todo::FeedbackLoopState::Usable, + crate::todo::FeedbackLoopState::Strong + ] + ); + assert_eq!( + merged_goals[0].feedback_loop_relevance_history, + vec![ + crate::todo::FeedbackLoopRelevance::Indirect, + crate::todo::FeedbackLoopRelevance::AcceptanceAligned, + ] + ); + assert_eq!( + merged_goals[0].feedback_loop_coverage_history, + vec![ + crate::todo::FeedbackLoopCoverage::Narrow, + crate::todo::FeedbackLoopCoverage::EdgeAndIntegrationPaths, + ] + ); + } + + /// A write that revises one assessment must not erase the others, or the + /// digest would read a stale `None` and re-raise a resolved point. + #[test] + fn omitted_goal_fields_inherit_the_stored_assessment() { + let stored = merge_goals( + &[], + Some(vec![TodoGoal { + group: Some("perf".to_string()), + closed_feedback_loop: Some(crate::todo::FeedbackLoopState::Closed), + feedback_loop: Some("cargo bench".to_string()), + feedback_loop_relevance: Some(crate::todo::FeedbackLoopRelevance::Representative), + feedback_loop_coverage: Some(crate::todo::FeedbackLoopCoverage::MainPaths), + delivery_state: Some(crate::todo::DeliveryState::OutcomeDelivered), + ..Default::default() + }]), + ); + let merged = merge_goals( + &stored, + Some(vec![TodoGoal { + group: Some("perf".to_string()), + ..Default::default() + }]), + ); + assert_eq!( + merged[0].closed_feedback_loop, + Some(crate::todo::FeedbackLoopState::Closed) + ); + assert_eq!(merged[0].feedback_loop.as_deref(), Some("cargo bench")); + assert_eq!( + merged[0].feedback_loop_relevance, + Some(crate::todo::FeedbackLoopRelevance::Representative) + ); + assert_eq!( + merged[0].feedback_loop_coverage, + Some(crate::todo::FeedbackLoopCoverage::MainPaths) + ); + assert_eq!( + merged[0].delivery_state, + Some(crate::todo::DeliveryState::OutcomeDelivered) + ); } #[test] @@ -863,7 +2401,45 @@ mod tests { assert!(parse(json!({"todos": "not json at all"})).is_err()); } - fn history_todo(id: &str, confidence: Option, history: Vec) -> TodoItem { + /// Sessions and model calls written before the rename carry + /// `hill_climbability`. Those must keep loading, or resuming an old session + /// silently drops its goal assessments and re-raises resolved gate points. + #[test] + fn pre_rename_hill_climbability_keys_still_load() { + let goal: crate::todo::TodoGoal = serde_json::from_value(json!({ + "group": "optimize grep", + "hill_climbability": 91, + "hill_climbability_history": [70, 91], + "feedback_loop": "cargo bench grep" + })) + .expect("the pre-rename key must still deserialize"); + assert_eq!( + goal.closed_feedback_loop, + Some(crate::todo::FeedbackLoopState::Strong) + ); + assert_eq!( + goal.closed_feedback_loop_history, + vec![ + crate::todo::FeedbackLoopState::Usable, + crate::todo::FeedbackLoopState::Strong + ] + ); + + let goals = parse(json!({ + "goals": [{"group": "optimize grep", "hill_climbability": "88", "feedback_loop": "bench"}] + })) + .expect("a pre-rename tool call must still parse") + .goals + .expect("goals should be present"); + assert_eq!( + goals[0].closed_feedback_loop, + Some(crate::todo::FeedbackLoopState::Strong) + ); + } + + use crate::todo::ConfidenceState as CS; + + fn history_todo(id: &str, confidence: Option, history: Vec) -> TodoItem { TodoItem { id: id.to_string(), content: format!("todo {id}"), @@ -877,59 +2453,75 @@ mod tests { #[test] fn confidence_history_appends_changes_and_skips_repeats() { - let previous = vec![history_todo("1", Some(75), vec![75])]; + let previous = vec![history_todo("1", Some(CS::Plausible), vec![CS::Plausible])]; // Same confidence again: no new entry. - let mut incoming = vec![history_todo("1", Some(75), Vec::new())]; + let mut incoming = vec![history_todo("1", Some(CS::Plausible), Vec::new())]; merge_confidence_history(&previous, &mut incoming); - assert_eq!(incoming[0].confidence_history, vec![75]); + assert_eq!(incoming[0].confidence_history, vec![CS::Plausible]); // Raised confidence: appended. - let mut incoming = vec![history_todo("1", Some(90), Vec::new())]; + let mut incoming = vec![history_todo("1", Some(CS::Validated), Vec::new())]; merge_confidence_history(&previous, &mut incoming); - assert_eq!(incoming[0].confidence_history, vec![75, 90]); + assert_eq!( + incoming[0].confidence_history, + vec![CS::Plausible, CS::Validated] + ); } #[test] fn confidence_history_records_completion_confidence() { - let previous = vec![history_todo("1", Some(75), vec![75])]; - let mut done = history_todo("1", Some(100), Vec::new()); + let previous = vec![history_todo("1", Some(CS::Plausible), vec![CS::Plausible])]; + let mut done = history_todo("1", Some(CS::Verified), Vec::new()); done.status = "completed".to_string(); - done.completion_confidence = Some(100); + done.completion_confidence = Some(CS::Verified); let mut incoming = vec![done]; merge_confidence_history(&previous, &mut incoming); // 75 (planning) -> 100 (final bulk stamp): the spike stays visible. - assert_eq!(incoming[0].confidence_history, vec![75, 100]); + assert_eq!( + incoming[0].confidence_history, + vec![CS::Plausible, CS::Verified] + ); } #[test] fn completion_write_contributes_only_one_final_confidence_observation() { - let previous = vec![history_todo("1", Some(70), vec![70])]; - let mut done = history_todo("1", Some(90), Vec::new()); + let previous = vec![history_todo("1", Some(CS::Plausible), vec![CS::Plausible])]; + let mut done = history_todo("1", Some(CS::Plausible), Vec::new()); done.status = "completed".to_string(); - done.completion_confidence = Some(100); + done.completion_confidence = Some(CS::Verified); let mut incoming = vec![done]; merge_confidence_history(&previous, &mut incoming); - assert_eq!(incoming[0].confidence_history, vec![70, 100]); + assert_eq!( + incoming[0].confidence_history, + vec![CS::Plausible, CS::Verified] + ); } #[test] fn confidence_history_seeds_legacy_todos_before_completion() { - let previous = vec![history_todo("1", Some(70), Vec::new())]; - let mut done = history_todo("1", Some(90), Vec::new()); + let previous = vec![history_todo("1", Some(CS::Plausible), Vec::new())]; + let mut done = history_todo("1", Some(CS::Plausible), Vec::new()); done.status = "completed".to_string(); - done.completion_confidence = Some(100); + done.completion_confidence = Some(CS::Verified); let mut incoming = vec![done]; merge_confidence_history(&previous, &mut incoming); - assert_eq!(incoming[0].confidence_history, vec![70, 100]); + assert_eq!( + incoming[0].confidence_history, + vec![CS::Plausible, CS::Verified] + ); } #[test] fn confidence_history_ignores_model_supplied_history_for_new_todos() { - let mut incoming = vec![history_todo("9", Some(80), vec![1, 2, 3])]; + let mut incoming = vec![history_todo( + "9", + Some(CS::Plausible), + vec![CS::Speculative, CS::Verified], + )]; merge_confidence_history(&[], &mut incoming); - assert_eq!(incoming[0].confidence_history, vec![80]); + assert_eq!(incoming[0].confidence_history, vec![CS::Plausible]); } } diff --git a/crates/jcode-app-core/src/tool/webfetch.rs b/crates/jcode-app-core/src/tool/webfetch.rs index 86ea085344..fb57592fc0 100644 --- a/crates/jcode-app-core/src/tool/webfetch.rs +++ b/crates/jcode-app-core/src/tool/webfetch.rs @@ -7,6 +7,13 @@ use serde_json::{Value, json}; use std::time::Duration; const MAX_SIZE: usize = 5 * 1024 * 1024; // 5MB +/// Cap on the text handed back to the model. Full pages routinely exceed 150 KB +/// (~40k tokens) which is rarely worth the context budget. +const MAX_OUTPUT_CHARS: usize = 40_000; +/// Links whose target exceeds this length are rendered as their anchor text +/// only. Long URLs are typically encoded payloads (pre-filled editors, tracking +/// parameters, data URIs) whose cost far exceeds their navigational value. +const MAX_URL_CHARS: usize = 300; const DEFAULT_TIMEOUT: u64 = 30; const MAX_TIMEOUT: u64 = 120; @@ -151,15 +158,43 @@ impl Tool for WebFetchTool { } }; + let full_len = output.len(); + let (output, output_truncated) = truncate_output(output); + + let note = if output_truncated { + format!( + "\n\n(output truncated to {MAX_OUTPUT_CHARS} of {full_len} chars; \ + fetch a more specific URL or anchor for the rest)" + ) + } else { + String::new() + }; + Ok(ToolOutput::new(format!( - "Fetched {} ({} bytes)\n\n{}", - params.url, - output.len(), - output + "Fetched {} ({} bytes)\n\n{}{}", + params.url, full_len, output, note ))) } } +/// Truncate at a char boundary, preferring to cut at the last newline so the tail +/// is not a half-formed line. +fn truncate_output(output: String) -> (String, bool) { + if output.len() <= MAX_OUTPUT_CHARS { + return (output, false); + } + let mut cut = MAX_OUTPUT_CHARS; + while cut > 0 && !output.is_char_boundary(cut) { + cut -= 1; + } + let slice = &output[..cut]; + let cut = match slice.rfind('\n') { + Some(nl) if nl > MAX_OUTPUT_CHARS / 2 => nl, + _ => cut, + }; + (output[..cut].to_string(), true) +} + mod html_regex { use regex::Regex; use std::sync::OnceLock; @@ -189,14 +224,52 @@ mod html_regex { static_regex!(script, r"(?is)]*>.*?"); static_regex!(style, r"(?is)]*>.*?"); - static_regex!(tag, r"<[^>]+>"); + // Match attribute values (which may themselves contain `>`) before falling + // back to bare `>`-terminated content, so tags carrying JSON payloads such as + // Parsoid's `data-mw` do not leak their contents into the output. + static_regex!( + tag, + r#"(?s)]*(?:\s+[^\s=/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*))?)*\s*/?>"# + ); static_regex!(whitespace, r"\n\s*\n\s*\n"); + // Runs of empty markdown list items left behind after tag stripping. + static_regex!(empty_bullets, r"(?m)^[ \t]*-[ \t]*$\n?"); + + /// HTML elements whose content is non-prose by specification: navigation, + /// complementary/tangential content, interactive controls, and embedded + /// non-text resources. This is deliberately limited to elements whose *spec + /// definition* excludes primary content, so it generalizes across sites + /// rather than encoding any single site's markup. + /// + /// Notably excludes `
`, which commonly wraps the article `

`, + /// byline, and publication date, and `