commit a789495a98ddfd5e2bdfe07cbfea3772bdd1d939 Author: wehub-resource-sync Date: Mon Jul 13 13:10:34 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..44945d5 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,18 @@ +[build] +# Set RUSTC_WRAPPER=sccache in your shell env; no hardcoded path needed. +# CI overrides this file, so leaving rustc-wrapper unset here is safe. +# Local fast-linker selection is handled by scripts/dev_cargo.sh so we don't +# hard-force a linker mode that may be broken on a contributor machine. +# +# This is a conservative *static fallback* for direct `cargo` invocations. The +# recommended build path (selfdev build -> scripts/dev_cargo.sh) ignores this +# and instead sizes the job count from currently-available memory, so several +# concurrent builds on one machine self-throttle instead of all assuming the +# full core count and tripping earlyoom/OOM. Override either path explicitly +# with CARGO_BUILD_JOBS / JCODE_BUILD_JOBS. +# +# The largest rustc unit (jcode-base) peaks at ~1.6 GiB RSS (was 2.5-3 GiB as a +# monolith), so 4 jobs (~6-7 GiB) keeps a single direct build comfortably +# memory-safe on a ~15 GiB machine while staying parallel. The adaptive +# dev_cargo.sh path uses more cores when memory allows. +jobs = 4 diff --git a/.claude/mcp.json b/.claude/mcp.json new file mode 100644 index 0000000..aa4d259 --- /dev/null +++ b/.claude/mcp.json @@ -0,0 +1 @@ +{"servers":{}} \ No newline at end of file diff --git a/.github/scripts/run_with_timeout.py b/.github/scripts/run_with_timeout.py new file mode 100644 index 0000000..27eeae2 --- /dev/null +++ b/.github/scripts/run_with_timeout.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +"""Run a command with a hard timeout and readable diagnostics.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +from typing import Sequence + + +def _usage() -> int: + print( + "usage: run_with_timeout.py [args...]", + file=sys.stderr, + ) + return 2 + + +def _kill_process_group(proc: subprocess.Popen[bytes]) -> None: + try: + pgid = os.getpgid(proc.pid) + except ProcessLookupError: + return + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return + + +def main(argv: Sequence[str]) -> int: + if len(argv) < 3: + return _usage() + + try: + timeout_seconds = int(argv[1]) + except ValueError: + print(f"invalid timeout: {argv[1]!r}", file=sys.stderr) + return 2 + + command = list(argv[2:]) + print(f"==> Running with timeout {timeout_seconds}s: {' '.join(command)}") + proc = subprocess.Popen(command, start_new_session=True) + try: + return proc.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + print( + f"::error::Command exceeded timeout after {timeout_seconds}s: {' '.join(command)}", + file=sys.stderr, + ) + _kill_process_group(proc) + try: + proc.wait(timeout=30) + return 124 + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except ProcessLookupError: + pass + return 124 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/.github/scripts/verify_windows_install.ps1 b/.github/scripts/verify_windows_install.ps1 new file mode 100644 index 0000000..e53ac1b --- /dev/null +++ b/.github/scripts/verify_windows_install.ps1 @@ -0,0 +1,64 @@ +param( + [Parameter(Mandatory = $true)][string]$ArtifactExePath, + [Parameter(Mandatory = $true)][string]$Version +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$resolvedArtifact = (Resolve-Path -LiteralPath $ArtifactExePath).Path +$tempRoot = Join-Path $env:RUNNER_TEMP ("jcode-windows-install-verify-" + [guid]::NewGuid().ToString('N')) +$localAppData = Join-Path $tempRoot 'localappdata' +$appData = Join-Path $tempRoot 'appdata' +$userProfile = Join-Path $tempRoot 'userprofile' +$jcodeHome = Join-Path $tempRoot '.jcode' +$installDir = Join-Path $localAppData 'jcode\bin' + +New-Item -ItemType Directory -Force -Path $localAppData, $appData, $userProfile, $jcodeHome | Out-Null + +$env:LOCALAPPDATA = $localAppData +$env:APPDATA = $appData +$env:USERPROFILE = $userProfile +$env:JCODE_HOME = $jcodeHome + +$installScript = Join-Path $repoRoot 'scripts\install.ps1' + +& $installScript ` + -InstallDir $installDir ` + -Version $Version ` + -ArtifactExePath $resolvedArtifact ` + -SkipAlacrittySetup ` + -SkipHotkeySetup + +$launcherPath = Join-Path $installDir 'jcode.exe' +$versionDir = Join-Path $localAppData ('jcode\builds\versions\' + $Version.TrimStart('v') + '\jcode.exe') +$stablePath = Join-Path $localAppData 'jcode\builds\stable\jcode.exe' + +foreach ($path in @($launcherPath, $versionDir, $stablePath)) { + if (-not (Test-Path -LiteralPath $path)) { + throw "Expected installed file missing: $path" + } +} + +$versionOutput = & $launcherPath --version +if ($LASTEXITCODE -ne 0) { + throw "Installed launcher failed to run --version" +} + +if ($versionOutput -notmatch 'jcode') { + throw "Installed launcher returned unexpected version output: $versionOutput" +} + +& $installScript ` + -InstallDir $installDir ` + -Version $Version ` + -ArtifactExePath $resolvedArtifact ` + -SkipAlacrittySetup ` + -SkipHotkeySetup + +if (-not (Test-Path -LiteralPath $launcherPath)) { + throw "Launcher missing after reinstall: $launcherPath" +} + +Write-Host "Windows install verification passed for $Version" -ForegroundColor Green diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4fe849f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,430 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + +jobs: + quality: + name: Quality Guardrails + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + submodules: recursive + + - name: Configure SSH for cargo git dependencies + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + key: quality-ubuntu + cache-all-crates: "true" + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Check all targets and all features + run: cargo check --all-targets --all-features + + - name: Run clippy with warnings denied + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Enforce warning budget + shell: bash + run: scripts/check_warning_budget.sh + + - name: Enforce oversized-file ratchet + shell: bash + run: python3 scripts/check_code_size_budget.py + + - name: Enforce oversized-test ratchet + shell: bash + run: python3 scripts/check_test_size_budget.py + + - name: Enforce panic-prone usage ratchet + shell: bash + run: python3 scripts/check_panic_budget.py + + - name: Enforce swallowed-error usage ratchet + shell: bash + run: python3 scripts/check_swallowed_error_budget.py + + - name: Enforce crate dependency boundaries + shell: bash + run: python3 scripts/check_dependency_boundaries.py + + - name: Enforce wildcard re-export ratchet + shell: bash + run: python3 scripts/check_wildcard_reexport_budget.py + + - name: Enforce no unused dependencies + shell: bash + run: | + cargo install cargo-machete --locked + cargo machete + + build: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + # 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, + # so the rustup proxy honours the file: convert_case pins `channel = "1.83.0"` + # and proc-macro2 requests `components = ["rust-src"]`. That made every + # Build & Test job (a) race two parallel `rust-src` downloads and (b) later + # try to compile convert_case with rustc 1.83.0, failing with E0514/E0599. + # RUSTUP_TOOLCHAIN takes precedence over any rust-toolchain.toml override, so + # pinning it here makes every step use this job's installed stable toolchain. + env: + RUSTUP_TOOLCHAIN: stable + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-latest + target: aarch64-apple-darwin + + steps: + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + submodules: recursive + + - name: Configure SSH for cargo git dependencies + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + # Pre-install rust-src in the (serial) toolchain step. Some build-script + # / proc-macro units trigger an on-demand `rustup component add rust-src`, + # and when several parallel cargo/rustc processes request it at once they + # race on the shared `~/.rustup/downloads/*.partial` file and fail with + # "could not rename ... No such file or directory". Fetching it once here + # removes the race. (Was failing every Build & Test job.) + components: rust-src + + - uses: Swatinem/rust-cache@v2 + with: + # Suffix bumped to evict caches that held 1.83.0 / + # artifacts produced before RUSTUP_TOOLCHAIN=stable pinned the job. + key: ${{ matrix.os }}-stablepin + + - name: Install mold linker (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq mold + + - name: Build + shell: bash + run: | + mkdir -p .cargo + if [ "$RUNNER_OS" = "Linux" ]; then + cat > .cargo/config.toml << 'EOF' + [target.x86_64-unknown-linux-gnu] + linker = "clang" + rustflags = ["-C", "link-arg=-fuse-ld=mold"] + EOF + fi + # NOTE: intentionally NOT exporting RUSTC_WRAPPER=sccache here. Only this + # step used sccache while the later `cargo test --no-run` steps did not, + # so sccache emitted rlibs/rmetas stamped `` that + # the non-sccache test compile then rejected with E0514 + # ("compiled by an incompatible version of rustc"). rust-cache already + # caches target/ across runs, so a clean, wrapper-consistent build is + # both correct and fast enough. + export RUSTC="$(rustup which rustc)" + CARGO_BIN="$(rustup which cargo)" + "$CARGO_BIN" build --release --target ${{ matrix.target }} + + - name: Compile library and binary tests + shell: bash + run: | + python3 .github/scripts/run_with_timeout.py 900 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} --lib --bins --no-run + + - name: Compile integration test binaries + shell: bash + # Build the integration-test binaries up front (not counted against the + # run steps' execution budgets). The e2e suite in particular is heavy + # (burst-spawn concurrency), and folding compilation into its 900s run + # budget was pushing slower hosted ubuntu/macos runners over the limit. + run: | + python3 .github/scripts/run_with_timeout.py 900 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} \ + --test provider_matrix --test e2e --no-run + + - name: Run provider matrix tests + shell: bash + run: | + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} --test provider_matrix + + - name: Run e2e tests + shell: bash + run: | + # e2e is fast (~1-2 min of execution); the generous budget only guards + # against an occasional slow hosted runner. Compilation is already done + # in the dedicated compile step above. + python3 .github/scripts/run_with_timeout.py 600 \ + "$(rustup which cargo)" test --target ${{ matrix.target }} --test e2e + + - name: Check PowerShell script syntax (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + ./scripts/check_powershell_syntax.ps1 + + - name: Enforce warning budget (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + scripts/check_warning_budget.sh + + - name: Security preflight (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cargo install cargo-audit --locked + scripts/security_preflight.sh --strict + + windows-build-test: + name: Build & Test (windows-latest) + runs-on: windows-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + submodules: recursive + + - name: Configure SSH for cargo git dependencies + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.7 + continue-on-error: true + + - uses: Swatinem/rust-cache@v2 + with: + key: windows-latest + cache-all-crates: "true" + + - name: Build release binary + shell: pwsh + run: | + if (Get-Command sccache -ErrorAction SilentlyContinue) { + sccache --start-server *> $null + if ($LASTEXITCODE -eq 0) { + $env:RUSTC_WRAPPER = 'sccache' + } + } + + & cargo build --locked --release --target x86_64-pc-windows-msvc + if ($LASTEXITCODE -ne 0) { + throw 'Windows release build failed' + } + + - name: Compile library and binary tests + shell: pwsh + run: | + & cargo test --locked --target x86_64-pc-windows-msvc --lib --bins --no-run + if ($LASTEXITCODE -ne 0) { + throw 'Windows library/binary test compilation failed' + } + + - name: Run targeted Windows validation tests + shell: pwsh + run: | + $tests = @( + 'command_candidates_adds_extension_on_windows', + 'command_exists_for_known_binary', + 'command_exists_absolute_path', + 'sibling_socket_path_roundtrip', + 'cleanup_socket_pair_removes_main_and_debug_files', + 'is_process_running_reports_exited_children_as_stopped', + 'spawn_replacement_process_returns_without_waiting_for_child_exit', + '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', + 'stream_pair_round_trips_bytes', + 'split_stream_supports_concurrent_read_and_write' + ) + + foreach ($testName in $tests) { + & ./scripts/invoke_cargo_with_timeout.ps1 ` + -Name "Windows targeted test: $testName" ` + -TimeoutSeconds 300 ` + -CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--lib', $testName, '--', '--nocapture') + } + + - name: Run Windows e2e smoke tests + shell: pwsh + run: | + $tests = @( + 'provider_behavior::test_socket_model_cycle_supported_models', + 'provider_behavior::test_model_switch_resets_provider_session' + ) + + foreach ($testName in $tests) { + & ./scripts/invoke_cargo_with_timeout.ps1 ` + -Name "Windows e2e smoke test: $testName" ` + -TimeoutSeconds 420 ` + -CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture') + } + + - name: Run Windows lifecycle e2e tests + shell: pwsh + env: + JCODE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/jcode-windows-e2e-logs + run: | + New-Item -ItemType Directory -Force -Path $env:JCODE_E2E_ARTIFACT_DIR | Out-Null + $tests = @( + 'windows_lifecycle::windows_binary_server_accepts_clients_and_debug_cli', + 'windows_lifecycle::windows_binary_server_rebinds_named_pipe_after_exit' + ) + foreach ($testName in $tests) { + & ./scripts/invoke_cargo_with_timeout.ps1 ` + -Name "Windows lifecycle e2e test: $testName" ` + -TimeoutSeconds 420 ` + -CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture') + } + + - name: Upload Windows e2e diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: windows-e2e-diagnostics + path: ${{ runner.temp }}/jcode-windows-e2e-logs + if-no-files-found: ignore + + - name: Verify built binary launches + shell: pwsh + run: | + & "target/x86_64-pc-windows-msvc/release/jcode.exe" --version + if ($LASTEXITCODE -ne 0) { + throw 'Built Windows binary failed to run --version' + } + + - 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 + + fmt: + name: Format + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + powershell-syntax: + name: PowerShell Syntax + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Check PowerShell script syntax (Windows PowerShell 5.1) + shell: powershell + run: | + & ./scripts/check_powershell_syntax.ps1 + + - name: Check PowerShell script syntax (PowerShell 7) + shell: pwsh + run: | + & ./scripts/check_powershell_syntax.ps1 + + windows-cross-check: + name: Windows Cross-Target Check (Linux) + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + submodules: recursive + + - name: Configure SSH for cargo git dependencies + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc,aarch64-pc-windows-msvc + + - uses: Swatinem/rust-cache@v2 + with: + key: windows-cross-check + cache-all-crates: "true" + + - name: Install LLVM toolchain for cargo-xwin + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq clang lld llvm ninja-build + + - name: Install cargo-xwin + run: cargo install --git https://github.com/rust-cross/cargo-xwin cargo-xwin + + - name: Check Windows x64 target + run: cargo xwin check --locked --target x86_64-pc-windows-msvc + + # cargo-xwin currently feeds clang-style ring builds MSVC /imsvc flags for + # aarch64-pc-windows-msvc on Linux. Keep this advisory until upstream + # cargo-xwin/ring interop is fixed; native Windows ARM64 smoke covers the + # release artifact path. + - name: Check Windows ARM64 target (advisory) + continue-on-error: true + run: cargo xwin check --locked --target aarch64-pc-windows-msvc --no-default-features --features pdf diff --git a/.github/workflows/freebsd-smoke.yml b/.github/workflows/freebsd-smoke.yml new file mode 100644 index 0000000..dab88db --- /dev/null +++ b/.github/workflows/freebsd-smoke.yml @@ -0,0 +1,88 @@ +name: FreeBSD Smoke + +# Builds and smoke-tests jcode inside a real FreeBSD VM. +# +# GitHub does not offer native FreeBSD runners, so we boot a FreeBSD guest +# (via QEMU) on a standard Ubuntu runner using vmactions/freebsd-vm. This runs +# the actual FreeBSD kernel and userland, giving a true build + run check +# instead of a cross-compile that can never link platform C dependencies +# (e.g. aws-lc-sys). See issue #416. + +on: + workflow_dispatch: + inputs: + release: + description: Build in release mode (slower, matches shipping binary) + required: false + default: false + type: boolean + push: + paths: + - '.github/workflows/freebsd-smoke.yml' + schedule: + # Weekly drift check so FreeBSD breakage is caught even without a push. + - cron: '0 7 * * 1' + +concurrency: + group: freebsd-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: FreeBSD Smoke (x86_64) + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build and test in FreeBSD VM + uses: vmactions/freebsd-vm@v1 + with: + release: "15.1" + usesh: true + # Give the build VM enough room: aws-lc-sys + tract pull in heavy C/Rust. + mem: 6144 + cpu: 4 + # Install the toolchain jcode needs. The project is OpenSSL-free + # (rustls + aws-lc-rs), so we only need a C/C++ toolchain, cmake, and + # rust. pkgconf is handy for any transitive pkg-config probes. + prepare: | + pkg install -y rust cmake gmake pkgconf bash git + # CARGO_BUILD_JOBS keeps memory in check; aws-lc-sys is memory hungry. + run: | + set -e + echo "::group::Toolchain versions" + uname -a + cc --version | head -1 + cargo --version + rustc --version + echo "::endgroup::" + + export CARGO_TERM_COLOR=always + export CARGO_BUILD_JOBS=3 + + if [ "${{ inputs.release }}" = "true" ]; then + PROFILE_FLAG="--release" + TARGET_DIR="release" + else + PROFILE_FLAG="" + TARGET_DIR="debug" + fi + + echo "::group::cargo build (jcode binary)" + cargo build --locked $PROFILE_FLAG -p jcode --bin jcode + echo "::endgroup::" + + echo "::group::Verify binary launches" + ./target/$TARGET_DIR/jcode --version + echo "::endgroup::" + + echo "::group::Compile platform unit tests" + cargo test --locked $PROFILE_FLAG -p jcode-base --lib --no-run + echo "::endgroup::" + + echo "::group::Run platform unit tests" + cargo test --locked $PROFILE_FLAG -p jcode-base --lib -- --nocapture platform + echo "::endgroup::" diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml new file mode 100644 index 0000000..749d7bf --- /dev/null +++ b/.github/workflows/ios-testflight.yml @@ -0,0 +1,144 @@ +name: iOS TestFlight + +on: + workflow_dispatch: + push: + branches: [master] + paths: + - "ios/**" + - ".github/workflows/ios-testflight.yml" + +concurrency: + group: ios-testflight-${{ github.ref }} + cancel-in-progress: true + +env: + BUNDLE_ID: com.jcode.mobile + SCHEME: JCodeMobile + TEAM_ID: TAS6ARKDN7 + +jobs: + test: + name: swift test (JCodeKit) + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Swift toolchain info + run: | + sw_vers + xcodebuild -version + swift --version + + - name: Run JCodeKit tests on macOS + working-directory: ios + run: swift test + + compile-check: + name: xcodebuild (simulator, unsigned) + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: ios + run: xcodegen generate + + - name: Build app target for iOS simulator (no signing) + working-directory: ios + run: | + set -o pipefail + xcodebuild build \ + -project JCodeMobile.xcodeproj \ + -scheme "$SCHEME" \ + -configuration Debug \ + -destination "generic/platform=iOS Simulator" \ + CODE_SIGNING_ALLOWED=NO \ + | tail -60 + + build-and-upload: + name: Build, sign, upload to TestFlight + needs: [test, compile-check] + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: ios + run: xcodegen generate + + - name: Write App Store Connect API key + env: + APPSTORE_API_KEY_P8: ${{ secrets.APPSTORE_API_KEY_P8 }} + run: | + mkdir -p ~/private_keys + 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) + working-directory: ios + run: | + set -o pipefail + 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 \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + CURRENT_PROJECT_VERSION="${{ github.run_number }}" \ + | tail -100 + + - name: Write export options + working-directory: ios + run: | + cat > ExportOptions.plist <<'EOF' + + + + + method + app-store-connect + destination + upload + teamID + TAS6ARKDN7 + uploadSymbols + + manageAppVersionAndBuildNumber + + + + EOF + + - name: Export and upload to App Store Connect + working-directory: ios + run: | + set -o pipefail + xcodebuild -exportArchive \ + -archivePath build/JCodeMobile.xcarchive \ + -exportOptionsPlist ExportOptions.plist \ + -exportPath build/export \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$HOME/private_keys/AuthKey_${{ secrets.APPSTORE_API_KEY_ID }}.p8" \ + -authenticationKeyID "${{ secrets.APPSTORE_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.APPSTORE_ISSUER_ID }}" \ + | tail -60 + + - name: Cleanup API key + if: always() + run: rm -rf ~/private_keys diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dc97b76 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,575 @@ +name: Release + +on: + push: + tags: + - 'v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + CARGO_INCREMENTAL: "0" + +jobs: + create-release: + name: Create release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Render a human-readable changelog for the release body (issue #435): + # changelog/v.json when present, otherwise grouped commit + # subjects since the previous tag, always ending with the compare link. + - name: Generate release notes + run: scripts/generate_release_notes.sh "${GITHUB_REF_NAME}" > release_notes.md + + # Publish the release up front so each platform build can attach + # its own asset the moment it finishes, instead of everyone waiting for + # the slowest job. action-gh-release is idempotent: it creates the release + # for this tag if missing and updates it otherwise. + - name: Create release if missing + uses: softprops/action-gh-release@v2 + with: + body_path: release_notes.md + + build-linux-macos: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + needs: create-release + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - # Build Linux x86_64 release assets on a CentOS 7 / manylinux2014 + # glibc 2.17 baseline so they run on older distros as well as newer + # Debian/Ubuntu containers used by many TB tasks. + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + artifact: jcode-linux-x86_64 + compat_container: true + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact: jcode-linux-aarch64 + - os: macos-latest + target: aarch64-apple-darwin + artifact: jcode-macos-aarch64 + - os: macos-15-intel + target: x86_64-apple-darwin + artifact: jcode-macos-x86_64 + + steps: + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + submodules: recursive + fetch-depth: 0 + + - name: Configure SSH for cargo git dependencies + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.DEPLOY_KEY }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.7 + continue-on-error: true + id: sccache + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + cache-all-crates: "true" + + - name: Install mold linker (Linux) + if: runner.os == 'Linux' && matrix.compat_container != true + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq mold + + - name: Build release binary + if: matrix.compat_container != true + shell: bash + run: | + mkdir -p .cargo + if [ "$RUNNER_OS" = "Linux" ]; then + cat > .cargo/config.toml << 'EOF' + [target.x86_64-unknown-linux-gnu] + linker = "clang" + rustflags = ["-C", "link-arg=-fuse-ld=mold"] + 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 }} + env: + JCODE_RELEASE_BUILD: "1" + JCODE_BUILD_SEMVER: ${{ github.ref_name }} + + - name: Build portable Linux x86_64 release binary + if: matrix.compat_container == true + shell: bash + run: scripts/build_linux_compat.sh dist + env: + JCODE_RELEASE_BUILD: "1" + JCODE_BUILD_SEMVER: ${{ github.ref_name }} + JCODE_COMPAT_ARTIFACT: ${{ matrix.artifact }} + + - name: Package binary + if: matrix.compat_container != true + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/jcode dist/${{ matrix.artifact }} + chmod +x dist/${{ matrix.artifact }} + cd dist && tar czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: dist/${{ matrix.artifact }}.tar.gz + + # Attach this platform's asset to the release immediately, so e.g. the + # macOS binary is downloadable ~15 min in without waiting for the slower + # Windows/Linux x86_64 jobs. + - name: Publish asset to release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${GITHUB_REF_NAME}" "dist/${{ matrix.artifact }}.tar.gz" --clobber + + build-windows: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + needs: create-release + # Windows x64 release smoke tests compile the e2e harness after the release + # binary. GitHub-hosted Windows runners sometimes exceed 25 minutes, which + # cancels otherwise healthy releases before artifacts can be uploaded. + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact: jcode-windows-x86_64 + - os: windows-11-arm + target: aarch64-pc-windows-msvc + artifact: jcode-windows-aarch64 + cargo_args: "--no-default-features --features pdf" + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure MSVC build environment (x64) + if: matrix.target == 'x86_64-pc-windows-msvc' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + + - name: Configure MSVC build environment (ARM64) + if: matrix.target == 'aarch64-pc-windows-msvc' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64_arm64 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.7 + continue-on-error: true + id: sccache + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + cache-all-crates: "true" + + - name: Build release binary + shell: pwsh + run: | + if (Get-Command sccache -ErrorAction SilentlyContinue) { + sccache --start-server *> $null + if ($LASTEXITCODE -eq 0) { + $env:RUSTC_WRAPPER = "sccache" + } + } + + $cargoArgs = @("build", "--release", "--target", "${{ matrix.target }}") + $extraArgs = "${{ matrix.cargo_args }}" + if (-not [string]::IsNullOrWhiteSpace($extraArgs)) { + $cargoArgs += $extraArgs -split ' ' + } + + & cargo @cargoArgs + env: + JCODE_RELEASE_BUILD: "1" + JCODE_BUILD_SEMVER: ${{ github.ref_name }} + + - name: Run Windows runtime smoke tests (x64) + if: matrix.target == 'x86_64-pc-windows-msvc' + shell: pwsh + run: | + $tests = @( + 'provider_behavior::test_socket_model_cycle_supported_models', + 'provider_behavior::test_model_switch_resets_provider_session' + ) + + foreach ($testName in $tests) { + & cargo test --locked --target ${{ matrix.target }} --test e2e $testName -- --exact --nocapture + if ($LASTEXITCODE -ne 0) { + throw "Windows smoke test failed: $testName" + } + } + + - name: Verify built Windows binary launches + shell: pwsh + run: | + & "target/${{ matrix.target }}/release/jcode.exe" --version + if ($LASTEXITCODE -ne 0) { + throw "Built Windows binary failed to run --version" + } + + - name: Verify Windows installer with local artifact + shell: pwsh + run: | + & ./.github/scripts/verify_windows_install.ps1 ` + -ArtifactExePath "target/${{ matrix.target }}/release/jcode.exe" ` + -Version "${{ github.ref_name }}" + + - name: Package binary + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Copy-Item "target/${{ matrix.target }}/release/jcode.exe" "dist/${{ matrix.artifact }}.exe" + tar -czf "dist/${{ matrix.artifact }}.tar.gz" -C dist "${{ matrix.artifact }}.exe" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + dist/${{ matrix.artifact }}.tar.gz + dist/${{ matrix.artifact }}.exe + + - name: Publish asset to release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${env:GITHUB_REF_NAME}" "dist/${{ matrix.artifact }}.tar.gz" "dist/${{ matrix.artifact }}.exe" --clobber + + build-freebsd: + name: Build (x86_64-unknown-freebsd) + runs-on: ubuntu-latest + needs: create-release + # GitHub has no native FreeBSD runners, so build inside a FreeBSD VM + # (QEMU via vmactions), same approach as freebsd-smoke.yml (issues #416, + # #433). Best-effort: a flaky VM build must not block the rest of the + # release, so failures are surfaced but non-fatal. + continue-on-error: true + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build release binary in FreeBSD VM + uses: vmactions/freebsd-vm@v1 + with: + release: "15.1" + usesh: true + # Give the build VM enough room: aws-lc-sys + tract pull in heavy C/Rust. + mem: 6144 + cpu: 4 + # The project is OpenSSL-free (rustls + aws-lc-rs): only a C/C++ + # toolchain, cmake, and rust are needed. + prepare: | + pkg install -y rust cmake gmake pkgconf bash git + run: | + set -e + echo "::group::Toolchain versions" + uname -a + cc --version | head -1 + cargo --version + rustc --version + echo "::endgroup::" + + export CARGO_TERM_COLOR=always + # 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_BUILD_SEMVER="${{ github.ref_name }}" + + echo "::group::cargo build (jcode binary)" + cargo build --locked --release -p jcode --bin jcode + echo "::endgroup::" + + echo "::group::Verify binary launches" + ./target/release/jcode --version + echo "::endgroup::" + + mkdir -p dist + cp target/release/jcode dist/jcode-freebsd-x86_64 + chmod +x dist/jcode-freebsd-x86_64 + cd dist && tar czf jcode-freebsd-x86_64.tar.gz jcode-freebsd-x86_64 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: jcode-freebsd-x86_64 + path: dist/jcode-freebsd-x86_64.tar.gz + + - name: Publish asset to release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${GITHUB_REF_NAME}" "dist/jcode-freebsd-x86_64.tar.gz" --clobber + + release: + name: Finalize release + needs: [build-linux-macos, build-windows, build-freebsd] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: jcode-* + + - name: Generate checksums + shell: bash + run: | + python3 - << 'PY' + import hashlib + from pathlib import Path + + files = sorted( + p for p in Path("artifacts").rglob("*") + if p.is_file() and (p.name.endswith(".tar.gz") or p.name.endswith(".exe")) + ) + if not files: + raise SystemExit("No release assets found for checksum generation") + + with Path("SHA256SUMS").open("w", encoding="utf-8") as out: + for path in files: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + out.write(f"{digest} {path.name}\n") + PY + cat SHA256SUMS + + # Per-platform assets were already attached by their own build jobs as + # they finished; here we only add the cross-cutting checksums file. + - name: Upload checksums to release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${GITHUB_REF_NAME}" SHA256SUMS --clobber + + - name: Update Homebrew formula + env: + HOMEBREW_DEPLOY_KEY: ${{ secrets.HOMEBREW_DEPLOY_KEY }} + if: env.HOMEBREW_DEPLOY_KEY != '' + run: | + VERSION="${GITHUB_REF_NAME}" + VERSION_NUM="${VERSION#v}" + + LINUX_SHA=$(sha256sum artifacts/jcode-linux-x86_64/jcode-linux-x86_64.tar.gz | cut -d' ' -f1) + LINUX_ARM_SHA=$(sha256sum artifacts/jcode-linux-aarch64/jcode-linux-aarch64.tar.gz | cut -d' ' -f1) + MACOS_ARM_SHA=$(sha256sum artifacts/jcode-macos-aarch64/jcode-macos-aarch64.tar.gz | cut -d' ' -f1) + MACOS_INTEL_SHA=$(sha256sum artifacts/jcode-macos-x86_64/jcode-macos-x86_64.tar.gz | cut -d' ' -f1) + + mkdir -p ~/.ssh + echo "$HOMEBREW_DEPLOY_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" + + git clone git@github.com:1jehuang/homebrew-jcode.git /tmp/homebrew-jcode + + cat > /tmp/homebrew-jcode/Formula/jcode.rb << FORMULA + class Jcode < Formula + desc "AI coding agent powered by Claude and ChatGPT" + homepage "https://github.com/1jehuang/jcode" + version "${VERSION_NUM}" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-macos-aarch64.tar.gz" + sha256 "${MACOS_ARM_SHA}" + + def install + bin.install "jcode-macos-aarch64" => "jcode" + end + end + + on_intel do + url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-macos-x86_64.tar.gz" + sha256 "${MACOS_INTEL_SHA}" + + def install + bin.install "jcode-macos-x86_64" => "jcode" + end + end + end + + on_linux do + on_intel do + url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-linux-x86_64.tar.gz" + sha256 "${LINUX_SHA}" + + def install + libexec.install "jcode-linux-x86_64", "jcode-linux-x86_64.bin" + 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" "$@" + SH + end + end + + on_arm do + url "https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-linux-aarch64.tar.gz" + sha256 "${LINUX_ARM_SHA}" + + def install + bin.install "jcode-linux-aarch64" => "jcode" + end + end + end + + test do + assert_match "jcode", shell_output("#{bin}/jcode --version") + end + end + FORMULA + + sed -i 's/^ //' /tmp/homebrew-jcode/Formula/jcode.rb + + cd /tmp/homebrew-jcode + git config user.name "jcode-release-bot" + git config user.email "release@jcode.dev" + git add Formula/jcode.rb + git commit -m "Update to ${VERSION}" || echo "No changes" + git push + + - name: Update AUR package + env: + AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} + if: env.AUR_SSH_KEY != '' + run: | + set -euo pipefail + + retry() { + local attempts="$1" + local delay="$2" + shift 2 + local try=1 + + until "$@"; do + local exit_code=$? + if [ "$try" -ge "$attempts" ]; then + return "$exit_code" + fi + echo "Attempt ${try}/${attempts} failed; retrying in ${delay}s..." + sleep "$delay" + try=$((try + 1)) + done + } + + VERSION="${GITHUB_REF_NAME}" + VERSION_NUM="${VERSION#v}" + + LINUX_SHA=$(sha256sum artifacts/jcode-linux-x86_64/jcode-linux-x86_64.tar.gz | cut -d' ' -f1) + LINUX_URL="https://github.com/1jehuang/jcode/releases/download/${VERSION}/jcode-linux-x86_64.tar.gz" + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$AUR_SSH_KEY" > ~/.ssh/aur_key + chmod 600 ~/.ssh/aur_key + touch ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + retry 3 5 bash -lc 'ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts' + + export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o ConnectTimeout=10 -o ConnectionAttempts=3" + + retry 3 5 bash -lc 'rm -rf /tmp/jcode-aur && git clone --depth 1 ssh://aur@aur.archlinux.org/jcode-bin.git /tmp/jcode-aur' + cd /tmp/jcode-aur + git remote set-url origin ssh://aur@aur.archlinux.org/jcode-bin.git + + cat > PKGBUILD << 'PKGBUILD_END' + # Maintainer: Jeremy Huang + pkgname=jcode-bin + pkgver=VERSION_PLACEHOLDER + pkgrel=1 + pkgdesc="AI coding agent powered by Claude and ChatGPT" + arch=('x86_64') + url="https://github.com/1jehuang/jcode" + license=('MIT') + provides=('jcode') + conflicts=('jcode') + source=("URL_PLACEHOLDER") + sha256sums=('SHA_PLACEHOLDER') + + package() { + install -Dm755 "${srcdir}/jcode-linux-x86_64" "${pkgdir}/usr/lib/jcode/jcode-linux-x86_64" + install -Dm755 "${srcdir}/jcode-linux-x86_64.bin" "${pkgdir}/usr/lib/jcode/jcode-linux-x86_64.bin" + if compgen -G "${srcdir}/libssl.so*" >/dev/null; then + install -Dm644 "${srcdir}"/libssl.so* "${pkgdir}/usr/lib/jcode/" + fi + if compgen -G "${srcdir}/libcrypto.so*" >/dev/null; then + install -Dm644 "${srcdir}"/libcrypto.so* "${pkgdir}/usr/lib/jcode/" + fi + mkdir -p "${pkgdir}/usr/bin" + ln -s /usr/lib/jcode/jcode-linux-x86_64 "${pkgdir}/usr/bin/jcode" + } + PKGBUILD_END + + sed -i "s|VERSION_PLACEHOLDER|${VERSION_NUM}|" PKGBUILD + sed -i "s|URL_PLACEHOLDER|${LINUX_URL}|" PKGBUILD + sed -i "s|SHA_PLACEHOLDER|${LINUX_SHA}|" PKGBUILD + sed -i 's/^ //' PKGBUILD + + # Generate .SRCINFO without makepkg (AUR uses tab indentation) + printf 'pkgbase = jcode-bin\n' > .SRCINFO + printf '\tpkgdesc = AI coding agent powered by Claude and ChatGPT\n' >> .SRCINFO + printf '\tpkgver = %s\n' "${VERSION_NUM}" >> .SRCINFO + printf '\tpkgrel = 1\n' >> .SRCINFO + printf '\turl = https://github.com/1jehuang/jcode\n' >> .SRCINFO + printf '\tarch = x86_64\n' >> .SRCINFO + printf '\tlicense = MIT\n' >> .SRCINFO + printf '\tprovides = jcode\n' >> .SRCINFO + printf '\tconflicts = jcode\n' >> .SRCINFO + printf '\tsource = %s\n' "${LINUX_URL}" >> .SRCINFO + printf '\tsha256sums = %s\n' "${LINUX_SHA}" >> .SRCINFO + printf '\npkgname = jcode-bin\n' >> .SRCINFO + + git config user.name "Jeremy Huang" + git config user.email "jeremyhuang55555@gmail.com" + git add PKGBUILD .SRCINFO + git commit -m "Update to ${VERSION}" || echo "No changes" + retry 3 5 git push origin master diff --git a/.github/workflows/require-issue.yml b/.github/workflows/require-issue.yml new file mode 100644 index 0000000..e2a02fd --- /dev/null +++ b/.github/workflows/require-issue.yml @@ -0,0 +1,111 @@ +name: Require Linked Issue + +# 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 +# * a closing keyword in the body/title (e.g. "Closes #123"), or +# * a plain mention (#123 / owner/repo#123) or full issue URL. +# Any referenced number is verified against the API: it must resolve to an +# existing issue (not a pull request) in this repository. + +on: + pull_request: + types: [opened, edited, reopened, synchronize, ready_for_review] + +permissions: + contents: read + pull-requests: read + issues: read + +concurrency: + group: require-issue-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + require-issue: + name: Require Linked Issue + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check for a linked, existing issue + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + if (!pr) { + core.info('No pull_request payload; skipping.'); + return; + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const text = `${pr.title || ''}\n\n${pr.body || ''}`; + + // Collect candidate issue numbers that belong to THIS repo. + const candidates = new Set(); + + // Full issue URLs: https://github.com/owner/repo/issues/123 + const urlRe = /https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)/gi; + for (const m of text.matchAll(urlRe)) { + if (m[1].toLowerCase() === owner.toLowerCase() && + m[2].toLowerCase() === repo.toLowerCase()) { + candidates.add(Number(m[3])); + } + } + + // owner/repo#123 or bare #123 (bare assumed to be this repo). + const refRe = /(?:([\w.-]+)\/([\w.-]+))?#(\d+)/g; + for (const m of text.matchAll(refRe)) { + const refOwner = m[1]; + const refRepo = m[2]; + const num = Number(m[3]); + if (!refOwner && !refRepo) { + candidates.add(num); // bare #123 -> this repo + } else if (refOwner?.toLowerCase() === owner.toLowerCase() && + refRepo?.toLowerCase() === repo.toLowerCase()) { + candidates.add(num); + } + } + + // Issues linked via the Development sidebar (closing references). + try { + const query = `query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + closingIssuesReferences(first: 10) { nodes { number } } + } + } + }`; + const result = await github.graphql(query, { owner, repo, number: pr.number }); + const nodes = result?.repository?.pullRequest?.closingIssuesReferences?.nodes || []; + for (const n of nodes) candidates.add(Number(n.number)); + } catch (err) { + core.warning(`Could not query closing issue references: ${err}`); + } + + // Verify at least one candidate is a real issue (not a PR, not this PR). + let linkedIssue = null; + for (const num of candidates) { + if (num === pr.number) continue; + try { + const { data } = await github.rest.issues.get({ owner, repo, issue_number: num }); + if (!data.pull_request) { // issues have no pull_request field + linkedIssue = num; + break; + } + } catch (err) { + // 404 -> number doesn't exist; ignore and keep checking. + } + } + + if (!linkedIssue) { + core.setFailed( + 'This PR must be linked to an existing GitHub issue in this repository. ' + + 'Add a closing keyword (e.g. "Closes #123") to the PR description, link an ' + + 'issue via the Development sidebar, or reference an existing issue number ' + + '(e.g. "#123"). If no issue exists yet, please open one first.' + ); + return; + } + + core.info(`Linked to existing issue #${linkedIssue}. ✅`); diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml new file mode 100644 index 0000000..1ae8c60 --- /dev/null +++ b/.github/workflows/windows-smoke.yml @@ -0,0 +1,215 @@ +name: Windows Smoke + +on: + workflow_dispatch: + inputs: + target: + description: Windows target to verify + required: true + default: x64 + type: choice + options: + - x64 + - arm64 + - both + +concurrency: + group: windows-smoke-${{ github.ref }}-${{ github.event.inputs.target || 'x64' }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + SCCACHE_GHA_ENABLED: "true" + +jobs: + smoke-x64: + name: Windows Smoke (x64) + if: github.event.inputs.target == 'x64' || github.event.inputs.target == 'both' + runs-on: windows-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - uses: mozilla-actions/sccache-action@v0.0.7 + continue-on-error: true + + - uses: Swatinem/rust-cache@v2 + with: + key: windows-smoke-x64 + cache-all-crates: "true" + + - name: Build release binary + shell: pwsh + run: | + if (Get-Command sccache -ErrorAction SilentlyContinue) { + sccache --start-server *> $null + if ($LASTEXITCODE -eq 0) { + $env:RUSTC_WRAPPER = 'sccache' + } + } + + & cargo build --locked --release --target x86_64-pc-windows-msvc + + - name: Compile library and binary tests + shell: pwsh + run: | + & cargo test --locked --target x86_64-pc-windows-msvc --lib --bins --no-run + if ($LASTEXITCODE -ne 0) { + throw 'Windows library/binary test compilation failed' + } + + - name: Run targeted Windows validation tests + shell: pwsh + run: | + $tests = @( + 'command_candidates_adds_extension_on_windows', + 'command_exists_for_known_binary', + 'command_exists_absolute_path', + 'sibling_socket_path_roundtrip', + 'cleanup_socket_pair_removes_main_and_debug_files', + 'is_process_running_reports_exited_children_as_stopped', + 'spawn_replacement_process_returns_without_waiting_for_child_exit', + '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', + 'stream_pair_round_trips_bytes', + 'split_stream_supports_concurrent_read_and_write', + 'auto_provider_noninteractive_skips_untrusted_external_auth_instead_of_blocking' + ) + + foreach ($testName in $tests) { + & ./scripts/invoke_cargo_with_timeout.ps1 ` + -Name "Windows targeted test: $testName" ` + -TimeoutSeconds 300 ` + -CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--lib', $testName, '--', '--nocapture') + } + + - name: Run Windows smoke tests + shell: pwsh + run: | + $tests = @( + 'provider_behavior::test_socket_model_cycle_supported_models', + 'provider_behavior::test_model_switch_resets_provider_session' + ) + + foreach ($testName in $tests) { + & ./scripts/invoke_cargo_with_timeout.ps1 ` + -Name "Windows e2e smoke test: $testName" ` + -TimeoutSeconds 420 ` + -CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture') + } + + - name: Run Windows lifecycle e2e tests + shell: pwsh + env: + JCODE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/jcode-windows-e2e-logs + run: | + New-Item -ItemType Directory -Force -Path $env:JCODE_E2E_ARTIFACT_DIR | Out-Null + $tests = @( + 'windows_lifecycle::windows_binary_server_accepts_clients_and_debug_cli', + 'windows_lifecycle::windows_binary_server_rebinds_named_pipe_after_exit' + ) + foreach ($testName in $tests) { + & ./scripts/invoke_cargo_with_timeout.ps1 ` + -Name "Windows lifecycle e2e test: $testName" ` + -TimeoutSeconds 420 ` + -CargoArgs @('test', '--locked', '--target', 'x86_64-pc-windows-msvc', '--test', 'e2e', $testName, '--', '--exact', '--nocapture') + } + + - name: Upload Windows e2e diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: windows-smoke-e2e-diagnostics-x64 + path: ${{ runner.temp }}/jcode-windows-e2e-logs + if-no-files-found: ignore + + - name: Verify built binary launches + shell: pwsh + run: | + & "target/x86_64-pc-windows-msvc/release/jcode.exe" --version + if ($LASTEXITCODE -ne 0) { + throw 'Built Windows binary failed to run --version' + } + + - 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 + + smoke-arm64: + name: Windows Smoke (ARM64) + if: github.event.inputs.target == 'arm64' || github.event.inputs.target == 'both' + runs-on: windows-11-arm + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: amd64_arm64 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-pc-windows-msvc + + - uses: mozilla-actions/sccache-action@v0.0.7 + continue-on-error: true + + - uses: Swatinem/rust-cache@v2 + with: + key: windows-smoke-arm64 + cache-all-crates: "true" + + - name: Build release binary + shell: pwsh + run: | + if (Get-Command sccache -ErrorAction SilentlyContinue) { + sccache --start-server *> $null + if ($LASTEXITCODE -eq 0) { + $env:RUSTC_WRAPPER = 'sccache' + } + } + + & cargo build --locked --release --target aarch64-pc-windows-msvc --no-default-features --features pdf + + - name: Verify built binary launches + shell: pwsh + run: | + & "target/aarch64-pc-windows-msvc/release/jcode.exe" --version + if ($LASTEXITCODE -ne 0) { + throw 'Built Windows ARM64 binary failed to run --version' + } + + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c71a3e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/target +Cargo.lock +__pycache__/ +ios_simulator_screenshot.png +/.wrangler/ +/tmp/ +# Local onboarding sandbox + auth fixtures. May contain REAL OAuth tokens copied +# from $HOME (see scripts/onboarding_sandbox.sh seed-real-logins). Never commit. +/.tmp/ +/.jcode/generated-images/ +/telemetry-worker/backups/ +target-ttest/ +target-ttest*/ + +# Stray experiment/debug artifacts at the repo root. Real assets live under +# assets/, docs/, ios/, and tests/ and are committed explicitly. +/*.log +/*.avif +/*.mp4 +/*.png diff --git a/.jcode/skills/optimization/SKILL.md b/.jcode/skills/optimization/SKILL.md new file mode 100644 index 0000000..e0a89f5 --- /dev/null +++ b/.jcode/skills/optimization/SKILL.md @@ -0,0 +1,84 @@ +--- +name: optimization +description: Use when improving performance, latency, throughput, memory usage, or general efficiency. Start by defining target metrics, measuring comprehensively, attributing bottlenecks, validating with static analysis, and prioritizing macro-optimizations before micro-optimizations. +allowed-tools: bash, read, write, grep, agentgrep, batch, todo +--- + +# Optimization + +Use this skill when the task is about making a system faster, lighter, more scalable, or otherwise more efficient. + +## Core principle + +To optimize properly, you must know: + +1. **What metrics you are chasing** +2. **What your real bottlenecks are** + +Do not optimize blindly. + +## 1. Define the target metrics first + +Before changing code, make sure you have the right measurements. + +- Identify the exact metrics that matter: latency, throughput, memory, CPU, startup time, compile time, query count, token usage, cost, etc. +- Measure **comprehensively**, not just a convenient subset. +- Make sure the metrics are accurate and representative of the real workload. +- Prefer measurements that are fast to run so you can iterate quickly. +- If possible, create repeatable benchmarks or scripts so improvements are verifiable. + +## 2. Get full bottleneck attribution + +You should have strong attribution for what each part of the system is doing. + +- Instrument the system so you can see where time and resources are going. +- Prefer both: + - **Ad hoc inspection** for quick debugging + - **Logged measurements** for later analysis and comparison +- Attribute work across the full path, not just the obviously slow component. +- Make sure the data is detailed enough to explain where the cost comes from. + +If you can analyze runs after the fact with logs or traces, that is often much more powerful than relying only on live inspection. + +## 3. Use static analysis too + +Not every optimization problem needs runtime profiling first. Often, code inspection reveals the issue. + +Check for: + +- Wrong asymptotic complexity +- The wrong algorithm or data structure +- Unnecessary repeated work +- Work happening in the wrong layer +- Inefficient architecture or control flow +- Directionally incorrect approaches + +Make sure your asymptotics are right and the overall algorithm makes sense before tuning small details. + +## 4. Macro-optimize before micro-optimizing + +Prioritize the largest wins first. + +- Remove whole classes of work before making existing work slightly cheaper. +- Fix architecture, batching, caching, query patterns, algorithm choice, parallelism, and data movement before focusing on tiny low-level tweaks. +- If you are very far from the expected metrics, spend more time on macro-optimization. + +Micro-optimizations matter most after the major inefficiencies are already addressed. + +## Recommended workflow + +1. Define success metrics. +2. Reproduce the current baseline. +3. Add measurement and attribution if missing. +4. Identify the top bottleneck. +5. Check for algorithmic or architectural issues. +6. Apply the highest-leverage fix first. +7. Re-measure. +8. Repeat until the target is met or tradeoffs stop being worth it. + +## Guardrails + +- Do not claim an optimization without before/after evidence. +- Be careful not to optimize the wrong metric. +- Watch for regressions in correctness, reliability, maintainability, and security. +- Prefer changes that are measurable, explainable, and reversible. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..efd53c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# Repository Guidelines + +## 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 + +## Install Notes +- `~/.local/bin/jcode` is the launcher symlink used from `PATH`. +- `~/.jcode/builds/current/jcode` is the active local/source-build channel; self-dev builds and `scripts/install_release.sh` point the launcher here. +- `~/.jcode/builds/stable/jcode` is the stable release channel; `scripts/install.sh` installs this and points the launcher here. +- `~/.jcode/builds/versions//jcode` stores immutable binaries. +- `~/.jcode/builds/canary/jcode` still exists for canary/testing flows, but it is not the primary self-dev install path. +- 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`. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..49f1aa3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to jcode + +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. + +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. + +## Pull request policy + +Pull requests are welcome and encouraged. + +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. + +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. + +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. + +The best PRs therefore include: + +- a clear description of the problem being solved +- a minimal reproduction or failing test when possible +- notes about edge cases and tradeoffs +- 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. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..45fc6a9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,491 @@ +[package] +name = "jcode" +version = "0.46.0" +description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" +edition = "2024" +autobins = false + +[workspace] +members = [ + ".", + "crates/jcode-agent-runtime", + "crates/jcode-app-core", + "crates/jcode-base", + "crates/jcode-tui", + "crates/jcode-build-meta", + "crates/jcode-ambient-types", + "crates/jcode-auth-types", + "crates/jcode-embedding", + "crates/jcode-gateway-types", + "crates/jcode-import-core", + "crates/jcode-pdf", + "crates/jcode-logging", + "crates/jcode-background-types", + "crates/jcode-batch-types", + "crates/jcode-build-support", + "crates/jcode-compaction-core", + "crates/jcode-config-types", + "crates/jcode-core", + "crates/jcode-fuzzy", + "crates/jcode-memory-types", + "crates/jcode-message-types", + "crates/jcode-overnight-core", + "crates/jcode-plan", + "crates/jcode-productivity-core", + "crates/jcode-swarm-core", + "crates/jcode-protocol", + "crates/jcode-selfdev-types", + "crates/jcode-session-types", + "crates/jcode-setup-hints", + "crates/jcode-storage", + "crates/jcode-side-panel-types", + "crates/jcode-azure-auth", + "crates/jcode-notify-email", + "crates/jcode-provider-metadata", + "crates/jcode-provider-env", + "crates/jcode-provider-core", + "crates/jcode-provider-bedrock", + "crates/jcode-provider-anthropic", + "crates/jcode-provider-antigravity", + "crates/jcode-provider-copilot", + "crates/jcode-provider-openrouter", + "crates/jcode-provider-openai", + "crates/jcode-provider-gemini", + "crates/jcode-provider-gemini-runtime", + "crates/jcode-provider-cursor-runtime", + "crates/jcode-provider-antigravity-runtime", + "crates/jcode-provider-copilot-runtime", + "crates/jcode-provider-claude-cli-runtime", + "crates/jcode-provider-openrouter-runtime", + "crates/jcode-provider-anthropic-runtime", + "crates/jcode-provider-openai-runtime", + "crates/jcode-provider-doctor", + "crates/jcode-tui-markdown", + "crates/jcode-tui-messages", + "crates/jcode-usage-types", + "crates/jcode-tui-core", + "crates/jcode-tui-mermaid", + "crates/jcode-task-types", + "crates/jcode-tool-core", + "crates/jcode-tool-types", + "crates/jcode-tui-account-picker", + "crates/jcode-tui-anim", + "crates/jcode-tui-render", + "crates/jcode-tui-visual-debug", + "crates/jcode-tui-session-picker", + "crates/jcode-tui-style", + "crates/jcode-tui-permissions", + "crates/jcode-tui-tool-display", + "crates/jcode-tui-usage-overlay", + "crates/jcode-update-core", + "crates/jcode-terminal-launch", + "crates/jcode-terminal-image", + "crates/jcode-telemetry-core", + "crates/jcode-tui-workspace", + "crates/jcode-desktop", + "crates/jcode-render-core", +] + +[lib] +name = "jcode" +path = "src/lib.rs" + +[[bin]] +name = "jcode" +path = "src/main.rs" + +[[bin]] +name = "test_api" +path = "src/bin/test_api.rs" + +[[bin]] +name = "jcode-harness" +path = "src/bin/harness.rs" + +[[bin]] +name = "session_memory_bench" +path = "src/bin/session_memory_bench.rs" +required-features = ["dev-bins"] + +[[bin]] +name = "memory_recall_bench" +path = "src/bin/memory_recall_bench.rs" +required-features = ["dev-bins"] + +[[bin]] +name = "mermaid_side_panel_probe" +path = "src/bin/mermaid_side_panel_probe.rs" +required-features = ["dev-bins"] + +[[bin]] +name = "tui_bench" +path = "src/bin/tui_bench.rs" +required-features = ["dev-bins"] + +[dependencies] +# Memory allocator (reduces fragmentation for long-running server) +tikv-jemallocator = { version = "0.6", features = ["unprefixed_malloc_on_supported_platforms"], optional = true } + +# Async runtime +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +futures = "0.3" +async-trait = "0.1" + +# HTTP client +reqwest = { version = "0.12", default-features = false, features = ["json", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } +toml = "0.8" + +# CLI +clap = { version = "4", features = ["derive"] } + +# File operations + +# Utilities +dirs = "5" # home directory +anyhow = "1" +libc = "0.2" # Unix system calls (flock) +chrono = { version = "0.4", features = ["serde"] } + +# Embeddings (local inference) - behind feature flag (163 crates, slow to compile) + +# OAuth +sha2 = "0.10" +hex = "0.4" +open = "5" # Open URLs in browser +jcode-tui-session-picker = { path = "crates/jcode-tui-session-picker", features = ["serde"] } + +# Streaming +tokio-stream = "0.1" + +# TUI +ratatui = "0.30" +crossterm = { version = "0.29", features = ["event-stream"] } + +# Markdown & syntax highlighting + +# PDF parsing (behind feature flag) +jcode-build-meta = { path = "crates/jcode-build-meta" } +# 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 + +# bin) re-exports everything via `pub use jcode_tui::*`. default-features=false +# so the root feature set fully controls the downstream features (see +# [features]). +jcode-tui = { path = "crates/jcode-tui", default-features = false } +# `jcode provider-doctor` / provider diagnostics; sits downstream of jcode-base +# so doctor edits do not rebuild the base -> app-core -> tui spine. +jcode-provider-doctor = { path = "crates/jcode-provider-doctor" } +# Gemini provider runtime; sits downstream of jcode-base so gemini edits do +# not rebuild the base -> app-core -> tui spine. Registered with the external +# provider registry at startup (src/cli/startup.rs). +jcode-provider-gemini-runtime = { path = "crates/jcode-provider-gemini-runtime" } +jcode-provider-cursor-runtime = { path = "crates/jcode-provider-cursor-runtime" } +jcode-provider-antigravity-runtime = { path = "crates/jcode-provider-antigravity-runtime" } +jcode-provider-copilot-runtime = { path = "crates/jcode-provider-copilot-runtime" } +jcode-provider-claude-cli-runtime = { path = "crates/jcode-provider-claude-cli-runtime" } +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-selfdev-types = { path = "crates/jcode-selfdev-types" } + +# Archive extraction (for auto-update) +tempfile = "3" + +[features] +# Include local ONNX/tokenizer embeddings in default builds so memory recall, +# semantic retrieval, and embedding-backed features work out of the box. +# Use `JCODE_DEV_FEATURE_PROFILE=minimal` for compile-speed probes that need +# to skip optional default feature stacks. +default = ["pdf", "embeddings", "bedrock"] +dev-bins = ["jcode-tui/dev-bins"] +jemalloc = [ + "dep:tikv-jemallocator", + "tikv-jemallocator/stats", + "jcode-tui/jemalloc", +] +jemalloc-prof = [ + "jemalloc", + "tikv-jemallocator/profiling", + "jcode-tui/jemalloc-prof", +] +# Local embeddings and PDF extraction live in jcode-base / jcode-app-core; +# these features just forward down the stack. +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"] +mmdr-size-api = ["jcode-tui/mmdr-size-api"] +pdf = ["jcode-tui/pdf"] + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + +[target.'cfg(target_os = "macos")'.dependencies] +global-hotkey = "0.7" +objc2 = "0.6" +block2 = "0.6" +objc2-foundation = { version = "0.3", features = [ + "NSString", + "NSThread", + "NSRunLoop", + "NSDate", + "NSTimer", + "NSUserDefaults", + "NSValue", + "NSAttributedString", + "NSDictionary", + "block2", +] } +objc2-app-kit = { version = "0.3", features = [ + "NSAppearance", + "NSApplication", + "NSResponder", + "NSStatusBar", + "NSStatusItem", + "NSStatusBarButton", + "NSButton", + "NSCell", + "NSColor", + "NSControl", + "NSFont", + "NSFontDescriptor", + "NSImage", + "NSAttributedString", + "NSMenu", + "NSMenuItem", + "NSRunningApplication", +] } + +[profile.release] +opt-level = 1 +debug = 0 +codegen-units = 256 +incremental = true + +[profile.selfdev] +inherits = "release" +opt-level = 0 + +# Full LTO release for stable/distribution builds +[profile.release-lto] +inherits = "release" +lto = "thin" +codegen-units = 16 +incremental = false + +[profile.dev] +debug = 0 +incremental = true + +[profile.dev.package."*"] +opt-level = 0 + +# Keep the idle-animation math kernels fully optimized even in debug-style +# builds. This crate is pure, dependency-free numeric code (the trig-heavy 3D +# samplers that otherwise dominate idle CPU). Pinning it to opt-level = 3 here +# means the optimized object is compiled once and reused across iterative +# dev/selfdev rebuilds, since it only recompiles when the animation code itself +# changes. The "*" glob above sets the default for dependencies; these explicit +# package entries take precedence for this one crate in each profile. +[profile.dev.package."jcode-tui-anim"] +opt-level = 3 + +[profile.selfdev.package."jcode-tui-anim"] +opt-level = 3 + +[profile.test.package."jcode-tui-anim"] +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 +# transcript glyph shaping. At opt-level = 0 they are 15-40x slower, which made +# scrolling real (emoji/markdown-heavy) transcripts janky in dev/selfdev builds +# even though the production release build was smooth. These are stable +# third-party crates that almost never recompile, so pinning them to opt-level = +# 3 costs a one-time compile and is then reused across every iterative jcode +# rebuild (same rationale as jcode-tui-anim above). It does NOT slow down +# recompiles of jcode's own crates. +[profile.dev.package.cosmic-text] +opt-level = 3 +[profile.selfdev.package.cosmic-text] +opt-level = 3 +[profile.test.package.cosmic-text] +opt-level = 3 + +[profile.dev.package.rustybuzz] +opt-level = 3 +[profile.selfdev.package.rustybuzz] +opt-level = 3 +[profile.test.package.rustybuzz] +opt-level = 3 + +[profile.dev.package.ttf-parser] +opt-level = 3 +[profile.selfdev.package.ttf-parser] +opt-level = 3 +[profile.test.package.ttf-parser] +opt-level = 3 + +[profile.dev.package.swash] +opt-level = 3 +[profile.selfdev.package.swash] +opt-level = 3 +[profile.test.package.swash] +opt-level = 3 + +[profile.dev.package.yazi] +opt-level = 3 +[profile.selfdev.package.yazi] +opt-level = 3 +[profile.test.package.yazi] +opt-level = 3 + +[profile.dev.package.fontdb] +opt-level = 3 +[profile.selfdev.package.fontdb] +opt-level = 3 +[profile.test.package.fontdb] +opt-level = 3 + +# Keep the raster image pipeline optimized in dev/selfdev/test builds. +# +# Inline transcript images decode (png/zune-jpeg/fdeflate/miniz_oxide), resize +# (image), and base64-encode (kitty transmit) at draw time; at opt-level 0 a +# single screenshot costs 100-200ms per decode/resize, which reads as input +# lag. These are pure pixel crates with no jcode logic, so optimizing them +# does not hurt rebuild times for our own code. +[profile.dev.package.image] +opt-level = 3 +[profile.selfdev.package.image] +opt-level = 3 +[profile.test.package.image] +opt-level = 3 + +[profile.dev.package.png] +opt-level = 3 +[profile.selfdev.package.png] +opt-level = 3 +[profile.test.package.png] +opt-level = 3 + +[profile.dev.package.zune-jpeg] +opt-level = 3 +[profile.selfdev.package.zune-jpeg] +opt-level = 3 +[profile.test.package.zune-jpeg] +opt-level = 3 + +[profile.dev.package.fdeflate] +opt-level = 3 +[profile.selfdev.package.fdeflate] +opt-level = 3 +[profile.test.package.fdeflate] +opt-level = 3 + +[profile.dev.package.miniz_oxide] +opt-level = 3 +[profile.selfdev.package.miniz_oxide] +opt-level = 3 +[profile.test.package.miniz_oxide] +opt-level = 3 + +[profile.dev.package.base64] +opt-level = 3 +[profile.selfdev.package.base64] +opt-level = 3 +[profile.test.package.base64] +opt-level = 3 + +# session_search scans multi-megabyte session files with memchr-based +# case-insensitive matching; keep the SIMD scanner optimized everywhere. +[profile.dev.package.memchr] +opt-level = 3 +[profile.selfdev.package.memchr] +opt-level = 3 +[profile.test.package.memchr] +opt-level = 3 + +# The embedding stack (tract ONNX inference + HF tokenizer) runs the MiniLM +# model for memory recall/maintenance on the shared server. At opt-level 0 a +# single embed measured ~666 ms (matmul inner loops unoptimized), which keeps +# the model "busy" long enough that the idle unloader rarely fires and burns +# CPU on every memory-maintenance pass. These crates are pure compute and +# change only when the dependency is bumped, so the optimized objects are +# compiled once and cached across dev/selfdev iterations. +[profile.dev.package.tract-core] +opt-level = 3 +[profile.selfdev.package.tract-core] +opt-level = 3 +[profile.test.package.tract-core] +opt-level = 3 +[profile.dev.package.tract-linalg] +opt-level = 3 +[profile.selfdev.package.tract-linalg] +opt-level = 3 +[profile.test.package.tract-linalg] +opt-level = 3 +[profile.dev.package.tract-data] +opt-level = 3 +[profile.selfdev.package.tract-data] +opt-level = 3 +[profile.test.package.tract-data] +opt-level = 3 +[profile.dev.package.tract-hir] +opt-level = 3 +[profile.selfdev.package.tract-hir] +opt-level = 3 +[profile.test.package.tract-hir] +opt-level = 3 +[profile.dev.package.tract-onnx] +opt-level = 3 +[profile.selfdev.package.tract-onnx] +opt-level = 3 +[profile.test.package.tract-onnx] +opt-level = 3 +[profile.dev.package.tract-onnx-opl] +opt-level = 3 +[profile.selfdev.package.tract-onnx-opl] +opt-level = 3 +[profile.test.package.tract-onnx-opl] +opt-level = 3 +[profile.dev.package.tract-nnef] +opt-level = 3 +[profile.selfdev.package.tract-nnef] +opt-level = 3 +[profile.test.package.tract-nnef] +opt-level = 3 +[profile.dev.package.ndarray] +opt-level = 3 +[profile.selfdev.package.ndarray] +opt-level = 3 +[profile.test.package.ndarray] +opt-level = 3 +[profile.dev.package.tokenizers] +opt-level = 3 +[profile.selfdev.package.tokenizers] +opt-level = 3 +[profile.test.package.tokenizers] +opt-level = 3 + +[profile.test] +debug = 0 +incremental = true +codegen-units = 256 + +[dev-dependencies] +async-stream = "0.3" +# 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 +# target. Feature unification applies this only to test/bench/example builds, +# never to a normal `cargo build`/release binary. +jcode-tui = { path = "crates/jcode-tui", features = ["test-support"] } + +[build-dependencies] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e921bcc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jeremy Huang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OAUTH.md b/OAUTH.md new file mode 100644 index 0000000..3a31f03 --- /dev/null +++ b/OAUTH.md @@ -0,0 +1,371 @@ +# Auth Notes: OAuth + API-key Providers + +This document explains how authentication works in J-Code. + +## Overview + +J-Code can detect existing local credentials and can also run built-in OAuth and API-key login flows. + +For auth files managed by other tools/CLIs, jcode asks before reading them. If you +approve a source, jcode remembers that approval for that external auth file path +for future sessions and still leaves the original file untouched (no move, +rewrite, or permission mutation). Symlinked external auth files are rejected. + +Credentials are stored locally: +- J-Code Claude OAuth (if logged in via `jcode login --provider claude`): `~/.jcode/auth.json` +- Claude Code CLI: `~/.claude/.credentials.json` (Linux/Windows), or the **macOS login Keychain** item `Claude Code-credentials` (the default on macOS, where the JSON file usually does not exist), or the `CLAUDE_CODE_OAUTH_TOKEN` env var (set by `claude setup-token`) +- OpenCode (optional provider/OAuth import source): `~/.local/share/opencode/auth.json` +- pi (optional provider/OAuth import source): `~/.pi/agent/auth.json` +- J-Code OpenAI/Codex OAuth: `~/.jcode/openai-auth.json` +- Codex CLI auth source (read in place only after confirmation): `~/.codex/auth.json` +- Gemini native OAuth: `~/.jcode/gemini_oauth.json` +- Gemini CLI import fallback: `~/.gemini/oauth_creds.json` +- Copilot CLI plaintext fallback: `~/.copilot/config.json` +- Legacy Copilot JSON sources: `~/.config/github-copilot/hosts.json`, `~/.config/github-copilot/apps.json` + +Relevant code: +- Claude provider: `src/provider/claude.rs` +- OpenAI login + refresh: `src/auth/oauth.rs` +- OpenAI credentials parsing: `src/auth/codex.rs` +- OpenAI requests: `src/provider/openai.rs` +- Azure OpenAI auth/config: `src/auth/azure.rs` +- Azure OpenAI transport: `src/provider/openrouter.rs` +- Gemini login + refresh: `src/auth/gemini.rs` +- Gemini Code Assist provider: `src/provider/gemini.rs` +- OpenAI-compatible provider metadata/login descriptors: `crates/jcode-provider-metadata/src/lib.rs` + +## Claude (Claude Max) + +### Login steps +1. Run `jcode login --provider claude` (recommended), or `jcode login` and choose Claude. + - For headless / SSH use: `jcode login --provider claude --no-browser` + - For scriptable remote flows: `jcode login --provider claude --print-auth-url`, then later complete with `--callback-url` or `--auth-code` +2. Alternative: run `claude` (or `claude setup-token`). jcode can detect Claude Code's credentials, ask before reading them, and remember that approval for future sessions. This works whether Claude Code stored them in `~/.claude/.credentials.json` (Linux/Windows), the macOS login Keychain (`Claude Code-credentials`), or the `CLAUDE_CODE_OAUTH_TOKEN` env var. On macOS, approving the Keychain source copies the credentials into `~/.jcode/auth.json` once so later sessions never re-prompt the Keychain. +3. Verify with `jcode --provider claude run "Say hello from jcode"`. + +Credential discovery order is: +1. `~/.jcode/auth.json` +2. `~/.claude/.credentials.json` +3. Claude Code native credentials (macOS Keychain `Claude Code-credentials`, or `CLAUDE_CODE_OAUTH_TOKEN` env var) once approved +4. `~/.local/share/opencode/auth.json` +5. `~/.pi/agent/auth.json` + +### Direct Anthropic API (default) +`--provider claude` uses the direct Anthropic Messages API by default. +jcode owns the full runtime path itself: auth, refresh, request shaping, tool +compatibility, and transport. + +#### Claude OAuth direct API compatibility +Claude Code OAuth tokens can be used directly against the Messages API, but only +if the request matches the Claude Code "OAuth contract". jcode applies this +automatically for the default Claude runtime path. + +Required behaviors (applied by the Anthropic provider): +- Use the Messages endpoint with `?beta=true`. +- Send `User-Agent: claude-cli/1.0.0`. +- Send `anthropic-beta: oauth-2025-04-20,claude-code-20250219`. +- Prepend the system blocks with the Claude Code identity line as the first + block: + - `You are Claude Code, Anthropic's official CLI for Claude.` + +Tool name allow-list: +Claude OAuth requests reject certain tool names. jcode remaps a small set of +builtin tool names on the wire to the Claude-Code builtin names and maps them +back on responses so native tools continue to work. Every other tool is +forwarded under its own name, so the full custom toolset (websearch, webfetch, +browser, codesearch, memory, swarm, multiedit, open, ...) stays available on +OAuth. The remapped names are: +- `bash` → `Bash` +- `read` → `Read` +- `write` → `Write` +- `edit` → `Edit` +- `glob` → `Glob` +- `grep` → `Grep` +- `subagent` → `Agent` +- `schedule` → `ScheduleWakeup` +- `skill_manage` → `Skill` + +Notes: +- If the OAuth token expires, refresh via the Claude OAuth refresh endpoint. +- Without the identity line and allow-listed tool names, the API will reject + OAuth requests even if the token is otherwise valid. + +### Deprecated Claude CLI transport +The old Claude CLI shell-out path is deprecated and should only be used for +legacy compatibility. + +You can still force it temporarily with: +- `JCODE_USE_CLAUDE_CLI=1` +- or `--provider claude-subprocess` (deprecated hidden compatibility value) + +These environment variables control the deprecated Claude Code CLI transport: +- `JCODE_CLAUDE_CLI_PATH` (default: `claude`) +- `JCODE_CLAUDE_CLI_MODEL` (default: `claude-opus-4-5-20251101`) +- `JCODE_CLAUDE_CLI_PERMISSION_MODE` (default: `bypassPermissions`) +- `JCODE_CLAUDE_CLI_PARTIAL` (set to `0` to disable partial streaming) + +## OpenAI / Codex OAuth + +### Login steps +1. Run `jcode login --provider openai`. + - For headless / SSH use: `jcode login --provider openai --no-browser` + - For scriptable remote flows: `jcode login --provider openai --print-auth-url`, then later complete with `--callback-url` +2. Your browser opens to the OpenAI OAuth page unless you use `--no-browser`. The local callback listens on + `http://localhost:1455/auth/callback` by default. + If port `1455` is unavailable, jcode falls back to a manual paste flow where + you can paste the full callback URL or query string. +3. After login, tokens are saved to `~/.jcode/openai-auth.json`. + +Credential discovery order is: +1. `~/.jcode/openai-auth.json` +2. `~/.codex/auth.json` +3. trusted OpenCode/pi OAuth in `~/.local/share/opencode/auth.json` / `~/.pi/agent/auth.json` +4. `OPENAI_API_KEY` + +If jcode finds existing credentials in `~/.codex/auth.json`, it asks before +reading them. When approved, it remembers that trust decision for future jcode +sessions and still does not move, delete, or rewrite the Codex file. + +### Request details +J-Code uses the Responses API. If you have a ChatGPT subscription (refresh +token or id_token present), requests go to: +- `https://chatgpt.com/backend-api/codex/responses` +with headers: +- `originator: codex_cli_rs` +- `chatgpt-account-id: ` + +Otherwise it uses: +- `https://api.openai.com/v1/responses` + +For **API-key** usage (no ChatGPT/Codex OAuth), the Responses API base URL is +overridable so you can target a local or proxied Responses-API endpoint. Set one +of (checked in this order) to an absolute `http(s)://` base that ends in the API +version, e.g. `http://127.0.0.1:8317/v1`: +- `JCODE_OPENAI_API_BASE` +- `OPENAI_BASE_URL` +- `OPENAI_API_BASE` + +jcode appends `/responses` itself, derives the WebSocket and `/compact` +endpoints from the same base, and also points the `/models` catalog probe at it. +The override is ignored in ChatGPT/Codex OAuth mode (that backend is fixed), and +a malformed value is logged and ignored rather than breaking requests. + +### Troubleshooting +- Claude 401/auth errors: run `jcode login --provider claude`. +- 401/403: re-run `jcode login --provider openai`. +- Callback issues: make sure port 1455 is free and the browser can reach + `http://localhost:1455/auth/callback`. + +## Azure OpenAI + +This was added after comparing J-Code to OpenCode/Crush. The meaningful auth gap +was not another browser OAuth flow, but support for **Azure OpenAI** using either: +- **Microsoft Entra ID** credentials (via Azure's `DefaultAzureCredential` chain), or +- **Azure OpenAI API keys**. + +### Login/setup steps +1. Run `jcode login --provider azure`. +2. Enter your Azure OpenAI endpoint, for example: + - `https://your-resource.openai.azure.com` +3. Enter your Azure deployment/model name. +4. Choose one auth mode: + - **Entra ID** (recommended) + - **API key** +5. jcode saves settings to `~/.config/jcode/azure-openai.env`. + +### Stored configuration +The Azure env file may contain: +- `AZURE_OPENAI_ENDPOINT` +- `AZURE_OPENAI_MODEL` +- `AZURE_OPENAI_USE_ENTRA` +- `AZURE_OPENAI_API_KEY` (only when using key auth) + +### Runtime behavior +- jcode normalizes the endpoint to the newer Azure OpenAI `/openai/v1` base. +- In **Entra ID** mode, jcode obtains bearer tokens using `azure_identity::DefaultAzureCredential` with scope: + - `https://cognitiveservices.azure.com/.default` +- In **API key** mode, jcode sends the credential in the Azure-style `api-key` header. +- The Azure provider currently reuses J-Code's OpenAI-compatible transport layer under the hood. +- Model catalog fetching is disabled for Azure by default, so you should configure a deployment/model explicitly. + +### Entra ID credential sources +`DefaultAzureCredential` can resolve credentials from sources like: +- `az login` +- managed identity +- Azure environment credentials + +### Troubleshooting +- If Entra ID auth fails locally, try `az login` first. +- Make sure your identity has access to the Azure OpenAI resource. +- If requests fail with deployment/model errors, verify `AZURE_OPENAI_MODEL` matches your deployed model name. +- If you prefer static credentials, re-run `jcode login --provider azure` and choose API key mode. + +## Gemini OAuth + +### Login steps +1. Run `jcode login --provider gemini` or `/login gemini` inside the TUI. + - For headless / SSH use: `jcode login --provider gemini --no-browser` + - For scriptable remote flows: `jcode login --provider gemini --print-auth-url`, then later complete with `--auth-code` +2. jcode opens a browser to the Google OAuth flow used for Gemini Code Assist unless you use `--no-browser`. +3. If local callback binding is unavailable, jcode falls back to a manual paste flow using `https://codeassist.google.com/authcode`. +4. Tokens are saved to `~/.jcode/gemini_oauth.json`. + +### Credential discovery order +1. Native jcode Gemini tokens: `~/.jcode/gemini_oauth.json` +2. Gemini CLI OAuth source (read only after approval): `~/.gemini/oauth_creds.json` +3. trusted OpenCode/pi OAuth in `~/.local/share/opencode/auth.json` / `~/.pi/agent/auth.json` + +### Runtime notes +- jcode uses native Google OAuth and talks to the Google Code Assist backend directly. +- Expired tokens are refreshed automatically using the Google refresh token. +- Some school / Workspace accounts may require `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` for Code Assist entitlement checks. + +### Troubleshooting +- If browser launch fails, use `--no-browser` and the pasted callback/code flow. +- If entitlement or onboarding fails for a Workspace account, set `GOOGLE_CLOUD_PROJECT` and retry. +- If login succeeds but requests fail later, re-run `jcode login --provider gemini` to refresh the stored session. + +### Auth verification +Use the built-in auth verifier to test the full local auth/runtime path after login: + +```bash +# Run Gemini login now, then verify token refresh + provider smoke +jcode --provider gemini auth-test --login + +# Verify existing Gemini auth without re-running login +jcode --provider gemini auth-test + +# Check every currently configured supported auth provider +jcode auth-test --all-configured +``` + +For model providers, `auth-test` attempts: +1. credential discovery +2. refresh/auth probe +3. a real provider smoke prompt expecting `AUTH_TEST_OK` +4. a tool-enabled smoke prompt using the same tool-attached request path as normal chat + +Use `--no-tool-smoke` if you only want the auth/simple-runtime checks. + +For Gmail/Google it verifies credential discovery and token refresh, but skips model smoke because it is not a model provider. + +## OpenAI-compatible API-key providers + +J-Code also ships first-class provider presets for many OpenAI-compatible APIs. +These providers use the same built-in login flow pattern: `jcode login --provider `. + +For arbitrary OpenAI-compatible APIs, especially when an agent is doing setup, prefer the named profile command instead of hand-editing config: + +```bash +printf '%s' "$MY_API_KEY" | jcode provider add my-api \ + --base-url https://llm.example.com/v1 \ + --model my-model-id \ + --api-key-stdin \ + --set-default \ + --json + +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: + +### Fireworks +- Login: `jcode login --provider fireworks` +- Stored env file: `~/.config/jcode/fireworks.env` +- API key env var: `FIREWORKS_API_KEY` +- Base URL: `https://api.fireworks.ai/inference/v1` +- Default model hint: `accounts/fireworks/routers/kimi-k2p5-turbo` +- Docs: + +### MiniMax +- Login: `jcode login --provider minimax` +- Stored env file: `~/.config/jcode/minimax.env` +- API key env var: `OPENAI_API_KEY` +- Base URL: `https://api.minimax.io/v1` +- Default model hint: `MiniMax-M2.7` +- Docs: + +These are first-class jcode provider presets, not just manual custom endpoint examples. +You can still use `openai-compatible` for arbitrary custom providers when there is not a built-in preset. + +If jcode finds matching API keys in trusted OpenCode/pi auth files, it can reuse them for the corresponding provider preset without asking you to paste the key again. + +## Experimental CLI Providers + +J-Code also supports experimental CLI-backed providers, plus Antigravity with native OAuth login: +- `--provider cursor` +- `--provider copilot` +- `--provider antigravity` + +Cursor uses jcode's native HTTPS transport. Copilot uses GitHub device-flow auth. Antigravity login/auth storage is handled natively by jcode. + +### Cursor +- Login: `jcode login --provider cursor` + - saves `CURSOR_API_KEY` to `~/.config/jcode/cursor.env` +- Runtime: + - jcode uses native HTTPS requests + - if a Cursor API key is configured, jcode exchanges/uses it directly +- Env vars: + - `JCODE_CURSOR_MODEL` (default: `composer-1.5`) + - `CURSOR_API_KEY` (optional; overrides saved key) + +### GitHub Copilot +- Login: `jcode login --provider copilot` + - Headless / SSH: `jcode login --provider copilot --no-browser` + - Scriptable remote flow: `jcode login --provider copilot --print-auth-url`, then later `jcode login --provider copilot --complete` + - jcode uses GitHub device code flow and can print the verification URL/QR without opening a local browser. +- Credential discovery order: + 1. `COPILOT_GITHUB_TOKEN` + 2. `GH_TOKEN` + 3. `GITHUB_TOKEN` + 4. trusted `~/.copilot/config.json` + 5. trusted legacy `~/.config/github-copilot/hosts.json` + 6. trusted legacy `~/.config/github-copilot/apps.json` + 7. trusted OpenCode/pi OAuth entries + 8. `gh auth token` +- Env vars: + - `JCODE_COPILOT_CLI_PATH` (optional override for CLI path) + - `JCODE_COPILOT_MODEL` (default: `claude-sonnet-4`) + +### Antigravity +- Login: `jcode login --provider antigravity` (native Google OAuth flow; does **not** require Antigravity to be installed) + - Headless / SSH: `jcode login --provider antigravity --no-browser` + - Scriptable remote flow: `jcode login --provider antigravity --print-auth-url`, then later complete with `--callback-url` +- Tokens: `~/.jcode/antigravity_oauth.json` +- Credential discovery order: + 1. native jcode tokens at `~/.jcode/antigravity_oauth.json` + 2. trusted OpenCode/pi OAuth entries when present +- Runtime: + - jcode authenticates directly and stores/refreshes Antigravity OAuth tokens itself + - the provider transport still shells out to the Antigravity CLI for completions if you choose `--provider antigravity` +- Env vars: + - `JCODE_ANTIGRAVITY_CLIENT_ID` (optional override for OAuth client id) + - `JCODE_ANTIGRAVITY_CLIENT_SECRET` (optional override for OAuth client secret) + - `JCODE_ANTIGRAVITY_VERSION` (optional override for Antigravity request fingerprint/version) + - `JCODE_ANTIGRAVITY_CLI_PATH` (default: `antigravity`, runtime only) + - `JCODE_ANTIGRAVITY_MODEL` (default: `default`) + - `JCODE_ANTIGRAVITY_PROMPT_FLAG` (default: `-p`) + - `JCODE_ANTIGRAVITY_MODEL_FLAG` (default: `--model`) + +## Google / Gmail OAuth + +### Login steps +1. Run `jcode login --provider google`. + - For headless / SSH use: `jcode login --provider google --no-browser` + - For scriptable remote flows after credentials are already configured: `jcode login --provider google --print-auth-url` +2. If Google credentials are not configured yet, jcode first walks you through saving your client ID/client secret or importing the JSON credentials file. +3. For scriptable Google flows, choose the Gmail scope with `--google-access-tier full|readonly` if you do not want the default full access tier. +4. Complete the printed flow later with `jcode login --provider google --callback-url ''`. + +### Notes +- Google/Gmail scriptable auth requires saved OAuth client credentials first. +- The callback URL can come from a remote browser session that fails on the loopback redirect. Copy the final URL from the address bar and paste or pass it back to jcode. + +## Scriptable auth state lifecycle + +- jcode stores temporary scriptable login state in `~/.jcode/pending-login/*.json` +- pending state expires automatically +- stale pending entries are cleaned up when scriptable login flows start or resume +- Copilot `--print-auth-url` stores the GitHub device code session and `--complete` resumes polling later diff --git a/PLAN_MCP_SKILLS.md b/PLAN_MCP_SKILLS.md new file mode 100644 index 0000000..e06dd2b --- /dev/null +++ b/PLAN_MCP_SKILLS.md @@ -0,0 +1,129 @@ +# Plan: Dynamic Skills and MCP Support + +## Goals +1. Hot-reload skills without restart +2. MCP (Model Context Protocol) server support +3. Dynamic tool registration at runtime +4. Agent can add/configure MCP servers itself + +## Current State +- Skills: Loaded from `~/.claude/skills/` and `./.claude/skills/` at startup +- Tools: Hardcoded in `Registry::new()` +- No MCP support + +--- + +## Implementation Plan + +### Phase 1: Hot-reload Skills + +**Changes to `src/skill.rs`:** +- Add `reload(&mut self)` method to `SkillRegistry` +- Skills can be reloaded without restarting + +**New tool `reload_skills`:** +- Agent can trigger `reload_skills` to pick up new skills + +### Phase 2: Dynamic Tool Registry + +**Changes to `src/tool/mod.rs`:** +```rust +impl Registry { + /// Register a new tool at runtime + pub async fn register(&self, tool: Arc); + + /// Unregister a tool by name + pub async fn unregister(&self, name: &str); + + /// List all registered tools + pub async fn list(&self) -> Vec; +} +``` + +### Phase 3: MCP Client + +**New module `src/mcp/mod.rs`:** +- MCP protocol types (JSON-RPC 2.0) +- MCP client for stdio-based servers +- MCP tool wrapper (converts MCP tools to our Tool trait) + +**Config file `~/.claude/mcp.json`:** +```json +{ + "servers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"], + "env": {} + }, + "github": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-server-github"], + "env": {"GITHUB_TOKEN": "..."} + } + } +} +``` + +**MCP Manager:** +- Load config on startup +- Connect to configured servers +- Convert MCP tools to jcode Tool trait +- Handle server lifecycle (start, stop, restart) + +### Phase 4: Agent Self-Configuration + +**New tools:** +- `mcp_list` - List connected MCP servers +- `mcp_connect` - Start a new MCP server +- `mcp_disconnect` - Stop an MCP server +- `mcp_reload` - Reload all MCP servers + +**Flow:** +1. Agent calls `mcp_connect {"name": "playwright", "command": "npx", "args": ["-y", "@anthropic/mcp-server-playwright"]}` +2. jcode spawns the process, does MCP handshake +3. Tools from server are added to registry +4. Agent can immediately use the new tools + +--- + +## MCP Protocol Summary + +MCP uses JSON-RPC 2.0 over stdio: + +**Initialize:** +```json +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"jcode","version":"0.1.0"}}} +``` + +**List tools:** +```json +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} +``` + +**Call tool:** +```json +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/tmp/test.txt"}}} +``` + +--- + +## Files to Create/Modify + +1. `src/mcp/mod.rs` - MCP module +2. `src/mcp/protocol.rs` - JSON-RPC types +3. `src/mcp/client.rs` - MCP client +4. `src/mcp/manager.rs` - Multi-server manager +5. `src/mcp/tool.rs` - MCP tool wrapper +6. `src/tool/mod.rs` - Add dynamic registration +7. `src/tool/mcp_tools.rs` - mcp_connect, mcp_list, etc. +8. `src/skill.rs` - Add reload() +9. `src/tool/reload_skills.rs` - reload_skills tool + +## Order of Implementation +1. Dynamic tool registry (prerequisite) +2. Skill hot-reload (quick win) +3. MCP protocol types +4. MCP client (single server) +5. MCP manager (multi-server) +6. MCP tools for agent self-config diff --git a/README.md b/README.md new file mode 100644 index 0000000..78f36e4 --- /dev/null +++ b/README.md @@ -0,0 +1,861 @@ +
+ +# jcode + +[![Latest Release](https://badgen.net/github/release/1jehuang/jcode?icon=github)](https://github.com/1jehuang/jcode/releases) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) +[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-blue?style=flat-square)](https://github.com/1jehuang/jcode/releases) +[![Last Commit](https://badgen.net/github/last-commit/1jehuang/jcode/master?icon=github)](https://github.com/1jehuang/jcode/commits/master) +[![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. + +
+ + + jcode memory demonstration + + +
+ +[Website](https://jcode.sh) · [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Further Reading](#further-reading) · [Contributing](CONTRIBUTING.md) + +
+ +--- + +
+ +## Installation + +
+ +```bash +# macOS & Linux +curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash +``` + +Need Windows, Homebrew, source builds, provider setup, or tell your agent to set it up for you? +[Jump to detailed installation](#detailed-installation). + +--- + + +
+ +## Performance & Resource Efficiency + +
+ +jcode is built to be as performant and resource efficient as possible. Every metric is optimized to the bone, which is important for scaling multi-session workflows. Here we sample a few metrics to show the difference: RAM usage and boot up. + +### RAM comparison + +
+ + + + + + + +
+ 1 active session + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolPSSComparison
jcode (local embedding off)27.8 MBbaseline
jcode167.1 MB6.0× more RAM
pi144.4 MB5.2× more RAM
Codex CLI140.0 MB5.0× more RAM
OpenCode371.5 MB13.4× more RAM
GitHub Copilot CLI333.3 MB12.0× more RAM
Cursor Agent214.9 MB7.7× more RAM
Claude Code386.6 MB13.9× more RAM
Antigravity CLI243.7 MB8.8× more RAM
+
+ 10 active sessions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolPSSComparison
jcode (local embedding off)117.0 MBbaseline
jcode260.8 MB2.2× more RAM
pi833.0 MB7.1× more RAM
Codex CLI334.8 MB2.9× more RAM
OpenCode3237.2 MB27.7× more RAM
GitHub Copilot CLI1756.5 MB15.0× more RAM
Cursor Agent1632.4 MB14.0× more RAM
Claude Code2300.6 MB19.7× more RAM
Antigravity CLI1021.2 MB8.7× more RAM
+
+ +
+ +### Time to first frame + +
+ +| Tool | Time to first frame | Range | Comparison | +|---|---:|---:|---:| +| **jcode** | **14.0 ms** | 10.1–19.3 ms | baseline | +| **Antigravity CLI** | **383.5 ms** | 363.1–415.4 ms | **27.4× slower** | +| **pi** | **590.7 ms** | 369.6–934.8 ms | **42.2× slower** | +| **Codex CLI** | **882.8 ms** | 742.3–1640.9 ms | **63.1× slower** | +| **OpenCode** | **1035.9 ms** | 922.5–1104.4 ms | **74.0× slower** | +| **GitHub Copilot CLI** | **1518.6 ms** | 1357.4–1826.8 ms | **108.5× slower** | +| **Cursor Agent** | **1949.7 ms** | 1711.0–2104.8 ms | **139.3× slower** | +| **Claude Code** | **3436.9 ms** | 2032.7–8927.2 ms | **245.5× slower** | + +
+ +Measured on this Linux machine across 10 interactive PTY launches. + +### Time to first input +(time until typed probe text appears on the rendered screen; Antigravity uses its internal input-ready log marker because the sign-in screen suppresses probe echo.) +
+ +| Tool | Time to first input | Range | Comparison | +|---|---:|---:|---:| +| **jcode** | **48.7 ms** | 30.3–62.7 ms | baseline | +| **Antigravity CLI** | **383.7 ms** | 363.4–415.7 ms | **7.9× slower** | +| **pi** | **596.4 ms** | 373.9–955.2 ms | **12.2× slower** | +| **Codex CLI** | **905.8 ms** | 760.1–1675.7 ms | **18.6× slower** | +| **OpenCode** | **1047.9 ms** | 931.1–1116.9 ms | **21.5× slower** | +| **GitHub Copilot CLI** | **1583.4 ms** | 1422.8–1880.0 ms | **32.5× slower** | +| **Cursor Agent** | **1978.7 ms** | 1727.3–2130.0 ms | **40.6× slower** | +| **Claude Code** | **3512.8 ms** | 2137.4–9002.0 ms | **72.2× slower** | + +
+ +Measured on this Linux machine across 10 interactive PTY launches. Antigravity CLI was unauthenticated for this run; its sign-in screen rendered normally and emitted an internal `CLI ready for user input` marker, but did not echo the typed probe. + +### Additional clients / memory scaling + +
+ +| Tool | Extra PSS per added session | Comparison | +|---|---:|---:| +| **jcode (local embedding off)** | **~9.9 MB** | baseline | +| **jcode** | **~10.4 MB** | **1.1× more RAM** | +| **pi** | **~76.5 MB** | **7.7× more RAM** | +| **Codex CLI** | **~21.6 MB** | **2.2× more RAM** | +| **OpenCode** | **~318.4 MB** | **32.2× more RAM** | +| **GitHub Copilot CLI** | **~158.1 MB** | **16.0× more RAM** | +| **Cursor Agent** | **~157.5 MB** | **15.9× more RAM** | +| **Claude Code** | **~212.7 MB** | **21.5× more RAM** | +| **Antigravity CLI** | **~86.4 MB** | **8.7× more RAM** | + +
+versions tested for this corrected memory rerun: + +- `jcode v0.9.1888-dev (be386f2)` +- `pi 0.62.0` +- `codex-cli 0.120.0` +- `opencode 1.0.203` +- `GitHub Copilot CLI 1.0.24` for the 1-session rerun, `GitHub Copilot CLI 1.0.27` for the 10-session rerun +- `Cursor Agent 2026.04.08-a41fba1` +- `Claude Code 2.1.86 (Claude Code)` +- `Antigravity CLI 1.0.0` + +
+ + + jcode performance demonstration + + +

jcode performance demonstration

+ +
+ + +--- + +## Memory (Agent memory) + +Jcode embeds each turn/response as a semantic vector. Every turn does queries a graph of memories to efficiently find related memory entries via a cosine similarity check. The embedding hits are fed into the conversation, or optionally uses a memory sideagent which verifies the memories are relevant, and potentially does more work for information retreival before injecting into the conversation. This results in a human like memory system which allows the agent to automatically recall relevant information to the conversation without actively calling memory tools or being a token burner. +ot +To have memories which are retrieved, they must also be extracted and stored. Every so often (semantic drift, K turns since last extraction, session end, etc), memories are extracted via a memory sideagent, and put into the memory graph. + +The harness also provides explicit memory tools to allow the agent to actively search or store the memory without relying on a passive background process. The harness also provides session search for traditional RAG on previous sessions. + +Memories are automatically consolidated every so often via the ambient mode. This reorganizes, checks for staleness and conflicts, etc + +
+ + + jcode memory demonstration + + +

jcode memory demonstration

+ +
+ + + +--- + +## UI: Side panels, Diagrams, Info Widgets, rendering, scrolling, alignment + +The side panel is a place for auxiliary information. Tell your jcode agent to load a file into the side panel and see it update in real time, or tell your agent to write directly to the side panel, or use it as a diff viewer. The side panel (and chat) is able to render mermaid diagrams inline. +image + +To make this possible, I created a new mermaid rendering library to render diagrams 1800x faster. It has no browser or Typescript dependency. See https://github.com/1jehuang/mermaid-rs-renderer + +To show you important information without taking space away from the screen that could be used for responses, I developed info widgets. Info widgets will only ever take up the negative space on the screen to show you information, and will get out of the way if there isn't any. + +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. + +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. + +--- + +## Swarm + +Spawn two or more agents in the same repo, and they will automatically be managed by the server to allow native collaboration. When agent A edits a file that agent B has read (code shifting under its feet), the server notifies agent B. Agent B can ignore it if it is not relevant, or it can check the diff to make sure that it doesn't conflict. Each agent has messaging abilities, capable of DMing just one agent, broadcasting to all other agents hosted by the server, or just agents working in that repo. This allows you to spawn multiple sessions in the same repo, and have all conflicts automatically resolved. + +
+ + + jcode swarm demonstration + + +

jcode swarm demonstration

+ +
+ +Agents are also able to spawn their own swarms autonomously. They have a swarm tool which allows them to spawn in their own teamates to accomplish tasks in parallel. Doing so turns the main agent into a coordinator and the spawned agents into workers. Groups of agents, their messaging channels, their completion statuses, etc are all automatically managed. This can be done headlessly or headed. + +--- + +## OAuth and Providers + +jcode works with subscription-backed OAuth flows and many provider integrations, so you can use the models you already pay for and still fall back to direct API providers when needed. + +### Supported built-in login flows + +- **Claude** (`jcode login --provider claude`) +- **OpenAI / ChatGPT / Codex** (`jcode login --provider openai`) +- **Google Gemini** (`jcode login --provider gemini`) +- **GitHub Copilot** (`jcode login --provider copilot`) +- **Azure OpenAI** (`jcode login --provider azure`) +- **Alibaba Cloud Coding Plan** (`jcode login --provider alibaba-coding-plan`) +- **Fireworks** (`jcode login --provider fireworks`) +- **MiniMax** (`jcode login --provider minimax`) +- **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. + +### 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. + +#### OpenAI-compatible providers + +Many hosted services speak the standard OpenAI `/v1/chat/completions` API. jcode talks to them through one shared OpenAI-compatible provider, so you can use almost any such endpoint without waiting for a dedicated integration. + +There are two ways to set one up: + +- **Built-in named profiles** — jcode ships ready-made profiles for several popular OpenAI-compatible services. Log in by id and jcode fills in the base URL and key environment variable for you: + + ```bash + jcode login --provider + # for example: + jcode login --provider openrouter + jcode login --provider deepseek + jcode login --provider opencode # OpenCode Zen + jcode login --provider moonshotai + ``` + + 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. + +- **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`. +- 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. + +For details on self-hosting, local runtimes, and the exact config file shape, see below. + +#### Self-hosted OpenAI-compatible endpoints, including vLLM + +For agents and scripts, the preferred path is the one-shot provider profile command. It writes a named profile to `~/.jcode/config.toml`, stores secrets in jcode's private app config directory when requested, and prints exact run/validation commands: + +```bash +# Secret-safe setup for a hosted OpenAI-compatible API. +printf '%s' "$MY_API_KEY" | jcode provider add my-api \ + --base-url https://llm.example.com/v1 \ + --model my-model-id \ + --api-key-stdin \ + --set-default \ + --json + +# Smoke test the profile. +jcode --provider-profile my-api auth-test --prompt 'Reply exactly JCODE_PROVIDER_SETUP_OK' + +# Use it directly. +jcode --provider-profile my-api run 'hello' +``` + +For local servers that do not require auth: + +```bash +jcode provider add local-vllm \ + --base-url http://localhost:8000/v1 \ + --model Qwen/Qwen3-Coder-30B-A3B-Instruct \ + --no-api-key \ + --set-default +``` + +Built-in local profiles are available for the common desktop/local runtimes: + +```bash +# Ollama: start the local server and install a model first. +ollama pull llama3.2 +jcode login --provider ollama +jcode --provider ollama --model llama3.2 run 'hello' + +# LM Studio: start the Local Server, load a chat model, then use the exact +# model identifier shown by LM Studio or by curl http://localhost:1234/v1/models. +jcode login --provider lmstudio +jcode --provider lmstudio --model '' run 'hello' +``` + +Ollama and LM Studio both expose OpenAI-compatible `/v1/models` and `/v1/chat/completions` endpoints. jcode uses streaming chat completions, function/tool calling, and OpenAI-style image content for vision-capable local models. If a local server requires a token, enter it during `jcode login` or create a named profile with `--api-key-stdin`. + +Useful flags: + +- `--api-key-env NAME`: reference an existing environment variable instead of storing a key. +- `--api-key-stdin`: read and store a key without putting it in shell history. +- `--context-window TOKENS`: persist the model context window for model selection and routing. +- `--overwrite`: replace an existing profile of the same name. +- `--model-catalog`: use the endpoint's `/models` response in addition to configured models. + +The generated profile can also be edited manually in `~/.jcode/config.toml`: + +```toml +[provider] +default_provider = "my-api" +default_model = "my-model-id" + +[providers.my-api] +type = "openai-compatible" +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" + +[[providers.my-api.models]] +id = "my-model-id" +context_window = 128000 +``` + +##### 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. + +1. Per named profile, via `extra_body` in `config.toml` (a TOML table merged verbatim into the JSON body): + + ```toml + [providers.my-nim] + type = "openai-compatible" + base_url = "https://integrate.api.nvidia.com/v1" + api_key_env = "NVIDIA_API_KEY" + default_model = "deepseek-ai/deepseek-v4-flash" + + [providers.my-nim.extra_body.chat_template_kwargs] + thinking = true + reasoning_effort = "high" + ``` + +2. For built-in profiles (e.g. `nvidia-nim`) or any endpoint, via the `JCODE_OPENAI_EXTRA_BODY` environment variable (a JSON object string). It can live in the provider's env file (`~/.config/jcode/nvidia-nim.env`) next to the API key: + + ```bash + JCODE_OPENAI_EXTRA_BODY={"chat_template_kwargs":{"thinking":true,"reasoning_effort":"high"}} + ``` + +Keys from `extra_body` are merged last and override any jcode-generated body field with the same name (`JCODE_OPENAI_EXTRA_BODY` wins over the config `extra_body` on key collisions). Invalid values are logged and ignored rather than failing the request. + +The custom OpenAI-compatible provider reads overrides from environment variables or from an env file in jcode's app config directory. On Linux this is usually `~/.config/jcode/`, so the default file is usually: + +```text +~/.config/jcode/openai-compatible.env +``` + +Example for a local or LAN vLLM server: + +```bash +JCODE_OPENAI_COMPAT_API_BASE=http://192.168.1.50:8000/v1 +JCODE_OPENAI_COMPAT_DEFAULT_MODEL=Qwen/Qwen3-Coder-30B-A3B-Instruct +# Optional if your server expects auth +OPENAI_COMPAT_API_KEY=your-token-here +``` + +Notes: + +- `jcode login --provider openai-compatible` can create or update this for you. +- Plain `http://` is accepted for `localhost` and private LAN IPs. Public remote HTTP is still rejected. +- HTTPS endpoints work as usual. + +#### MCP config files + +MCP config is separate from `config.toml`. + +Primary config files: + +- `~/.jcode/mcp.json` for global MCP servers +- `.jcode/mcp.json` for project-local MCP servers + +Claude Code compatibility: + +- `~/.claude.json` (Claude Code's user config): top-level `mcpServers`, plus per-project servers under `projects..mcpServers` for the current directory +- `.mcp.json` at the repo root (Claude Code's project config) +- `.claude/mcp.json` (legacy fallback) + +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: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "/path/to/mcp-server", + "args": ["--root", "/workspace"], + "env": {}, + "shared": true + } + } +} +``` + +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. + +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. + +For more scriptable remote flows, `claude`, `openai`, `gemini`, and `antigravity` also support a two-step pattern: + +```bash +# Step 1: print a resumable auth URL +jcode login --provider openai --print-auth-url --json + +# Step 2: complete later with the callback URL or auth code +jcode login --provider openai --callback-url 'http://localhost:1455/auth/callback?...' +jcode login --provider gemini --auth-code '...' +``` + +Additional scriptable cases: + +```bash +# Copilot device flow: print URL + user code, then complete later +jcode login --provider copilot --print-auth-url --json +jcode login --provider copilot --complete + +# Gmail/Google OAuth after credentials are already configured +jcode login --provider google --print-auth-url --google-access-tier readonly +jcode login --provider google --callback-url 'http://127.0.0.1:8456?...' +``` + +Pending scriptable login state is stored under `~/.jcode/pending-login/`, automatically expires, and stale entries are cleaned up when new scriptable logins start or resume. + +For the built-in OpenAI login flow, jcode opens a local callback on +`http://localhost:1455/auth/callback` by default. + +Screenshot from 2026-04-02 14-28-51 +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` + +Jcode also supports easy multi-account switching. Ran out of tokens on your first ChatGPT Pro subscription? /account and quickly switch to your second. + +--- + +## Customizability / Self-Dev + +Jcode is inventing a new form of customizability. One that doesn't limit you to what a plugin or extension can do. Tell your jcode agent to enter self dev mode, and it will start modifying its own source code. Jcode is optimized to iterate on itself. There is significant infrastructure around self developement, which allows it to edit, build, and test its own source code, then reload its own binary and continue work in your (potentially many) sessions, fully automatically. + +It is reccomended that you use a frontier model for this. The jcode codebase is not a simple one, and weaker models can make subtle, breaking changes. GPT 5.5 or the latest available frontier model works well. + + + +--- + +## Misc. + +The devil is in the details. There are many undocumented optimizations and niceties that jcode implements. Some examples: + +Anthropic's Claude cache goes cold after 5 minutes. If you initiate Claude after these 5 minutes, you have a cache miss, potentially costing you lots of tokens. The ui warns you when the cache went cold, and notfies you if there was an unexpected cache miss. + +jcode comes with instructions on how to set up Firefox Agent Bridge. Ask you agent to set it up, and then you will have browser automation in jcode as well. + +Agent grep is a grep tool I made for the jcode agent. It adds file strucuture information (ie the list of functions, their displacement, etc) to the grep return, so that the agent can infer more of what the file doesn without actually reading the file. It also implements a harness-level integration that adaptively truncates returns based on what the agent has already seen. This saves on context a lot. + +Inputs are by default interleaved with the working agent. It sends the input as soon as it safely can without breaking the KV cache. Submit with shift enter instead, and it will send a queue send, and wait for the agent to fully finish its turn before sending. + +Resume sessions from different harnesses. Claude code broke on you? Resume the session from jcode and continue where you left off. Session resume is supported for codex, claude code, opencode, and pi. + +Screenshot from 2026-04-11 16-28-52 +image of /Resume for codex sessions + + +Skills are not all loaded on startup. The conversation is embedded as a semantic vector, and will automatically inject a skill if there is an embedding hit similar to memories. The agent has a skill tool for you to manually activate a skill at anytime. You may also activate via slash commands. + +--- + +## iOS Application / Native OpenClaw + +A native iOS application version of jcode is coming soon. This will allow you to work with jcode on your personal machine's environment from your phone, via Tailscale. Openclaw like features will be bundled with this iOS application. + +--- + +## Other planned features + +Agents dont like to commit in dirty git state with active changes. Git was clearly not built for multi-agent workflows, and git worktrees is not a good solution. Given this, I believe that is an opporunity for a new git like primitive to be born. + +Build speed improvements: An incremental debug cargo build with cache enabled takes about 1 minute on my machine. The goal is 5-20 seconds. Refactors and crates seams should be able to make this happen. + + + +--- + +
+ +## Quick Start + +
+ +```bash +# Launch the TUI +jcode + +# Run a single command non-interactively +jcode run "say hello" + +# Resume a previous session by memorable name +jcode --resume fox + +# Run as a persistent background server, then attach more clients +jcode serve +jcode connect + +# Send voice input from your configured STT command +jcode dictate +``` + +jcode supports interactive TUI use, non-interactive runs, persistent server/client workflows, +and hotkey-friendly dictation without requiring a bundled speech-to-text stack. + +
+ + + jcode workflow demonstration + + +

jcode workflow demonstration

+ +
+ +--- + +## Browser Automation + +jcode includes a first-class built-in `browser` tool for browser control inside agent sessions. + +Current built-in backend: +- Firefox via Firefox Agent Bridge + +Current built-in tool actions include: +- `status` +- `setup` +- `open` +- `snapshot` +- `get_content` +- `interactables` +- `click` +- `type` +- `fill_form` +- `select` +- `wait` +- `screenshot` +- `eval` +- `scroll` +- `upload` +- `press` + +Quick setup: + +```bash +jcode browser status +jcode browser setup +``` + +Once setup is complete, the model can use the built-in `browser` tool directly. The UI also summarizes browser tool calls compactly, for example opening a URL, clicking a selector, or typing into a field without echoing sensitive typed text. + +Notes: +- the provider/tool architecture is in place for additional backends +- Firefox is the wired built-in backend today +- Chrome bridge / remote debugging style providers can be added on top of the same browser tool later + +--- + +## Further Reading + +- [Ambient Mode / OpenClaw](docs/AMBIENT_MODE.md) +- [Browser Provider Protocol](docs/BROWSER_PROVIDER_PROTOCOL.md) +- [Memory Architecture](docs/MEMORY_ARCHITECTURE.md) +- [Swarm Architecture](docs/SWARM_ARCHITECTURE.md) +- [Server Architecture](docs/SERVER_ARCHITECTURE.md) +- [Safety System](docs/SAFETY_SYSTEM.md) +- [Sponsored Discovery Sponsor Onboarding](docs/SPONSORED_DISCOVERY_SPONSOR_ONBOARDING.md) +- [Windows Notes](docs/WINDOWS.md) +- [Wrappers and Shell Integration](docs/WRAPPERS.md) +- [Refactoring Notes](docs/REFACTORING.md) + +--- + +## Detailed Installation + +### Setup + +If you want another agent to set up jcode for you, give it this prompt: + +```text +Set up jcode on this machine for me. + +1. Detect the operating system, available package managers, and shell environment, then install jcode using the best matching command below instead of referring me somewhere else: + + - macOS with Homebrew available: + brew tap 1jehuang/jcode + brew install jcode + + - macOS or Linux via install script: + curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash + + - Windows PowerShell: + irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.ps1 | iex + + - From source if the above paths are not appropriate: + git clone https://github.com/1jehuang/jcode.git + cd jcode + cargo build --release + scripts/install_release.sh + + - For local self-dev / refactor work on Linux x86_64, prefer: + scripts/dev_cargo.sh build --release -p jcode --bin jcode + scripts/dev_cargo.sh --print-setup + scripts/install_release.sh + +2. Verify that `jcode` is on my `PATH`. +3. Launch `jcode` once in a new terminal window/session to confirm it starts successfully. +4. Before attempting any interactive login flow, assess which providers are already available non-interactively and prefer those first. Check existing local credentials, config files, CLI sessions, and environment variables such as: + - Claude: `~/.jcode/auth.json`, `~/.claude/.credentials.json`, `~/.local/share/opencode/auth.json`, `ANTHROPIC_API_KEY` + - OpenAI: `~/.jcode/openai-auth.json`, `~/.codex/auth.json`, `OPENAI_API_KEY` + - Gemini: `~/.jcode/gemini_oauth.json`, `~/.gemini/oauth_creds.json` + - GitHub Copilot: existing auth under `~/.config/github-copilot/` + - 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` + - 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 +5. Prefer whichever provider is already configured and verify it with `jcode auth-test --all-configured` or a provider-specific auth test when appropriate. +6. Only if no usable provider is already configured, guide me through the minimal manual step needed: + - Claude: `jcode login --provider claude` + - GitHub Copilot: `jcode login --provider copilot` + - OpenAI: `jcode login --provider openai` + - Gemini: `jcode login --provider gemini` + - Azure OpenAI: `jcode login --provider azure` + - Fireworks: `jcode login --provider fireworks` + - MiniMax: `jcode login --provider minimax` + - NVIDIA NIM: `jcode login --provider nvidia-nim` + - Alibaba Cloud Coding Plan: `jcode login --provider alibaba-coding-plan` + - OpenRouter: help me set `OPENROUTER_API_KEY` + - Anthropic direct API: help me set `ANTHROPIC_API_KEY` +7. After setup, run a simple smoke test with `jcode run "say hello"` and confirm it works. +8. If I want browser automation, also check `jcode browser status`. If browser automation is not ready, run `jcode browser setup`, verify the built-in `browser` tool works, and explain any remaining manual step. +9. Explain any manual step that still needs me, especially browser OAuth, device login, API key entry, or browser extension approval. +``` + +This is intended to be a copy-paste bootstrap prompt for jcode itself or any other coding agent. + +### Quick Install + +```bash +# macOS & Linux +curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash +``` + +On Termux, install the glibc runtime and `patchelf` first so the installer can +patch the downloaded Linux binary to Termux's glibc dynamic linker and create a +launcher that avoids Termux's `LD_PRELOAD` shim: + +```bash +pkg install glibc patchelf +curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash +``` + +```powershell +# Windows (PowerShell) +irm https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.ps1 | iex +``` + +### macOS via Homebrew + +```bash +brew tap 1jehuang/jcode +brew install jcode +``` + +### From Source (all platforms) + +```bash +git clone https://github.com/1jehuang/jcode.git +cd jcode +cargo build --release +``` + +For local self-dev / refactor work on Linux x86_64, prefer: + +```bash +scripts/dev_cargo.sh build --release -p jcode --bin jcode +scripts/dev_cargo.sh --print-setup +``` + +That wrapper automatically uses `sccache` when available, prefers a fast +working local linker setup (`clang + lld`) instead of assuming every machine's +`mold` configuration is valid, and can print the active linker/cache setup via +`--print-setup` so slow-path builds are easier to diagnose. + +Then symlink to your PATH: + +```bash +scripts/install_release.sh +``` + +### Uninstall + +Removes installed binaries and the launcher but keeps your config, auth, and +sessions so a clean reinstall picks up where you left off: + +```bash +curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/uninstall.sh | bash -s -- --yes +``` + +For a full wipe of everything including config, auth, sessions, logs, and +memory (useful for recovering from a broken install): + +```bash +curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/uninstall.sh | bash -s -- --purge --yes +``` + +Add `--dry-run` to preview what would be removed without deleting anything. + +### Platform Support + +| Platform | Status | +|---|---| +| **Linux** x86_64 / aarch64 | Fully supported | +| **macOS** Apple Silicon & Intel | Supported | +| **Windows** x86_64 | Supported (native + WSL2) | +| **Termux** aarch64 / x86_64 | Supported with `pkg install glibc patchelf` | + + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..63ed9b5 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`1jehuang/jcode` +- 原始仓库:https://github.com/1jehuang/jcode +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..5b27884 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,159 @@ +# Releasing jcode + +jcode has two release paths: a fast local path for hotfixes, and CI for full releases. + +## Quick Release (local, ~2.5 minutes) + +For hotfixes and urgent updates. Builds Linux + macOS locally and uploads directly. + +```bash +scripts/quick-release.sh v0.5.5 # Build + tag + release +scripts/quick-release.sh v0.5.5 "Fix bug" # With custom title +scripts/quick-release.sh --dry-run v0.5.5 # Build only, don't publish +``` + +### How it works + +1. Builds Linux x86_64 natively and macOS aarch64 via osxcross **in parallel** +2. Verifies both binaries (ELF and Mach-O checks) +3. Creates a git tag and pushes it (this also triggers CI for the Windows build) +4. Uploads both binaries to a GitHub Release via `gh release create` +5. Users can immediately run `jcode update` + +### Prerequisites + +Already set up on the dev laptop (xps13): + +- **osxcross** at `~/.osxcross` with macOS 14.5 SDK (darwin triple: `aarch64-apple-darwin23.5`) +- **rustup** with `aarch64-apple-darwin` target installed +- **`~/.cargo/config.toml`** has the osxcross linker configured +- **`gh` CLI** authenticated with GitHub + +### Timeline + +``` +0s Start parallel builds (Linux native + macOS cross-compile) +~90s Linux build finishes +~150s macOS build finishes +~153s Binaries uploaded, release live + ✅ Linux + macOS users can `jcode update` +~16m CI finishes Windows build, uploads to same release + ✅ Windows users can `jcode update` +``` + +## CI Release (automated, ~11 min Linux+macOS, ~16 min Windows) + +Triggered automatically when a `v*` tag is pushed to GitHub. + +### Workflow: `.github/workflows/release.yml` + +``` +Tag push (v*) + │ + ├─► build-linux-macos (parallel) + │ ├─► Linux x86_64 (ubuntu-latest) ~8 min + │ └─► macOS aarch64 (macos-latest) ~11 min + │ + ├─► build-windows (parallel, non-blocking) + │ ├─► Windows x86_64 (windows-latest) ~16 min + │ └─► Windows ARM64 (windows-11-arm) ~16 min + │ + ├─► release (after Linux + macOS complete) + │ ├─► Create GitHub Release with binaries + │ ├─► Update Homebrew formula (1jehuang/homebrew-jcode) + │ └─► Update AUR package (jcode-bin) + │ + └─► upload-windows-assets (after Windows + release complete) + └─► Upload Windows binaries to existing release +``` + +Key design decisions: +- **Windows does not block the release.** Linux and macOS binaries are published as soon as they're ready. Windows is added later. +- **Shallow clones** (`fetch-depth: 1`) to minimize checkout time. +- **`CARGO_INCREMENTAL=0`** for CI (incremental adds overhead on clean CI builds). +- **sccache + rust-cache** for dependency caching across runs. +- **mold linker** on Linux for faster linking. + +### Package manager updates + +CI handles Homebrew and AUR updates automatically: + +- **Homebrew**: Updates `Formula/jcode.rb` in `1jehuang/homebrew-jcode` with new SHA256 hashes +- **AUR**: Updates `PKGBUILD` and `.SRCINFO` in the `jcode-bin` AUR repo + +Both are triggered by the `release` job after Linux + macOS builds complete. + +## Which to use + +| Scenario | Method | Time to Linux+macOS | Time to Windows | +|----------|--------|-------------------|-----------------| +| Hotfix / urgent bug | `scripts/quick-release.sh` | **~2.5 min** | ~16 min (CI) | +| Regular release | Push `v*` tag | ~11 min | ~16 min | +| Need Homebrew/AUR | Push `v*` tag | ~11 min | ~16 min | + +For quick releases that also need Homebrew/AUR updates, use the script first (gets binaries out fast), then the CI tag push handles the package manager updates automatically. CI's `softprops/action-gh-release` will update the existing release created by the script. + +## Cross-Compilation Setup + +macOS binaries are cross-compiled from Linux using [osxcross](https://github.com/tpoechtrager/osxcross). + +### Current configuration + +| Component | Value | +|-----------|-------| +| SDK | macOS 14.5 | +| SDK source | [joseluisq/macosx-sdks](https://github.com/joseluisq/macosx-sdks) | +| Install location | `~/.osxcross/` | +| Darwin triple | `aarch64-apple-darwin23.5` | +| Linker | `aarch64-apple-darwin23.5-clang` | + +### Cargo config (`~/.cargo/config.toml`) + +```toml +[target.aarch64-apple-darwin] +linker = "aarch64-apple-darwin23.5-clang" + +[env] +CC_aarch64_apple_darwin = "aarch64-apple-darwin23.5-clang" +CXX_aarch64_apple_darwin = "aarch64-apple-darwin23.5-clang++" +``` + +### Rebuilding osxcross from scratch + +```bash +git clone https://github.com/tpoechtrager/osxcross /tmp/osxcross +curl -L -o /tmp/osxcross/tarballs/MacOSX14.5.sdk.tar.xz \ + https://github.com/joseluisq/macosx-sdks/releases/download/14.5/MacOSX14.5.sdk.tar.xz +cd /tmp/osxcross && UNATTENDED=1 TARGET_DIR=~/.osxcross ./build.sh +rustup target add aarch64-apple-darwin +``` + +Build takes ~5 minutes. Requires `clang`, `cmake`, `libxml2` (all available via pacman on Arch). + +### Why osxcross (not zigbuild) + +`cargo-zigbuild` can cross-compile pure Rust code to macOS, but jcode depends on crates that link against macOS system frameworks: +- `arboard` (clipboard) - links `AppKit`, `Foundation` +- `native-tls` / `security-framework` - links `Security`, `SystemConfiguration` +- `objc2` - links Objective-C runtime + +These require actual macOS SDK headers and framework stubs, which osxcross provides. + +## Build Performance + +### Current timing (laptop, 8-core Intel Ultra 7 256V) + +| Build | Clean | Cached deps | +|-------|-------|-------------| +| Linux x86_64 (native) | ~90s | ~90s | +| macOS aarch64 (cross) | ~3 min | ~2.5 min | +| Both in parallel | ~3 min | ~2.5 min | + +The bottleneck is compiling jcode itself (120k lines of Rust). Dependencies are cached and don't need recompilation. The `build.rs` timestamp causes a full recompile of the main crate on every build. + +### Why not faster + +- `opt-level = 1`, `codegen-units = 256`, `incremental = true` are already set in `[profile.release]` +- 8 cores is the hardware limit +- Splitting into workspace crates would allow partial recompilation (~1 min for small changes) +- A 20+ core machine on LAN (not Tailscale) would cut build time to ~40-50s diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000..1686f7b --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,284 @@ +# 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. + +## What We Collect + +### Install Event (sent once, on first launch) + +| Field | Example | Purpose | +|-------|---------|----------| +| `id` | `a1b2c3d4-...` | Random UUID, not tied to your identity | +| `event` | `"install"` | Event type | +| `version` | `"0.6.0"` | jcode version | +| `os` | `"linux"` | Operating system | +| `arch` | `"x86_64"` | CPU architecture | + +### Upgrade Event + +| Field | Example | Purpose | +|-------|---------|----------| +| `id` | `a1b2c3d4-...` | Same random UUID | +| `event` | `"upgrade"` | Event type | +| `version` | `"0.9.1"` | Current jcode version | +| `from_version` | `"0.8.1"` | Previously recorded jcode version | +| `os` / `arch` | `"linux"` / `"x86_64"` | Environment breakdown | + +### Auth Success Event + +| Field | Example | Purpose | +|-------|---------|----------| +| `id` | `a1b2c3d4-...` | Same random UUID | +| `event` | `"auth_success"` | Event type | +| `auth_provider` | `"claude"` | Which provider/account system was configured | +| `auth_method` | `"oauth"` | Coarse auth method only | +| `version` / `os` / `arch` | `"0.9.1"` / `"linux"` / `"x86_64"` | Activation funnel dimensions | + +### Onboarding Step Event + +| Field | Example | Purpose | +|-------|---------|----------| +| `event` | `"onboarding_step"` | Event type | +| `step` | `"first_prompt_sent"` | Coarse funnel step | +| `auth_provider` | `"openai"` | Optional provider dimension for auth steps | +| `auth_method` | `"oauth"` | Optional auth-method dimension for auth steps | +| `milestone_elapsed_ms` | `42000` | Rough time from install to milestone | + +### Feedback Event + +| Field | Example | Purpose | +|-------|---------|----------| +| `event` | `"feedback"` | Event type | +| `feedback_text` | `"The model switcher is confusing"` | Freeform feedback explicitly submitted with `/feedback ...` | +| `feedback_rating` | `"up"` / `"down"` | Legacy explicit product sentiment, if present | +| `feedback_reason` | `"slow"` | Legacy optional coarse reason bucket, if present | + +### Sponsored Discovery Event + +One event is sent after each `discover_tools` attempt. A random per-request ID +is also sent to the discovery API as the `x-jcode-discovery-request-id` header, +allowing client reliability telemetry to be correlated with server request logs +without exposing prompts or a persistent telemetry identifier to that service. + +| Field | Example | Purpose | +|-------|---------|----------| +| `event` | `"discovery"` | Event type | +| `request_id` | `"9a23..."` | Random correlation ID scoped to one request | +| `phase` | `"browse"` / `"select"` / `"unknown"` | Discovery funnel stage | +| `category` | `"payments"` | Fixed discovery category, when valid | +| `selected_tool` | `"agentcard"` | Public catalog tool name in the select phase | +| `outcome` | `"success"` / `"failure"` | Attempt result | +| `failure_reason` | `"timeout"` | Allowlisted coarse failure class only | +| `http_status` | `200` | Discovery service response status, if received | +| `latency_ms` | `125` | End-to-end client attempt latency | +| `response_bytes` | `2048` | Response payload size, if received | +| `result_count` | `3` | Number of browse results, or one for selection | +| `query_present` / `reason_present` | `true` / `true` | Presence flags only | +| `custom_endpoint` | `false` | Whether a non-default discovery endpoint was configured | + +The query text, selection-reason text, endpoint URL, prompts, transcript, file +paths, and tool setup instructions are **not** included in telemetry. The +backend rejects unknown phase, outcome, and failure labels rather than storing +arbitrary strings. + +### Session Start Event + +| Field | Example | Purpose | +|-------|---------|----------| +| `id` | `a1b2c3d4-...` | Same random UUID | +| `event` | `"session_start"` | Event type | +| `version` | `"0.6.0"` | jcode version | +| `os` | `"linux"` | Operating system | +| `arch` | `"x86_64"` | CPU architecture | +| `provider_start` | `"OpenAI"` | Provider when session started | +| `model_start` | `"gpt-5.4"` | Model when session started | +| `resumed_session` | `false` | Whether this was a resumed session | +| `session_start_hour_utc` | `13` | Coarse hour-of-day bucket for workflow timing | +| `session_start_weekday_utc` | `2` | Weekday bucket for usage cadence | +| `previous_session_gap_secs` | `3600` | How long since this install's previous session | +| `sessions_started_24h` / `sessions_started_7d` | `3` / `8` | How bursty a user's workflow is recently | +| `active_sessions_at_start` | `2` | Concurrent sessions observed including this one | +| `other_active_sessions_at_start` | `1` | Other sessions already open when this started | + +### Session End / Crash Event + +| Field | Example | Purpose | +|-------|---------|----------| +| `id` | `a1b2c3d4-...` | Same random UUID | +| `event` | `"session_end"` / `"session_crash"` | Event type | +| `version` | `"0.6.0"` | jcode version | +| `os` | `"linux"` | Operating system | +| `arch` | `"x86_64"` | CPU architecture | +| `provider_start` | `"OpenAI"` | Provider when session started | +| `provider_end` | `"OpenAI"` | Provider when session ended | +| `model_start` | `"gpt-5.4"` | Model when session started | +| `model_end` | `"gpt-5.4"` | Model when session ended | +| `provider_switches` | `0` | How many times you switched providers | +| `model_switches` | `1` | How many times you switched models | +| `duration_mins` | `45` | Session length in minutes | +| `duration_secs` | `2700` | Finer-grained session length | +| `turns` | `23` | Number of user prompts sent | +| `had_user_prompt` | `true` | Whether any real prompt was submitted | +| `had_assistant_response` | `true` | Whether the assistant produced a response | +| `assistant_responses` | `6` | Number of assistant responses | +| `first_assistant_response_ms` | `1200` | Time to first assistant response | +| `first_tool_call_ms` | `900` | Time to first tool invocation | +| `first_tool_success_ms` | `1500` | Time to first successful tool execution | +| `first_file_edit_ms` | `2200` | Time to first successful file edit | +| `first_test_pass_ms` | `4100` | Time to first successful test run | +| `tool_calls` | `8` | Number of tool executions | +| `tool_failures` | `1` | Number of tool execution failures | +| `executed_tool_calls` | `10` | Centralized count of actual registry tool executions | +| `executed_tool_successes` | `9` | Successful registry tool executions | +| `executed_tool_failures` | `1` | Failed registry tool executions | +| `tool_latency_total_ms` | `4200` | Aggregate tool execution latency | +| `tool_latency_max_ms` | `1800` | Slowest single tool call | +| `file_write_calls` | `2` | Count of write/edit/patch style tool uses | +| `tests_run` | `1` | Coarse count of test runs triggered | +| `tests_passed` | `1` | Coarse count of successful test runs | +| `input_tokens` / `output_tokens` | `12345` / `678` | Session-level provider-reported token usage totals | +| `cache_read_input_tokens` / `cache_creation_input_tokens` | `9000` / `1200` | Session-level provider-reported prompt-cache token totals when available | +| `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, selfdev, background, subagents) | +| `tool_cat_*` | `0..N` | Coarse tool category counts (read/search, write, shell, web, memory, subagent, swarm, email, side-panel, goal, MCP, other) | +| `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 | +| `session_success` | `true` | Coarse success proxy based on outcomes like responses, successful tools, tests, or edits | +| `abandoned_before_response` | `false` | Whether the user engaged but got no successful outcome before ending | +| `session_stop_reason` | `"tool_error_loop"` | Coarse inferred pain/churn bucket, such as crash, auth blocked, rate limited, no first response, too slow, tool failures, no useful action, or completed successfully | +| `agent_role` | `"foreground"` / `"subagent"` | Coarse role classification for the session: foreground, background, subagent, or swarm | +| `parent_session_id` | `"session_..."` | Optional parent session ID for attributing spawned/background/subagent work to the initiating session | +| `agent_active_ms_total` | `7200000` | Sum of active agent time across finalized turns; two agents active for two hours count as four agent-hours in aggregate | +| `agent_model_ms_total` / `agent_tool_ms_total` | `5400000` / `1800000` | Approximate active-time split between model/agent thinking and registry tool execution latency | +| `session_idle_ms_total` | `300000` | Time around turns where the session was open but no agent activity was observed | +| `time_to_first_agent_action_ms` | `900` | Time from session start to the first assistant response or tool action | +| `time_to_first_useful_action_ms` | `1500` | Time from session start to the first successful tool/file/test outcome, falling back to first response | +| `spawned_agent_count` | `3` | Count of background, subagent, and swarm task invocations attributed to the session | +| `background_task_count` / `background_task_completed_count` | `1` / `1` | Background work started and successfully completed via background/scheduled tool paths | +| `subagent_task_count` / `subagent_success_count` | `1` / `1` | Subagent task invocations and successful completions | +| `swarm_task_count` / `swarm_success_count` | `1` / `0` | Swarm/agent-coordination task invocations and successful completions | +| `user_cancelled_count` | `1` | Urgent interrupt count, used to detect sessions where the user stopped the agent mid-work | +| `transport_https` | `2` | Number of provider requests sent over HTTPS/SSE | +| `transport_persistent_ws_fresh` | `1` | Number of fresh persistent WebSocket requests | +| `transport_persistent_ws_reuse` | `5` | Number of turns that reused an existing persistent WebSocket | +| `transport_cli_subprocess` | `0` | Number of requests sent through a CLI subprocess transport | +| `transport_native_http2` | `0` | Number of requests sent through native HTTP/2 transports | +| `transport_other` | `0` | Number of requests using any other transport | +| `project_repo_present` | `true` | Whether the working directory looked like a repo | +| `project_lang_*` | `true/false` | Coarse project-language buckets (Rust, JS/TS, Python, Go, Markdown, mixed) | +| `days_since_install` | `12` | Rough install age in days | +| `active_days_7d` / `active_days_30d` | `4` / `9` | How many distinct active days this install had recently | +| `session_start_hour_utc` / `session_end_hour_utc` | `13` / `14` | Session timing buckets for workflow analysis | +| `session_start_weekday_utc` / `session_end_weekday_utc` | `2` / `2` | Weekday timing buckets | +| `previous_session_gap_secs` | `1800` | Time since the previous session on this install | +| `sessions_started_24h` / `sessions_started_7d` | `5` / `12` | Recent session burstiness | +| `active_sessions_at_start` / `other_active_sessions_at_start` | `2` / `1` | Concurrent-session snapshot at session start | +| `max_concurrent_sessions` | `3` | Highest concurrent session count seen during the session | +| `multi_sessioned` | `true` | Whether the user appeared to be running multiple sessions | +| `resumed_session` | `false` | Whether this session was resumed | +| `end_reason` | `"normal_exit"` | Coarse end reason | +| `errors` | `{"provider_timeout": 0, ...}` | Count of errors by category | + +### Turn End Event + +This is a privacy-safe per-prompt summary event. It contains no prompt text, no response text, and no tool inputs/outputs. + +| Field | Example | Purpose | +|-------|---------|----------| +| `event` | `"turn_end"` | Event type | +| `turn_index` | `4` | Which user turn in the session this was | +| `turn_started_ms` | `182000` | Time from session start to turn start | +| `turn_active_duration_ms` | `8200` | Active duration until the last meaningful response/tool activity | +| `idle_before_turn_ms` / `idle_after_turn_ms` | `45000` / `12000` | Workflow pacing around the turn | +| `assistant_responses` | `1` | Responses produced during this turn | +| `first_assistant_response_ms` | `1200` | Time to first response within the turn | +| `first_tool_call_ms` / `first_tool_success_ms` | `900` / `1500` | Tool timing within the turn | +| `first_file_edit_ms` / `first_test_pass_ms` | `2200` / `4100` | Useful outcome timing within the turn | +| `tool_calls` / `tool_failures` | `3` / `1` | Coarse tool activity within the turn | +| `executed_tool_calls` / `executed_tool_successes` / `executed_tool_failures` | `4` / `3` / `1` | Registry tool execution outcomes | +| `tool_latency_total_ms` / `tool_latency_max_ms` | `2600` / `1400` | Tool latency footprint within the turn | +| `file_write_calls` / `tests_run` / `tests_passed` | `1` / `1` / `1` | Outcome proxies for coding workflows | +| `input_tokens` / `output_tokens` | `1200` / `180` | Turn-level provider-reported token usage totals | +| `cache_read_input_tokens` / `cache_creation_input_tokens` | `8000` / `600` | Turn-level provider-reported prompt-cache token totals when available | +| `total_tokens` | `9980` | Sum of input, output, cache-read, and cache-creation tokens for the turn | +| `feature_*_used` | `true/false` | Which feature families were touched in the turn | +| `tool_cat_*` | `0..N` | Tool category mix for the turn | +| `workflow_*_used` | `true/false` | What kind of workflow this turn looked like | +| `turn_success` | `true` | Whether the turn produced a useful response/outcome | +| `turn_abandoned` | `false` | Whether the turn appears to have ended without success | +| `turn_end_reason` | `"next_user_prompt"` | Why the turn was finalized | + +### Shared Event Metadata + +Most events also carry a few coarse quality / cleanup fields: + +| Field | Example | Purpose | +|-------|---------|----------| +| `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 | +| `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 | + +## What We Do NOT Collect + +- 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 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 + +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 It Works + +1. On first launch, jcode generates a random UUID and sends an `install` event +2. When a session begins, jcode sends a `session_start` event +3. When a session ends normally, jcode sends a `session_end` event with coarse session metrics +4. When auth succeeds, jcode sends a coarse `auth_success` event for activation-funnel analysis +5. When jcode detects a version change, it sends an `upgrade` event +6. On best-effort crash/signal handling, jcode sends a `session_crash` event +7. jcode may also send one-off onboarding milestone events and explicit feedback events when triggered +8. Requests are fire-and-forget HTTP POSTs that don't block normal usage (install/session shutdown have short bounded blocking timeouts) +9. If a request fails (offline, firewall, etc.), jcode silently continues - no retries, no queuing + +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/). + +### 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. + +## How to Opt Out + +Any of these methods will disable telemetry completely: + +```bash +# Option 1: Environment variable +export JCODE_NO_TELEMETRY=1 + +# Option 2: Standard DO_NOT_TRACK (https://consoledonottrack.com/) +export DO_NOT_TRACK=1 + +# Option 3: File-based opt-out +touch ~/.jcode/no_telemetry +``` + +When opted out, zero network requests are made. The telemetry module short-circuits immediately. + +## 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. + +## 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. + +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/assets/app-icons/Jcode.icns b/assets/app-icons/Jcode.icns new file mode 100644 index 0000000..f459fd9 Binary files /dev/null and b/assets/app-icons/Jcode.icns differ diff --git a/assets/demos/duck_fast-on-mid-stream_autoedit_timeline.json b/assets/demos/duck_fast-on-mid-stream_autoedit_timeline.json new file mode 100644 index 0000000..36338b3 --- /dev/null +++ b/assets/demos/duck_fast-on-mid-stream_autoedit_timeline.json @@ -0,0 +1,2333 @@ +[ + { + "t": 17674, + "event": "user_message", + "text": "could you make it so that fast on command could work mid stream" + }, + { + "t": 18674, + "event": "thinking", + "duration": 800 + }, + { + "t": 19474, + "event": "token_usage", + "input": 11724, + "output": 166, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 19474, + "event": "tool_start", + "name": "todowrite", + "input": { + "todos": [ + { + "content": "Locate fast mode command handling and current streaming behavior", + "id": "t1", + "priority": "high", + "status": "in_progress" + }, + { + "content": "Implement support for enabling fast mode mid-stream", + "id": "t2", + "priority": "high", + "status": "pending" + }, + { + "content": "Build and validate behavior", + "id": "t3", + "priority": "high", + "status": "pending" + } + ] + } + }, + { + "t": 19674, + "event": "tool_done", + "name": "todowrite", + "output": "[\n {\n \"content\": \"Locate fast mode command handling and current streaming behavior\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"t1\"\n },\n {\n \"content\": \"Implement support for enabling fast mode mid-stream\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t2\"\n },\n {\n \"content\": \"Build and validate behavior\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t3\"\n }\n]", + "is_error": false + }, + { + "t": 24330, + "event": "thinking", + "duration": 800 + }, + { + "t": 25130, + "event": "stream_text", + "text": "I’m looking through the command/streaming path now to find where `fast on` is gated and why it only works before a stream starts. Then I’ll patch it, build, and verify.", + "speed": 80 + }, + { + "t": 25667, + "event": "token_usage", + "input": 12124, + "output": 199, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 25667, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "pwd && rg -n \"fast on|fast off|fast mode|/fast|fast\" . --glob '!target' --glob '!node_modules'", + "description": "Search fast command references", + "timeout": 120000, + "tool": "bash" + }, + { + "ignore": [ + "target", + "node_modules", + ".git" + ], + "path": ".", + "tool": "ls" + }, + { + "command": "git status --short", + "description": "Check working tree status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 25867, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n/home/jeremy/jcode\n./figma/jcode-mobile-design-spec.md:102:- `jcode-mobile-mockup.svg` gives a fast importable preview\n./Cargo.toml:4:description = \"Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools\"\n./RELEASING.md:3:jcode has two release paths: a fast local path for hotfixes, and CI for full releases.\n./RELEASING.md:74:- **mold linker** on Linux for faster linking.\n./RELEASING.md:93:For quick releases that also nee...", + "is_error": false + }, + { + "t": 29812, + "event": "thinking", + "duration": 800 + }, + { + "t": 30612, + "event": "stream_text", + "text": "I found the repo state has a lot of unrelated local changes already, so I’m being careful to only touch the fast-command/streaming path. Now I’m narrowing to the exact handler and interrupt loop.", + "speed": 80 + }, + { + "t": 31233, + "event": "token_usage", + "input": 18631, + "output": 194, + "cache_read": 12160, + "cache_creation": null + }, + { + "t": 31233, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"fast on|fast off|FastMode|fast_mode|toggle.*fast|set_fast|fast.*stream|mid stream|streaming\" src --glob '!target'", + "description": "Find fast and streaming code", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"command.*fast|/fast|:fast|fast command\" src docs --glob '!target'", + "description": "Search fast command parser", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 31433, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/server.rs:1158: /// Broadcast channel for streaming events to all subscribers\nsrc/config.rs:300: /// FPS for active redraw (processing, streaming): 1-120 (default: 30)\nsrc/config.rs:1017:# Active redraw FPS (processing, streaming, spinners): 1-120 (default: 60)\nsrc/protocol.rs:383: /// shown during streaming is replaced with the clean prefix text.\nsrc/protocol.rs:391: /// Tool input delta (streaming JSON)\nsrc/protocol.rs:480: /// so it does not blend into s...", + "is_error": false + }, + { + "t": 34989, + "event": "thinking", + "duration": 800 + }, + { + "t": 35789, + "event": "token_usage", + "input": 25438, + "output": 166, + "cache_read": 18432, + "cache_creation": null + }, + { + "t": 35789, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/model_context.rs", + "limit": 110, + "offset": 830, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 90, + "offset": 2920, + "tool": "read" + }, + { + "file_path": "src/tui/app/turn.rs", + "limit": 340, + "offset": 556, + "tool": "read" + }, + { + "file_path": "src/tui/app/input.rs", + "limit": 80, + "offset": 1000, + "tool": "read" + }, + { + "file_path": "src/tui/app/state_ui.rs", + "limit": 40, + "offset": 430, + "tool": "read" + } + ] + } + }, + { + "t": 35989, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 831\t match app.provider.set_reasoning_effort(level) {\n 832\t Ok(()) => {\n 833\t let new_effort = app.provider.reasoning_effort();\n 834\t let label = new_effort\n 835\t .as_deref()\n 836\t .map(effort_display_label)\n 837\t .unwrap_or(\"default\");\n 838\t app.push_display_message(DisplayMessage::system(format!(\n 839\t \"✓ Reasoning effort →...", + "is_error": false + }, + { + "t": 41078, + "event": "thinking", + "duration": 800 + }, + { + "t": 41878, + "event": "token_usage", + "input": 31968, + "output": 159, + "cache_read": 25344, + "cache_creation": null + }, + { + "t": 41878, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/turn.rs", + "limit": 180, + "offset": 724, + "tool": "read" + }, + { + "file_path": "src/tui/app/input.rs", + "limit": 260, + "offset": 0, + "tool": "read" + }, + { + "command": "rg -n \"handle_key\\(|submit_input|process_command|command.*stream|is_processing\\(|processing.*command|input.*processing\" src/tui/app --glob '!target'", + "description": "Find command submit and processing gates", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 42078, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 725\t match event {\n 726\t Some(Ok(Event::Key(key))) => {\n 727\t self.update_copy_badge_key_event(key);\n 728\t if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {\n 729\t let scroll_only = super::input::is_scroll_only_key(self, key.code, key.modifiers);\n 730\t let _ = self.handle_k...", + "is_error": false + }, + { + "t": 45250, + "event": "thinking", + "duration": 800 + }, + { + "t": 46050, + "event": "token_usage", + "input": 40709, + "output": 111, + "cache_read": 31488, + "cache_creation": null + }, + { + "t": 46050, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/input.rs", + "limit": 420, + "offset": 740, + "tool": "read" + }, + { + "command": "rg -n \"handle_command\\(|process_slash|submit_input\\(|interleave_message|pending_soft_interrupts|queued_messages\" src/tui/app --glob '!target'", + "description": "Find submit and command helpers", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 46250, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 741\t if matches!(\n 742\t event.kind,\n 743\t crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat\n 744\t ) {\n 745\t let _ = self.handle_key(event.code, event.modifiers);\n 746\t }\n 747\t }\n 748\t\n 749\t pub(super) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {\n 750\t let mut code = code;\n 751\t let mut modifiers = modifiers;\n 752\t ctrl_...", + "is_error": false + }, + { + "t": 52105, + "event": "thinking", + "duration": 800 + }, + { + "t": 52905, + "event": "stream_text", + "text": "I’ve isolated the likely culprit: slash commands *are* allowed during processing, but the submit path still routes through the normal input pipeline rather than a “safe mid-stream command” path. I’m checking the Enter handling and remote/local differences now to see where `/fast on` gets treated like a queued/interleaved message instead of an immediate setting change.", + "speed": 80 + }, + { + "t": 54086, + "event": "token_usage", + "input": 50057, + "output": 272, + "cache_read": 40576, + "cache_creation": null + }, + { + "t": 54086, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/input.rs", + "limit": 220, + "offset": 520, + "tool": "read" + }, + { + "command": "rg -n \"fn handle_enter|handle_enter\\(\" src/tui/app/input.rs src/tui/app/*.rs", + "description": "Locate enter handler", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"handle_model_command\\(|handle_info_command\\(|handle_auth_command\\(|handle_dev_command\\(\" src/tui/app --glob '!target'", + "description": "Find command dispatch usage", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 54286, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 521\t\n 522\t Ok(false)\n 523\t}\n 524\t\n 525\tpub(super) fn handle_global_control_shortcuts(\n 526\t app: &mut App,\n 527\t code: KeyCode,\n 528\t diagram_available: bool,\n 529\t) -> bool {\n 530\t if app.handle_diagram_ctrl_key(code, diagram_available) {\n 531\t return true;\n 532\t }\n 533\t\n 534\t match code {\n 535\t KeyCode::Char('c') | KeyCode::Char('d') => {\n 536\t if app.is_processing {\n 537\t app.cancel_requested = ...", + "is_error": false + }, + { + "t": 63952, + "event": "thinking", + "duration": 800 + }, + { + "t": 64752, + "event": "token_usage", + "input": 53442, + "output": 574, + "cache_read": 50176, + "cache_creation": null + }, + { + "t": 64752, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/remote.rs", + "limit": 220, + "offset": 2240, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 460, + "offset": 3000, + "tool": "read" + }, + { + "command": "rg -n \"service_tier|set_service_tier|remote_service_tier|fast mode\" src/tui/app/remote.rs src/tui/backend.rs src/protocol.rs src/server.rs src/server -g '!target'", + "description": "Trace remote fast mode path", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"struct .*Provider|fn set_service_tier|service_tier\\(\" src/provider src -g '!target'", + "description": "Trace provider service tier state", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 64952, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2241\t _ => false,\n 2242\t }\n 2243\t}\n 2244\t\n 2245\tpub(super) fn handle_remote_char_input(app: &mut App, c: char) {\n 2246\t if app.input.is_empty() && !app.is_processing && app.display_messages.is_empty() {\n 2247\t if let Some(digit) = c.to_digit(10) {\n 2248\t let suggestions = app.suggestion_prompts();\n 2249\t let idx = digit as usize;\n 2250\t if idx >= 1 && idx <= suggestions.len() {\n 2251\t let (_label, prompt) = ...", + "is_error": false + }, + { + "t": 68060, + "event": "thinking", + "duration": 800 + }, + { + "t": 68860, + "event": "token_usage", + "input": 61229, + "output": 136, + "cache_read": 52992, + "cache_creation": null + }, + { + "t": 68860, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/remote.rs", + "limit": 380, + "offset": 2460, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 320, + "offset": 1740, + "tool": "read" + }, + { + "file_path": "src/tui/backend.rs", + "limit": 60, + "offset": 380, + "tool": "read" + } + ] + } + }, + { + "t": 69060, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2461\t return Ok(());\n 2462\t }\n 2463\t\n 2464\t if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('m')) {\n 2465\t app.toggle_diagram_pane();\n 2466\t return Ok(());\n 2467\t }\n 2468\t if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('t')) {\n 2469\t app.toggle_diagram_pane_position();\n 2470\t return Ok(());\n 2471\t }\n 2472\t if let Some(direction) = app.model_switch_keys.direction_for(code, ...", + "is_error": false + }, + { + "t": 82896, + "event": "thinking", + "duration": 800 + }, + { + "t": 83696, + "event": "stream_text", + "text": "I’ve confirmed the client side already tries to treat `/fast` as an immediate command, even while processing. So the remaining likely failure is deeper: on the server/local turn loop, the command doesn’t get applied in time to affect an active agentic stream, or in remote mode the control request is serialized behind the running stream. I’m tracing that now.", + "speed": 80 + }, + { + "t": 84839, + "event": "token_usage", + "input": 69213, + "output": 702, + "cache_read": 61184, + "cache_creation": null + }, + { + "t": 84839, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/server/client_lifecycle.rs", + "limit": 120, + "offset": 680, + "tool": "read" + }, + { + "file_path": "src/server/provider_control.rs", + "limit": 70, + "offset": 180, + "tool": "read" + }, + { + "command": "rg -n \"SetServiceTier|set_service_tier|Request::SetServiceTier|process_message_streaming_mpsc|tokio::select!|handle_request\" src/server src --glob '!target'", + "description": "Trace server request scheduling", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/server/client_comm.rs", + "limit": 220, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 85039, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 681\t if handle_resume_session(\n 682\t id,\n 683\t session_id,\n 684\t &mut client_selfdev,\n 685\t &mut client_session_id,\n 686\t &client_connection_id,\n 687\t &agent,\n 688\t &provider,\n 689\t ®istry,\n 690\t &sessions,\n 691\t &shutdown_signals,\n 692\t &s...", + "is_error": false + }, + { + "t": 87691, + "event": "thinking", + "duration": 800 + }, + { + "t": 88491, + "event": "token_usage", + "input": 75696, + "output": 56, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 88491, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/server/client_lifecycle.rs", + "limit": 170, + "offset": 300 + } + }, + { + "t": 88691, + "event": "tool_done", + "name": "read", + "output": " 301\t }\n 302\t let _stdin_forwarder = {\n 303\t let client_event_tx = client_event_tx.clone();\n 304\t let stdin_responses = stdin_responses.clone();\n 305\t let tool_call_id = String::new();\n 306\t tokio::spawn(async move {\n 307\t while let Some(req) = stdin_req_rx.recv().await {\n 308\t let request_id = req.request_id.clone();\n 309\t stdin_responses\n 310\t .lock()\n 311\t .await\n 31...", + "is_error": false + }, + { + "t": 94511, + "event": "thinking", + "duration": 800 + }, + { + "t": 95311, + "event": "token_usage", + "input": 77633, + "output": 65, + "cache_read": 75520, + "cache_creation": null + }, + { + "t": 95311, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/server/client_lifecycle.rs", + "limit": 260, + "offset": 469 + } + }, + { + "t": 95511, + "event": "tool_done", + "name": "read", + "output": " 470\t let n = match n {\n 471\t Ok(n) => n,\n 472\t Err(e) => {\n 473\t crate::logging::error(&format!(\"Client read error: {}\", e));\n 474\t break;\n 475\t }\n 476\t };\n 477\t if n == 0 {\n 478\t break; // Client disconnected\n 479\t }\n 480\t let mut connections = client_connections.write().await;\n 481\t ...", + "is_error": false + }, + { + "t": 103279, + "event": "thinking", + "duration": 800 + }, + { + "t": 104079, + "event": "token_usage", + "input": 80003, + "output": 223, + "cache_read": 77568, + "cache_creation": null + }, + { + "t": 104079, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/provider/openai.rs", + "limit": 110, + "offset": 1968, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 2036, + "tool": "read" + }, + { + "command": "rg -n \"impl Clone for OpenAIProvider|derive\\(.*Clone.*\\).*OpenAIProvider|struct OpenAIProvider\" src/provider/openai.rs", + "description": "Locate OpenAI provider clone impl", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/mod.rs", + "limit": 40, + "offset": 3428, + "tool": "read" + } + ] + } + }, + { + "t": 104279, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1969\t Ok(())\n 1970\t }\n 1971\t Err(_) => Err(anyhow::anyhow!(\n 1972\t \"Failed to acquire lock for reasoning effort\"\n 1973\t )),\n 1974\t }\n 1975\t }\n 1976\t\n 1977\t fn available_efforts(&self) -> Vec<&'static str> {\n 1978\t vec![\"none\", \"low\", \"medium\", \"high\", \"xhigh\"]\n 1979\t }\n 1980\t\n 1981\t fn service_tier(&self) -> Option {\n 1982\t self.service_tier.try_read().ok().and_then(|g| g....", + "is_error": false + }, + { + "t": 114860, + "event": "thinking", + "duration": 800 + }, + { + "t": 115660, + "event": "token_usage", + "input": 82583, + "output": 268, + "cache_read": 79616, + "cache_creation": null + }, + { + "t": 115660, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"service_tier\\.|service_tier\\)|read\\(\\).*service_tier|service_tier: .*service_tier|service_tier.clone|service_tier.read|service_tier.write\" src/provider/openai.rs", + "description": "Trace OpenAI service tier lock usage", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 140, + "offset": 210, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 120, + "offset": 860, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 120, + "offset": 1210, + "tool": "read" + } + ] + } + }, + { + "t": 115860, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n237: service_tier: Arc::new(RwLock::new(service_tier)),\n398: if let Some(service_tier) = service_tier {\n399: request[\"service_tier\"] = serde_json::json!(service_tier);\n1679: let service_tier = self.service_tier.read().await.clone();\n1688: service_tier.as_deref(),\n1982: self.service_tier.try_read().ok().and_then(|g| g.clone())\n1986: let normalized = Self::normalize_service_tier(service_tier)?;\n1987: match self...", + "is_error": false + }, + { + "t": 118413, + "event": "thinking", + "duration": 800 + }, + { + "t": 119213, + "event": "token_usage", + "input": 87376, + "output": 64, + "cache_read": 81664, + "cache_creation": null + }, + { + "t": 119213, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 1668 + } + }, + { + "t": 119413, + "event": "tool_done", + "name": "read", + "output": " 1669\t let credentials = self.credentials.read().await;\n 1670\t let is_chatgpt = Self::is_chatgpt_mode(&credentials);\n 1671\t let instructions = if is_chatgpt {\n 1672\t Self::chatgpt_instructions_with_selfdev(system)\n 1673\t } else {\n 1674\t system.to_string()\n 1675\t };\n 1676\t (instructions, is_chatgpt)\n 1677\t };\n 1678\t let reasoning_effort = self.reasoning_effort.read().await.clone();\n 16...", + "is_error": false + }, + { + "t": 129830, + "event": "thinking", + "duration": 800 + }, + { + "t": 130630, + "event": "token_usage", + "input": 87982, + "output": 497, + "cache_read": 69760, + "cache_creation": null + }, + { + "t": 130630, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"fn provider_handle|provider_handle\\(\" src/agent.rs src --glob '!target'", + "description": "Find provider handle implementation", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/agent.rs", + "limit": 60, + "offset": 120, + "tool": "read" + }, + { + "file_path": "src/agent.rs", + "limit": 120, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 130830, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/agent.rs:1233: pub fn provider_handle(&self) -> Arc {\nsrc/server/client_state.rs:88: let provider = agent_guard.provider_handle();\nsrc/server/provider_control.rs:172: agent_guard.provider_handle()\nsrc/server/provider_control.rs:201: agent_guard.provider_handle()\nsrc/server/provider_control.rs:230: agent_guard.provider_handle()\nsrc/server/provider_control.rs:346: let provider = agent_guard.provider_handle();\nsrc/a...", + "is_error": false + }, + { + "t": 134342, + "event": "thinking", + "duration": 800 + }, + { + "t": 135142, + "event": "token_usage", + "input": 90705, + "output": 30, + "cache_read": 87808, + "cache_creation": null + }, + { + "t": 135142, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/agent.rs", + "limit": 20, + "offset": 1228 + } + }, + { + "t": 135342, + "event": "tool_done", + "name": "read", + "output": " 1229\t pub fn provider_fork(&self) -> Arc {\n 1230\t self.provider.fork()\n 1231\t }\n 1232\t\n 1233\t pub fn provider_handle(&self) -> Arc {\n 1234\t Arc::clone(&self.provider)\n 1235\t }\n 1236\t\n 1237\t pub fn available_models(&self) -> Vec<&'static str> {\n 1238\t self.provider.available_models()\n 1239\t }\n 1240\t\n 1241\t pub fn available_models_display(&self) -> Vec {\n 1242\t self.provider.available_models_display()\n 1243\t ...", + "is_error": false + }, + { + "t": 139838, + "event": "thinking", + "duration": 800 + }, + { + "t": 140638, + "event": "token_usage", + "input": 90998, + "output": 218, + "cache_read": 90496, + "cache_creation": null + }, + { + "t": 140638, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"provider_fork\\(|\\.fork\\(\" src --glob '!target'", + "description": "Find provider fork usages", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/mod.rs", + "limit": 50, + "offset": 620, + "tool": "read" + } + ] + } + }, + { + "t": 140838, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/ambient_runner.rs:625: let cycle_provider = provider.fork();\nsrc/agent.rs:1229: pub fn provider_fork(&self) -> Arc {\nsrc/agent.rs:1230: self.provider.fork()\nsrc/server/client_actions.rs:184: let provider = agent_guard.provider_fork();\nsrc/server/headless.rs:43: let provider = provider_template.fork();\nsrc/server/swarm.rs:432: agent.provider_fork(),\nsrc/server/client_lifecycle.rs:93: let provider = provider_template.fo...", + "is_error": false + }, + { + "t": 155355, + "event": "thinking", + "duration": 800 + }, + { + "t": 156155, + "event": "stream_text", + "text": "I found the concrete blocker: OpenAI’s `set_service_tier()` currently uses a non-blocking `try_write()` on a Tokio `RwLock`, and returns `Cannot change service tier while a request is in progress`. So `/fast on` is effectively rejected during an active stream. I’m changing that state to a synchronous lock so toggling fast mode can land immediately for subsequent requests in the same session/turn.", + "speed": 80 + }, + { + "t": 157414, + "event": "token_usage", + "input": 92032, + "output": 746, + "cache_read": 91008, + "cache_creation": null + }, + { + "t": 157414, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"service_tier|fast mode|/fast\" src/provider/openai.rs src/tui/app/tests.rs src/server -g '!target'", + "description": "Find relevant tests", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 150, + "tool": "read" + }, + { + "file_path": "src/tui/app/tests.rs", + "limit": 120, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 157614, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/tui/app/tests.rs:1861: service_tier: None,\nsrc/tui/app/tests.rs:1904: service_tier: None,\nsrc/tui/app/tests.rs:2248: service_tier: None,\nsrc/tui/app/tests.rs:2308: service_tier: None,\nsrc/tui/app/tests.rs:2364: service_tier: None,\nsrc/provider/openai.rs:170: service_tier: Arc>>,\nsrc/provider/openai.rs:219: let service_tier = Self::load_service_tier(\nsrc/provider/openai.rs:222: ...", + "is_error": false + }, + { + "t": 159136, + "event": "thinking", + "duration": 800 + }, + { + "t": 159936, + "event": "token_usage", + "input": 95505, + "output": 121, + "cache_read": 92672, + "cache_creation": null + }, + { + "t": 159936, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"#\\[cfg\\(test\\)\\]|mod tests|test_.*service_tier|normalize_service_tier\" src/provider/openai.rs", + "description": "Find OpenAI tests section", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 220, + "offset": 4870, + "tool": "read" + } + ] + } + }, + { + "t": 160136, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n299: fn normalize_service_tier(raw: &str) -> Result> {\n320: match Self::normalize_service_tier(raw) {\n1986: let normalized = Self::normalize_service_tier(service_tier)?;\n3443:#[cfg(test)]\n3534:#[cfg(test)]\n3535:mod tests {\n\n\n--- [2] read ---\n 4871\t let mut pending = VecDeque::new();\n 4872\t let mut events = Vec::new();\n 4873\t\n 4874\t if let Some(first) = handle_openai_output_item(item, &mut saw_text_delta, &mut pending) {\n 48...", + "is_error": false + }, + { + "t": 162581, + "event": "thinking", + "duration": 800 + }, + { + "t": 163381, + "event": "token_usage", + "input": 97043, + "output": 61, + "cache_read": 94976, + "cache_creation": null + }, + { + "t": 163381, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/provider/openai.rs", + "limit": 220, + "offset": 3535 + } + }, + { + "t": 163581, + "event": "tool_done", + "name": "read", + "output": " 3536\t use super::*;\n 3537\t use crate::auth::codex::CodexCredentials;\n 3538\t use anyhow::Result;\n 3539\t use std::collections::{HashMap, HashSet};\n 3540\t use std::ffi::OsString;\n 3541\t use std::path::PathBuf;\n 3542\t use std::sync::{Mutex, MutexGuard};\n 3543\t use std::time::{Duration, Instant};\n 3544\t const BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE: &str =\n 3545\t include_str!(\"../../tests/fixtures/openai/bright_pearl_wrapped_tool_call.txt\");\n 3546\t static ENV_L...", + "is_error": false + }, + { + "t": 168464, + "event": "thinking", + "duration": 800 + }, + { + "t": 169264, + "event": "token_usage", + "input": 99550, + "output": 136, + "cache_read": 96896, + "cache_creation": null + }, + { + "t": 169264, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/provider/openai.rs", + "limit": 70, + "offset": 0, + "tool": "read" + }, + { + "command": "rg -n \"use tokio::sync|std::sync::RwLock|parking_lot\" src/provider/openai.rs", + "description": "Inspect lock imports", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 169464, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1\tuse super::{EventStream, Provider};\n 2\tuse crate::auth::codex::CodexCredentials;\n 3\tuse crate::auth::oauth;\n 4\tuse crate::message::{\n 5\t ContentBlock, Message as ChatMessage, Role, StreamEvent, TOOL_OUTPUT_MISSING_TEXT,\n 6\t ToolDefinition,\n 7\t};\n 8\tuse anyhow::{Context, Result};\n 9\tuse async_trait::async_trait;\n 10\tuse bytes::Bytes;\n 11\tuse futures::{SinkExt, Stream, StreamExt as FuturesStreamExt};\n 12\tuse reqwest::header::HeaderValue...", + "is_error": false + }, + { + "t": 201162, + "event": "thinking", + "duration": 800 + }, + { + "t": 201962, + "event": "token_usage", + "input": 100984, + "output": 1733, + "cache_read": 99456, + "cache_creation": null + }, + { + "t": 201962, + "event": "tool_start", + "name": "apply_patch", + "input": { + "patch_text": "*** Begin Patch\n*** Update File: src/provider/openai.rs\n@@\n use serde::Deserialize;\n use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant};\n use tokio::net::TcpStream;\n use tokio::sync::{Mutex, RwLock, mpsc};\n@@\n pub struct OpenAIProvider {\n client: Client,\n credentials: Arc>,\n model: Arc>,\n@@\n prompt_cache_key: Option,\n prompt_cache_retention: Option,\n max_output_tokens: Option,\n reasoning_effort: Arc>>,\n- service_tier: Arc>>,\n+ service_tier: Arc>>,\n transport_mode: Arc>,\n@@\n prompt_cache_retention,\n max_output_tokens,\n reasoning_effort: Arc::new(RwLock::new(reasoning_effort)),\n- service_tier: Arc::new(RwLock::new(service_tier)),\n+ service_tier: Arc::new(StdRwLock::new(service_tier)),\n transport_mode: Arc::new(RwLock::new(transport_mode)),\n@@\n- let service_tier = self.service_tier.read().await.clone();\n+ let service_tier = self\n+ .service_tier\n+ .read()\n+ .map(|guard| guard.clone())\n+ .unwrap_or_else(|poisoned| poisoned.into_inner().clone());\n@@\n fn service_tier(&self) -> Option {\n- self.service_tier.try_read().ok().and_then(|g| g.clone())\n+ self.service_tier\n+ .read()\n+ .map(|guard| guard.clone())\n+ .unwrap_or_else(|poisoned| poisoned.into_inner().clone())\n }\n \n fn set_service_tier(&self, service_tier: &str) -> Result<()> {\n let normalized = Self::normalize_service_tier(service_tier)?;\n- match self.service_tier.try_write() {\n- Ok(mut guard) => {\n- *guard = normalized;\n- Ok(())\n- }\n- Err(_) => Err(anyhow::anyhow!(\n- \"Cannot change service tier while a request is in progress\"\n- )),\n- }\n+ let mut guard = self\n+ .service_tier\n+ .write()\n+ .unwrap_or_else(|poisoned| poisoned.into_inner());\n+ *guard = normalized;\n+ Ok(())\n }\n@@\n prompt_cache_key: self.prompt_cache_key.clone(),\n prompt_cache_retention: self.prompt_cache_retention.clone(),\n max_output_tokens: self.max_output_tokens,\n reasoning_effort: Arc::new(RwLock::new(self.reasoning_effort())),\n- service_tier: Arc::new(RwLock::new(self.service_tier())),\n+ service_tier: Arc::new(StdRwLock::new(self.service_tier())),\n transport_mode: Arc::clone(&self.transport_mode),\n websocket_cooldowns: Arc::clone(&self.websocket_cooldowns),\n websocket_failure_streaks: Arc::clone(&self.websocket_failure_streaks),\n persistent_ws: Arc::new(Mutex::new(None)),\n@@\n fn test_openai_supports_codex_models() {\n@@\n provider.set_model(\"gpt-5.1-codex-mini\").unwrap();\n assert_eq!(provider.model(), \"gpt-5.1-codex-mini\");\n }\n+\n+ #[test]\n+ fn test_service_tier_can_be_changed_while_a_request_snapshot_is_held() {\n+ let provider = Arc::new(OpenAIProvider::new(CodexCredentials {\n+ access_token: \"test\".to_string(),\n+ refresh_token: String::new(),\n+ id_token: None,\n+ account_id: None,\n+ expires_at: None,\n+ }));\n+\n+ let read_guard = provider\n+ .service_tier\n+ .read()\n+ .expect(\"service tier read lock should be available\");\n+\n+ let (tx, rx) = std::sync::mpsc::channel();\n+ let provider_for_write = Arc::clone(&provider);\n+ let handle = std::thread::spawn(move || {\n+ let result = provider_for_write.set_service_tier(\"priority\");\n+ tx.send(result).expect(\"send result from setter thread\");\n+ });\n+\n+ std::thread::sleep(Duration::from_millis(20));\n+ assert!(\n+ rx.try_recv().is_err(),\n+ \"writer should wait for the in-flight snapshot to finish\"\n+ );\n+\n+ drop(read_guard);\n+\n+ rx.recv()\n+ .expect(\"receive service tier setter result\")\n+ .expect(\"service tier update should succeed once read lock is released\");\n+ handle.join().expect(\"join setter thread\");\n+\n+ assert_eq!(provider.service_tier(), Some(\"priority\".to_string()));\n+ }\n \n #[test]\n fn test_build_responses_input_injects_missing_tool_output() {\n*** End Patch" + } + }, + { + "t": 202162, + "event": "tool_done", + "name": "apply_patch", + "output": "✓ src/provider/openai.rs: modified (9 hunks)\n19- use std::sync::{Arc, LazyLock};\n19+ use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n170- service_tier: Arc>>,\n170+ service_tier: Arc>>,\n237- service_tier: Arc::new(RwLock::new(service_tier)),\n237+ service_tier: Arc::new(StdRwLock::new(service_tier)),\n1679- let service_tier = self.service_tier.read().await.clone();\n1679+ let service_tier = self\n...", + "is_error": false + }, + { + "t": 210304, + "event": "thinking", + "duration": 800 + }, + { + "t": 211104, + "event": "stream_text", + "text": "Patch is in. I changed OpenAI’s `service_tier` state to a synchronous `RwLock`, so `/fast on` no longer hard-fails just because a request is active; it waits for the tiny request-snapshot read to finish, then updates cleanly. I also added a regression test around that lock behavior. Now I’m formatting and running targeted tests/build.", + "speed": 80 + }, + { + "t": 212166, + "event": "token_usage", + "input": 104521, + "output": 319, + "cache_read": 101120, + "cache_creation": null + }, + { + "t": 212166, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "cargo fmt --all", + "description": "Format Rust code", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "cargo test test_service_tier_can_be_changed_while_a_request_snapshot_is_held --lib", + "description": "Run new targeted test", + "timeout": 1200000, + "tool": "bash" + }, + { + "command": "cargo test test_openai_supports_codex_models --lib", + "description": "Run nearby OpenAI test", + "timeout": 1200000, + "tool": "bash" + } + ] + } + }, + { + "t": 212966, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nCommand completed successfully (no output)\n\n--- [2] bash ---\n\nrunning 1 test\ntest provider::openai::tests::test_service_tier_can_be_changed_while_a_request_snapshot_is_held ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1147 filtered out; finished in 0.04s\n\n\n Blocking waiting for file lock on package cache\n Compiling jcode v0.6.1 (/home/jeremy/jcode)\nwarning: method `build_system_prompt` is never used\n --> src/agent/prompt_support.rs:24:19\n |\n 5 |...", + "is_error": false + }, + { + "t": 222238, + "event": "thinking", + "duration": 800 + }, + { + "t": 223038, + "event": "stream_text", + "text": "Targeted tests passed. I’m doing the required release build now; because this is jcode itself, I’ll reload onto the new binary afterward. I’m not auto-committing here because the worktree already has a lot of unrelated edits, and I don’t want to accidentally sweep those into a commit.", + "speed": 80 + }, + { + "t": 223953, + "event": "token_usage", + "input": 114021, + "output": 305, + "cache_read": 104704, + "cache_creation": null + }, + { + "t": 223953, + "event": "tool_start", + "name": "bash", + "input": { + "command": "cargo build --release", + "description": "Build release binary", + "notify": true, + "run_in_background": true, + "timeout": 120000 + } + }, + { + "t": 224153, + "event": "tool_done", + "name": "bash", + "output": "Command started in background.\n\nTask ID: 076911y3sg\nOutput file: /tmp/jcode-bg-tasks/076911y3sg.output\nStatus file: /tmp/jcode-bg-tasks/076911y3sg.status.json\n\nYou will be notified when the task completes.\nTo check progress: use the `bg` tool with action=\"status\" and task_id=\"076911y3sg\"\nTo see output: use the `read` tool on the output file, or `bg` with action=\"output\"", + "is_error": false + }, + { + "t": 225212, + "event": "thinking", + "duration": 800 + }, + { + "t": 226012, + "event": "token_usage", + "input": 114636, + "output": 59, + "cache_read": 114176, + "cache_creation": null + }, + { + "t": 226012, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 226212, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 229117, + "event": "thinking", + "duration": 800 + }, + { + "t": 229917, + "event": "stream_text", + "text": "Release build is still running. Once it finishes I’ll reload onto the rebuilt binary and then I’ll give you the exact change summary.", + "speed": 80 + }, + { + "t": 230345, + "event": "token_usage", + "input": 114765, + "output": 88, + "cache_read": 114560, + "cache_creation": null + }, + { + "t": 230345, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 230545, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 231479, + "event": "thinking", + "duration": 800 + }, + { + "t": 232279, + "event": "token_usage", + "input": 114991, + "output": 43, + "cache_read": 114688, + "cache_creation": null + }, + { + "t": 232279, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 232479, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 234740, + "event": "thinking", + "duration": 800 + }, + { + "t": 235540, + "event": "token_usage", + "input": 115148, + "output": 53, + "cache_read": 114816, + "cache_creation": null + }, + { + "t": 235540, + "event": "tool_start", + "name": "bg", + "input": { + "action": "output", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 235740, + "event": "tool_done", + "name": "bg", + "output": "[stderr] Compiling jcode v0.6.1 (/home/jeremy/jcode)\n[stderr] warning: method `build_system_prompt` is never used\n[stderr] --> src/agent/prompt_support.rs:24:19\n[stderr] |\n[stderr] 5 | impl Agent {\n[stderr] | ---------- method in this implementation\n[stderr] ...\n[stderr] 24 | pub(super) fn build_system_prompt(&self, memory_prompt: Option<&str>) -> String {\n[stderr] | ^^^^^^^^^^^^^^^^^^^\n[stderr] |\n[stderr] = note: `#[warn(dead_code)]` (part of `#[war...", + "is_error": false + }, + { + "t": 237304, + "event": "thinking", + "duration": 800 + }, + { + "t": 238104, + "event": "token_usage", + "input": 122697, + "output": 45, + "cache_read": 115072, + "cache_creation": null + }, + { + "t": 238104, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 238304, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 240365, + "event": "thinking", + "duration": 800 + }, + { + "t": 241165, + "event": "token_usage", + "input": 122856, + "output": 41, + "cache_read": 122624, + "cache_creation": null + }, + { + "t": 241165, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 241365, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 243911, + "event": "thinking", + "duration": 800 + }, + { + "t": 244711, + "event": "token_usage", + "input": 123011, + "output": 45, + "cache_read": 122752, + "cache_creation": null + }, + { + "t": 244711, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 244911, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 246910, + "event": "thinking", + "duration": 800 + }, + { + "t": 247710, + "event": "stream_text", + "text": "Build is in the final stretch; it’s still compiling, no errors so far—just existing warning noise from other parts of the codebase. I’ll reload as soon as it flips to done.", + "speed": 80 + }, + { + "t": 248266, + "event": "token_usage", + "input": 123170, + "output": 92, + "cache_read": 122880, + "cache_creation": null + }, + { + "t": 248266, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 248466, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 250675, + "event": "thinking", + "duration": 800 + }, + { + "t": 251475, + "event": "token_usage", + "input": 123413, + "output": 50, + "cache_read": 123136, + "cache_creation": null + }, + { + "t": 251475, + "event": "tool_start", + "name": "bg", + "input": { + "action": "output", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 251675, + "event": "tool_done", + "name": "bg", + "output": "[stderr] Compiling jcode v0.6.1 (/home/jeremy/jcode)\n[stderr] warning: method `build_system_prompt` is never used\n[stderr] --> src/agent/prompt_support.rs:24:19\n[stderr] |\n[stderr] 5 | impl Agent {\n[stderr] | ---------- method in this implementation\n[stderr] ...\n[stderr] 24 | pub(super) fn build_system_prompt(&self, memory_prompt: Option<&str>) -> String {\n[stderr] | ^^^^^^^^^^^^^^^^^^^\n[stderr] |\n[stderr] = note: `#[warn(dead_code)]` (part of `#[war...", + "is_error": false + }, + { + "t": 253802, + "event": "thinking", + "duration": 800 + }, + { + "t": 254602, + "event": "token_usage", + "input": 130959, + "output": 52, + "cache_read": 123264, + "cache_creation": null + }, + { + "t": 254602, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 254802, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 256362, + "event": "thinking", + "duration": 800 + }, + { + "t": 257162, + "event": "token_usage", + "input": 131125, + "output": 41, + "cache_read": 130816, + "cache_creation": null + }, + { + "t": 257162, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 257362, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 258735, + "event": "thinking", + "duration": 800 + }, + { + "t": 259535, + "event": "token_usage", + "input": 131280, + "output": 46, + "cache_read": 130944, + "cache_creation": null + }, + { + "t": 259535, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 259735, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 263139, + "event": "thinking", + "duration": 800 + }, + { + "t": 263939, + "event": "stream_text", + "text": "Still waiting on the release link step. As soon as it’s done I’ll reload the agent binary and wrap up.", + "speed": 80 + }, + { + "t": 264270, + "event": "token_usage", + "input": 131440, + "output": 98, + "cache_read": 131200, + "cache_creation": null + }, + { + "t": 264270, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 264470, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 266557, + "event": "thinking", + "duration": 800 + }, + { + "t": 267357, + "event": "token_usage", + "input": 131673, + "output": 77, + "cache_read": 131328, + "cache_creation": null + }, + { + "t": 267357, + "event": "tool_start", + "name": "bash", + "input": { + "command": "tail -n 20 /tmp/jcode-bg-tasks/076911y3sg.output", + "description": "Check build output tail", + "notify": false, + "run_in_background": false, + "timeout": 120000 + } + }, + { + "t": 267557, + "event": "tool_done", + "name": "bash", + "output": "[stderr] | ^^^^^^^^ help: convert the identifier to snake case: `crash_bg`\n[stderr] \n[stderr] warning: variable `CRASH_FG` should have a snake case name\n[stderr] --> src/tui/session_picker.rs:2039:13\n[stderr] |\n[stderr] 2039 | let CRASH_FG: Color = rgb(255, 140, 140);\n[stderr] | ^^^^^^^^ help: convert the identifier to snake case: `crash_fg`\n[stderr] \n[stderr] warning: variable `CRASH_ICON` should have a snake case name\n[stderr] --> src/t...", + "is_error": false + }, + { + "t": 268912, + "event": "thinking", + "duration": 800 + }, + { + "t": 269712, + "event": "token_usage", + "input": 132138, + "output": 38, + "cache_read": 131584, + "cache_creation": null + }, + { + "t": 269712, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 269912, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 271269, + "event": "thinking", + "duration": 800 + }, + { + "t": 272069, + "event": "token_usage", + "input": 132290, + "output": 57, + "cache_read": 131968, + "cache_creation": null + }, + { + "t": 272069, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 272269, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 273723, + "event": "thinking", + "duration": 800 + }, + { + "t": 274523, + "event": "token_usage", + "input": 132461, + "output": 50, + "cache_read": 132224, + "cache_creation": null + }, + { + "t": 274523, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 274723, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 279804, + "event": "thinking", + "duration": 800 + }, + { + "t": 280604, + "event": "token_usage", + "input": 132625, + "output": 97, + "cache_read": 132352, + "cache_creation": null + }, + { + "t": 280604, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "git diff -- src/provider/openai.rs", + "description": "Show patch diff", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "git status --short src/provider/openai.rs", + "description": "Check file status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 280804, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\ndiff --git a/src/provider/openai.rs b/src/provider/openai.rs\nindex 6ee1a1b..73a762e 100644\n--- a/src/provider/openai.rs\n+++ b/src/provider/openai.rs\n@@ -16,7 +16,7 @@ use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant}...", + "is_error": false + }, + { + "t": 282349, + "event": "thinking", + "duration": 800 + }, + { + "t": 283149, + "event": "token_usage", + "input": 134194, + "output": 47, + "cache_read": 131840, + "cache_creation": null + }, + { + "t": 283149, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 283349, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: completed\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:52:13.620314075+00:00\nCompleted: 2026-03-13T20:52:13.620319598+00:00\nDuration: 56.71s\nExit code: 0\n", + "is_error": false + }, + { + "t": 285924, + "event": "thinking", + "duration": 800 + }, + { + "t": 286724, + "event": "stream_text", + "text": "Release build finished successfully. Reloading onto the new binary now.", + "speed": 80 + }, + { + "t": 286945, + "event": "token_usage", + "input": 134393, + "output": 103, + "cache_read": 133888, + "cache_creation": null + }, + { + "t": 286945, + "event": "tool_start", + "name": "selfdev", + "input": { + "action": "reload", + "context": "Patched OpenAI fast mode toggling so `/fast on` can be changed mid-stream by switching service_tier from a Tokio try_write lock to a synchronous RwLock; added regression test and validated targeted tests plus cargo build --release." + } + }, + { + "t": 287145, + "event": "tool_done", + "name": "selfdev", + "output": "Reload initiated. Process restarting...", + "is_error": false + }, + { + "t": 330752, + "event": "thinking", + "duration": 800 + }, + { + "t": 331552, + "event": "stream_text", + "text": "Reloaded onto the rebuilt binary. I’m doing one quick sanity check on the changed file/state, then I’ll wrap with what changed and how to verify `/fast on` mid-stream.", + "speed": 80 + }, + { + "t": 332086, + "event": "token_usage", + "input": 125379, + "output": 284, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 332086, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "git diff -- src/provider/openai.rs", + "description": "Verify persisted OpenAI diff", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "cargo test test_service_tier_can_be_changed_while_a_request_snapshot_is_held --lib", + "description": "Re-run regression after reload", + "timeout": 1200000, + "tool": "bash" + }, + { + "command": "git status --short src/provider/openai.rs", + "description": "Check modified file status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 332886, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\ndiff --git a/src/provider/openai.rs b/src/provider/openai.rs\nindex 6ee1a1b..73a762e 100644\n--- a/src/provider/openai.rs\n+++ b/src/provider/openai.rs\n@@ -16,7 +16,7 @@ use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant}...", + "is_error": false + }, + { + "t": 340723, + "event": "thinking", + "duration": 800 + }, + { + "t": 341523, + "event": "token_usage", + "input": 131694, + "output": 106, + "cache_read": 125568, + "cache_creation": null + }, + { + "t": 341523, + "event": "tool_start", + "name": "todowrite", + "input": { + "todos": [ + { + "content": "Locate fast mode command handling and current streaming behavior", + "id": "t1", + "priority": "high", + "status": "completed" + }, + { + "content": "Implement support for enabling fast mode mid-stream", + "id": "t2", + "priority": "high", + "status": "completed" + }, + { + "content": "Build and validate behavior", + "id": "t3", + "priority": "high", + "status": "completed" + } + ] + } + }, + { + "t": 341723, + "event": "tool_done", + "name": "todowrite", + "output": "[\n {\n \"content\": \"Locate fast mode command handling and current streaming behavior\",\n \"status\": \"completed\",\n \"priority\": \"high\",\n \"id\": \"t1\"\n },\n {\n \"content\": \"Implement support for enabling fast mode mid-stream\",\n \"status\": \"completed\",\n \"priority\": \"high\",\n \"id\": \"t2\"\n },\n {\n \"content\": \"Build and validate behavior\",\n \"status\": \"completed\",\n \"priority\": \"high\",\n \"id\": \"t3\"\n }\n]", + "is_error": false + }, + { + "t": 348819, + "event": "thinking", + "duration": 800 + }, + { + "t": 349619, + "event": "stream_text", + "text": "Done.\n\n### What changed\nI fixed the OpenAI/Codex fast-mode toggle so `/fast on` can be changed while a stream is already in progress.\n\nRoot cause:\n- `src/provider/openai.rs` stored `service_tier` in a Tokio `RwLock`\n- `set_service_tier()` used `try_write()`\n- during an active request that returned:\n - `Cannot change service tier while a request is in progress`\n\nSo mid-stream `/fast on` effectively failed.\n\n### Fix\nI changed `service_tier` to use a synchronous `std::sync::RwLock` instead, and updated reads/writes accordingly:\n- active request snapshot can keep its current tier\n- `/fast on` now waits briefly for that snapshot read to finish\n- then the tier updates successfully for subsequent request(s) in the same session flow\n\n### Validation\nI ran:\n- `cargo fmt --all`\n- `cargo test test_service_tier_can_be_changed_while_a_request_snapshot_is_held --lib`\n- `cargo test test_openai_supports_codex_models --lib`\n- `cargo build --release`\n- reloaded onto the rebuilt binary\n\nI also added a regression test covering the “change service tier while request snapshot is held” case.\n\n### Note\nI did **not** make a git commit because the worktree already had many unrelated local changes and I didn’t want to accidentally include them.\n\nIf you want, next I can also make `/fast on` show a slightly clearer status like “will apply to the next request chunk/turn” when used mid-stream.", + "speed": 80 + }, + { + "t": 353978, + "event": "token_usage", + "input": 132041, + "output": 363, + "cache_read": 130816, + "cache_creation": null + }, + { + "t": 353978, + "event": "done" + }, + { + "t": 355978, + "event": "user_message", + "text": " yeah do that" + }, + { + "t": 356978, + "event": "thinking", + "duration": 800 + }, + { + "t": 357778, + "event": "stream_text", + "text": "I’ve got it — I’m wiring the `/fast on` feedback so if you toggle it during an active stream, jcode explicitly says it’ll apply on the next request/turn instead of sounding like it affected the already-running request. Then I’ll run targeted tests again.", + "speed": 80 + }, + { + "t": 358603, + "event": "token_usage", + "input": 132580, + "output": 359, + "cache_read": 125184, + "cache_creation": null + }, + { + "t": 358603, + "event": "tool_start", + "name": "todowrite", + "input": { + "todos": [ + { + "content": "Add clearer mid-stream fast mode status messaging", + "id": "t4", + "priority": "high", + "status": "in_progress" + }, + { + "content": "Test updated local and remote fast-mode messaging", + "id": "t5", + "priority": "high", + "status": "pending" + } + ] + } + }, + { + "t": 358803, + "event": "tool_done", + "name": "todowrite", + "output": "[\n {\n \"content\": \"Add clearer mid-stream fast mode status messaging\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"t4\"\n },\n {\n \"content\": \"Test updated local and remote fast-mode messaging\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t5\"\n }\n]", + "is_error": false + }, + { + "t": 362639, + "event": "thinking", + "duration": 800 + }, + { + "t": 363439, + "event": "token_usage", + "input": 133176, + "output": 142, + "cache_read": 132736, + "cache_creation": null + }, + { + "t": 363439, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"Fast mode|/fast|service_tier\" src/tui/app/tests.rs src/tui/app/model_context.rs src/tui/app/remote.rs --glob '!target'", + "description": "Find fast command messaging tests", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/tui/app/model_context.rs", + "limit": 90, + "offset": 860, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 70, + "offset": 1998, + "tool": "read" + } + ] + } + }, + { + "t": 363639, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/tui/app/remote.rs:1762: service_tier,\nsrc/tui/app/remote.rs:1822: app.remote_service_tier = service_tier;\nsrc/tui/app/remote.rs:2003: service_tier,\nsrc/tui/app/remote.rs:2013: app.remote_service_tier = service_tier.clone();\nsrc/tui/app/remote.rs:2014: let enabled = service_tier.as_deref() == Some(\"priority\");\nsrc/tui/app/remote.rs:2015: let label = service_tier\nsrc/tui/app/remote.rs:2017: ...", + "is_error": false + }, + { + "t": 372662, + "event": "thinking", + "duration": 800 + }, + { + "t": 373462, + "event": "stream_text", + "text": "I found the two spots to adjust:\n- local `/fast on` success message\n- remote `ServiceTierChanged` event message\n\nI’m making them context-aware so they mention the current request won’t change if you flip fast mode mid-stream. After that I’ll add tests for both paths.", + "speed": 80 + }, + { + "t": 374315, + "event": "token_usage", + "input": 135930, + "output": 282, + "cache_read": 132864, + "cache_creation": null + }, + { + "t": 374315, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"ServerEvent::ServiceTierChanged|handle_server_event|apply_server_event|process_server_event\" src/tui/app/remote.rs src/tui/app/tests.rs --glob '!target'", + "description": "Find remote event handler for testing", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"handle_model_command\\(|Fast mode\" src/tui/app/tests.rs --glob '!target'", + "description": "Find local command tests", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/tui/app/tests.rs", + "limit": 220, + "offset": 1820, + "tool": "read" + } + ] + } + }, + { + "t": 374515, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/tui/app/tests.rs:1778: app.handle_server_event(\nsrc/tui/app/tests.rs:1782: app.handle_server_event(\nsrc/tui/app/tests.rs:1791: app.handle_server_event(\nsrc/tui/app/tests.rs:1812:fn test_handle_server_event_updates_connection_type() {\nsrc/tui/app/tests.rs:1818: app.handle_server_event(\nsrc/tui/app/tests.rs:1829:fn test_handle_server_event_history_clears_connection_type_on_session_change_when_missing() {\nsrc/tui/app/tests.rs:1838: app.handle_server_event(\nsrc...", + "is_error": false + }, + { + "t": 377013, + "event": "thinking", + "duration": 800 + }, + { + "t": 377813, + "event": "token_usage", + "input": 139521, + "output": 110, + "cache_read": 136064, + "cache_creation": null + }, + { + "t": 377813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/model_context.rs", + "limit": 80, + "offset": 0, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 40, + "offset": 1338, + "tool": "read" + } + ] + } + }, + { + "t": 378013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1\tuse super::*;\n 2\t\n 3\timpl App {\n 4\t pub(super) fn cycle_model(&mut self, direction: i8) {\n 5\t let models = self.provider.available_models();\n 6\t if models.is_empty() {\n 7\t self.push_display_message(DisplayMessage::error(\n 8\t \"Model switching is not available for this provider.\",\n 9\t ));\n 10\t self.set_status_notice(\"Model switching not available\");\n 11\t return;\n 12\t ...", + "is_error": false + }, + { + "t": 382627, + "event": "thinking", + "duration": 800 + }, + { + "t": 383427, + "event": "token_usage", + "input": 141021, + "output": 110, + "cache_read": 139008, + "cache_creation": null + }, + { + "t": 383427, + "event": "tool_start", + "name": "bash", + "input": { + "command": "rg -n \"fn service_tier_display_label|service_tier_display_label\\(\" src/tui/app src/tui --glob '!target'", + "description": "Locate service tier label helper", + "notify": false, + "run_in_background": false, + "timeout": 120000 + } + }, + { + "t": 383627, + "event": "tool_done", + "name": "bash", + "output": "src/tui/app/helpers.rs:263:pub(super) fn service_tier_display_label(service_tier: &str) -> &str {\nsrc/tui/app/helpers.rs:263:pub(super) fn service_tier_display_label(service_tier: &str) -> &str {\n", + "is_error": false + }, + { + "t": 384866, + "event": "thinking", + "duration": 800 + }, + { + "t": 385666, + "event": "token_usage", + "input": 141289, + "output": 31, + "cache_read": 140928, + "cache_creation": null + }, + { + "t": 385666, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/tui/app/helpers.rs", + "limit": 60, + "offset": 240 + } + }, + { + "t": 385866, + "event": "tool_done", + "name": "read", + "output": " 241\t match effort {\n 242\t \"xhigh\" => \"Max\",\n 243\t \"high\" => \"High\",\n 244\t \"medium\" => \"Medium\",\n 245\t \"low\" => \"Low\",\n 246\t \"none\" => \"None\",\n 247\t other => other,\n 248\t }\n 249\t}\n 250\t\n 251\tpub(super) fn effort_bar(index: usize, total: usize) -> String {\n 252\t let mut bar = String::new();\n 253\t for i in 0..total {\n 254\t if i == index {\n 255\t bar.push('●');\n 256\t } else {\n 257\t bar.pus...", + "is_error": false + }, + { + "t": 395812, + "event": "thinking", + "duration": 800 + }, + { + "t": 396612, + "event": "token_usage", + "input": 142010, + "output": 194, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 396612, + "event": "tool_start", + "name": "bash", + "input": { + "command": "rg -n \"status_notice|set_status_notice|Fast:|display_messages\\(\\).*last\\(|command_help\" src/tui/app/tests.rs src/tui/app/state_ui.rs src/tui/app.rs --glob '!target'", + "description": "Find status notice accessors in tests", + "notify": false, + "run_in_background": false, + "timeout": 120000 + } + }, + { + "t": 396812, + "event": "tool_done", + "name": "bash", + "output": "src/tui/app.rs:406: status_notice: Option<(String, Instant)>,\nsrc/tui/app/state_ui.rs:763: self.set_status_notice(format!(\nsrc/tui/app/state_ui.rs:780: self.set_status_notice(\"📋 Input restored from stash\");\nsrc/tui/app/state_ui.rs:783: self.set_status_notice(\"📋 Swapped input with stash\");\nsrc/tui/app/state_ui.rs:789: self.set_status_notice(\"📋 Input stashed\");\nsrc/tui/app/state_ui.rs:872: self.set_status_notice(\"📌 Return...", + "is_error": false + }, + { + "t": 402526, + "event": "thinking", + "duration": 800 + }, + { + "t": 403326, + "event": "stream_text", + "text": "I’ve got enough context now. I’m adding a shared helper for fast-mode confirmation text, then covering:\n- local `/fast on` while processing\n- remote `ServiceTierChanged` while processing\n\nso the wording is consistent in both modes.", + "speed": 80 + }, + { + "t": 404060, + "event": "token_usage", + "input": 142834, + "output": 189, + "cache_read": 141056, + "cache_creation": null + }, + { + "t": 404060, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/tests.rs", + "limit": 120, + "offset": 2100, + "tool": "read" + }, + { + "file_path": "src/tui/app/tests.rs", + "limit": 120, + "offset": 1240, + "tool": "read" + }, + { + "file_path": "src/tui/app/state_ui.rs", + "limit": 40, + "offset": 730, + "tool": "read" + } + ] + } + }, + { + "t": 404260, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2101\t assert_eq!(last.role, \"system\");\n 2102\t assert_eq!(\n 2103\t last.content,\n 2104\t \"📦 **Context compacted** (semantic) — older messages were summarized to stay within the context window. Previous size: ~12,345 tokens.\"\n 2105\t );\n 2106\t}\n 2107\t\n 2108\t#[test]\n 2109\tfn test_handle_server_event_compaction_mode_changed_updates_remote_mode() {\n 2110\t let mut app = create_test_app();\n 2111\t let rt = tokio::runtime::Runtime::new().unwrap();\n 2112...", + "is_error": false + }, + { + "t": 414749, + "event": "thinking", + "duration": 800 + }, + { + "t": 415549, + "event": "token_usage", + "input": 146205, + "output": 590, + "cache_read": 141184, + "cache_creation": null + }, + { + "t": 415549, + "event": "tool_start", + "name": "bash", + "input": { + "command": "rg -n \"struct .*Provider;|impl Provider for .*service_tier|set_service_tier\\(&self\" src/tui/app/tests.rs", + "description": "Find test providers with service tier", + "notify": false, + "run_in_background": false, + "timeout": 120000 + } + }, + { + "t": 415749, + "event": "tool_done", + "name": "bash", + "output": "6:struct MockProvider;\n40: struct GeminiMockProvider;\n", + "is_error": false + }, + { + "t": 427535, + "event": "thinking", + "duration": 800 + }, + { + "t": 428335, + "event": "token_usage", + "input": 146912, + "output": 592, + "cache_read": 146176, + "cache_creation": null + }, + { + "t": 428335, + "event": "tool_start", + "name": "apply_patch", + "input": { + "patch_text": "*** Begin Patch\n*** Update File: src/tui/app/helpers.rs\n@@\n pub(super) fn service_tier_display_label(service_tier: &str) -> &str {\n match service_tier {\n \"priority\" => \"Fast\",\n \"flex\" => \"Flex\",\n other => other,\n }\n }\n+\n+pub(super) fn fast_mode_success_message(\n+ enabled: bool,\n+ label: &str,\n+ applies_next_request: bool,\n+) -> String {\n+ let status = if enabled { \"on\" } else { \"off\" };\n+ if applies_next_request {\n+ format!(\n+ \"✓ Fast mode {} ({})\\nApplies to the next request/turn. The current in-flight request keeps its existing tier.\",\n+ status, label\n+ )\n+ } else {\n+ format!(\"✓ Fast mode {} ({})\", status, label)\n+ }\n+}\n+\n+pub(super) fn fast_mode_status_notice(enabled: bool, applies_next_request: bool) -> String {\n+ let status = if enabled { \"on\" } else { \"off\" };\n+ if applies_next_request {\n+ format!(\"Fast: {} (next request)\", status)\n+ } else {\n+ format!(\"Fast: {}\", status)\n+ }\n+}\n \n pub(super) fn mask_email(email: &str) -> String {\n*** End Patch" + } + }, + { + "t": 428535, + "event": "tool_done", + "name": "apply_patch", + "output": "✓ src/tui/app/helpers.rs: modified (1 hunks)\n271+ pub(super) fn fast_mode_success_message(\n272+ enabled: bool,\n273+ label: &str,\n274+ applies_next_request: bool,\n275+ ) -> String {\n276+ let status = if enabled { \"on\" } else { \"off\" };\n277+ if applies_next_request {\n278+ format!(\n279+ \"✓ Fast mode {} ({})\\nApplies to the next request/turn. The current in-flight request keeps its existing tier.\",\n280+ status, label\n281+ )\n282+ ...", + "is_error": false + }, + { + "t": 437690, + "event": "thinking", + "duration": 800 + }, + { + "t": 438490, + "event": "token_usage", + "input": 148136, + "output": 305, + "cache_read": 146176, + "cache_creation": null + }, + { + "t": 438490, + "event": "tool_start", + "name": "apply_patch", + "input": { + "patch_text": "*** Begin Patch\n*** Update File: src/tui/app/model_context.rs\n@@\n match app.provider.set_service_tier(target) {\n Ok(()) => {\n let current = app.provider.service_tier();\n let enabled = current.as_deref() == Some(\"priority\");\n let label = current\n .as_deref()\n .map(service_tier_display_label)\n .unwrap_or(\"Standard\");\n- app.push_display_message(DisplayMessage::system(format!(\n- \"✓ Fast mode {} ({})\",\n- if enabled { \"on\" } else { \"off\" },\n- label\n- )));\n- app.set_status_notice(format!(\"Fast: {}\", if enabled { \"on\" } else { \"off\" }));\n+ let applies_next_request = app.is_processing;\n+ app.push_display_message(DisplayMessage::system(fast_mode_success_message(\n+ enabled,\n+ label,\n+ applies_next_request,\n+ )));\n+ app.set_status_notice(fast_mode_status_notice(enabled, applies_next_request));\n }\n Err(e) => {\n app.push_display_message(DisplayMessage::error(format!(\n \"Failed to set fast mode: {}\",\n*** End Patch" + } + }, + { + "t": 438690, + "event": "tool_done", + "name": "apply_patch", + "output": "✓ src/tui/app/model_context.rs: modified (1 hunks)\n916- app.push_display_message(DisplayMessage::system(format!(\n917- \"✓ Fast mode {} ({})\",\n918- if enabled { \"on\" } else { \"off\" },\n919- label\n916+ let applies_next_request = app.is_processing;\n917+ app.push_display_message(DisplayMessage::system(fast_mode_success_message(\n918+ enabled,\n919+ la...", + "is_error": false + }, + { + "t": 444646, + "event": "thinking", + "duration": 800 + }, + { + "t": 445446, + "event": "token_usage", + "input": 148925, + "output": 284, + "cache_read": 147200, + "cache_creation": null + }, + { + "t": 445446, + "event": "tool_start", + "name": "apply_patch", + "input": { + "patch_text": "*** Begin Patch\n*** Update File: src/tui/app/remote.rs\n@@\n } else {\n app.remote_service_tier = service_tier.clone();\n let enabled = service_tier.as_deref() == Some(\"priority\");\n let label = service_tier\n .as_deref()\n .map(super::service_tier_display_label)\n .unwrap_or(\"Standard\");\n- app.push_display_message(DisplayMessage::system(format!(\n- \"✓ Fast mode {} ({})\",\n- if enabled { \"on\" } else { \"off\" },\n- label\n- )));\n- app.set_status_notice(format!(\"Fast: {}\", if enabled { \"on\" } else { \"off\" }));\n+ let applies_next_request = app.is_processing;\n+ app.push_display_message(DisplayMessage::system(\n+ super::fast_mode_success_message(enabled, label, applies_next_request),\n+ ));\n+ app.set_status_notice(super::fast_mode_status_notice(\n+ enabled,\n+ applies_next_request,\n+ ));\n }\n false\n }\n*** End Patch" + } + }, + { + "t": 445646, + "event": "tool_done", + "name": "apply_patch", + "output": "✓ src/tui/app/remote.rs: modified (1 hunks)\n2021- app.push_display_message(DisplayMessage::system(format!(\n2022- \"✓ Fast mode {} ({})\",\n2023- if enabled { \"on\" } else { \"off\" },\n2024- label\n2025- )));\n2026- app.set_status_notice(format!(\"Fast: {}\", if enabled { \"on\" } else { \"off\" }));\n2021+ let applies_next_request = app.is_processing;\n2022+ app.push...", + "is_error": false + }, + { + "t": 445650, + "event": "done" + } +] diff --git a/assets/demos/duck_fast-on-mid-stream_autoedit_v2_timeline.json b/assets/demos/duck_fast-on-mid-stream_autoedit_v2_timeline.json new file mode 100644 index 0000000..3961610 --- /dev/null +++ b/assets/demos/duck_fast-on-mid-stream_autoedit_v2_timeline.json @@ -0,0 +1,4512 @@ +[ + { + "t": 17674, + "event": "user_message", + "text": "could you make it so that fast on command could work mid stream" + }, + { + "t": 18674, + "event": "thinking", + "duration": 800 + }, + { + "t": 19474, + "event": "token_usage", + "input": 11724, + "output": 166, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 19474, + "event": "tool_start", + "name": "todowrite", + "input": { + "todos": [ + { + "content": "Locate fast mode command handling and current streaming behavior", + "id": "t1", + "priority": "high", + "status": "in_progress" + }, + { + "content": "Implement support for enabling fast mode mid-stream", + "id": "t2", + "priority": "high", + "status": "pending" + }, + { + "content": "Build and validate behavior", + "id": "t3", + "priority": "high", + "status": "pending" + } + ] + } + }, + { + "t": 19674, + "event": "tool_done", + "name": "todowrite", + "output": "[\n {\n \"content\": \"Locate fast mode command handling and current streaming behavior\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"t1\"\n },\n {\n \"content\": \"Implement support for enabling fast mode mid-stream\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t2\"\n },\n {\n \"content\": \"Build and validate behavior\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t3\"\n }\n]", + "is_error": false + }, + { + "t": 20674, + "event": "thinking", + "duration": 800 + }, + { + "t": 21474, + "event": "stream_text", + "text": "I’m looking through the command/streaming path now to find where `fast on` is gated and why it only works before a stream starts. Then I’ll patch it, build, and verify.", + "speed": 80 + }, + { + "t": 22011, + "event": "token_usage", + "input": 12124, + "output": 199, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 22011, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "pwd && rg -n \"fast on|fast off|fast mode|/fast|fast\" . --glob '!target' --glob '!node_modules'", + "description": "Search fast command references", + "timeout": 120000, + "tool": "bash" + }, + { + "ignore": [ + "target", + "node_modules", + ".git" + ], + "path": ".", + "tool": "ls" + }, + { + "command": "git status --short", + "description": "Check working tree status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 22211, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n/home/jeremy/jcode\n./figma/jcode-mobile-design-spec.md:102:- `jcode-mobile-mockup.svg` gives a fast importable preview\n./Cargo.toml:4:description = \"Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools\"\n./RELEASING.md:3:jcode has two release paths: a fast local path for hotfixes, and CI for full releases.\n./RELEASING.md:74:- **mold linker** on Linux for faster linking.\n./RELEASING.md:93:For quick releases that also nee...", + "is_error": false + }, + { + "t": 23211, + "event": "thinking", + "duration": 800 + }, + { + "t": 24011, + "event": "stream_text", + "text": "I found the repo state has a lot of unrelated local changes already, so I’m being careful to only touch the fast-command/streaming path. Now I’m narrowing to the exact handler and interrupt loop.", + "speed": 80 + }, + { + "t": 24632, + "event": "token_usage", + "input": 18631, + "output": 194, + "cache_read": 12160, + "cache_creation": null + }, + { + "t": 24632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"fast on|fast off|FastMode|fast_mode|toggle.*fast|set_fast|fast.*stream|mid stream|streaming\" src --glob '!target'", + "description": "Find fast and streaming code", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"command.*fast|/fast|:fast|fast command\" src docs --glob '!target'", + "description": "Search fast command parser", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 24832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/server.rs:1158: /// Broadcast channel for streaming events to all subscribers\nsrc/config.rs:300: /// FPS for active redraw (processing, streaming): 1-120 (default: 30)\nsrc/config.rs:1017:# Active redraw FPS (processing, streaming, spinners): 1-120 (default: 60)\nsrc/protocol.rs:383: /// shown during streaming is replaced with the clean prefix text.\nsrc/protocol.rs:391: /// Tool input delta (streaming JSON)\nsrc/protocol.rs:480: /// so it does not blend into s...", + "is_error": false + }, + { + "t": 25832, + "event": "thinking", + "duration": 800 + }, + { + "t": 26632, + "event": "token_usage", + "input": 25438, + "output": 166, + "cache_read": 18432, + "cache_creation": null + }, + { + "t": 26632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/model_context.rs", + "limit": 110, + "offset": 830, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 90, + "offset": 2920, + "tool": "read" + }, + { + "file_path": "src/tui/app/turn.rs", + "limit": 340, + "offset": 556, + "tool": "read" + }, + { + "file_path": "src/tui/app/input.rs", + "limit": 80, + "offset": 1000, + "tool": "read" + }, + { + "file_path": "src/tui/app/state_ui.rs", + "limit": 40, + "offset": 430, + "tool": "read" + } + ] + } + }, + { + "t": 26832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 831\t match app.provider.set_reasoning_effort(level) {\n 832\t Ok(()) => {\n 833\t let new_effort = app.provider.reasoning_effort();\n 834\t let label = new_effort\n 835\t .as_deref()\n 836\t .map(effort_display_label)\n 837\t .unwrap_or(\"default\");\n 838\t app.push_display_message(DisplayMessage::system(format!(\n 839\t \"✓ Reasoning effort →...", + "is_error": false + }, + { + "t": 27832, + "event": "thinking", + "duration": 800 + }, + { + "t": 28632, + "event": "token_usage", + "input": 31968, + "output": 159, + "cache_read": 25344, + "cache_creation": null + }, + { + "t": 28632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/turn.rs", + "limit": 180, + "offset": 724, + "tool": "read" + }, + { + "file_path": "src/tui/app/input.rs", + "limit": 260, + "offset": 0, + "tool": "read" + }, + { + "command": "rg -n \"handle_key\\(|submit_input|process_command|command.*stream|is_processing\\(|processing.*command|input.*processing\" src/tui/app --glob '!target'", + "description": "Find command submit and processing gates", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 28832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 725\t match event {\n 726\t Some(Ok(Event::Key(key))) => {\n 727\t self.update_copy_badge_key_event(key);\n 728\t if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {\n 729\t let scroll_only = super::input::is_scroll_only_key(self, key.code, key.modifiers);\n 730\t let _ = self.handle_k...", + "is_error": false + }, + { + "t": 29832, + "event": "thinking", + "duration": 800 + }, + { + "t": 30632, + "event": "token_usage", + "input": 40709, + "output": 111, + "cache_read": 31488, + "cache_creation": null + }, + { + "t": 30632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/input.rs", + "limit": 420, + "offset": 740, + "tool": "read" + }, + { + "command": "rg -n \"handle_command\\(|process_slash|submit_input\\(|interleave_message|pending_soft_interrupts|queued_messages\" src/tui/app --glob '!target'", + "description": "Find submit and command helpers", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 30832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 741\t if matches!(\n 742\t event.kind,\n 743\t crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat\n 744\t ) {\n 745\t let _ = self.handle_key(event.code, event.modifiers);\n 746\t }\n 747\t }\n 748\t\n 749\t pub(super) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {\n 750\t let mut code = code;\n 751\t let mut modifiers = modifiers;\n 752\t ctrl_...", + "is_error": false + }, + { + "t": 31832, + "event": "thinking", + "duration": 800 + }, + { + "t": 32632, + "event": "stream_text", + "text": "I’ve isolated the likely culprit: slash commands *are* allowed during processing, but the submit path still routes through the normal input pipeline rather than a “safe mid-stream command” path. I’m checking the Enter handling and remote/local differences now to see where `/fast on` gets treated like a queued/interleaved message instead of an immediate setting change.", + "speed": 80 + }, + { + "t": 33813, + "event": "token_usage", + "input": 50057, + "output": 272, + "cache_read": 40576, + "cache_creation": null + }, + { + "t": 33813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/input.rs", + "limit": 220, + "offset": 520, + "tool": "read" + }, + { + "command": "rg -n \"fn handle_enter|handle_enter\\(\" src/tui/app/input.rs src/tui/app/*.rs", + "description": "Locate enter handler", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"handle_model_command\\(|handle_info_command\\(|handle_auth_command\\(|handle_dev_command\\(\" src/tui/app --glob '!target'", + "description": "Find command dispatch usage", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 34013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 521\t\n 522\t Ok(false)\n 523\t}\n 524\t\n 525\tpub(super) fn handle_global_control_shortcuts(\n 526\t app: &mut App,\n 527\t code: KeyCode,\n 528\t diagram_available: bool,\n 529\t) -> bool {\n 530\t if app.handle_diagram_ctrl_key(code, diagram_available) {\n 531\t return true;\n 532\t }\n 533\t\n 534\t match code {\n 535\t KeyCode::Char('c') | KeyCode::Char('d') => {\n 536\t if app.is_processing {\n 537\t app.cancel_requested = ...", + "is_error": false + }, + { + "t": 35013, + "event": "thinking", + "duration": 800 + }, + { + "t": 35813, + "event": "token_usage", + "input": 53442, + "output": 574, + "cache_read": 50176, + "cache_creation": null + }, + { + "t": 35813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/remote.rs", + "limit": 220, + "offset": 2240, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 460, + "offset": 3000, + "tool": "read" + }, + { + "command": "rg -n \"service_tier|set_service_tier|remote_service_tier|fast mode\" src/tui/app/remote.rs src/tui/backend.rs src/protocol.rs src/server.rs src/server -g '!target'", + "description": "Trace remote fast mode path", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"struct .*Provider|fn set_service_tier|service_tier\\(\" src/provider src -g '!target'", + "description": "Trace provider service tier state", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 36013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2241\t _ => false,\n 2242\t }\n 2243\t}\n 2244\t\n 2245\tpub(super) fn handle_remote_char_input(app: &mut App, c: char) {\n 2246\t if app.input.is_empty() && !app.is_processing && app.display_messages.is_empty() {\n 2247\t if let Some(digit) = c.to_digit(10) {\n 2248\t let suggestions = app.suggestion_prompts();\n 2249\t let idx = digit as usize;\n 2250\t if idx >= 1 && idx <= suggestions.len() {\n 2251\t let (_label, prompt) = ...", + "is_error": false + }, + { + "t": 37013, + "event": "thinking", + "duration": 800 + }, + { + "t": 37813, + "event": "token_usage", + "input": 61229, + "output": 136, + "cache_read": 52992, + "cache_creation": null + }, + { + "t": 37813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/remote.rs", + "limit": 380, + "offset": 2460, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 320, + "offset": 1740, + "tool": "read" + }, + { + "file_path": "src/tui/backend.rs", + "limit": 60, + "offset": 380, + "tool": "read" + } + ] + } + }, + { + "t": 38013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2461\t return Ok(());\n 2462\t }\n 2463\t\n 2464\t if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('m')) {\n 2465\t app.toggle_diagram_pane();\n 2466\t return Ok(());\n 2467\t }\n 2468\t if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('t')) {\n 2469\t app.toggle_diagram_pane_position();\n 2470\t return Ok(());\n 2471\t }\n 2472\t if let Some(direction) = app.model_switch_keys.direction_for(code, ...", + "is_error": false + }, + { + "t": 39013, + "event": "thinking", + "duration": 800 + }, + { + "t": 39813, + "event": "stream_text", + "text": "I’ve confirmed the client side already tries to treat `/fast` as an immediate command, even while processing. So the remaining likely failure is deeper: on the server/local turn loop, the command doesn’t get applied in time to affect an active agentic stream, or in remote mode the control request is serialized behind the running stream. I’m tracing that now.", + "speed": 80 + }, + { + "t": 40956, + "event": "token_usage", + "input": 69213, + "output": 702, + "cache_read": 61184, + "cache_creation": null + }, + { + "t": 40956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/server/client_lifecycle.rs", + "limit": 120, + "offset": 680, + "tool": "read" + }, + { + "file_path": "src/server/provider_control.rs", + "limit": 70, + "offset": 180, + "tool": "read" + }, + { + "command": "rg -n \"SetServiceTier|set_service_tier|Request::SetServiceTier|process_message_streaming_mpsc|tokio::select!|handle_request\" src/server src --glob '!target'", + "description": "Trace server request scheduling", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/server/client_comm.rs", + "limit": 220, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 41156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 681\t if handle_resume_session(\n 682\t id,\n 683\t session_id,\n 684\t &mut client_selfdev,\n 685\t &mut client_session_id,\n 686\t &client_connection_id,\n 687\t &agent,\n 688\t &provider,\n 689\t ®istry,\n 690\t &sessions,\n 691\t &shutdown_signals,\n 692\t &s...", + "is_error": false + }, + { + "t": 42156, + "event": "thinking", + "duration": 800 + }, + { + "t": 42956, + "event": "token_usage", + "input": 75696, + "output": 56, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 42956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/server/client_lifecycle.rs", + "limit": 170, + "offset": 300 + } + }, + { + "t": 43156, + "event": "tool_done", + "name": "read", + "output": " 301\t }\n 302\t let _stdin_forwarder = {\n 303\t let client_event_tx = client_event_tx.clone();\n 304\t let stdin_responses = stdin_responses.clone();\n 305\t let tool_call_id = String::new();\n 306\t tokio::spawn(async move {\n 307\t while let Some(req) = stdin_req_rx.recv().await {\n 308\t let request_id = req.request_id.clone();\n 309\t stdin_responses\n 310\t .lock()\n 311\t .await\n 31...", + "is_error": false + }, + { + "t": 44156, + "event": "thinking", + "duration": 800 + }, + { + "t": 44956, + "event": "token_usage", + "input": 77633, + "output": 65, + "cache_read": 75520, + "cache_creation": null + }, + { + "t": 44956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/server/client_lifecycle.rs", + "limit": 260, + "offset": 469 + } + }, + { + "t": 45156, + "event": "tool_done", + "name": "read", + "output": " 470\t let n = match n {\n 471\t Ok(n) => n,\n 472\t Err(e) => {\n 473\t crate::logging::error(&format!(\"Client read error: {}\", e));\n 474\t break;\n 475\t }\n 476\t };\n 477\t if n == 0 {\n 478\t break; // Client disconnected\n 479\t }\n 480\t let mut connections = client_connections.write().await;\n 481\t ...", + "is_error": false + }, + { + "t": 46156, + "event": "thinking", + "duration": 800 + }, + { + "t": 46956, + "event": "token_usage", + "input": 80003, + "output": 223, + "cache_read": 77568, + "cache_creation": null + }, + { + "t": 46956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/provider/openai.rs", + "limit": 110, + "offset": 1968, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 2036, + "tool": "read" + }, + { + "command": "rg -n \"impl Clone for OpenAIProvider|derive\\(.*Clone.*\\).*OpenAIProvider|struct OpenAIProvider\" src/provider/openai.rs", + "description": "Locate OpenAI provider clone impl", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/mod.rs", + "limit": 40, + "offset": 3428, + "tool": "read" + } + ] + } + }, + { + "t": 47156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1969\t Ok(())\n 1970\t }\n 1971\t Err(_) => Err(anyhow::anyhow!(\n 1972\t \"Failed to acquire lock for reasoning effort\"\n 1973\t )),\n 1974\t }\n 1975\t }\n 1976\t\n 1977\t fn available_efforts(&self) -> Vec<&'static str> {\n 1978\t vec![\"none\", \"low\", \"medium\", \"high\", \"xhigh\"]\n 1979\t }\n 1980\t\n 1981\t fn service_tier(&self) -> Option {\n 1982\t self.service_tier.try_read().ok().and_then(|g| g....", + "is_error": false + }, + { + "t": 48156, + "event": "thinking", + "duration": 800 + }, + { + "t": 48956, + "event": "token_usage", + "input": 82583, + "output": 268, + "cache_read": 79616, + "cache_creation": null + }, + { + "t": 48956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"service_tier\\.|service_tier\\)|read\\(\\).*service_tier|service_tier: .*service_tier|service_tier.clone|service_tier.read|service_tier.write\" src/provider/openai.rs", + "description": "Trace OpenAI service tier lock usage", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 140, + "offset": 210, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 120, + "offset": 860, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 120, + "offset": 1210, + "tool": "read" + } + ] + } + }, + { + "t": 49156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n237: service_tier: Arc::new(RwLock::new(service_tier)),\n398: if let Some(service_tier) = service_tier {\n399: request[\"service_tier\"] = serde_json::json!(service_tier);\n1679: let service_tier = self.service_tier.read().await.clone();\n1688: service_tier.as_deref(),\n1982: self.service_tier.try_read().ok().and_then(|g| g.clone())\n1986: let normalized = Self::normalize_service_tier(service_tier)?;\n1987: match self...", + "is_error": false + }, + { + "t": 50156, + "event": "thinking", + "duration": 800 + }, + { + "t": 50956, + "event": "token_usage", + "input": 87376, + "output": 64, + "cache_read": 81664, + "cache_creation": null + }, + { + "t": 50956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 1668 + } + }, + { + "t": 51156, + "event": "tool_done", + "name": "read", + "output": " 1669\t let credentials = self.credentials.read().await;\n 1670\t let is_chatgpt = Self::is_chatgpt_mode(&credentials);\n 1671\t let instructions = if is_chatgpt {\n 1672\t Self::chatgpt_instructions_with_selfdev(system)\n 1673\t } else {\n 1674\t system.to_string()\n 1675\t };\n 1676\t (instructions, is_chatgpt)\n 1677\t };\n 1678\t let reasoning_effort = self.reasoning_effort.read().await.clone();\n 16...", + "is_error": false + }, + { + "t": 52156, + "event": "thinking", + "duration": 800 + }, + { + "t": 52956, + "event": "token_usage", + "input": 87982, + "output": 497, + "cache_read": 69760, + "cache_creation": null + }, + { + "t": 52956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"fn provider_handle|provider_handle\\(\" src/agent.rs src --glob '!target'", + "description": "Find provider handle implementation", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/agent.rs", + "limit": 60, + "offset": 120, + "tool": "read" + }, + { + "file_path": "src/agent.rs", + "limit": 120, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 53156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/agent.rs:1233: pub fn provider_handle(&self) -> Arc {\nsrc/server/client_state.rs:88: let provider = agent_guard.provider_handle();\nsrc/server/provider_control.rs:172: agent_guard.provider_handle()\nsrc/server/provider_control.rs:201: agent_guard.provider_handle()\nsrc/server/provider_control.rs:230: agent_guard.provider_handle()\nsrc/server/provider_control.rs:346: let provider = agent_guard.provider_handle();\nsrc/a...", + "is_error": false + }, + { + "t": 54156, + "event": "thinking", + "duration": 800 + }, + { + "t": 54956, + "event": "token_usage", + "input": 90705, + "output": 30, + "cache_read": 87808, + "cache_creation": null + }, + { + "t": 54956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/agent.rs", + "limit": 20, + "offset": 1228 + } + }, + { + "t": 55156, + "event": "tool_done", + "name": "read", + "output": " 1229\t pub fn provider_fork(&self) -> Arc {\n 1230\t self.provider.fork()\n 1231\t }\n 1232\t\n 1233\t pub fn provider_handle(&self) -> Arc {\n 1234\t Arc::clone(&self.provider)\n 1235\t }\n 1236\t\n 1237\t pub fn available_models(&self) -> Vec<&'static str> {\n 1238\t self.provider.available_models()\n 1239\t }\n 1240\t\n 1241\t pub fn available_models_display(&self) -> Vec {\n 1242\t self.provider.available_models_display()\n 1243\t ...", + "is_error": false + }, + { + "t": 56156, + "event": "thinking", + "duration": 800 + }, + { + "t": 56956, + "event": "token_usage", + "input": 90998, + "output": 218, + "cache_read": 90496, + "cache_creation": null + }, + { + "t": 56956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"provider_fork\\(|\\.fork\\(\" src --glob '!target'", + "description": "Find provider fork usages", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/mod.rs", + "limit": 50, + "offset": 620, + "tool": "read" + } + ] + } + }, + { + "t": 57156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/ambient_runner.rs:625: let cycle_provider = provider.fork();\nsrc/agent.rs:1229: pub fn provider_fork(&self) -> Arc {\nsrc/agent.rs:1230: self.provider.fork()\nsrc/server/client_actions.rs:184: let provider = agent_guard.provider_fork();\nsrc/server/headless.rs:43: let provider = provider_template.fork();\nsrc/server/swarm.rs:432: agent.provider_fork(),\nsrc/server/client_lifecycle.rs:93: let provider = provider_template.fo...", + "is_error": false + }, + { + "t": 58156, + "event": "thinking", + "duration": 800 + }, + { + "t": 58956, + "event": "stream_text", + "text": "I found the concrete blocker: OpenAI’s `set_service_tier()` currently uses a non-blocking `try_write()` on a Tokio `RwLock`, and returns `Cannot change service tier while a request is in progress`. So `/fast on` is effectively rejected during an active stream. I’m changing that state to a synchronous lock so toggling fast mode can land immediately for subsequent requests in the same session/turn.", + "speed": 80 + }, + { + "t": 60215, + "event": "token_usage", + "input": 92032, + "output": 746, + "cache_read": 91008, + "cache_creation": null + }, + { + "t": 60215, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"service_tier|fast mode|/fast\" src/provider/openai.rs src/tui/app/tests.rs src/server -g '!target'", + "description": "Find relevant tests", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 150, + "tool": "read" + }, + { + "file_path": "src/tui/app/tests.rs", + "limit": 120, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 60415, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/tui/app/tests.rs:1861: service_tier: None,\nsrc/tui/app/tests.rs:1904: service_tier: None,\nsrc/tui/app/tests.rs:2248: service_tier: None,\nsrc/tui/app/tests.rs:2308: service_tier: None,\nsrc/tui/app/tests.rs:2364: service_tier: None,\nsrc/provider/openai.rs:170: service_tier: Arc>>,\nsrc/provider/openai.rs:219: let service_tier = Self::load_service_tier(\nsrc/provider/openai.rs:222: ...", + "is_error": false + }, + { + "t": 61415, + "event": "thinking", + "duration": 800 + }, + { + "t": 62215, + "event": "token_usage", + "input": 95505, + "output": 121, + "cache_read": 92672, + "cache_creation": null + }, + { + "t": 62215, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"#\\[cfg\\(test\\)\\]|mod tests|test_.*service_tier|normalize_service_tier\" src/provider/openai.rs", + "description": "Find OpenAI tests section", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 220, + "offset": 4870, + "tool": "read" + } + ] + } + }, + { + "t": 62415, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n299: fn normalize_service_tier(raw: &str) -> Result> {\n320: match Self::normalize_service_tier(raw) {\n1986: let normalized = Self::normalize_service_tier(service_tier)?;\n3443:#[cfg(test)]\n3534:#[cfg(test)]\n3535:mod tests {\n\n\n--- [2] read ---\n 4871\t let mut pending = VecDeque::new();\n 4872\t let mut events = Vec::new();\n 4873\t\n 4874\t if let Some(first) = handle_openai_output_item(item, &mut saw_text_delta, &mut pending) {\n 48...", + "is_error": false + }, + { + "t": 63415, + "event": "thinking", + "duration": 800 + }, + { + "t": 64215, + "event": "token_usage", + "input": 97043, + "output": 61, + "cache_read": 94976, + "cache_creation": null + }, + { + "t": 64215, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/provider/openai.rs", + "limit": 220, + "offset": 3535 + } + }, + { + "t": 64415, + "event": "tool_done", + "name": "read", + "output": " 3536\t use super::*;\n 3537\t use crate::auth::codex::CodexCredentials;\n 3538\t use anyhow::Result;\n 3539\t use std::collections::{HashMap, HashSet};\n 3540\t use std::ffi::OsString;\n 3541\t use std::path::PathBuf;\n 3542\t use std::sync::{Mutex, MutexGuard};\n 3543\t use std::time::{Duration, Instant};\n 3544\t const BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE: &str =\n 3545\t include_str!(\"../../tests/fixtures/openai/bright_pearl_wrapped_tool_call.txt\");\n 3546\t static ENV_L...", + "is_error": false + }, + { + "t": 65415, + "event": "thinking", + "duration": 800 + }, + { + "t": 66215, + "event": "token_usage", + "input": 99550, + "output": 136, + "cache_read": 96896, + "cache_creation": null + }, + { + "t": 66215, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/provider/openai.rs", + "limit": 70, + "offset": 0, + "tool": "read" + }, + { + "command": "rg -n \"use tokio::sync|std::sync::RwLock|parking_lot\" src/provider/openai.rs", + "description": "Inspect lock imports", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 66415, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1\tuse super::{EventStream, Provider};\n 2\tuse crate::auth::codex::CodexCredentials;\n 3\tuse crate::auth::oauth;\n 4\tuse crate::message::{\n 5\t ContentBlock, Message as ChatMessage, Role, StreamEvent, TOOL_OUTPUT_MISSING_TEXT,\n 6\t ToolDefinition,\n 7\t};\n 8\tuse anyhow::{Context, Result};\n 9\tuse async_trait::async_trait;\n 10\tuse bytes::Bytes;\n 11\tuse futures::{SinkExt, Stream, StreamExt as FuturesStreamExt};\n 12\tuse reqwest::header::HeaderValue...", + "is_error": false + }, + { + "t": 67415, + "event": "thinking", + "duration": 800 + }, + { + "t": 68215, + "event": "token_usage", + "input": 100984, + "output": 1733, + "cache_read": 99456, + "cache_creation": null + }, + { + "t": 68215, + "event": "tool_start", + "name": "apply_patch", + "input": { + "patch_text": "*** Begin Patch\n*** Update File: src/provider/openai.rs\n@@\n use serde::Deserialize;\n use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant};\n use tokio::net::TcpStream;\n use tokio::sync::{Mutex, RwLock, mpsc};\n@@\n pub struct OpenAIProvider {\n client: Client,\n credentials: Arc>,\n model: Arc>,\n@@\n prompt_cache_key: Option,\n prompt_cache_retention: Option,\n max_output_tokens: Option,\n reasoning_effort: Arc>>,\n- service_tier: Arc>>,\n+ service_tier: Arc>>,\n transport_mode: Arc>,\n@@\n prompt_cache_retention,\n max_output_tokens,\n reasoning_effort: Arc::new(RwLock::new(reasoning_effort)),\n- service_tier: Arc::new(RwLock::new(service_tier)),\n+ service_tier: Arc::new(StdRwLock::new(service_tier)),\n transport_mode: Arc::new(RwLock::new(transport_mode)),\n@@\n- let service_tier = self.service_tier.read().await.clone();\n+ let service_tier = self\n+ .service_tier\n+ .read()\n+ .map(|guard| guard.clone())\n+ .unwrap_or_else(|poisoned| poisoned.into_inner().clone());\n@@\n fn service_tier(&self) -> Option {\n- self.service_tier.try_read().ok().and_then(|g| g.clone())\n+ self.service_tier\n+ .read()\n+ .map(|guard| guard.clone())\n+ .unwrap_or_else(|poisoned| poisoned.into_inner().clone())\n }\n \n fn set_service_tier(&self, service_tier: &str) -> Result<()> {\n let normalized = Self::normalize_service_tier(service_tier)?;\n- match self.service_tier.try_write() {\n- Ok(mut guard) => {\n- *guard = normalized;\n- Ok(())\n- }\n- Err(_) => Err(anyhow::anyhow!(\n- \"Cannot change service tier while a request is in progress\"\n- )),\n- }\n+ let mut guard = self\n+ .service_tier\n+ .write()\n+ .unwrap_or_else(|poisoned| poisoned.into_inner());\n+ *guard = normalized;\n+ Ok(())\n }\n@@\n prompt_cache_key: self.prompt_cache_key.clone(),\n prompt_cache_retention: self.prompt_cache_retention.clone(),\n max_output_tokens: self.max_output_tokens,\n reasoning_effort: Arc::new(RwLock::new(self.reasoning_effort())),\n- service_tier: Arc::new(RwLock::new(self.service_tier())),\n+ service_tier: Arc::new(StdRwLock::new(self.service_tier())),\n transport_mode: Arc::clone(&self.transport_mode),\n websocket_cooldowns: Arc::clone(&self.websocket_cooldowns),\n websocket_failure_streaks: Arc::clone(&self.websocket_failure_streaks),\n persistent_ws: Arc::new(Mutex::new(None)),\n@@\n fn test_openai_supports_codex_models() {\n@@\n provider.set_model(\"gpt-5.1-codex-mini\").unwrap();\n assert_eq!(provider.model(), \"gpt-5.1-codex-mini\");\n }\n+\n+ #[test]\n+ fn test_service_tier_can_be_changed_while_a_request_snapshot_is_held() {\n+ let provider = Arc::new(OpenAIProvider::new(CodexCredentials {\n+ access_token: \"test\".to_string(),\n+ refresh_token: String::new(),\n+ id_token: None,\n+ account_id: None,\n+ expires_at: None,\n+ }));\n+\n+ let read_guard = provider\n+ .service_tier\n+ .read()\n+ .expect(\"service tier read lock should be available\");\n+\n+ let (tx, rx) = std::sync::mpsc::channel();\n+ let provider_for_write = Arc::clone(&provider);\n+ let handle = std::thread::spawn(move || {\n+ let result = provider_for_write.set_service_tier(\"priority\");\n+ tx.send(result).expect(\"send result from setter thread\");\n+ });\n+\n+ std::thread::sleep(Duration::from_millis(20));\n+ assert!(\n+ rx.try_recv().is_err(),\n+ \"writer should wait for the in-flight snapshot to finish\"\n+ );\n+\n+ drop(read_guard);\n+\n+ rx.recv()\n+ .expect(\"receive service tier setter result\")\n+ .expect(\"service tier update should succeed once read lock is released\");\n+ handle.join().expect(\"join setter thread\");\n+\n+ assert_eq!(provider.service_tier(), Some(\"priority\".to_string()));\n+ }\n \n #[test]\n fn test_build_responses_input_injects_missing_tool_output() {\n*** End Patch" + } + }, + { + "t": 68415, + "event": "tool_done", + "name": "apply_patch", + "output": "✓ src/provider/openai.rs: modified (9 hunks)\n19- use std::sync::{Arc, LazyLock};\n19+ use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n170- service_tier: Arc>>,\n170+ service_tier: Arc>>,\n237- service_tier: Arc::new(RwLock::new(service_tier)),\n237+ service_tier: Arc::new(StdRwLock::new(service_tier)),\n1679- let service_tier = self.service_tier.read().await.clone();\n1679+ let service_tier = self\n...", + "is_error": false + }, + { + "t": 69415, + "event": "thinking", + "duration": 800 + }, + { + "t": 70215, + "event": "stream_text", + "text": "Patch is in. I changed OpenAI’s `service_tier` state to a synchronous `RwLock`, so `/fast on` no longer hard-fails just because a request is active; it waits for the tiny request-snapshot read to finish, then updates cleanly. I also added a regression test around that lock behavior. Now I’m formatting and running targeted tests/build.", + "speed": 80 + }, + { + "t": 71277, + "event": "token_usage", + "input": 104521, + "output": 319, + "cache_read": 101120, + "cache_creation": null + }, + { + "t": 71277, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "cargo fmt --all", + "description": "Format Rust code", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "cargo test test_service_tier_can_be_changed_while_a_request_snapshot_is_held --lib", + "description": "Run new targeted test", + "timeout": 1200000, + "tool": "bash" + }, + { + "command": "cargo test test_openai_supports_codex_models --lib", + "description": "Run nearby OpenAI test", + "timeout": 1200000, + "tool": "bash" + } + ] + } + }, + { + "t": 72077, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nCommand completed successfully (no output)\n\n--- [2] bash ---\n\nrunning 1 test\ntest provider::openai::tests::test_service_tier_can_be_changed_while_a_request_snapshot_is_held ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1147 filtered out; finished in 0.04s\n\n\n Blocking waiting for file lock on package cache\n Compiling jcode v0.6.1 (/home/jeremy/jcode)\nwarning: method `build_system_prompt` is never used\n --> src/agent/prompt_support.rs:24:19\n |\n 5 |...", + "is_error": false + }, + { + "t": 73077, + "event": "thinking", + "duration": 800 + }, + { + "t": 73877, + "event": "stream_text", + "text": "Targeted tests passed. I’m doing the required release build now; because this is jcode itself, I’ll reload onto the new binary afterward. I’m not auto-committing here because the worktree already has a lot of unrelated edits, and I don’t want to accidentally sweep those into a commit.", + "speed": 80 + }, + { + "t": 74792, + "event": "token_usage", + "input": 114021, + "output": 305, + "cache_read": 104704, + "cache_creation": null + }, + { + "t": 74792, + "event": "tool_start", + "name": "bash", + "input": { + "command": "cargo build --release", + "description": "Build release binary", + "notify": true, + "run_in_background": true, + "timeout": 120000 + } + }, + { + "t": 74992, + "event": "tool_done", + "name": "bash", + "output": "Command started in background.\n\nTask ID: 076911y3sg\nOutput file: /tmp/jcode-bg-tasks/076911y3sg.output\nStatus file: /tmp/jcode-bg-tasks/076911y3sg.status.json\n\nYou will be notified when the task completes.\nTo check progress: use the `bg` tool with action=\"status\" and task_id=\"076911y3sg\"\nTo see output: use the `read` tool on the output file, or `bg` with action=\"output\"", + "is_error": false + }, + { + "t": 75992, + "event": "thinking", + "duration": 800 + }, + { + "t": 76792, + "event": "token_usage", + "input": 114636, + "output": 59, + "cache_read": 114176, + "cache_creation": null + }, + { + "t": 76792, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 76992, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 77992, + "event": "thinking", + "duration": 800 + }, + { + "t": 78792, + "event": "stream_text", + "text": "Release build is still running. Once it finishes I’ll reload onto the rebuilt binary and then I’ll give you the exact change summary.", + "speed": 80 + }, + { + "t": 79220, + "event": "token_usage", + "input": 114765, + "output": 88, + "cache_read": 114560, + "cache_creation": null + }, + { + "t": 79220, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 79420, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 80354, + "event": "thinking", + "duration": 800 + }, + { + "t": 81154, + "event": "token_usage", + "input": 114991, + "output": 43, + "cache_read": 114688, + "cache_creation": null + }, + { + "t": 81154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 81354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 82354, + "event": "thinking", + "duration": 800 + }, + { + "t": 83154, + "event": "token_usage", + "input": 115148, + "output": 53, + "cache_read": 114816, + "cache_creation": null + }, + { + "t": 83154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "output", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 83354, + "event": "tool_done", + "name": "bg", + "output": "[stderr] Compiling jcode v0.6.1 (/home/jeremy/jcode)\n[stderr] warning: method `build_system_prompt` is never used\n[stderr] --> src/agent/prompt_support.rs:24:19\n[stderr] |\n[stderr] 5 | impl Agent {\n[stderr] | ---------- method in this implementation\n[stderr] ...\n[stderr] 24 | pub(super) fn build_system_prompt(&self, memory_prompt: Option<&str>) -> String {\n[stderr] | ^^^^^^^^^^^^^^^^^^^\n[stderr] |\n[stderr] = note: `#[warn(dead_code)]` (part of `#[war...", + "is_error": false + }, + { + "t": 84354, + "event": "thinking", + "duration": 800 + }, + { + "t": 85154, + "event": "token_usage", + "input": 122697, + "output": 45, + "cache_read": 115072, + "cache_creation": null + }, + { + "t": 85154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 85354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 86354, + "event": "thinking", + "duration": 800 + }, + { + "t": 87154, + "event": "token_usage", + "input": 122856, + "output": 41, + "cache_read": 122624, + "cache_creation": null + }, + { + "t": 87154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 87354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 88354, + "event": "thinking", + "duration": 800 + }, + { + "t": 89154, + "event": "token_usage", + "input": 123011, + "output": 45, + "cache_read": 122752, + "cache_creation": null + }, + { + "t": 89154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 89354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 90354, + "event": "thinking", + "duration": 800 + }, + { + "t": 91154, + "event": "stream_text", + "text": "Build is in the final stretch; it’s still compiling, no errors so far—just existing warning noise from other parts of the codebase. I’ll reload as soon as it flips to done.", + "speed": 80 + }, + { + "t": 91710, + "event": "token_usage", + "input": 123170, + "output": 92, + "cache_read": 122880, + "cache_creation": null + }, + { + "t": 91710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 91910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 92910, + "event": "thinking", + "duration": 800 + }, + { + "t": 93710, + "event": "token_usage", + "input": 123413, + "output": 50, + "cache_read": 123136, + "cache_creation": null + }, + { + "t": 93710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "output", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 93910, + "event": "tool_done", + "name": "bg", + "output": "[stderr] Compiling jcode v0.6.1 (/home/jeremy/jcode)\n[stderr] warning: method `build_system_prompt` is never used\n[stderr] --> src/agent/prompt_support.rs:24:19\n[stderr] |\n[stderr] 5 | impl Agent {\n[stderr] | ---------- method in this implementation\n[stderr] ...\n[stderr] 24 | pub(super) fn build_system_prompt(&self, memory_prompt: Option<&str>) -> String {\n[stderr] | ^^^^^^^^^^^^^^^^^^^\n[stderr] |\n[stderr] = note: `#[warn(dead_code)]` (part of `#[war...", + "is_error": false + }, + { + "t": 94910, + "event": "thinking", + "duration": 800 + }, + { + "t": 95710, + "event": "token_usage", + "input": 130959, + "output": 52, + "cache_read": 123264, + "cache_creation": null + }, + { + "t": 95710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 95910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 96910, + "event": "thinking", + "duration": 800 + }, + { + "t": 97710, + "event": "token_usage", + "input": 131125, + "output": 41, + "cache_read": 130816, + "cache_creation": null + }, + { + "t": 97710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 97910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 98910, + "event": "thinking", + "duration": 800 + }, + { + "t": 99710, + "event": "token_usage", + "input": 131280, + "output": 46, + "cache_read": 130944, + "cache_creation": null + }, + { + "t": 99710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 99910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 100910, + "event": "thinking", + "duration": 800 + }, + { + "t": 101710, + "event": "stream_text", + "text": "Still waiting on the release link step. As soon as it’s done I’ll reload the agent binary and wrap up.", + "speed": 80 + }, + { + "t": 102041, + "event": "token_usage", + "input": 131440, + "output": 98, + "cache_read": 131200, + "cache_creation": null + }, + { + "t": 102041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 102241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 103241, + "event": "thinking", + "duration": 800 + }, + { + "t": 104041, + "event": "token_usage", + "input": 131673, + "output": 77, + "cache_read": 131328, + "cache_creation": null + }, + { + "t": 104041, + "event": "tool_start", + "name": "bash", + "input": { + "command": "tail -n 20 /tmp/jcode-bg-tasks/076911y3sg.output", + "description": "Check build output tail", + "notify": false, + "run_in_background": false, + "timeout": 120000 + } + }, + { + "t": 104241, + "event": "tool_done", + "name": "bash", + "output": "[stderr] | ^^^^^^^^ help: convert the identifier to snake case: `crash_bg`\n[stderr] \n[stderr] warning: variable `CRASH_FG` should have a snake case name\n[stderr] --> src/tui/session_picker.rs:2039:13\n[stderr] |\n[stderr] 2039 | let CRASH_FG: Color = rgb(255, 140, 140);\n[stderr] | ^^^^^^^^ help: convert the identifier to snake case: `crash_fg`\n[stderr] \n[stderr] warning: variable `CRASH_ICON` should have a snake case name\n[stderr] --> src/t...", + "is_error": false + }, + { + "t": 105241, + "event": "thinking", + "duration": 800 + }, + { + "t": 106041, + "event": "token_usage", + "input": 132138, + "output": 38, + "cache_read": 131584, + "cache_creation": null + }, + { + "t": 106041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 106241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 107241, + "event": "thinking", + "duration": 800 + }, + { + "t": 108041, + "event": "token_usage", + "input": 132290, + "output": 57, + "cache_read": 131968, + "cache_creation": null + }, + { + "t": 108041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 108241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 109241, + "event": "thinking", + "duration": 800 + }, + { + "t": 110041, + "event": "token_usage", + "input": 132461, + "output": 50, + "cache_read": 132224, + "cache_creation": null + }, + { + "t": 110041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 110241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 111241, + "event": "thinking", + "duration": 800 + }, + { + "t": 112041, + "event": "token_usage", + "input": 132625, + "output": 97, + "cache_read": 132352, + "cache_creation": null + }, + { + "t": 112041, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "git diff -- src/provider/openai.rs", + "description": "Show patch diff", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "git status --short src/provider/openai.rs", + "description": "Check file status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 112241, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\ndiff --git a/src/provider/openai.rs b/src/provider/openai.rs\nindex 6ee1a1b..73a762e 100644\n--- a/src/provider/openai.rs\n+++ b/src/provider/openai.rs\n@@ -16,7 +16,7 @@ use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant}...", + "is_error": false + }, + { + "t": 113241, + "event": "thinking", + "duration": 800 + }, + { + "t": 114041, + "event": "token_usage", + "input": 134194, + "output": 47, + "cache_read": 131840, + "cache_creation": null + }, + { + "t": 114041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 114241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: completed\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:52:13.620314075+00:00\nCompleted: 2026-03-13T20:52:13.620319598+00:00\nDuration: 56.71s\nExit code: 0\n", + "is_error": false + }, + { + "t": 115241, + "event": "thinking", + "duration": 800 + }, + { + "t": 116041, + "event": "stream_text", + "text": "Release build finished successfully. Reloading onto the new binary now.", + "speed": 80 + }, + { + "t": 116262, + "event": "token_usage", + "input": 134393, + "output": 103, + "cache_read": 133888, + "cache_creation": null + }, + { + "t": 116262, + "event": "tool_start", + "name": "selfdev", + "input": { + "action": "reload", + "context": "Patched OpenAI fast mode toggling so `/fast on` can be changed mid-stream by switching service_tier from a Tokio try_write lock to a synchronous RwLock; added regression test and validated targeted tests plus cargo build --release." + } + }, + { + "t": 116462, + "event": "tool_done", + "name": "selfdev", + "output": "Reload initiated. Process restarting...", + "is_error": false + }, + { + "t": 116474, + "event": "done" + }, + { + "t": 145740, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bat_1773435170984", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bee_1773435171006", + "friendly_name": "bee", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 145758, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bat_1773435170984", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bee_1773435171006", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 145775, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bat_1773435170984", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bee_1773435171006", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 145975, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bee_1773435171006", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 145978, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 146225, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 146254, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 160328, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 176600, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 189534, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 209344, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "running", + "detail": "yeah do that", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 230125, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "running", + "detail": "yeah do that", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 444264, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621780, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_falcon_1773435647121", + "friendly_name": "falcon", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621787, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_falcon_1773435647121", + "friendly_name": "falcon", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621794, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_falcon_1773435647121", + "friendly_name": "falcon", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621801, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621809, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_deer_1773435647135", + "friendly_name": "deer", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 621811, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_deer_1773435647135", + "friendly_name": "deer", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621814, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_deer_1773435647135", + "friendly_name": "deer", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + } + ] + }, + { + "t": 621821, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621830, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_goat_1773435647162", + "friendly_name": "goat", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621836, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_goat_1773435647162", + "friendly_name": "goat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 621847, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "goat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 624934, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "goat", + "status": "running", + "detail": "do some batching commands", + "role": "agent" + } + ] + }, + { + "t": 652622, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you make a replay out of the cosmic duck session? and then run the auto edit and name it well based on the sesion...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "goat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 876040, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "goat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1452065, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "running", + "detail": "can you remake the video? it seems like some chunk of hte video is just showing the self dev reload which is taking a...", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "goat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1779896, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "bee", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_sun_1773435170946", + "friendly_name": "sun", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "bat", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "goat", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810290, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810292, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810296, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810298, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810301, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bird_1773436835610", + "friendly_name": "bird", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810304, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bird_1773436835610", + "friendly_name": "bird", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810309, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bird_1773436835610", + "friendly_name": "bird", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810317, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bird_1773436835610", + "friendly_name": "bird", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810323, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_bird_1773436835610", + "friendly_name": "bird", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + } + ] + }, + { + "t": 1810325, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810329, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_swan_1773436835625", + "friendly_name": "swan", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810334, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_swan_1773436835625", + "friendly_name": "swan", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810344, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_swan_1773436835625", + "friendly_name": "swan", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + } + ] + }, + { + "t": 1810346, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810349, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_amber_1773436835617", + "friendly_name": "amber", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810359, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_amber_1773436835617", + "friendly_name": "amber", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810369, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_amber_1773436835617", + "friendly_name": "amber", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + }, + { + "session_id": "session_crow_1773436835624", + "friendly_name": "crow", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810388, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_crow_1773436835624", + "friendly_name": "crow", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810400, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_crow_1773436835624", + "friendly_name": "crow", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + } + ] + }, + { + "t": 1810405, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810409, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_panda_1773436835592", + "friendly_name": "panda", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + } + ] + }, + { + "t": 1810411, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810414, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773436835644", + "friendly_name": "tiger", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810418, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773436835644", + "friendly_name": "tiger", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810424, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773436835644", + "friendly_name": "tiger", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + } + ] + }, + { + "t": 1810437, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810454, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "spawned", + "role": "agent" + } + ] + }, + { + "t": 1810458, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810488, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_snake_1773436835657", + "friendly_name": "snake", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810515, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_snake_1773436835657", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810537, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_snake_1773436835657", + "friendly_name": "snake", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810560, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810567, + "event": "swarm_status", + "members": [ + { + "session_id": "session_ant_1773436835682", + "friendly_name": "ant", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810600, + "event": "swarm_status", + "members": [ + { + "session_id": "session_ant_1773436835682", + "friendly_name": "ant", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810623, + "event": "swarm_status", + "members": [ + { + "session_id": "session_ant_1773436835682", + "friendly_name": "ant", + "status": "stopped", + "detail": "disconnected", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810636, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810640, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_shark_1773436835702", + "friendly_name": "shark", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810643, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "ready", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_shark_1773436835702", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810687, + "event": "swarm_status", + "members": [ + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_cliff_1773436835603", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_shark_1773436835702", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810802, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_shark_1773436835702", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773436835644", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810806, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_shark_1773436835702", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810811, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810817, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_snake_1773436835586", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810821, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1810857, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "ready", + "role": "agent" + } + ] + }, + { + "t": 1811009, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 1811013, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_shark_1773436836358", + "friendly_name": "shark", + "status": "spawned", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 1811028, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "moss", + "status": "running", + "detail": "", + "role": "coordinator" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_shark_1773436836358", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 1811142, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 1813011, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_rose_1773434214747", + "friendly_name": "shark", + "status": "crashed", + "detail": "disconnect while running", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "running", + "detail": "", + "role": "agent" + } + ] + }, + { + "t": 1813021, + "event": "display_message", + "role": "swarm", + "title": "Swarm · snake", + "content": "You are now the coordinator for this swarm." + }, + { + "t": 1813033, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "running", + "detail": "", + "role": "coordinator" + } + ] + }, + { + "t": 1832295, + "event": "swarm_status", + "members": [ + { + "session_id": "session_sun_1773435170946", + "friendly_name": "cliff", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_tiger_1773434539982", + "friendly_name": "otter", + "status": "running", + "detail": "", + "role": "agent" + }, + { + "session_id": "session_otter_1773434581677", + "friendly_name": "shark", + "status": "ready", + "role": "agent" + }, + { + "session_id": "session_duck_1773434834183", + "friendly_name": "snake", + "status": "ready", + "role": "coordinator" + } + ] + } +] diff --git a/assets/demos/duck_fast-on-mid-stream_autoedit_v2_trimmed_timeline.json b/assets/demos/duck_fast-on-mid-stream_autoedit_v2_trimmed_timeline.json new file mode 100644 index 0000000..22b6558 --- /dev/null +++ b/assets/demos/duck_fast-on-mid-stream_autoedit_v2_trimmed_timeline.json @@ -0,0 +1,1754 @@ +[ + { + "t": 17674, + "event": "user_message", + "text": "could you make it so that fast on command could work mid stream" + }, + { + "t": 18674, + "event": "thinking", + "duration": 800 + }, + { + "t": 19474, + "event": "token_usage", + "input": 11724, + "output": 166, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 19474, + "event": "tool_start", + "name": "todowrite", + "input": { + "todos": [ + { + "content": "Locate fast mode command handling and current streaming behavior", + "id": "t1", + "priority": "high", + "status": "in_progress" + }, + { + "content": "Implement support for enabling fast mode mid-stream", + "id": "t2", + "priority": "high", + "status": "pending" + }, + { + "content": "Build and validate behavior", + "id": "t3", + "priority": "high", + "status": "pending" + } + ] + } + }, + { + "t": 19674, + "event": "tool_done", + "name": "todowrite", + "output": "[\n {\n \"content\": \"Locate fast mode command handling and current streaming behavior\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"t1\"\n },\n {\n \"content\": \"Implement support for enabling fast mode mid-stream\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t2\"\n },\n {\n \"content\": \"Build and validate behavior\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"t3\"\n }\n]", + "is_error": false + }, + { + "t": 20674, + "event": "thinking", + "duration": 800 + }, + { + "t": 21474, + "event": "stream_text", + "text": "I\u2019m looking through the command/streaming path now to find where `fast on` is gated and why it only works before a stream starts. Then I\u2019ll patch it, build, and verify.", + "speed": 80 + }, + { + "t": 22011, + "event": "token_usage", + "input": 12124, + "output": 199, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 22011, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "pwd && rg -n \"fast on|fast off|fast mode|/fast|fast\" . --glob '!target' --glob '!node_modules'", + "description": "Search fast command references", + "timeout": 120000, + "tool": "bash" + }, + { + "ignore": [ + "target", + "node_modules", + ".git" + ], + "path": ".", + "tool": "ls" + }, + { + "command": "git status --short", + "description": "Check working tree status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 22211, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n/home/jeremy/jcode\n./figma/jcode-mobile-design-spec.md:102:- `jcode-mobile-mockup.svg` gives a fast importable preview\n./Cargo.toml:4:description = \"Possibly the greatest coding agent ever built \u2014 blazing-fast TUI, multi-model, swarm coordination, 30+ tools\"\n./RELEASING.md:3:jcode has two release paths: a fast local path for hotfixes, and CI for full releases.\n./RELEASING.md:74:- **mold linker** on Linux for faster linking.\n./RELEASING.md:93:For quick releases that also nee...", + "is_error": false + }, + { + "t": 23211, + "event": "thinking", + "duration": 800 + }, + { + "t": 24011, + "event": "stream_text", + "text": "I found the repo state has a lot of unrelated local changes already, so I\u2019m being careful to only touch the fast-command/streaming path. Now I\u2019m narrowing to the exact handler and interrupt loop.", + "speed": 80 + }, + { + "t": 24632, + "event": "token_usage", + "input": 18631, + "output": 194, + "cache_read": 12160, + "cache_creation": null + }, + { + "t": 24632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"fast on|fast off|FastMode|fast_mode|toggle.*fast|set_fast|fast.*stream|mid stream|streaming\" src --glob '!target'", + "description": "Find fast and streaming code", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"command.*fast|/fast|:fast|fast command\" src docs --glob '!target'", + "description": "Search fast command parser", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 24832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/server.rs:1158: /// Broadcast channel for streaming events to all subscribers\nsrc/config.rs:300: /// FPS for active redraw (processing, streaming): 1-120 (default: 30)\nsrc/config.rs:1017:# Active redraw FPS (processing, streaming, spinners): 1-120 (default: 60)\nsrc/protocol.rs:383: /// shown during streaming is replaced with the clean prefix text.\nsrc/protocol.rs:391: /// Tool input delta (streaming JSON)\nsrc/protocol.rs:480: /// so it does not blend into s...", + "is_error": false + }, + { + "t": 25832, + "event": "thinking", + "duration": 800 + }, + { + "t": 26632, + "event": "token_usage", + "input": 25438, + "output": 166, + "cache_read": 18432, + "cache_creation": null + }, + { + "t": 26632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/model_context.rs", + "limit": 110, + "offset": 830, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 90, + "offset": 2920, + "tool": "read" + }, + { + "file_path": "src/tui/app/turn.rs", + "limit": 340, + "offset": 556, + "tool": "read" + }, + { + "file_path": "src/tui/app/input.rs", + "limit": 80, + "offset": 1000, + "tool": "read" + }, + { + "file_path": "src/tui/app/state_ui.rs", + "limit": 40, + "offset": 430, + "tool": "read" + } + ] + } + }, + { + "t": 26832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 831\t match app.provider.set_reasoning_effort(level) {\n 832\t Ok(()) => {\n 833\t let new_effort = app.provider.reasoning_effort();\n 834\t let label = new_effort\n 835\t .as_deref()\n 836\t .map(effort_display_label)\n 837\t .unwrap_or(\"default\");\n 838\t app.push_display_message(DisplayMessage::system(format!(\n 839\t \"\u2713 Reasoning effort \u2192...", + "is_error": false + }, + { + "t": 27832, + "event": "thinking", + "duration": 800 + }, + { + "t": 28632, + "event": "token_usage", + "input": 31968, + "output": 159, + "cache_read": 25344, + "cache_creation": null + }, + { + "t": 28632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/turn.rs", + "limit": 180, + "offset": 724, + "tool": "read" + }, + { + "file_path": "src/tui/app/input.rs", + "limit": 260, + "offset": 0, + "tool": "read" + }, + { + "command": "rg -n \"handle_key\\(|submit_input|process_command|command.*stream|is_processing\\(|processing.*command|input.*processing\" src/tui/app --glob '!target'", + "description": "Find command submit and processing gates", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 28832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 725\t match event {\n 726\t Some(Ok(Event::Key(key))) => {\n 727\t self.update_copy_badge_key_event(key);\n 728\t if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {\n 729\t let scroll_only = super::input::is_scroll_only_key(self, key.code, key.modifiers);\n 730\t let _ = self.handle_k...", + "is_error": false + }, + { + "t": 29832, + "event": "thinking", + "duration": 800 + }, + { + "t": 30632, + "event": "token_usage", + "input": 40709, + "output": 111, + "cache_read": 31488, + "cache_creation": null + }, + { + "t": 30632, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/input.rs", + "limit": 420, + "offset": 740, + "tool": "read" + }, + { + "command": "rg -n \"handle_command\\(|process_slash|submit_input\\(|interleave_message|pending_soft_interrupts|queued_messages\" src/tui/app --glob '!target'", + "description": "Find submit and command helpers", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 30832, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 741\t if matches!(\n 742\t event.kind,\n 743\t crossterm::event::KeyEventKind::Press | crossterm::event::KeyEventKind::Repeat\n 744\t ) {\n 745\t let _ = self.handle_key(event.code, event.modifiers);\n 746\t }\n 747\t }\n 748\t\n 749\t pub(super) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {\n 750\t let mut code = code;\n 751\t let mut modifiers = modifiers;\n 752\t ctrl_...", + "is_error": false + }, + { + "t": 31832, + "event": "thinking", + "duration": 800 + }, + { + "t": 32632, + "event": "stream_text", + "text": "I\u2019ve isolated the likely culprit: slash commands *are* allowed during processing, but the submit path still routes through the normal input pipeline rather than a \u201csafe mid-stream command\u201d path. I\u2019m checking the Enter handling and remote/local differences now to see where `/fast on` gets treated like a queued/interleaved message instead of an immediate setting change.", + "speed": 80 + }, + { + "t": 33813, + "event": "token_usage", + "input": 50057, + "output": 272, + "cache_read": 40576, + "cache_creation": null + }, + { + "t": 33813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/input.rs", + "limit": 220, + "offset": 520, + "tool": "read" + }, + { + "command": "rg -n \"fn handle_enter|handle_enter\\(\" src/tui/app/input.rs src/tui/app/*.rs", + "description": "Locate enter handler", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"handle_model_command\\(|handle_info_command\\(|handle_auth_command\\(|handle_dev_command\\(\" src/tui/app --glob '!target'", + "description": "Find command dispatch usage", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 34013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 521\t\n 522\t Ok(false)\n 523\t}\n 524\t\n 525\tpub(super) fn handle_global_control_shortcuts(\n 526\t app: &mut App,\n 527\t code: KeyCode,\n 528\t diagram_available: bool,\n 529\t) -> bool {\n 530\t if app.handle_diagram_ctrl_key(code, diagram_available) {\n 531\t return true;\n 532\t }\n 533\t\n 534\t match code {\n 535\t KeyCode::Char('c') | KeyCode::Char('d') => {\n 536\t if app.is_processing {\n 537\t app.cancel_requested = ...", + "is_error": false + }, + { + "t": 35013, + "event": "thinking", + "duration": 800 + }, + { + "t": 35813, + "event": "token_usage", + "input": 53442, + "output": 574, + "cache_read": 50176, + "cache_creation": null + }, + { + "t": 35813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/remote.rs", + "limit": 220, + "offset": 2240, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 460, + "offset": 3000, + "tool": "read" + }, + { + "command": "rg -n \"service_tier|set_service_tier|remote_service_tier|fast mode\" src/tui/app/remote.rs src/tui/backend.rs src/protocol.rs src/server.rs src/server -g '!target'", + "description": "Trace remote fast mode path", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "rg -n \"struct .*Provider|fn set_service_tier|service_tier\\(\" src/provider src -g '!target'", + "description": "Trace provider service tier state", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 36013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2241\t _ => false,\n 2242\t }\n 2243\t}\n 2244\t\n 2245\tpub(super) fn handle_remote_char_input(app: &mut App, c: char) {\n 2246\t if app.input.is_empty() && !app.is_processing && app.display_messages.is_empty() {\n 2247\t if let Some(digit) = c.to_digit(10) {\n 2248\t let suggestions = app.suggestion_prompts();\n 2249\t let idx = digit as usize;\n 2250\t if idx >= 1 && idx <= suggestions.len() {\n 2251\t let (_label, prompt) = ...", + "is_error": false + }, + { + "t": 37013, + "event": "thinking", + "duration": 800 + }, + { + "t": 37813, + "event": "token_usage", + "input": 61229, + "output": 136, + "cache_read": 52992, + "cache_creation": null + }, + { + "t": 37813, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/tui/app/remote.rs", + "limit": 380, + "offset": 2460, + "tool": "read" + }, + { + "file_path": "src/tui/app/remote.rs", + "limit": 320, + "offset": 1740, + "tool": "read" + }, + { + "file_path": "src/tui/backend.rs", + "limit": 60, + "offset": 380, + "tool": "read" + } + ] + } + }, + { + "t": 38013, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 2461\t return Ok(());\n 2462\t }\n 2463\t\n 2464\t if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('m')) {\n 2465\t app.toggle_diagram_pane();\n 2466\t return Ok(());\n 2467\t }\n 2468\t if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('t')) {\n 2469\t app.toggle_diagram_pane_position();\n 2470\t return Ok(());\n 2471\t }\n 2472\t if let Some(direction) = app.model_switch_keys.direction_for(code, ...", + "is_error": false + }, + { + "t": 39013, + "event": "thinking", + "duration": 800 + }, + { + "t": 39813, + "event": "stream_text", + "text": "I\u2019ve confirmed the client side already tries to treat `/fast` as an immediate command, even while processing. So the remaining likely failure is deeper: on the server/local turn loop, the command doesn\u2019t get applied in time to affect an active agentic stream, or in remote mode the control request is serialized behind the running stream. I\u2019m tracing that now.", + "speed": 80 + }, + { + "t": 40956, + "event": "token_usage", + "input": 69213, + "output": 702, + "cache_read": 61184, + "cache_creation": null + }, + { + "t": 40956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/server/client_lifecycle.rs", + "limit": 120, + "offset": 680, + "tool": "read" + }, + { + "file_path": "src/server/provider_control.rs", + "limit": 70, + "offset": 180, + "tool": "read" + }, + { + "command": "rg -n \"SetServiceTier|set_service_tier|Request::SetServiceTier|process_message_streaming_mpsc|tokio::select!|handle_request\" src/server src --glob '!target'", + "description": "Trace server request scheduling", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/server/client_comm.rs", + "limit": 220, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 41156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 681\t if handle_resume_session(\n 682\t id,\n 683\t session_id,\n 684\t &mut client_selfdev,\n 685\t &mut client_session_id,\n 686\t &client_connection_id,\n 687\t &agent,\n 688\t &provider,\n 689\t ®istry,\n 690\t &sessions,\n 691\t &shutdown_signals,\n 692\t &s...", + "is_error": false + }, + { + "t": 42156, + "event": "thinking", + "duration": 800 + }, + { + "t": 42956, + "event": "token_usage", + "input": 75696, + "output": 56, + "cache_read": 0, + "cache_creation": null + }, + { + "t": 42956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/server/client_lifecycle.rs", + "limit": 170, + "offset": 300 + } + }, + { + "t": 43156, + "event": "tool_done", + "name": "read", + "output": " 301\t }\n 302\t let _stdin_forwarder = {\n 303\t let client_event_tx = client_event_tx.clone();\n 304\t let stdin_responses = stdin_responses.clone();\n 305\t let tool_call_id = String::new();\n 306\t tokio::spawn(async move {\n 307\t while let Some(req) = stdin_req_rx.recv().await {\n 308\t let request_id = req.request_id.clone();\n 309\t stdin_responses\n 310\t .lock()\n 311\t .await\n 31...", + "is_error": false + }, + { + "t": 44156, + "event": "thinking", + "duration": 800 + }, + { + "t": 44956, + "event": "token_usage", + "input": 77633, + "output": 65, + "cache_read": 75520, + "cache_creation": null + }, + { + "t": 44956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/server/client_lifecycle.rs", + "limit": 260, + "offset": 469 + } + }, + { + "t": 45156, + "event": "tool_done", + "name": "read", + "output": " 470\t let n = match n {\n 471\t Ok(n) => n,\n 472\t Err(e) => {\n 473\t crate::logging::error(&format!(\"Client read error: {}\", e));\n 474\t break;\n 475\t }\n 476\t };\n 477\t if n == 0 {\n 478\t break; // Client disconnected\n 479\t }\n 480\t let mut connections = client_connections.write().await;\n 481\t ...", + "is_error": false + }, + { + "t": 46156, + "event": "thinking", + "duration": 800 + }, + { + "t": 46956, + "event": "token_usage", + "input": 80003, + "output": 223, + "cache_read": 77568, + "cache_creation": null + }, + { + "t": 46956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/provider/openai.rs", + "limit": 110, + "offset": 1968, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 2036, + "tool": "read" + }, + { + "command": "rg -n \"impl Clone for OpenAIProvider|derive\\(.*Clone.*\\).*OpenAIProvider|struct OpenAIProvider\" src/provider/openai.rs", + "description": "Locate OpenAI provider clone impl", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/mod.rs", + "limit": 40, + "offset": 3428, + "tool": "read" + } + ] + } + }, + { + "t": 47156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1969\t Ok(())\n 1970\t }\n 1971\t Err(_) => Err(anyhow::anyhow!(\n 1972\t \"Failed to acquire lock for reasoning effort\"\n 1973\t )),\n 1974\t }\n 1975\t }\n 1976\t\n 1977\t fn available_efforts(&self) -> Vec<&'static str> {\n 1978\t vec![\"none\", \"low\", \"medium\", \"high\", \"xhigh\"]\n 1979\t }\n 1980\t\n 1981\t fn service_tier(&self) -> Option {\n 1982\t self.service_tier.try_read().ok().and_then(|g| g....", + "is_error": false + }, + { + "t": 48156, + "event": "thinking", + "duration": 800 + }, + { + "t": 48956, + "event": "token_usage", + "input": 82583, + "output": 268, + "cache_read": 79616, + "cache_creation": null + }, + { + "t": 48956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"service_tier\\.|service_tier\\)|read\\(\\).*service_tier|service_tier: .*service_tier|service_tier.clone|service_tier.read|service_tier.write\" src/provider/openai.rs", + "description": "Trace OpenAI service tier lock usage", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 140, + "offset": 210, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 120, + "offset": 860, + "tool": "read" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 120, + "offset": 1210, + "tool": "read" + } + ] + } + }, + { + "t": 49156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n237: service_tier: Arc::new(RwLock::new(service_tier)),\n398: if let Some(service_tier) = service_tier {\n399: request[\"service_tier\"] = serde_json::json!(service_tier);\n1679: let service_tier = self.service_tier.read().await.clone();\n1688: service_tier.as_deref(),\n1982: self.service_tier.try_read().ok().and_then(|g| g.clone())\n1986: let normalized = Self::normalize_service_tier(service_tier)?;\n1987: match self...", + "is_error": false + }, + { + "t": 50156, + "event": "thinking", + "duration": 800 + }, + { + "t": 50956, + "event": "token_usage", + "input": 87376, + "output": 64, + "cache_read": 81664, + "cache_creation": null + }, + { + "t": 50956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 1668 + } + }, + { + "t": 51156, + "event": "tool_done", + "name": "read", + "output": " 1669\t let credentials = self.credentials.read().await;\n 1670\t let is_chatgpt = Self::is_chatgpt_mode(&credentials);\n 1671\t let instructions = if is_chatgpt {\n 1672\t Self::chatgpt_instructions_with_selfdev(system)\n 1673\t } else {\n 1674\t system.to_string()\n 1675\t };\n 1676\t (instructions, is_chatgpt)\n 1677\t };\n 1678\t let reasoning_effort = self.reasoning_effort.read().await.clone();\n 16...", + "is_error": false + }, + { + "t": 52156, + "event": "thinking", + "duration": 800 + }, + { + "t": 52956, + "event": "token_usage", + "input": 87982, + "output": 497, + "cache_read": 69760, + "cache_creation": null + }, + { + "t": 52956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"fn provider_handle|provider_handle\\(\" src/agent.rs src --glob '!target'", + "description": "Find provider handle implementation", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/agent.rs", + "limit": 60, + "offset": 120, + "tool": "read" + }, + { + "file_path": "src/agent.rs", + "limit": 120, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 53156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/agent.rs:1233: pub fn provider_handle(&self) -> Arc {\nsrc/server/client_state.rs:88: let provider = agent_guard.provider_handle();\nsrc/server/provider_control.rs:172: agent_guard.provider_handle()\nsrc/server/provider_control.rs:201: agent_guard.provider_handle()\nsrc/server/provider_control.rs:230: agent_guard.provider_handle()\nsrc/server/provider_control.rs:346: let provider = agent_guard.provider_handle();\nsrc/a...", + "is_error": false + }, + { + "t": 54156, + "event": "thinking", + "duration": 800 + }, + { + "t": 54956, + "event": "token_usage", + "input": 90705, + "output": 30, + "cache_read": 87808, + "cache_creation": null + }, + { + "t": 54956, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/agent.rs", + "limit": 20, + "offset": 1228 + } + }, + { + "t": 55156, + "event": "tool_done", + "name": "read", + "output": " 1229\t pub fn provider_fork(&self) -> Arc {\n 1230\t self.provider.fork()\n 1231\t }\n 1232\t\n 1233\t pub fn provider_handle(&self) -> Arc {\n 1234\t Arc::clone(&self.provider)\n 1235\t }\n 1236\t\n 1237\t pub fn available_models(&self) -> Vec<&'static str> {\n 1238\t self.provider.available_models()\n 1239\t }\n 1240\t\n 1241\t pub fn available_models_display(&self) -> Vec {\n 1242\t self.provider.available_models_display()\n 1243\t ...", + "is_error": false + }, + { + "t": 56156, + "event": "thinking", + "duration": 800 + }, + { + "t": 56956, + "event": "token_usage", + "input": 90998, + "output": 218, + "cache_read": 90496, + "cache_creation": null + }, + { + "t": 56956, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"provider_fork\\(|\\.fork\\(\" src --glob '!target'", + "description": "Find provider fork usages", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/mod.rs", + "limit": 50, + "offset": 620, + "tool": "read" + } + ] + } + }, + { + "t": 57156, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/ambient_runner.rs:625: let cycle_provider = provider.fork();\nsrc/agent.rs:1229: pub fn provider_fork(&self) -> Arc {\nsrc/agent.rs:1230: self.provider.fork()\nsrc/server/client_actions.rs:184: let provider = agent_guard.provider_fork();\nsrc/server/headless.rs:43: let provider = provider_template.fork();\nsrc/server/swarm.rs:432: agent.provider_fork(),\nsrc/server/client_lifecycle.rs:93: let provider = provider_template.fo...", + "is_error": false + }, + { + "t": 58156, + "event": "thinking", + "duration": 800 + }, + { + "t": 58956, + "event": "stream_text", + "text": "I found the concrete blocker: OpenAI\u2019s `set_service_tier()` currently uses a non-blocking `try_write()` on a Tokio `RwLock`, and returns `Cannot change service tier while a request is in progress`. So `/fast on` is effectively rejected during an active stream. I\u2019m changing that state to a synchronous lock so toggling fast mode can land immediately for subsequent requests in the same session/turn.", + "speed": 80 + }, + { + "t": 60215, + "event": "token_usage", + "input": 92032, + "output": 746, + "cache_read": 91008, + "cache_creation": null + }, + { + "t": 60215, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"service_tier|fast mode|/fast\" src/provider/openai.rs src/tui/app/tests.rs src/server -g '!target'", + "description": "Find relevant tests", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 40, + "offset": 150, + "tool": "read" + }, + { + "file_path": "src/tui/app/tests.rs", + "limit": 120, + "offset": 0, + "tool": "read" + } + ] + } + }, + { + "t": 60415, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nsrc/tui/app/tests.rs:1861: service_tier: None,\nsrc/tui/app/tests.rs:1904: service_tier: None,\nsrc/tui/app/tests.rs:2248: service_tier: None,\nsrc/tui/app/tests.rs:2308: service_tier: None,\nsrc/tui/app/tests.rs:2364: service_tier: None,\nsrc/provider/openai.rs:170: service_tier: Arc>>,\nsrc/provider/openai.rs:219: let service_tier = Self::load_service_tier(\nsrc/provider/openai.rs:222: ...", + "is_error": false + }, + { + "t": 61415, + "event": "thinking", + "duration": 800 + }, + { + "t": 62215, + "event": "token_usage", + "input": 95505, + "output": 121, + "cache_read": 92672, + "cache_creation": null + }, + { + "t": 62215, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "rg -n \"#\\[cfg\\(test\\)\\]|mod tests|test_.*service_tier|normalize_service_tier\" src/provider/openai.rs", + "description": "Find OpenAI tests section", + "timeout": 120000, + "tool": "bash" + }, + { + "file_path": "src/provider/openai.rs", + "limit": 220, + "offset": 4870, + "tool": "read" + } + ] + } + }, + { + "t": 62415, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\n299: fn normalize_service_tier(raw: &str) -> Result> {\n320: match Self::normalize_service_tier(raw) {\n1986: let normalized = Self::normalize_service_tier(service_tier)?;\n3443:#[cfg(test)]\n3534:#[cfg(test)]\n3535:mod tests {\n\n\n--- [2] read ---\n 4871\t let mut pending = VecDeque::new();\n 4872\t let mut events = Vec::new();\n 4873\t\n 4874\t if let Some(first) = handle_openai_output_item(item, &mut saw_text_delta, &mut pending) {\n 48...", + "is_error": false + }, + { + "t": 63415, + "event": "thinking", + "duration": 800 + }, + { + "t": 64215, + "event": "token_usage", + "input": 97043, + "output": 61, + "cache_read": 94976, + "cache_creation": null + }, + { + "t": 64215, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/provider/openai.rs", + "limit": 220, + "offset": 3535 + } + }, + { + "t": 64415, + "event": "tool_done", + "name": "read", + "output": " 3536\t use super::*;\n 3537\t use crate::auth::codex::CodexCredentials;\n 3538\t use anyhow::Result;\n 3539\t use std::collections::{HashMap, HashSet};\n 3540\t use std::ffi::OsString;\n 3541\t use std::path::PathBuf;\n 3542\t use std::sync::{Mutex, MutexGuard};\n 3543\t use std::time::{Duration, Instant};\n 3544\t const BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE: &str =\n 3545\t include_str!(\"../../tests/fixtures/openai/bright_pearl_wrapped_tool_call.txt\");\n 3546\t static ENV_L...", + "is_error": false + }, + { + "t": 65415, + "event": "thinking", + "duration": 800 + }, + { + "t": 66215, + "event": "token_usage", + "input": 99550, + "output": 136, + "cache_read": 96896, + "cache_creation": null + }, + { + "t": 66215, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "file_path": "src/provider/openai.rs", + "limit": 70, + "offset": 0, + "tool": "read" + }, + { + "command": "rg -n \"use tokio::sync|std::sync::RwLock|parking_lot\" src/provider/openai.rs", + "description": "Inspect lock imports", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 66415, + "event": "tool_done", + "name": "batch", + "output": "--- [1] read ---\n 1\tuse super::{EventStream, Provider};\n 2\tuse crate::auth::codex::CodexCredentials;\n 3\tuse crate::auth::oauth;\n 4\tuse crate::message::{\n 5\t ContentBlock, Message as ChatMessage, Role, StreamEvent, TOOL_OUTPUT_MISSING_TEXT,\n 6\t ToolDefinition,\n 7\t};\n 8\tuse anyhow::{Context, Result};\n 9\tuse async_trait::async_trait;\n 10\tuse bytes::Bytes;\n 11\tuse futures::{SinkExt, Stream, StreamExt as FuturesStreamExt};\n 12\tuse reqwest::header::HeaderValue...", + "is_error": false + }, + { + "t": 67415, + "event": "thinking", + "duration": 800 + }, + { + "t": 68215, + "event": "token_usage", + "input": 100984, + "output": 1733, + "cache_read": 99456, + "cache_creation": null + }, + { + "t": 68215, + "event": "tool_start", + "name": "apply_patch", + "input": { + "patch_text": "*** Begin Patch\n*** Update File: src/provider/openai.rs\n@@\n use serde::Deserialize;\n use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant};\n use tokio::net::TcpStream;\n use tokio::sync::{Mutex, RwLock, mpsc};\n@@\n pub struct OpenAIProvider {\n client: Client,\n credentials: Arc>,\n model: Arc>,\n@@\n prompt_cache_key: Option,\n prompt_cache_retention: Option,\n max_output_tokens: Option,\n reasoning_effort: Arc>>,\n- service_tier: Arc>>,\n+ service_tier: Arc>>,\n transport_mode: Arc>,\n@@\n prompt_cache_retention,\n max_output_tokens,\n reasoning_effort: Arc::new(RwLock::new(reasoning_effort)),\n- service_tier: Arc::new(RwLock::new(service_tier)),\n+ service_tier: Arc::new(StdRwLock::new(service_tier)),\n transport_mode: Arc::new(RwLock::new(transport_mode)),\n@@\n- let service_tier = self.service_tier.read().await.clone();\n+ let service_tier = self\n+ .service_tier\n+ .read()\n+ .map(|guard| guard.clone())\n+ .unwrap_or_else(|poisoned| poisoned.into_inner().clone());\n@@\n fn service_tier(&self) -> Option {\n- self.service_tier.try_read().ok().and_then(|g| g.clone())\n+ self.service_tier\n+ .read()\n+ .map(|guard| guard.clone())\n+ .unwrap_or_else(|poisoned| poisoned.into_inner().clone())\n }\n \n fn set_service_tier(&self, service_tier: &str) -> Result<()> {\n let normalized = Self::normalize_service_tier(service_tier)?;\n- match self.service_tier.try_write() {\n- Ok(mut guard) => {\n- *guard = normalized;\n- Ok(())\n- }\n- Err(_) => Err(anyhow::anyhow!(\n- \"Cannot change service tier while a request is in progress\"\n- )),\n- }\n+ let mut guard = self\n+ .service_tier\n+ .write()\n+ .unwrap_or_else(|poisoned| poisoned.into_inner());\n+ *guard = normalized;\n+ Ok(())\n }\n@@\n prompt_cache_key: self.prompt_cache_key.clone(),\n prompt_cache_retention: self.prompt_cache_retention.clone(),\n max_output_tokens: self.max_output_tokens,\n reasoning_effort: Arc::new(RwLock::new(self.reasoning_effort())),\n- service_tier: Arc::new(RwLock::new(self.service_tier())),\n+ service_tier: Arc::new(StdRwLock::new(self.service_tier())),\n transport_mode: Arc::clone(&self.transport_mode),\n websocket_cooldowns: Arc::clone(&self.websocket_cooldowns),\n websocket_failure_streaks: Arc::clone(&self.websocket_failure_streaks),\n persistent_ws: Arc::new(Mutex::new(None)),\n@@\n fn test_openai_supports_codex_models() {\n@@\n provider.set_model(\"gpt-5.1-codex-mini\").unwrap();\n assert_eq!(provider.model(), \"gpt-5.1-codex-mini\");\n }\n+\n+ #[test]\n+ fn test_service_tier_can_be_changed_while_a_request_snapshot_is_held() {\n+ let provider = Arc::new(OpenAIProvider::new(CodexCredentials {\n+ access_token: \"test\".to_string(),\n+ refresh_token: String::new(),\n+ id_token: None,\n+ account_id: None,\n+ expires_at: None,\n+ }));\n+\n+ let read_guard = provider\n+ .service_tier\n+ .read()\n+ .expect(\"service tier read lock should be available\");\n+\n+ let (tx, rx) = std::sync::mpsc::channel();\n+ let provider_for_write = Arc::clone(&provider);\n+ let handle = std::thread::spawn(move || {\n+ let result = provider_for_write.set_service_tier(\"priority\");\n+ tx.send(result).expect(\"send result from setter thread\");\n+ });\n+\n+ std::thread::sleep(Duration::from_millis(20));\n+ assert!(\n+ rx.try_recv().is_err(),\n+ \"writer should wait for the in-flight snapshot to finish\"\n+ );\n+\n+ drop(read_guard);\n+\n+ rx.recv()\n+ .expect(\"receive service tier setter result\")\n+ .expect(\"service tier update should succeed once read lock is released\");\n+ handle.join().expect(\"join setter thread\");\n+\n+ assert_eq!(provider.service_tier(), Some(\"priority\".to_string()));\n+ }\n \n #[test]\n fn test_build_responses_input_injects_missing_tool_output() {\n*** End Patch" + } + }, + { + "t": 68415, + "event": "tool_done", + "name": "apply_patch", + "output": "\u2713 src/provider/openai.rs: modified (9 hunks)\n19- use std::sync::{Arc, LazyLock};\n19+ use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n170- service_tier: Arc>>,\n170+ service_tier: Arc>>,\n237- service_tier: Arc::new(RwLock::new(service_tier)),\n237+ service_tier: Arc::new(StdRwLock::new(service_tier)),\n1679- let service_tier = self.service_tier.read().await.clone();\n1679+ let service_tier = self\n...", + "is_error": false + }, + { + "t": 69415, + "event": "thinking", + "duration": 800 + }, + { + "t": 70215, + "event": "stream_text", + "text": "Patch is in. I changed OpenAI\u2019s `service_tier` state to a synchronous `RwLock`, so `/fast on` no longer hard-fails just because a request is active; it waits for the tiny request-snapshot read to finish, then updates cleanly. I also added a regression test around that lock behavior. Now I\u2019m formatting and running targeted tests/build.", + "speed": 80 + }, + { + "t": 71277, + "event": "token_usage", + "input": 104521, + "output": 319, + "cache_read": 101120, + "cache_creation": null + }, + { + "t": 71277, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "cargo fmt --all", + "description": "Format Rust code", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "cargo test test_service_tier_can_be_changed_while_a_request_snapshot_is_held --lib", + "description": "Run new targeted test", + "timeout": 1200000, + "tool": "bash" + }, + { + "command": "cargo test test_openai_supports_codex_models --lib", + "description": "Run nearby OpenAI test", + "timeout": 1200000, + "tool": "bash" + } + ] + } + }, + { + "t": 72077, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\nCommand completed successfully (no output)\n\n--- [2] bash ---\n\nrunning 1 test\ntest provider::openai::tests::test_service_tier_can_be_changed_while_a_request_snapshot_is_held ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1147 filtered out; finished in 0.04s\n\n\n Blocking waiting for file lock on package cache\n Compiling jcode v0.6.1 (/home/jeremy/jcode)\nwarning: method `build_system_prompt` is never used\n --> src/agent/prompt_support.rs:24:19\n |\n 5 |...", + "is_error": false + }, + { + "t": 73077, + "event": "thinking", + "duration": 800 + }, + { + "t": 73877, + "event": "stream_text", + "text": "Targeted tests passed. I\u2019m doing the required release build now; because this is jcode itself, I\u2019ll reload onto the new binary afterward. I\u2019m not auto-committing here because the worktree already has a lot of unrelated edits, and I don\u2019t want to accidentally sweep those into a commit.", + "speed": 80 + }, + { + "t": 74792, + "event": "token_usage", + "input": 114021, + "output": 305, + "cache_read": 104704, + "cache_creation": null + }, + { + "t": 74792, + "event": "tool_start", + "name": "bash", + "input": { + "command": "cargo build --release", + "description": "Build release binary", + "notify": true, + "run_in_background": true, + "timeout": 120000 + } + }, + { + "t": 74992, + "event": "tool_done", + "name": "bash", + "output": "Command started in background.\n\nTask ID: 076911y3sg\nOutput file: /tmp/jcode-bg-tasks/076911y3sg.output\nStatus file: /tmp/jcode-bg-tasks/076911y3sg.status.json\n\nYou will be notified when the task completes.\nTo check progress: use the `bg` tool with action=\"status\" and task_id=\"076911y3sg\"\nTo see output: use the `read` tool on the output file, or `bg` with action=\"output\"", + "is_error": false + }, + { + "t": 75992, + "event": "thinking", + "duration": 800 + }, + { + "t": 76792, + "event": "token_usage", + "input": 114636, + "output": 59, + "cache_read": 114176, + "cache_creation": null + }, + { + "t": 76792, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 76992, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 77992, + "event": "thinking", + "duration": 800 + }, + { + "t": 78792, + "event": "stream_text", + "text": "Release build is still running. Once it finishes I\u2019ll reload onto the rebuilt binary and then I\u2019ll give you the exact change summary.", + "speed": 80 + }, + { + "t": 79220, + "event": "token_usage", + "input": 114765, + "output": 88, + "cache_read": 114560, + "cache_creation": null + }, + { + "t": 79220, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 79420, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 80354, + "event": "thinking", + "duration": 800 + }, + { + "t": 81154, + "event": "token_usage", + "input": 114991, + "output": 43, + "cache_read": 114688, + "cache_creation": null + }, + { + "t": 81154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 81354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 82354, + "event": "thinking", + "duration": 800 + }, + { + "t": 83154, + "event": "token_usage", + "input": 115148, + "output": 53, + "cache_read": 114816, + "cache_creation": null + }, + { + "t": 83154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "output", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 83354, + "event": "tool_done", + "name": "bg", + "output": "[stderr] Compiling jcode v0.6.1 (/home/jeremy/jcode)\n[stderr] warning: method `build_system_prompt` is never used\n[stderr] --> src/agent/prompt_support.rs:24:19\n[stderr] |\n[stderr] 5 | impl Agent {\n[stderr] | ---------- method in this implementation\n[stderr] ...\n[stderr] 24 | pub(super) fn build_system_prompt(&self, memory_prompt: Option<&str>) -> String {\n[stderr] | ^^^^^^^^^^^^^^^^^^^\n[stderr] |\n[stderr] = note: `#[warn(dead_code)]` (part of `#[war...", + "is_error": false + }, + { + "t": 84354, + "event": "thinking", + "duration": 800 + }, + { + "t": 85154, + "event": "token_usage", + "input": 122697, + "output": 45, + "cache_read": 115072, + "cache_creation": null + }, + { + "t": 85154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 85354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 86354, + "event": "thinking", + "duration": 800 + }, + { + "t": 87154, + "event": "token_usage", + "input": 122856, + "output": 41, + "cache_read": 122624, + "cache_creation": null + }, + { + "t": 87154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 87354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 88354, + "event": "thinking", + "duration": 800 + }, + { + "t": 89154, + "event": "token_usage", + "input": 123011, + "output": 45, + "cache_read": 122752, + "cache_creation": null + }, + { + "t": 89154, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 89354, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 90354, + "event": "thinking", + "duration": 800 + }, + { + "t": 91154, + "event": "stream_text", + "text": "Build is in the final stretch; it\u2019s still compiling, no errors so far\u2014just existing warning noise from other parts of the codebase. I\u2019ll reload as soon as it flips to done.", + "speed": 80 + }, + { + "t": 91710, + "event": "token_usage", + "input": 123170, + "output": 92, + "cache_read": 122880, + "cache_creation": null + }, + { + "t": 91710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 91910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 92910, + "event": "thinking", + "duration": 800 + }, + { + "t": 93710, + "event": "token_usage", + "input": 123413, + "output": 50, + "cache_read": 123136, + "cache_creation": null + }, + { + "t": 93710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "output", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 93910, + "event": "tool_done", + "name": "bg", + "output": "[stderr] Compiling jcode v0.6.1 (/home/jeremy/jcode)\n[stderr] warning: method `build_system_prompt` is never used\n[stderr] --> src/agent/prompt_support.rs:24:19\n[stderr] |\n[stderr] 5 | impl Agent {\n[stderr] | ---------- method in this implementation\n[stderr] ...\n[stderr] 24 | pub(super) fn build_system_prompt(&self, memory_prompt: Option<&str>) -> String {\n[stderr] | ^^^^^^^^^^^^^^^^^^^\n[stderr] |\n[stderr] = note: `#[warn(dead_code)]` (part of `#[war...", + "is_error": false + }, + { + "t": 94910, + "event": "thinking", + "duration": 800 + }, + { + "t": 95710, + "event": "token_usage", + "input": 130959, + "output": 52, + "cache_read": 123264, + "cache_creation": null + }, + { + "t": 95710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 95910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 96910, + "event": "thinking", + "duration": 800 + }, + { + "t": 97710, + "event": "token_usage", + "input": 131125, + "output": 41, + "cache_read": 130816, + "cache_creation": null + }, + { + "t": 97710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 97910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 98910, + "event": "thinking", + "duration": 800 + }, + { + "t": 99710, + "event": "token_usage", + "input": 131280, + "output": 46, + "cache_read": 130944, + "cache_creation": null + }, + { + "t": 99710, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 99910, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 100910, + "event": "thinking", + "duration": 800 + }, + { + "t": 101710, + "event": "stream_text", + "text": "Still waiting on the release link step. As soon as it\u2019s done I\u2019ll reload the agent binary and wrap up.", + "speed": 80 + }, + { + "t": 102041, + "event": "token_usage", + "input": 131440, + "output": 98, + "cache_read": 131200, + "cache_creation": null + }, + { + "t": 102041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 102241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 103241, + "event": "thinking", + "duration": 800 + }, + { + "t": 104041, + "event": "token_usage", + "input": 131673, + "output": 77, + "cache_read": 131328, + "cache_creation": null + }, + { + "t": 104041, + "event": "tool_start", + "name": "bash", + "input": { + "command": "tail -n 20 /tmp/jcode-bg-tasks/076911y3sg.output", + "description": "Check build output tail", + "notify": false, + "run_in_background": false, + "timeout": 120000 + } + }, + { + "t": 104241, + "event": "tool_done", + "name": "bash", + "output": "[stderr] | ^^^^^^^^ help: convert the identifier to snake case: `crash_bg`\n[stderr] \n[stderr] warning: variable `CRASH_FG` should have a snake case name\n[stderr] --> src/tui/session_picker.rs:2039:13\n[stderr] |\n[stderr] 2039 | let CRASH_FG: Color = rgb(255, 140, 140);\n[stderr] | ^^^^^^^^ help: convert the identifier to snake case: `crash_fg`\n[stderr] \n[stderr] warning: variable `CRASH_ICON` should have a snake case name\n[stderr] --> src/t...", + "is_error": false + }, + { + "t": 105241, + "event": "thinking", + "duration": 800 + }, + { + "t": 106041, + "event": "token_usage", + "input": 132138, + "output": 38, + "cache_read": 131584, + "cache_creation": null + }, + { + "t": 106041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 106241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 107241, + "event": "thinking", + "duration": 800 + }, + { + "t": 108041, + "event": "token_usage", + "input": 132290, + "output": 57, + "cache_read": 131968, + "cache_creation": null + }, + { + "t": 108041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 108241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 109241, + "event": "thinking", + "duration": 800 + }, + { + "t": 110041, + "event": "token_usage", + "input": 132461, + "output": 50, + "cache_read": 132224, + "cache_creation": null + }, + { + "t": 110041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 110241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: running\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:51:16.911384153+00:00\n", + "is_error": false + }, + { + "t": 111241, + "event": "thinking", + "duration": 800 + }, + { + "t": 112041, + "event": "token_usage", + "input": 132625, + "output": 97, + "cache_read": 132352, + "cache_creation": null + }, + { + "t": 112041, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "command": "git diff -- src/provider/openai.rs", + "description": "Show patch diff", + "timeout": 120000, + "tool": "bash" + }, + { + "command": "git status --short src/provider/openai.rs", + "description": "Check file status", + "timeout": 120000, + "tool": "bash" + } + ] + } + }, + { + "t": 112241, + "event": "tool_done", + "name": "batch", + "output": "--- [1] bash ---\ndiff --git a/src/provider/openai.rs b/src/provider/openai.rs\nindex 6ee1a1b..73a762e 100644\n--- a/src/provider/openai.rs\n+++ b/src/provider/openai.rs\n@@ -16,7 +16,7 @@ use serde_json::Value;\n use std::collections::{HashMap, HashSet, VecDeque};\n use std::pin::Pin;\n use std::sync::atomic::{AtomicU64, Ordering};\n-use std::sync::{Arc, LazyLock};\n+use std::sync::{Arc, LazyLock, RwLock as StdRwLock};\n use std::task::{Context as TaskContext, Poll};\n use std::time::{Duration, Instant}...", + "is_error": false + }, + { + "t": 113241, + "event": "thinking", + "duration": 800 + }, + { + "t": 114041, + "event": "token_usage", + "input": 134194, + "output": 47, + "cache_read": 131840, + "cache_creation": null + }, + { + "t": 114041, + "event": "tool_start", + "name": "bg", + "input": { + "action": "status", + "max_age_hours": 24, + "task_id": "076911y3sg" + } + }, + { + "t": 114241, + "event": "tool_done", + "name": "bg", + "output": "Task: 076911y3sg\nTool: bash\nStatus: completed\nSession: session_duck_1773434834183\nStarted: 2026-03-13T20:52:13.620314075+00:00\nCompleted: 2026-03-13T20:52:13.620319598+00:00\nDuration: 56.71s\nExit code: 0\n", + "is_error": false + }, + { + "t": 115241, + "event": "thinking", + "duration": 800 + }, + { + "t": 116041, + "event": "stream_text", + "text": "Release build finished successfully. Reloading onto the new binary now.", + "speed": 80 + }, + { + "t": 116262, + "event": "token_usage", + "input": 134393, + "output": 103, + "cache_read": 133888, + "cache_creation": null + }, + { + "t": 116262, + "event": "tool_start", + "name": "selfdev", + "input": { + "action": "reload", + "context": "Patched OpenAI fast mode toggling so `/fast on` can be changed mid-stream by switching service_tier from a Tokio try_write lock to a synchronous RwLock; added regression test and validated targeted tests plus cargo build --release." + } + }, + { + "t": 116462, + "event": "tool_done", + "name": "selfdev", + "output": "Reload initiated. Process restarting...", + "is_error": false + }, + { + "t": 116474, + "event": "done" + } +] \ No newline at end of file diff --git a/assets/demos/edited_timeline.json b/assets/demos/edited_timeline.json new file mode 100644 index 0000000..f911b47 --- /dev/null +++ b/assets/demos/edited_timeline.json @@ -0,0 +1,393 @@ +[ + { + "t": 1500, + "event": "user_message", + "text": "make me a simple flowchart. " + }, + { + "t": 8383, + "event": "thinking", + "duration": 800 + }, + { + "t": 10022, + "event": "stream_text", + "text": "\n\n```mermaid\nflowchart TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[Do Something]\n B -->|No| D[Do Something Else]\n C --> E[Process Result]\n D --> E\n E --> F{Another Check?}\n F -->|Yes| B\n F -->|No| G[End]\n```", + "speed": 80 + }, + { + "t": 11500, + "event": "token_usage", + "input": 447, + "output": 96, + "cache_read": 6487, + "cache_creation": 8560 + }, + { + "t": 11500, + "event": "done" + }, + { + "t": 14500, + "event": "user_message", + "text": "epxlore this codebase, and then make just a simple diagram of the memory arhcitecutre" + }, + { + "t": 18318, + "event": "thinking", + "duration": 800 + }, + { + "t": 18931, + "event": "stream_text", + "text": "\n\nLet me explore the memory-related code in this codebase first.", + "speed": 80 + }, + { + "t": 19085, + "event": "token_usage", + "input": 37, + "output": 156, + "cache_read": 15047, + "cache_creation": 541 + }, + { + "t": 19085, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "parameters": { + "include": "*.rs", + "path": "src/", + "pattern": "mod memory|pub struct Memory|memory" + }, + "tool": "file_grep" + }, + { + "parameters": { + "path": "src/" + }, + "tool": "ls" + }, + { + "parameters": { + "pattern": "src/**/*memory*" + }, + "tool": "file_glob" + } + ] + } + }, + { + "t": 19238, + "event": "tool_done", + "name": "batch", + "output": "--- [1] file_grep ---\nFound 100 matches for 'mod memory|pub struct Memory|memory'\n\nprovider/anthropic.rs:\n 1055: // Dynamic content - NOT cached (date, git status, memory)\n\nprovider/openrouter.rs:\n 400: /// In-memory cache\n 612: /// In-memory cache of per-model endpoint data\n 979: // Check in-memory cache first\n 1024: // Update in-memory cache\n 1036: // Clear in-memory cache\n 1058: // Check in-memory cache\n 1111: // Update in-memo...", + "is_error": false + }, + { + "t": 22045, + "event": "thinking", + "duration": 800 + }, + { + "t": 22659, + "event": "token_usage", + "input": 814, + "output": 211, + "cache_read": 15588, + "cache_creation": 188 + }, + { + "t": 22659, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "parameters": { + "file_path": "src/memory.rs", + "limit": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/memory_graph.rs", + "limit": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/memory_agent.rs", + "limit": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/tool/memory.rs", + "limit": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/tool/remember.rs", + "limit": 80 + }, + "tool": "file_read" + } + ] + } + }, + { + "t": 22812, + "event": "tool_done", + "name": "batch", + "output": "--- [1] file_read ---\n 1\t//! Memory system for cross-session learning\n 2\t//!\n 3\t//! Provides persistent memory that survives across sessions, organized by:\n 4\t//! - Project (per working directory)\n 5\t//! - Global (user-level preferences)\n 6\t//!\n 7\t//! Integrates with the Haiku sidecar for relevance verification and extraction.\n 8\t\n 9\tuse crate::memory_graph::{EdgeKind, MemoryGraph, GRAPH_VERSION};\n 10\tuse crate::sidecar::HaikuSidecar;\n 11\tuse crate::storage;\n ...", + "is_error": false + }, + { + "t": 25459, + "event": "thinking", + "duration": 800 + }, + { + "t": 26072, + "event": "token_usage", + "input": 1840, + "output": 252, + "cache_read": 15776, + "cache_creation": 1025 + }, + { + "t": 26072, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "parameters": { + "file_path": "src/memory.rs", + "limit": 200, + "offset": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/memory_graph.rs", + "limit": 150, + "offset": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/memory_agent.rs", + "limit": 150, + "offset": 120 + }, + "tool": "file_read" + }, + { + "parameters": { + "include": "*.rs", + "path": "src/", + "pattern": "pub struct.*Embed|mod embedding|pub fn embed" + }, + "tool": "file_grep" + }, + { + "parameters": { + "include": "*.rs", + "path": "src/", + "pattern": "HaikuSidecar|mod sidecar" + }, + "tool": "file_grep" + } + ] + } + }, + { + "t": 26225, + "event": "tool_done", + "name": "batch", + "output": "--- [1] file_read ---\n 121\t .map(str::trim)\n 122\t .filter(|line| !line.is_empty())\n 123\t .collect::>()\n 124\t .join(\"\\n\")\n 125\t .to_lowercase()\n 126\t}\n 127\t\n 128\t/// Take pending memory if available and fresh\n 129\tpub fn take_pending_memory() -> Option {\n 130\t if let Ok(mut guard) = PENDING_MEMORY.lock() {\n 131\t if let Some(pending) = guard.take() {\n 132\t if !pending.is_fresh() {\n 133\t ret...", + "is_error": false + }, + { + "t": 28363, + "event": "thinking", + "duration": 800 + }, + { + "t": 28977, + "event": "token_usage", + "input": 1606, + "output": 194, + "cache_read": 16801, + "cache_creation": 2092 + }, + { + "t": 28977, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "parameters": { + "file_path": "src/embedding.rs", + "limit": 80 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/sidecar.rs", + "limit": 80 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/memory.rs", + "limit": 200, + "offset": 300 + }, + "tool": "file_read" + }, + { + "parameters": { + "include": "*.rs", + "path": "src/", + "pattern": "struct MemoryManager|impl MemoryManager" + }, + "tool": "file_grep" + } + ] + } + }, + { + "t": 29130, + "event": "tool_done", + "name": "batch", + "output": "--- [1] file_read ---\n 1\t//! Local embedding generation using all-MiniLM-L6-v2\n 2\t//!\n 3\t//! Provides fast, free, consistent embeddings for memory similarity search.\n 4\t//! Uses tract for pure-Rust ONNX inference (no external dependencies).\n 5\t\n 6\tuse anyhow::{Context, Result};\n 7\tuse std::path::PathBuf;\n 8\tuse std::sync::{Arc, Mutex, OnceLock};\n 9\tuse std::time::{Duration, Instant};\n 10\tuse tokenizers::Tokenizer;\n 11\tuse tract_hir::prelude::*;\n 12\tuse tract_o...", + "is_error": false + }, + { + "t": 31410, + "event": "thinking", + "duration": 800 + }, + { + "t": 32024, + "event": "token_usage", + "input": 1190, + "output": 167, + "cache_read": 18893, + "cache_creation": 1800 + }, + { + "t": 32024, + "event": "tool_start", + "name": "batch", + "input": { + "tool_calls": [ + { + "parameters": { + "file_path": "src/memory.rs", + "limit": 150, + "offset": 870 + }, + "tool": "file_read" + }, + { + "parameters": { + "file_path": "src/memory_agent.rs", + "limit": 200, + "offset": 150 + }, + "tool": "file_read" + }, + { + "parameters": { + "include": "*.rs", + "path": "src/", + "pattern": "MemoryStore|struct.*Store" + }, + "tool": "file_grep" + } + ] + } + }, + { + "t": 32177, + "event": "tool_done", + "name": "batch", + "output": "--- [1] file_read ---\n 871\t project_dir: Option,\n 872\t /// When true, use isolated test storage instead of real memory\n 873\t test_mode: bool,\n 874\t}\n 875\t\n 876\timpl MemoryManager {\n 877\t pub fn new() -> Self {\n 878\t Self {\n 879\t project_dir: None,\n 880\t test_mode: false,\n 881\t }\n 882\t }\n 883\t\n 884\t /// Create a memory manager in test mode (isolated storage)\n 885\t pub fn new_test() -> Self {\n 886\t Self {\n ...", + "is_error": false + }, + { + "t": 33733, + "event": "thinking", + "duration": 800 + }, + { + "t": 34346, + "event": "token_usage", + "input": 1077, + "output": 93, + "cache_read": 20693, + "cache_creation": 1357 + }, + { + "t": 34346, + "event": "tool_start", + "name": "read", + "input": { + "file_path": "src/memory.rs", + "limit": 80, + "offset": 570 + } + }, + { + "t": 34500, + "event": "tool_done", + "name": "read", + "output": " 571\t \"entity\" => MemoryCategory::Entity,\n 572\t \"correction\" => MemoryCategory::Correction,\n 573\t other => MemoryCategory::Custom(other.to_string()),\n 574\t })\n 575\t }\n 576\t}\n 577\t\n 578\t#[derive(Debug, Clone, Serialize, Deserialize, Default)]\n 579\tpub struct MemoryStore {\n 580\t pub entries: Vec,\n 581\t #[serde(default)]\n 582\t pub metadata: HashMap,\n 583\t}\n 584\t\n 585\timpl MemoryStore {\n 586\t pub f...", + "is_error": false + }, + { + "t": 37500, + "event": "user_message", + "text": "a very simplifed version of it " + }, + { + "t": 47652, + "event": "thinking", + "duration": 800 + }, + { + "t": 48497, + "event": "stream_text", + "text": "\n\n```mermaid\nflowchart TD\n A[\"\ud83e\udde0 Memory Tool\\n(agent-facing API)\"]\n B[\"\ud83d\udcdd Remember Tool\\n(simple notes)\"]\n\n A --> C[\"MemoryManager\\n(project + global scope)\"]\n B --> D[\"NoteStore\\n(JSON file per project)\"]\n\n C --> E[\"MemoryStore\\n(entries, categories, tags)\"]\n C --> F[\"MemoryGraph\\n(edges, clusters, BFS traversal)\"]\n\n E --> G[\"\ud83d\udcbe JSON Files\\n~/.jcode/memory/\"]\n F --> G\n\n H[\"\ud83e\udd16 Memory Agent\\n(background async)\"] --> C\n H --> I[\"Embedder\\n(all-MiniLM-L6-v2, local ONNX)\"]\n H --> J[\"Haiku Sidecar\\n(relevance checks via LLM)\"]\n\n I -->|similarity search| F\n J -->|decide what's relevant| K[\"PENDING_MEMORY\\n(injected into system prompt)\"]\n\n K -->|surfaces memories| L[\"Main Agent\"]\n L -->|context updates| H\n\n style A fill:#4a9eff,color:#fff\n style B fill:#4a9eff,color:#fff\n style H fill:#ff9f43,color:#fff\n style G fill:#2ecc71,color:#fff\n style L fill:#9b59b6,color:#fff\n```\n\nThe main agent talks to the **Memory Agent** in the background. The memory agent uses **local embeddings** for similarity search and a **Haiku sidecar** (cheap LLM) to judge relevance, then injects relevant memories into the system prompt for the main agent to see.", + "speed": 80 + }, + { + "t": 52500, + "event": "token_usage", + "input": 1115, + "output": 427, + "cache_read": 22050, + "cache_creation": 1170 + }, + { + "t": 52500, + "event": "done" + } +] \ No newline at end of file diff --git a/assets/demos/exports/memory_demo_1m40_spedup.mp4 b/assets/demos/exports/memory_demo_1m40_spedup.mp4 new file mode 100644 index 0000000..064a165 Binary files /dev/null and b/assets/demos/exports/memory_demo_1m40_spedup.mp4 differ diff --git a/assets/demos/exports/memory_demo_1m40_spedup_v2.mp4 b/assets/demos/exports/memory_demo_1m40_spedup_v2.mp4 new file mode 100644 index 0000000..deff07e Binary files /dev/null and b/assets/demos/exports/memory_demo_1m40_spedup_v2.mp4 differ diff --git a/assets/demos/jcode-claudeai-demo.mp4 b/assets/demos/jcode-claudeai-demo.mp4 new file mode 100644 index 0000000..f66db56 Binary files /dev/null and b/assets/demos/jcode-claudeai-demo.mp4 differ diff --git a/assets/demos/jcode-vs-claude-code.png b/assets/demos/jcode-vs-claude-code.png new file mode 100644 index 0000000..4a2c91c Binary files /dev/null and b/assets/demos/jcode-vs-claude-code.png differ diff --git a/assets/demos/jcode_demo.mp4 b/assets/demos/jcode_demo.mp4 new file mode 100644 index 0000000..5aa59cb Binary files /dev/null and b/assets/demos/jcode_demo.mp4 differ diff --git a/assets/demos/jcode_mermaid_demo.mp4 b/assets/demos/jcode_mermaid_demo.mp4 new file mode 100644 index 0000000..d4e0737 Binary files /dev/null and b/assets/demos/jcode_mermaid_demo.mp4 differ diff --git a/assets/demos/jcode_mermaid_demo_final.mp4 b/assets/demos/jcode_mermaid_demo_final.mp4 new file mode 100644 index 0000000..d4e0737 Binary files /dev/null and b/assets/demos/jcode_mermaid_demo_final.mp4 differ diff --git a/assets/demos/jcode_mermaid_demo_v2.mp4 b/assets/demos/jcode_mermaid_demo_v2.mp4 new file mode 100644 index 0000000..f66a5e5 Binary files /dev/null and b/assets/demos/jcode_mermaid_demo_v2.mp4 differ diff --git a/assets/demos/jcode_replay_duck_fast-on-mid-stream_autoedit_2x.mp4 b/assets/demos/jcode_replay_duck_fast-on-mid-stream_autoedit_2x.mp4 new file mode 100644 index 0000000..504a8e2 Binary files /dev/null and b/assets/demos/jcode_replay_duck_fast-on-mid-stream_autoedit_2x.mp4 differ diff --git a/assets/demos/jcode_replay_duck_fast-on-mid-stream_autoedit_trimmed_2x.mp4 b/assets/demos/jcode_replay_duck_fast-on-mid-stream_autoedit_trimmed_2x.mp4 new file mode 100644 index 0000000..4f7a8ba Binary files /dev/null and b/assets/demos/jcode_replay_duck_fast-on-mid-stream_autoedit_trimmed_2x.mp4 differ diff --git a/assets/demos/jcode_wolf_demo_final.mp4 b/assets/demos/jcode_wolf_demo_final.mp4 new file mode 100644 index 0000000..1b5d3c7 Binary files /dev/null and b/assets/demos/jcode_wolf_demo_final.mp4 differ diff --git a/assets/demos/jcode_wolf_demo_v2.mp4 b/assets/demos/jcode_wolf_demo_v2.mp4 new file mode 100644 index 0000000..b634f90 Binary files /dev/null and b/assets/demos/jcode_wolf_demo_v2.mp4 differ diff --git a/assets/demos/memory_demo.mp4 b/assets/demos/memory_demo.mp4 new file mode 100644 index 0000000..deff07e Binary files /dev/null and b/assets/demos/memory_demo.mp4 differ diff --git a/assets/demos/pelican-bike/index.html b/assets/demos/pelican-bike/index.html new file mode 100644 index 0000000..8db9f5a --- /dev/null +++ b/assets/demos/pelican-bike/index.html @@ -0,0 +1,253 @@ + + + + + + + The Great Pelican Ride + + + +
+ + The Great Pelican Ride + A joyful pelican pedals a coral bicycle along a sunlit coastal road while clouds, sea, and birds drift past. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
An ocean road story

The GreatPelican Ride

+ +
West wind · perfect riding
+
Somewhere past the last lighthouse
keep your beak to the breeze
+
+
+ + + diff --git a/assets/demos/wolf_timeline.json b/assets/demos/wolf_timeline.json new file mode 100644 index 0000000..b371f2f --- /dev/null +++ b/assets/demos/wolf_timeline.json @@ -0,0 +1 @@ +[{"t": 800, "event": "user_message", "text": "are models able to use vision to acutallyt see images? "}, {"t": 1600, "event": "thinking", "duration": 800}, {"t": 1800, "event": "stream_text", "text": "\n\nGood question! Let me check how jcode handles images and whether models can actually \"see\" them.", "speed": 80}, {"t": 1900, "event": "token_usage", "input": 457, "output": 193, "cache_read": 6487, "cache_creation": 8560}, {"t": 1900, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/provider", "pattern": "image"}, "tool": "file_grep"}, {"parameters": {"path": "src/agent.rs", "pattern": "image|vision|base64"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/provider", "pattern": "image|ContentBlock|ImageContent|media_type"}, "tool": "file_grep"}]}}, {"t": 2000, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 6 matches for 'image'\n\ncli_common.rs:\n 87: out.push_str(\"[image]\\n\");\n\nanthropic.rs:\n 1208: #[serde(rename = \"image\")]\n\nopenrouter.rs:\n 1258: \"type\": \"image_url\",\n 1259: \"image_url\": {\n\nopenai.rs:\n 366: \"type\": \"input_image\",\n 367: \"image_url\": format!(\"data:{};base64,{}\", media_type, data)\n\n\n--- [2] file_grep ---...", "is_error": false}, {"t": 2500, "event": "thinking", "duration": 800}, {"t": 2700, "event": "token_usage", "input": 553, "output": 127, "cache_read": 15047, "cache_creation": 646}, {"t": 2700, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"file_path": "src/agent.rs", "limit": 60, "offset": 840}, "tool": "file_read"}, {"parameters": {"path": "src/message.rs", "pattern": "Image|image|media_type|base64"}, "tool": "file_grep"}]}}, {"t": 2800, "event": "tool_done", "name": "batch", "output": "--- [1] file_read ---\n 841\t );\n 842\t self.session.save()?;\n 843\t self.run_turn_streaming(event_tx).await\n 844\t }\n 845\t\n 846\t /// Run one conversation turn with streaming events via mpsc channel (per-client)\n 847\t pub async fn run_once_streaming_mpsc(\n 848\t &mut self,\n 849\t user_message: &str,\n 850\t images: Vec<(String, String)>,\n 851\t event_tx: mpsc::UnboundedSender,\n 852\t ) -> Result<()> {\n 853\t // ...", "is_error": false}, {"t": 3300, "event": "thinking", "duration": 800}, {"t": 3500, "event": "token_usage", "input": 473, "output": 93, "cache_read": 15693, "cache_creation": 680}, {"t": 3500, "event": "tool_start", "name": "read", "input": {"file_path": "src/message.rs", "limit": 50, "offset": 60}}, {"t": 3600, "event": "tool_done", "name": "read", "output": " 61\t name: String,\n 62\t input: serde_json::Value,\n 63\t },\n 64\t ToolResult {\n 65\t tool_use_id: String,\n 66\t content: String,\n 67\t #[serde(skip_serializing_if = \"Option::is_none\")]\n 68\t is_error: Option,\n 69\t },\n 70\t Image {\n 71\t media_type: String,\n 72\t data: String,\n 73\t },\n 74\t}\n 75\t\n 76\timpl Message {\n 77\t pub fn user(text: &str) -> Self {\n 78\t Self {\n 79\t ...", "is_error": false}, {"t": 4100, "event": "thinking", "duration": 800}, {"t": 4300, "event": "stream_text", "text": "Now let me check how images get sent to each provider:", "speed": 80}, {"t": 4400, "event": "token_usage", "input": 571, "output": 237, "cache_read": 16373, "cache_creation": 566}, {"t": 4400, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"file_path": "src/provider/anthropic.rs", "pattern": "Image|image|media_type|base64"}, "tool": "file_grep"}, {"parameters": {"file_path": "src/provider/openai.rs", "pattern": "Image|image|media_type|base64"}, "tool": "file_grep"}, {"parameters": {"file_path": "src/provider/openrouter.rs", "pattern": "Image|image|media_type|base64"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "image|paste|clipboard|drop"}, "tool": "file_grep"}]}}, {"t": 4500, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 100 matches for 'Image|image|media_type|base64'\n\n.git/logs/refs/heads/master:\n 1: 1a7d38d8102654932100b5d882e6cd0321585755 78ae36d7256aad88900f5c34890c5693d07f40b2 jeremy <94247773+1jehuang@users.noreply.github.com> 1771424399 -0800\tcommit: Optimize build times: disable unused image codecs, add release-fast profile\n\n.git/logs/HEAD:\n 1: 1a7d38d8102654932100b5d882e6cd0321585755 78ae36d7256aad88900f5c34890c5693d07f40b2 jeremy <94247773+1jehuang@users.noreply.g...", "is_error": false}, {"t": 5000, "event": "thinking", "duration": 800}, {"t": 5200, "event": "token_usage", "input": 1711, "output": 172, "cache_read": 16939, "cache_creation": 808}, {"t": 5200, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/provider", "pattern": "ContentBlock::Image"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "images|image_paste|attach|ContentBlock::Image"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/protocol.rs", "pattern": "image"}, "tool": "file_grep"}]}}, {"t": 5300, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 5 matches for 'ContentBlock::Image'\n\ncli_common.rs:\n 86: ContentBlock::Image { .. } => {\n\nanthropic.rs:\n 374: ContentBlock::Image { media_type, data } => Some(ApiContentBlock::Image {\n\nopenrouter.rs:\n 1256: ContentBlock::Image { media_type, data } => {\n\nopenai.rs:\n 364: ContentBlock::Image { media_type, data } => {\n\nclaude.rs:\n 140: ContentBlock::Image { .. } =...", "is_error": false}, {"t": 5500, "event": "thinking", "duration": 800}, {"t": 5700, "event": "token_usage", "input": 511, "output": 96, "cache_read": 17747, "cache_creation": 1883}, {"t": 5700, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 30, "offset": 525}}, {"t": 5800, "event": "tool_done", "name": "read", "output": " 526\t // Hot-rebuild: if set, do full git pull + cargo build + tests then exec\n 527\t rebuild_requested: Option,\n 528\t // Pasted content storage (displayed as placeholders, expanded on submit)\n 529\t pasted_contents: Vec,\n 530\t // Pending pasted images (media_type, base64_data) attached to next message\n 531\t pending_images: Vec<(String, String)>,\n 532\t // Debug socket broadcast channel (if enabled)\n 533\t debug_tx: Option,\n 817: pending_images: Vec::new(),\n 2297: self.pending_images.clear();\n 5042: // Alt+V: paste image from clipboard\n 5043: self.paste_image_from_clipboard();\n 5115: ...", "is_error": false}, {"t": 7200, "event": "thinking", "duration": 800}, {"t": 7400, "event": "stream_text", "text": "Now let me check how the Anthropic provider serializes images for the API:", "speed": 80}, {"t": 7500, "event": "token_usage", "input": 1067, "output": 114, "cache_read": 20237, "cache_creation": 665}, {"t": 7500, "event": "tool_start", "name": "read", "input": {"file_path": "src/provider/anthropic.rs", "limit": 30, "offset": 365}}, {"t": 7600, "event": "tool_done", "name": "read", "output": " 366\t tool_use_id,\n 367\t content,\n 368\t is_error,\n 369\t } => Some(ApiContentBlock::ToolResult {\n 370\t tool_use_id: tool_use_id.clone(),\n 371\t content: content.clone(),\n 372\t is_error: is_error.unwrap_or(false),\n 373\t }),\n 374\t ContentBlock::Image { media_type, data } => Some(ApiContentBlock::Image {\n 375\t so...", "is_error": false}, {"t": 8400, "event": "thinking", "duration": 800}, {"t": 8600, "event": "stream_text", "text": "**Yes, models can actually see images!** Here's the full picture:\n\n### How it works in jcode:\n\n1. **Image pasting** \u2014 When you press **Ctrl+V** or **Alt+V**, jcode checks the clipboard for image data (via `wl-paste` on Wayland, `osascript` on macOS, or `arboard` fallback). It also detects image URLs pasted as text (e.g., from Discord).\n\n2. **Base64 encoding** \u2014 Images are converted to base64 and stored as `pending_images` (with media type like `image/png`).\n\n3. **Sent to the model** \u2014 When you submit your message, images are included as `ContentBlock::Image` blocks alongside the text. Each provider serializes them correctly:\n - **Anthropic**: Native `image` content blocks with `base64` source\n - **OpenAI**: `input_image` blocks with `data:` URIs\n - **OpenRouter**: `image_url` blocks with `data:` URIs\n\n4. **The model actually sees them** \u2014 Claude, GPT-4o, and other vision-capable models process the raw image data and can describe, analyze, and reason about what's in the image.\n\n### To use it:\n- **Screenshot \u2192 Ctrl+V** \u2014 Take a screenshot, paste it into jcode\n- **Copy image \u2192 Ctrl+V** \u2014 Copy any image from a browser or app, paste it\n- **Paste image URL** \u2014 Paste an `` URL and it'll download + attach\n\nThe `[image attached]` indicator appears in the input area when an image is queued. The model receives and processes the actual pixel data \u2014 it's real vision, not just a placeholder.", "speed": 80}, {"t": 9100, "event": "token_usage", "input": 431, "output": 406, "cache_read": 20902, "cache_creation": 1181}, {"t": 9100, "event": "done"}, {"t": 9900, "event": "user_message", "text": "is the model able to like read an image normally though if i did not paste it in? like lets say i just told it to read this image"}, {"t": 10400, "event": "thinking", "duration": 800}, {"t": 10600, "event": "stream_text", "text": "\n\nAh, good question. Let me check what happens when the model tries to read an image file via the file_read tool or similar.", "speed": 80}, {"t": 10700, "event": "token_usage", "input": 43, "output": 164, "cache_read": 22083, "cache_creation": 834}, {"t": 10700, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/tool", "pattern": "binary|Binary|is_binary|image file"}, "tool": "file_grep"}, {"parameters": {"path": "src/tool/file_read.rs", "pattern": "base64|image|binary"}, "tool": "file_grep"}]}}, {"t": 10800, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 22 matches for 'binary|Binary|is_binary|image file'\n\ngrep.rs:\n 43: Respects .gitignore and skips binary files.\"\n 112: // Skip binary files\n 113: if is_binary_extension(path) {\n 182: fn is_binary_extension(path: &Path) -> bool {\n 185: let binary_exts = [\n 190: return binary_exts.contains(&ext.as_str());\n\nselfdev.rs:\n 106: Actions: 'reload' (restart with built binary), \\\n 124: ...", "is_error": false}, {"t": 11300, "event": "thinking", "duration": 800}, {"t": 11500, "event": "token_usage", "input": 362, "output": 103, "cache_read": 22917, "cache_creation": 209}, {"t": 11500, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tool", "pattern": "image|Image|base64|kitty_graphics|display\""}}, {"t": 11600, "event": "tool_done", "name": "grep", "output": "Found 23 matches for 'image|Image|base64|kitty_graphics|display\"'\n\nconversation_search.rs:\n 196: crate::message::ContentBlock::Image { .. } => {\n 197: output.push_str(\"[Image]\\n\");\n\nread.rs:\n 3: use crate::tui::image::{display_image, ImageDisplayParams, ImageProtocol};\n 83: // Check for image files and display in terminal if supported\n 84: if is_image_file(&path) {\n 85: return handle_image...", "is_error": false}, {"t": 11800, "event": "thinking", "duration": 800}, {"t": 12000, "event": "token_usage", "input": 539, "output": 95, "cache_read": 23126, "cache_creation": 465}, {"t": 12000, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 100, "offset": 80}}, {"t": 12100, "event": "tool_done", "name": "read", "output": " 81\t }\n 82\t\n 83\t // Check for image files and display in terminal if supported\n 84\t if is_image_file(&path) {\n 85\t return handle_image_file(&path, ¶ms.file_path);\n 86\t }\n 87\t\n 88\t // Check for PDF files and extract text\n 89\t if is_pdf_file(&path) {\n 90\t return handle_pdf_file(&path, ¶ms.file_path);\n 91\t }\n 92\t\n 93\t // Check for binary files\n 94\t if is_binary_file(&path) {\n ...", "is_error": false}, {"t": 12300, "event": "thinking", "duration": 800}, {"t": 12500, "event": "token_usage", "input": 1352, "output": 95, "cache_read": 23591, "cache_creation": 634}, {"t": 12500, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 80, "offset": 200}}, {"t": 12600, "event": "tool_done", "name": "read", "output": " 201\t\n 202\t/// Check if a file is an image based on extension\n 203\tfn is_image_file(path: &Path) -> bool {\n 204\t if let Some(ext) = path.extension() {\n 205\t let ext = ext.to_string_lossy().to_lowercase();\n 206\t matches!(\n 207\t ext.as_str(),\n 208\t \"png\" | \"jpg\" | \"jpeg\" | \"gif\" | \"webp\" | \"bmp\" | \"ico\"\n 209\t )\n 210\t } else {\n 211\t false\n 212\t }\n 213\t}\n 214\t\n 215\t/// Handle reading an image file - display in terminal if s...", "is_error": false}, {"t": 13100, "event": "thinking", "duration": 800}, {"t": 13300, "event": "stream_text", "text": "Now let me check the ToolOutput type - does it support returning images to the model, or only text?", "speed": 80}, {"t": 13400, "event": "token_usage", "input": 1197, "output": 135, "cache_read": 24225, "cache_creation": 1447}, {"t": 13400, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tool", "pattern": "struct ToolOutput|enum ToolOutput|pub fn new|images.*Vec|image.*content\""}}, {"t": 13500, "event": "tool_done", "name": "grep", "output": "Found 37 matches for 'struct ToolOutput|enum ToolOutput|pub fn new|images.*Vec|image.*content\"'\n\ninvalid.rs:\n 10: pub fn new() -> Self {\n\ncodesearch.rs:\n 19: pub fn new() -> Self {\n\ntodo.rs:\n 13: pub fn new() -> Self {\n 19: pub fn new() -> Self {\n\nwebfetch.rs:\n 17: pub fn new() -> Self {\n\nwebsearch.rs:\n 13: pub fn new() -> Self {\n\ndebug_socket.rs:\n 28: pub fn new() -> Self {\n\nbg.rs:\n 16: pub fn new() -> Self {\n\nskill.rs:\n 17: pub ...", "is_error": false}, {"t": 13700, "event": "thinking", "duration": 800}, {"t": 13900, "event": "token_usage", "input": 731, "output": 95, "cache_read": 25672, "cache_creation": 1332}, {"t": 13900, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/mod.rs", "limit": 40, "offset": 44}}, {"t": 14000, "event": "tool_done", "name": "read", "output": " 45\t\n 46\t#[derive(Debug, Clone)]\n 47\tpub struct ToolOutput {\n 48\t pub output: String,\n 49\t pub title: Option,\n 50\t pub metadata: Option,\n 51\t}\n 52\t\n 53\timpl ToolOutput {\n 54\t pub fn new(output: impl Into) -> Self {\n 55\t Self {\n 56\t output: output.into(),\n 57\t title: None,\n 58\t metadata: None,\n 59\t }\n 60\t }\n 61\t\n 62\t pub fn with_title(mut self, title: impl Into) -...", "is_error": false}, {"t": 14500, "event": "thinking", "duration": 800}, {"t": 14700, "event": "stream_text", "text": "That's the key finding. Let me also check how tool results get converted to message content:", "speed": 80}, {"t": 14800, "event": "token_usage", "input": 434, "output": 125, "cache_read": 27004, "cache_creation": 826}, {"t": 14800, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src", "pattern": "ToolResult.*content|tool_result.*content|ToolOutput.*content\""}}, {"t": 14900, "event": "tool_done", "name": "grep", "output": "Found 25 matches for 'ToolResult.*content|tool_result.*content|ToolOutput.*content\"'\n\nprovider/anthropic.rs:\n 1422: if let ApiContentBlock::ToolResult { content, .. } = &last_msg.content[0] {\n\nprovider/claude.rs:\n 137: ContentBlock::ToolResult { content, .. } => parts.push(content.clone()),\n\ntool/conversation_search.rs:\n 187: crate::message::ContentBlock::ToolResult { content, .. } => {\n 258: crate::message::ContentBlo...", "is_error": false}, {"t": 15700, "event": "thinking", "duration": 800}, {"t": 15900, "event": "stream_text", "text": "**No \u2014 the model cannot actually *see* images when you ask it to read a file.** Here's why:\n\n### What happens when you say \"read this image\":\n\n1. The `file_read` tool detects it's an image file (`is_image_file()`)\n2. It calls `handle_image_file()` which:\n - **Displays** the image in your terminal (via Kitty graphics protocol) so **you** can see it\n - Returns only **text metadata** to the model: `\"Image: foo.png (42.3 KB)\\nDimensions: 800x600\\nDisplayed in terminal using Kitty protocol\"`\n\n3. `ToolOutput` is **text-only** \u2014 it has `output: String`, no image/binary data field. The tool result goes into `ContentBlock::ToolResult { content: String, ... }`, never as `ContentBlock::Image`.\n\n### So the model gets:\n```\nImage: screenshot.png (42.3 KB)\nDimensions: 1920x1080\nDisplayed in terminal using Kitty protocol\n```\n\nIt knows the image exists and its dimensions, but **cannot see the actual pixels**. It's blind to the content.\n\n### What *would* work:\n- **Ctrl+V / Alt+V paste** \u2014 that puts the image into `ContentBlock::Image` with base64 data, which the model actually sees\n- The gap is: there's no code path that converts a file_read of an image into a `ContentBlock::Image` in the conversation\n\n### To fix this:\nWe'd need to either:\n1. Add an `images` field to `ToolOutput` (e.g., `Vec<(String, String)>` for media_type + base64), and have the agent inject those as `ContentBlock::Image` alongside the `ContentBlock::ToolResult`\n2. Or have `file_read` base64-encode the image and return it inline in a way the provider can interpret\n\nWant me to implement that? It'd let the model actually see any image file it reads.", "speed": 80}, {"t": 16700, "event": "token_usage", "input": 729, "output": 478, "cache_read": 27830, "cache_creation": 559}, {"t": 16700, "event": "done"}, {"t": 16800, "event": "user_message", "text": "what file types does file read support? shuld we extend it to more things/ "}, {"t": 16900, "event": "thinking", "duration": 800}, {"t": 17100, "event": "stream_text", "text": "\n\nLet me look at the full picture of what file_read handles:", "speed": 80}, {"t": 17200, "event": "token_usage", "input": 32, "output": 77, "cache_read": 28389, "cache_creation": 1204}, {"t": 17200, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs"}}, {"t": 17300, "event": "tool_done", "name": "read", "output": " 1\tuse super::{Tool, ToolContext, ToolOutput};\n 2\tuse crate::bus::{Bus, BusEvent, FileOp, FileTouch};\n 3\tuse crate::tui::image::{display_image, ImageDisplayParams, ImageProtocol};\n 4\tuse anyhow::Result;\n 5\tuse async_trait::async_trait;\n 6\tuse serde::Deserialize;\n 7\tuse serde_json::{json, Value};\n 8\tuse std::path::Path;\n 9\t\n 10\tconst DEFAULT_LIMIT: usize = 2000;\n 11\tconst MAX_LINE_LEN: usize = 2000;\n 12\t\n 13\tpub struct ReadTool;\n 14\t\n 15\timpl ReadTool {\n ...", "is_error": false}, {"t": 18100, "event": "thinking", "duration": 800}, {"t": 18300, "event": "stream_text", "text": "Here's the current state:\n\n### What `file_read` handles today:\n\n| Type | Extensions | What happens |\n|------|-----------|--------------|\n| **Text files** | `.rs`, `.py`, `.js`, `.md`, etc. | \u2705 Full content with line numbers |\n| **Images** | `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`, `.ico` | \u26a0\ufe0f Displays in terminal (Kitty), but model only gets metadata text |\n| **PDFs** | `.pdf` | \u2705 Extracts text via `pdf_extract` (pages, content) |\n| **Binary** | `.zip`, `.tar`, `.gz`, `.exe`, `.dll`, `.so`, `.mp3`, `.mp4`, `.wasm`, etc. | \u274c Just says \"Binary file detected\" |\n\n### Gaps worth filling:\n\n**High value \u2014 model could actually use the content:**\n- **SVG** \u2014 currently treated as text (which works), but could be rendered to image for vision\n- **CSV / TSV** \u2014 works as text, but could format as a table for better readability\n- **DOCX / XLSX** \u2014 super common, currently just \"binary file detected\" \n- **EPUB** \u2014 same, just \"binary\"\n- **SQLite / `.db`** \u2014 could dump schema + sample rows\n- **Jupyter notebooks (`.ipynb`)** \u2014 it's JSON, so it reads, but could strip output blobs and format nicely\n\n**Medium value \u2014 nice to have:**\n- **Archives** (`.zip`, `.tar.gz`) \u2014 could list contents instead of \"binary\"\n- **Audio/video** \u2014 could show duration/codec metadata via `ffprobe`\n- **`.plist`** (macOS) \u2014 binary plist could be decoded\n\n**The big one (from our earlier discussion):**\n- **Images \u2192 model vision** \u2014 `ToolOutput` needs an `images` field so the model can actually *see* file_read images, not just get metadata\n\nWhat do you want to tackle? I'd suggest starting with:\n1. **Image vision support** (biggest impact \u2014 fixes the blindness problem)\n2. **DOCX/XLSX** (very common in real workflows)\n3. **Archive listing** (quick win)", "speed": 80}, {"t": 19100, "event": "token_usage", "input": 5049, "output": 532, "cache_read": 29593, "cache_creation": 110}, {"t": 19100, "event": "done"}, {"t": 19900, "event": "user_message", "text": "okay. that would be great. lets not do the sqlite one though, or hte juputer notebook one "}, {"t": 20700, "event": "user_message", "text": "and lets also make it so that if it reads an image, it writes it to the daigram side panel. we should convert diagram sidepanel to be just pinned content panel ro something instead. "}, {"t": 21500, "event": "thinking", "duration": 800}, {"t": 21700, "event": "stream_text", "text": "\n\nLet me plan this out and understand the current diagram side panel before diving in.", "speed": 80}, {"t": 21800, "event": "token_usage", "input": 85, "output": 435, "cache_read": 15047, "cache_creation": 20248}, {"t": 21800, "event": "tool_start", "name": "todowrite", "input": {"todos": [{"content": "Add images field to ToolOutput so model can see images via vision", "id": "1", "priority": "high", "status": "pending"}, {"content": "Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation", "id": "2", "priority": "high", "status": "pending"}, {"content": "Rename diagram side panel to pinned content panel", "id": "3", "priority": "high", "status": "pending"}, {"content": "Display file_read images in the pinned content panel", "id": "4", "priority": "high", "status": "pending"}, {"content": "Add DOCX support to file_read", "id": "5", "priority": "medium", "status": "pending"}, {"content": "Add XLSX support to file_read", "id": "6", "priority": "medium", "status": "pending"}, {"content": "Add archive listing (.zip, .tar.gz) to file_read", "id": "7", "priority": "medium", "status": "pending"}, {"content": "Add audio/video metadata to file_read (ffprobe)", "id": "8", "priority": "low", "status": "pending"}, {"content": "Add EPUB text extraction to file_read", "id": "9", "priority": "low", "status": "pending"}, {"content": "Add SVG rendering to file_read", "id": "10", "priority": "low", "status": "pending"}]}}, {"t": 21900, "event": "tool_done", "name": "todowrite", "output": "[\n {\n \"content\": \"Add images field to ToolOutput so model can see images via vision\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"1\"\n },\n {\n \"content\": \"Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"2\"\n },\n {\n \"content\": \"Rename diagram side panel to pinned content panel\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"3\"\n },\n {\n \"content\": \"Display file...", "is_error": false}, {"t": 22400, "event": "thinking", "duration": 800}, {"t": 22600, "event": "stream_text", "text": "Now let me explore the diagram side panel to understand its current structure:", "speed": 80}, {"t": 22700, "event": "token_usage", "input": 503, "output": 193, "cache_read": 35295, "cache_creation": 513}, {"t": 22700, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "diagram.*panel|side.?panel|DiagramPanel|diagram_panel|show_diagram"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "mermaid_panel|mermaid_side|render_diagram|diagram_widget"}, "tool": "file_grep"}, {"parameters": {"path": "src/tui"}, "tool": "ls"}]}}, {"t": 22800, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 1 matches for 'diagram.*panel|side.?panel|DiagramPanel|diagram_panel|show_diagram'\n\nmarkdown.rs:\n 58: /// In this mode mermaid diagrams update an ephemeral side-panel preview\n\n\n--- [2] file_grep ---\nFound 9 matches for 'mermaid_panel|mermaid_side|render_diagram|diagram_widget'\n\nmarkdown.rs:\n 343: fn mermaid_sidebar_placeholder(text: &str) -> Line<'static> {\n 713: lines.push(mermaid_sidebar_placeholder(\"\u2197 mermaid diagram (sid...", "is_error": false}, {"t": 23300, "event": "thinking", "duration": 800}, {"t": 23500, "event": "token_usage", "input": 497, "output": 152, "cache_read": 35808, "cache_creation": 698}, {"t": 23500, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"file_path": "src/tui/info_widget.rs", "limit": 80, "offset": 1420}, "tool": "file_read"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "InfoTab|info_tab|Tab::Diagram|Diagrams|tab.*diagram|diagram.*tab"}, "tool": "file_grep"}]}}, {"t": 23600, "event": "tool_done", "name": "batch", "output": "--- [1] file_read ---\n 1421\t // Semi-transparent looking border (using dim colors)\n 1422\t let block = Block::default()\n 1423\t .borders(Borders::ALL)\n 1424\t .border_type(BorderType::Rounded)\n 1425\t .border_style(Style::default().fg(Color::Rgb(70, 70, 80)).dim());\n 1426\t\n 1427\t let inner = block.inner(rect);\n 1428\t\n 1429\t // Diagrams need special handling - render image instead of text\n 1430\t if placement.kind == WidgetKind::Diagrams {\n 1431\t frame.ren...", "is_error": false}, {"t": 24100, "event": "thinking", "duration": 800}, {"t": 24300, "event": "token_usage", "input": 673, "output": 98, "cache_read": 36506, "cache_creation": 649}, {"t": 24300, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/info_widget.rs", "limit": 120, "offset": 210}}, {"t": 24400, "event": "tool_done", "name": "read", "output": " 211\t /// Todo list with progress\n 212\t Todos,\n 213\t /// Token/context usage bar\n 214\t ContextUsage,\n 215\t /// Memory sidecar activity\n 216\t MemoryActivity,\n 217\t /// Subagents/sessions status\n 218\t SwarmStatus,\n 219\t /// Background work indicator\n 220\t BackgroundTasks,\n 221\t /// 5-hour/weekly subscription bars\n 222\t UsageLimits,\n 223\t /// Current model name\n 224\t ModelInfo,\n 225\t /// Mermaid diagrams\n 226\t Diagrams,\n 227\t //...", "is_error": false}, {"t": 24600, "event": "thinking", "duration": 800}, {"t": 24800, "event": "token_usage", "input": 1652, "output": 99, "cache_read": 37155, "cache_creation": 771}, {"t": 24800, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/info_widget.rs", "limit": 180, "offset": 1450}}, {"t": 24900, "event": "tool_done", "name": "read", "output": " 1451\t }\n 1452\t frame.render_widget(block, rect);\n 1453\t let para = Paragraph::new(lines);\n 1454\t frame.render_widget(para, inner);\n 1455\t}\n 1456\t\n 1457\t/// Render mermaid diagrams widget (renders images, not text)\n 1458\tfn render_diagrams_widget(frame: &mut Frame, inner: Rect, data: &InfoWidgetData) {\n 1459\t if data.diagrams.is_empty() {\n 1460\t return;\n 1461\t }\n 1462\t\n 1463\t // For now, just render the first/most recent diagram\n 1464\t // Could add pagination la...", "is_error": false}, {"t": 25400, "event": "thinking", "duration": 800}, {"t": 25600, "event": "stream_text", "text": "Now let me look at how diagrams data flows into the widget:", "speed": 80}, {"t": 25700, "event": "token_usage", "input": 2659, "output": 161, "cache_read": 37926, "cache_creation": 1751}, {"t": 25700, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "diagrams.*push|diagrams.*=|DiagramEntry|struct.*Diagram"}, "tool": "file_grep"}, {"parameters": {"file_path": "src/tui/info_widget.rs", "limit": 60, "offset": 680}, "tool": "file_read"}]}}, {"t": 25800, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 21 matches for 'diagrams.*push|diagrams.*=|DiagramEntry|struct.*Diagram'\n\ninfo_widget.rs:\n 614: pub struct DiagramInfo {\n\nmermaid.rs:\n 102: struct ActiveDiagram {\n 478: if let Ok(diagrams) = ACTIVE_DIAGRAMS.lock() {\n 479: out.active_diagrams = diagrams.len();\n 614: if let Ok(mut diagrams) = ACTIVE_DIAGRAMS.lock() {\n 615: if let Some(pos) = diagrams.iter().position(|d| d.hash == hash) {\n 622: diagrams.push(existing);...", "is_error": false}, {"t": 26300, "event": "thinking", "duration": 800}, {"t": 26500, "event": "token_usage", "input": 755, "output": 98, "cache_read": 39677, "cache_creation": 2820}, {"t": 26500, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/info_widget.rs", "limit": 40, "offset": 610}}, {"t": 26600, "event": "tool_done", "name": "read", "output": " 611\t\n 612\t/// Info about a mermaid diagram for display in the info widget\n 613\t#[derive(Debug, Clone)]\n 614\tpub struct DiagramInfo {\n 615\t /// Hash for mermaid cache lookup\n 616\t pub hash: u64,\n 617\t /// Original PNG width\n 618\t pub width: u32,\n 619\t /// Original PNG height\n 620\t pub height: u32,\n 621\t /// Optional label/title\n 622\t pub label: Option,\n 623\t}\n 624\t\n 625\t/// Ambient mode status data for the info widget\n 626\t#[derive(Debug, Clone)...", "is_error": false}, {"t": 27100, "event": "thinking", "duration": 800}, {"t": 27300, "event": "stream_text", "text": "Now let me look at how app.rs builds the diagram data:", "speed": 80}, {"t": 27400, "event": "token_usage", "input": 539, "output": 112, "cache_read": 42497, "cache_creation": 853}, {"t": 27400, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 40, "offset": 1350}}, {"t": 27500, "event": "tool_done", "name": "read", "output": " 1351\t } else {\n 1352\t self.set_status_notice(\"Focus: chat\");\n 1353\t }\n 1354\t }\n 1355\t\n 1356\t fn cycle_diagram(&mut self, direction: i32) {\n 1357\t let diagrams = crate::tui::mermaid::get_active_diagrams();\n 1358\t let count = diagrams.len();\n 1359\t if count == 0 {\n 1360\t return;\n 1361\t }\n 1362\t let current = self.diagram_index.min(count - 1);\n 1363\t let next = if direction < 0 {\n 1364\t if current == ...", "is_error": false}, {"t": 28000, "event": "thinking", "duration": 800}, {"t": 28200, "event": "stream_text", "text": "Now let me check what deps we currently have and what we might need for DOCX/XLSX:", "speed": 80}, {"t": 28300, "event": "token_usage", "input": 606, "output": 144, "cache_read": 43350, "cache_creation": 651}, {"t": 28300, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"file_path": "Cargo.toml", "pattern": "docx|xlsx|zip|calamine|office|epub"}, "tool": "file_grep"}, {"parameters": {"file_path": "Cargo.toml"}, "tool": "file_read"}]}}, {"t": 28400, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 11 matches for 'docx|xlsx|zip|calamine|office|epub'\n\nsrc/tool/edit.rs:\n 235: .zip(old_lines.iter())\n\nsrc/tool/grep.rs:\n 186: \"png\", \"jpg\", \"jpeg\", \"gif\", \"bmp\", \"ico\", \"webp\", \"pdf\", \"zip\", \"tar\", \"gz\", \"bz2\",\n\nsrc/tool/mod.rs:\n 706: for (d1, d2) in defs1.iter().zip(defs2.iter()) {\n\nsrc/tool/read.rs:\n 158: \"png\", \"jpg\", \"jpeg\", \"gif\", \"bmp\", \"ico\", \"webp\", \"zip\", \"tar\", \"gz\", \"bz2\", \"xz\",\n\nsrc/tui/app.rs:\n 8690: ...", "is_error": false}, {"t": 28900, "event": "thinking", "duration": 800}, {"t": 29100, "event": "token_usage", "input": 855, "output": 76, "cache_read": 44001, "cache_creation": 750}, {"t": 29100, "event": "tool_start", "name": "read", "input": {"file_path": "Cargo.toml", "offset": 23}}, {"t": 29200, "event": "tool_done", "name": "read", "output": " 24\t# Memory allocator (reduces fragmentation for long-running server)\n 25\ttikv-jemallocator = { version = \"0.6\", optional = true }\n 26\t\n 27\t# Async runtime\n 28\ttokio = { version = \"1\", features = [\"full\", \"process\"] }\n 29\tfutures = \"0.3\"\n 30\tasync-trait = \"0.1\"\n 31\t\n 32\t# HTTP client\n 33\treqwest = { version = \"0.12\", features = [\"json\", \"stream\", \"blocking\"] }\n 34\ttokio-tungstenite = { version = \"0.24\", default-features = false, features = [\"connect\", \"rustls-tls-native...", "is_error": false}, {"t": 29700, "event": "thinking", "duration": 800}, {"t": 29900, "event": "stream_text", "text": "Now I have a full picture. Let me check available Rust crates for DOCX/XLSX/archives:", "speed": 80}, {"t": 30000, "event": "token_usage", "input": 1503, "output": 161, "cache_read": 44751, "cache_creation": 931}, {"t": 30000, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"query": "rust docx text extraction crate docx-rs read"}, "tool": "codesearch"}, {"parameters": {"query": "rust calamine xlsx csv read spreadsheet crate"}, "tool": "codesearch"}, {"parameters": {"query": "rust epub text extraction crate"}, "tool": "codesearch"}]}}, {"t": 30200, "event": "tool_done", "name": "batch", "output": "--- [1] codesearch ---\n## Reading .docx docs - by Will - Rusting\nhttps://rusting.substack.com/p/reading-docx-docs\n\nnote on importing and parsing\n \n Will's avatar \n ... The DOCX file format is a staple in offices and educational institutions worldwide. It's mainly used in Microsoft Word ... There may come a time when you need to read or manipulate DOCX files with a Rust-based application. Perhaps you are developing a web service that allows users to upload reports, or maybe you're crafting a d...", "is_error": false}, {"t": 30700, "event": "thinking", "duration": 800}, {"t": 30900, "event": "token_usage", "input": 980, "output": 63, "cache_read": 45682, "cache_creation": 1664}, {"t": 30900, "event": "tool_start", "name": "codesearch", "input": {"query": "rust docx-lite crate text extraction usage example"}}, {"t": 31100, "event": "tool_done", "name": "codesearch", "output": "## docx-lite - crates.io: Rust Package Registry\nhttps://crates.io/crates/docx-lite\n\nCrate size: 11.4 KB\n- Downloads: 38,593 ... ## Owners\n\n- bulpara \u2014 Irfan Eralp Kavakli\n\n---\n\n## README\n\n# docx-lite Crates.io Documentation License A lightweight, fast DOCX text extraction library for Rust with minimal dependencies.\n\n## Features ... - \ud83c\udfaf **Simple API** - Easy to use with both simple and advanced APIs ... ## Installation\n\nAdd this to your `Cargo.toml`:\n\n```toml\n[dependencies]\ndocx-lite = \"0...", "is_error": false}, {"t": 31900, "event": "thinking", "duration": 800}, {"t": 32100, "event": "stream_text", "text": "Good - `docx-lite` is perfect (minimal deps: only zip, quick-xml, thiserror). `calamine` for xlsx. We already have `zip` support via `flate2`/`tar`. Let me now understand the full approach and start implementing.", "speed": 80}, {"t": 32300, "event": "token_usage", "input": 3679, "output": 480, "cache_read": 47346, "cache_creation": 1043}, {"t": 32300, "event": "tool_start", "name": "todowrite", "input": {"todos": [{"content": "Add images field to ToolOutput so model can see images via vision", "id": "1", "priority": "high", "status": "in_progress"}, {"content": "Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation", "id": "2", "priority": "high", "status": "pending"}, {"content": "Rename diagram side panel to pinned content panel", "id": "3", "priority": "high", "status": "pending"}, {"content": "Display file_read images in the pinned content panel", "id": "4", "priority": "high", "status": "pending"}, {"content": "Add DOCX support to file_read (docx-lite crate)", "id": "5", "priority": "medium", "status": "pending"}, {"content": "Add XLSX support to file_read (calamine crate)", "id": "6", "priority": "medium", "status": "pending"}, {"content": "Add archive listing (.zip, .tar.gz) to file_read", "id": "7", "priority": "medium", "status": "pending"}, {"content": "Add audio/video metadata to file_read (ffprobe)", "id": "8", "priority": "low", "status": "pending"}, {"content": "Add EPUB text extraction to file_read", "id": "9", "priority": "low", "status": "pending"}, {"content": "Add SVG rendering to file_read", "id": "10", "priority": "low", "status": "pending"}]}}, {"t": 32400, "event": "tool_done", "name": "todowrite", "output": "[\n {\n \"content\": \"Add images field to ToolOutput so model can see images via vision\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"1\"\n },\n {\n \"content\": \"Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"2\"\n },\n {\n \"content\": \"Rename diagram side panel to pinned content panel\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"3\"\n },\n {\n \"content\": \"Display ...", "is_error": false}, {"t": 32900, "event": "thinking", "duration": 800}, {"t": 33100, "event": "stream_text", "text": "Let me first understand how tool results flow through the agent to see the wiring needed:", "speed": 80}, {"t": 33200, "event": "token_usage", "input": 517, "output": 129, "cache_read": 48389, "cache_creation": 4163}, {"t": 33200, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/agent.rs", "pattern": "tool_result|ToolResult|ToolOutput|execute_tool|run_tool"}}, {"t": 33300, "event": "tool_done", "name": "grep", "output": "Found 59 matches for 'tool_result|ToolResult|ToolOutput|execute_tool|run_tool'\n\n 14: use crate::provider::{NativeToolResult, Provider};\n 305: if let ContentBlock::ToolResult { tool_use_id, .. } = block {\n 329: let tool_block = ContentBlock::ToolResult {\n 622: ContentBlock::ToolResult { content, .. } => {\n 751: ContentBlock::ToolResult {\n 1115: pub async fn execute_tool(\n 1119: ) -> Result {\n 1915\t Bus::global().publish(BusEvent::ToolUpdated(ToolEvent {\n 1916\t session_id: self.session.id.clone(),\n 1917\t message_id: message_id.clone(),\n 1918\t tool_call_id: tc.id.clone(),\n 1919\t tool_name: tc.name.clone(),\n 1920\t sta...", "is_error": false}, {"t": 35900, "event": "user_message", "text": "oh adn we shuld have control 1 2345 etc switch us to like the first, second, etc entry in the panel. so it shows us like fi we pressed control 1, the first thing that was aved toe hte panel, and then control 2 would be the soncd thing, etc. "}, {"t": 36700, "event": "thinking", "duration": 800}, {"t": 36900, "event": "stream_text", "text": "\n\nGot it. Let me look at the other places where tool results are built to understand the full scope, then plan the implementation:", "speed": 80}, {"t": 37000, "event": "token_usage", "input": 1208, "output": 247, "cache_read": 55991, "cache_creation": 1601}, {"t": 37000, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/agent.rs", "pattern": "add_message.*ToolResult|ToolResult.*content.*output"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "Ctrl.*1|Ctrl.*2|Ctrl.*3|KeyCode::Char\\('1'\\)|KeyCode::Char\\('2'\\)"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "diagram_index|diagram_scroll|diagram_pane"}, "tool": "file_grep"}]}}, {"t": 37100, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 0 matches for 'add_message.*ToolResult|ToolResult.*content.*output'\n\n\n\n--- [2] file_grep ---\nFound 0 matches for 'Ctrl.*1|Ctrl.*2|Ctrl.*3|KeyCode::Char\\('1'\\)|KeyCode::Char\\('2'\\)'\n\n\n\n--- [3] file_grep ---\nFound 100 matches for 'diagram_index|diagram_scroll|diagram_pane'\n\napp.rs:\n 398: diagram_index: usize,\n 399: diagram_scroll_x: i32,\n 400: diagram_scroll_y: i32,\n 401: diagram_pane_ratio: u8,\n 402: diagram_pane_enabled: bool,\n 403: ...", "is_error": false}, {"t": 37300, "event": "thinking", "duration": 800}, {"t": 37500, "event": "token_usage", "input": 474, "output": 96, "cache_read": 57592, "cache_creation": 1456}, {"t": 37500, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 30, "offset": 393}}, {"t": 37600, "event": "tool_done", "name": "read", "output": " 394\t processing_started: Option,\n 395\t status_notice: Option<(String, Instant)>,\n 396\t diagram_mode: crate::config::DiagramDisplayMode,\n 397\t diagram_focus: bool,\n 398\t diagram_index: usize,\n 399\t diagram_scroll_x: i32,\n 400\t diagram_scroll_y: i32,\n 401\t diagram_pane_ratio: u8,\n 402\t diagram_pane_enabled: bool,\n 403\t diagram_pane_position: crate::config::DiagramPanePosition,\n 404\t diagram_zoom: u8,\n 405\t}\n 406\t\n 407\tfn rect_from_capture...", "is_error": false}, {"t": 37800, "event": "thinking", "duration": 800}, {"t": 38000, "event": "stream_text", "text": "Now let me look at the config for diagram mode:", "speed": 80}, {"t": 38100, "event": "token_usage", "input": 456, "output": 110, "cache_read": 59048, "cache_creation": 570}, {"t": 38100, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src", "pattern": "DiagramDisplayMode|DiagramPanePosition\""}}, {"t": 38200, "event": "tool_done", "name": "grep", "output": "Found 35 matches for 'DiagramDisplayMode|DiagramPanePosition\"'\n\nbin/tui_bench.rs:\n 323: fn diagram_mode(&self) -> jcode::config::DiagramDisplayMode {\n 324: jcode::config::DiagramDisplayMode::Pinned\n\ntui/markdown.rs:\n 16: use crate::config::{config, DiagramDisplayMode};\n 53: static DIAGRAM_MODE_OVERRIDE: LazyLock>> =\n 63: pub fn set_diagram_mode_override(mode: Option) {\n 69: pub fn get_diagram_mode_override() -> O...", "is_error": false}, {"t": 38700, "event": "thinking", "duration": 800}, {"t": 38900, "event": "token_usage", "input": 902, "output": 104, "cache_read": 59618, "cache_creation": 566}, {"t": 38900, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/config.rs", "pattern": "DiagramDisplayMode|DiagramPanePosition|diagram\""}}, {"t": 39000, "event": "tool_done", "name": "grep", "output": "Found 4 matches for 'DiagramDisplayMode|DiagramPanePosition|diagram\"'\n\n 81: pub enum DiagramDisplayMode {\n 93: pub enum DiagramPanePosition {\n 116: pub diagram_mode: DiagramDisplayMode,\n 130: diagram_mode: DiagramDisplayMode::default(),\n", "is_error": false}, {"t": 39500, "event": "thinking", "duration": 800}, {"t": 39700, "event": "token_usage", "input": 95, "output": 93, "cache_read": 60184, "cache_creation": 1006}, {"t": 39700, "event": "tool_start", "name": "read", "input": {"file_path": "src/config.rs", "limit": 65, "offset": 75}}, {"t": 39800, "event": "tool_done", "name": "read", "output": " 76\t}\n 77\t\n 78\t/// How to display mermaid diagrams\n 79\t#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]\n 80\t#[serde(rename_all = \"lowercase\")]\n 81\tpub enum DiagramDisplayMode {\n 82\t /// Don't show diagrams in dedicated widgets (only inline in messages)\n 83\t None,\n 84\t /// Show diagrams in info widget margins (opportunistic, if space available)\n 85\t Margin,\n 86\t /// Show diagrams in a dedicated pinned pane (forces space al...", "is_error": false}, {"t": 40300, "event": "thinking", "duration": 800}, {"t": 40500, "event": "stream_text", "text": "Now let me look at the UI rendering code for the diagram pane:", "speed": 80}, {"t": 40600, "event": "token_usage", "input": 831, "output": 113, "cache_read": 61190, "cache_creation": 188}, {"t": 40600, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/ui.rs", "limit": 100, "offset": 1825}}, {"t": 40700, "event": "tool_done", "name": "read", "output": " 1826\t let diagrams = super::mermaid::get_active_diagrams();\n 1827\t let diagram_count = diagrams.len();\n 1828\t let selected_index = if diagram_count > 0 {\n 1829\t app.diagram_index().min(diagram_count - 1)\n 1830\t } else {\n 1831\t 0\n 1832\t };\n 1833\t let pane_enabled = app.diagram_pane_enabled();\n 1834\t let pane_position = app.diagram_pane_position();\n 1835\t let pinned_diagram =\n 1836\t if diagram_mode == crate::config::DiagramDisplayMode::Pinned && pan...", "is_error": false}, {"t": 41200, "event": "thinking", "duration": 800}, {"t": 41400, "event": "stream_text", "text": "Now let me see the pinned diagram rendering:", "speed": 80}, {"t": 41500, "event": "token_usage", "input": 1802, "output": 108, "cache_read": 61378, "cache_creation": 944}, {"t": 41500, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/ui.rs", "limit": 100, "offset": 3940}}, {"t": 41600, "event": "tool_done", "name": "read", "output": " 3941\t // during streaming/incremental markdown updates.\n 3942\t frame.render_widget(Clear, area);\n 3943\t\n 3944\t // Render text first\n 3945\t let paragraph = Paragraph::new(visible_lines);\n 3946\t frame.render_widget(paragraph, area);\n 3947\t\n 3948\t // Use pre-computed image regions (scanned once during preparation, not every frame)\n 3949\t let centered = app.centered_mode();\n 3950\t let diagram_mode = app.diagram_mode();\n 3951\t if diagram_mode != crate::config::DiagramDi...", "is_error": false}, {"t": 42100, "event": "thinking", "duration": 800}, {"t": 42300, "event": "stream_text", "text": "Now let me look at the pinned pane rendering (where the diagram is drawn in the side panel):", "speed": 80}, {"t": 42400, "event": "token_usage", "input": 1458, "output": 133, "cache_read": 62322, "cache_creation": 1910}, {"t": 42400, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/ui.rs", "pattern": "render_pinned|render_diagram_pane|diagram_area\""}}, {"t": 42500, "event": "tool_done", "name": "grep", "output": "Found 0 matches for 'render_pinned|render_diagram_pane|diagram_area\"'\n\n", "is_error": false}, {"t": 42500, "event": "user_message", "text": "set control b to be the hotkey for background as well instead of alt b. "}, {"t": 43000, "event": "thinking", "duration": 800}, {"t": 43200, "event": "stream_text", "text": "\n\nLet me find where the keybindings are defined and understand all the pieces I need to change:", "speed": 80}, {"t": 43300, "event": "token_usage", "input": 74, "output": 213, "cache_read": 64232, "cache_creation": 1591}, {"t": 43300, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "Alt.*'b'|alt.*background|background.*key|BackgroundTasks"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui", "pattern": "diagram_area|render.*diagram.*pane|draw.*pinned"}, "tool": "file_grep"}, {"parameters": {"file_path": "src/tui/ui.rs", "pattern": "diagram_area"}, "tool": "file_grep"}]}}, {"t": 43400, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 10 matches for 'Alt.*'b'|alt.*background|background.*key|BackgroundTasks'\n\ninfo_widget.rs:\n 220: BackgroundTasks,\n 243: WidgetKind::BackgroundTasks => 6,\n 260: WidgetKind::BackgroundTasks => Side::Left,\n 277: WidgetKind::BackgroundTasks => 2,\n 294: WidgetKind::BackgroundTasks,\n 310: WidgetKind::BackgroundTasks => \"background\",\n 341: | WidgetKind::BackgroundTasks\n 773: ...", "is_error": false}, {"t": 43900, "event": "thinking", "duration": 800}, {"t": 44100, "event": "token_usage", "input": 947, "output": 160, "cache_read": 65823, "cache_creation": 288}, {"t": 44100, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"include": "*.rs", "path": "src/tui/app.rs", "pattern": "Alt.*'b'|Char\\('b'\\).*Modifier.*ALT"}, "tool": "file_grep"}, {"parameters": {"include": "*.rs", "path": "src/tui/keybind.rs", "pattern": "background|alt.*b|Alt.*b"}, "tool": "file_grep"}]}}, {"t": 44200, "event": "tool_done", "name": "batch", "output": "--- [1] file_grep ---\nFound 0 matches for 'Alt.*'b'|Char\\('b'\\).*Modifier.*ALT'\n\n\n\n--- [2] file_grep ---\nFound 0 matches for 'background|alt.*b|Alt.*b'\n\n\n\nCompleted: 2 succeeded, 0 failed", "is_error": false}, {"t": 44400, "event": "thinking", "duration": 800}, {"t": 44600, "event": "token_usage", "input": 96, "output": 105, "cache_read": 66111, "cache_creation": 1107}, {"t": 44600, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui", "pattern": "\"background|bg_tool|toggle_bg|background_tasks\""}}, {"t": 44700, "event": "tool_done", "name": "grep", "output": "Found 2 matches for '\"background|bg_tool|toggle_bg|background_tasks\"'\n\ninfo_widget.rs:\n 310: WidgetKind::BackgroundTasks => \"background\",\n\napp.rs:\n 12344: assert!(msg.content.contains(\"background\"));\n", "is_error": false}, {"t": 44900, "event": "thinking", "duration": 800}, {"t": 45100, "event": "token_usage", "input": 88, "output": 109, "cache_read": 67218, "cache_creation": 201}, {"t": 45100, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/app.rs", "pattern": "\"run_in_background|background.*true|in_background\""}}, {"t": 45200, "event": "tool_done", "name": "grep", "output": "Found 0 matches for '\"run_in_background|background.*true|in_background\"'\n\n", "is_error": false}, {"t": 45700, "event": "thinking", "duration": 800}, {"t": 45900, "event": "token_usage", "input": 41, "output": 101, "cache_read": 15047, "cache_creation": 52574}, {"t": 45900, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/app.rs", "pattern": "\"Alt, 'b'\""}}, {"t": 46000, "event": "tool_done", "name": "grep", "output": "Found 0 matches for '\"Alt, 'b'\"'\n\n", "is_error": false}, {"t": 46500, "event": "thinking", "duration": 800}, {"t": 46700, "event": "token_usage", "input": 33, "output": 118, "cache_read": 67621, "cache_creation": 142}, {"t": 46700, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && grep -n \"ALT\\|alt_b\\|Alt-b\\|background\" src/tui/app.rs | head -30", "description": "Find background keybinding in app.rs"}}, {"t": 46800, "event": "tool_done", "name": "bash", "output": "3455: \"alt\" => modifiers |= KeyModifiers::ALT,\n3558: // Subscribe to bus for background task completion notifications\n3634: // Handle background task completion notifications\n4986: if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('m')) {\n4990: if modifiers.contains(KeyModifiers::ALT) && matches!(code, KeyCode::Char('t')) {\n5015: if modifiers.contains(KeyModifiers::ALT) {\n5019: remote.b...", "is_error": false}, {"t": 47300, "event": "thinking", "duration": 800}, {"t": 47500, "event": "token_usage", "input": 551, "output": 97, "cache_read": 67763, "cache_creation": 151}, {"t": 47500, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 30, "offset": 5010}}, {"t": 47600, "event": "tool_done", "name": "read", "output": " 5011\t return Ok(());\n 5012\t }\n 5013\t // Most key handling is the same as local mode\n 5014\t // Handle Alt combos\n 5015\t if modifiers.contains(KeyModifiers::ALT) {\n 5016\t match code {\n 5017\t KeyCode::Char('b') => {\n 5018\t if matches!(self.status, ProcessingStatus::RunningTool(_)) {\n 5019\t remote.background_tool().await?;\n 5020\t self.set_status_notice(\"Moving tool to...", "is_error": false}, {"t": 48100, "event": "thinking", "duration": 800}, {"t": 48300, "event": "stream_text", "text": "Now let me see the same pattern for local mode:", "speed": 80}, {"t": 48400, "event": "token_usage", "input": 467, "output": 109, "cache_read": 67914, "cache_creation": 648}, {"t": 48400, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 40, "offset": 5640}}, {"t": 48500, "event": "tool_done", "name": "read", "output": " 5641\t return Ok(());\n 5642\t }\n 5643\t self.normalize_diagram_state();\n 5644\t let diagram_available = self.diagram_available();\n 5645\t if self.handle_diagram_focus_key(code.clone(), modifiers, diagram_available) {\n 5646\t return Ok(());\n 5647\t }\n 5648\t // Handle Alt combos (readline word movement)\n 5649\t if modifiers.contains(KeyModifiers::ALT) {\n 5650\t match code {\n 5651\t KeyCode::Char('b') => {\n 5...", "is_error": false}, {"t": 49000, "event": "thinking", "duration": 800}, {"t": 49200, "event": "stream_text", "text": "I see - Alt+B is currently \"back one word\" (readline behavior), and when a tool is running in remote mode it doubles as \"background tool\". Let me find the Ctrl+B binding:", "speed": 80}, {"t": 49400, "event": "token_usage", "input": 639, "output": 164, "cache_read": 68562, "cache_creation": 576}, {"t": 49400, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/app.rs", "pattern": "Ctrl.*'b'|CONTROL.*'b'|Char\\\\('b'\\\\).*CONTROL\""}}, {"t": 49500, "event": "tool_done", "name": "grep", "output": "Found 0 matches for 'Ctrl.*'b'|CONTROL.*'b'|Char\\\\('b'\\\\).*CONTROL\"'\n\n", "is_error": false}, {"t": 49700, "event": "thinking", "duration": 800}, {"t": 49900, "event": "token_usage", "input": 54, "output": 104, "cache_read": 69138, "cache_creation": 803}, {"t": 49900, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && grep -n \"CONTROL\" src/tui/app.rs | head -40", "description": "Find all CONTROL keybindings"}}, {"t": 50000, "event": "tool_done", "name": "bash", "output": "1464: if !diagram_available || !self.diagram_focus || modifiers.contains(KeyModifiers::CONTROL) {\n1515: if mouse.modifiers.contains(KeyModifiers::CONTROL) {\n3454: \"ctrl\" | \"control\" => modifiers |= KeyModifiers::CONTROL,\n3794: && key.modifiers.contains(KeyModifiers::CONTROL)\n4166: KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {\n5073: if modifiers.conta...", "is_error": false}, {"t": 50500, "event": "thinking", "duration": 800}, {"t": 50700, "event": "token_usage", "input": 482, "output": 97, "cache_read": 69941, "cache_creation": 158}, {"t": 50700, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 80, "offset": 5070}}, {"t": 50800, "event": "tool_done", "name": "read", "output": " 5071\t\n 5072\t // Ctrl combos\n 5073\t if modifiers.contains(KeyModifiers::CONTROL) {\n 5074\t if self.handle_diagram_ctrl_key(code.clone(), diagram_available) {\n 5075\t return Ok(());\n 5076\t }\n 5077\t match code {\n 5078\t KeyCode::Char('c') | KeyCode::Char('d') => {\n 5079\t self.handle_quit_request();\n 5080\t return Ok(());\n 5081\t }\n 5082\t KeyCode::Char('r') ...", "is_error": false}, {"t": 51300, "event": "thinking", "duration": 800}, {"t": 51500, "event": "stream_text", "text": "Now let me see the Ctrl keybindings in local mode:", "speed": 80}, {"t": 51600, "event": "token_usage", "input": 1188, "output": 113, "cache_read": 70099, "cache_creation": 579}, {"t": 51600, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 80, "offset": 5715}}, {"t": 51700, "event": "tool_done", "name": "read", "output": " 5716\t }\n 5717\t\n 5718\t // Handle ctrl combos regardless of processing state\n 5719\t if modifiers.contains(KeyModifiers::CONTROL) {\n 5720\t if self.handle_diagram_ctrl_key(code.clone(), diagram_available) {\n 5721\t return Ok(());\n 5722\t }\n 5723\t match code {\n 5724\t KeyCode::Char('c') | KeyCode::Char('d') => {\n 5725\t self.handle_quit_request();\n 5726\t return Ok(());\n 5727\t ...", "is_error": false}, {"t": 52200, "event": "thinking", "duration": 800}, {"t": 52400, "event": "stream_text", "text": "Now I have a solid understanding. Let me also check the `handle_diagram_ctrl_key`:", "speed": 80}, {"t": 52500, "event": "token_usage", "input": 1219, "output": 132, "cache_read": 70678, "cache_creation": 1301}, {"t": 52500, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/app.rs", "pattern": "fn handle_diagram_ctrl_key|fn handle_diagram_focus\""}}, {"t": 52600, "event": "tool_done", "name": "grep", "output": "Found 1 matches for 'fn handle_diagram_ctrl_key|fn handle_diagram_focus\"'\n\n 1433: fn handle_diagram_ctrl_key(&mut self, code: KeyCode, diagram_available: bool) -> bool {\n", "is_error": false}, {"t": 53100, "event": "thinking", "duration": 800}, {"t": 53300, "event": "token_usage", "input": 76, "output": 97, "cache_read": 71979, "cache_creation": 1351}, {"t": 53300, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/app.rs", "limit": 60, "offset": 1433}}, {"t": 53400, "event": "tool_done", "name": "read", "output": " 1434\t if !diagram_available {\n 1435\t return false;\n 1436\t }\n 1437\t match code {\n 1438\t KeyCode::Left => {\n 1439\t self.cycle_diagram(-1);\n 1440\t true\n 1441\t }\n 1442\t KeyCode::Right => {\n 1443\t self.cycle_diagram(1);\n 1444\t true\n 1445\t }\n 1446\t KeyCode::Char('h') => {\n 1447\t self.set_diagram_focus(false);\n 1448\t true\n 1...", "is_error": false}, {"t": 53900, "event": "thinking", "duration": 800}, {"t": 54100, "event": "stream_text", "text": "Now let me see how the pinned diagram pane is rendered in ui.rs:", "speed": 80}, {"t": 54200, "event": "token_usage", "input": 880, "output": 116, "cache_read": 73330, "cache_creation": 173}, {"t": 54200, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/ui.rs", "limit": 60, "offset": 2055}}, {"t": 54300, "event": "tool_done", "name": "read", "output": " 2056\t if let Some(ref mut capture) = debug_capture {\n 2057\t capture.layout.messages_area = Some(messages_area.into());\n 2058\t capture.layout.diagram_area = diagram_area.map(|r| r.into());\n 2059\t }\n 2060\t record_layout_snapshot(messages_area, diagram_area);\n 2061\t\n 2062\t let margins = draw_messages(frame, app, messages_area, &prepared);\n 2063\t\n 2064\t // Render pinned diagram if we have one\n 2065\t if let (Some(diagram_info), Some(area)) = (&pinned_diagram, diagr...", "is_error": false}, {"t": 54800, "event": "thinking", "duration": 800}, {"t": 55000, "event": "token_usage", "input": 866, "output": 102, "cache_read": 73503, "cache_creation": 996}, {"t": 55000, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/ui.rs", "pattern": "fn draw_pinned_diagram\""}}, {"t": 55100, "event": "tool_done", "name": "grep", "output": "Found 0 matches for 'fn draw_pinned_diagram\"'\n\n", "is_error": false}, {"t": 55600, "event": "thinking", "duration": 800}, {"t": 55800, "event": "token_usage", "input": 36, "output": 102, "cache_read": 74499, "cache_creation": 968}, {"t": 55800, "event": "tool_start", "name": "grep", "input": {"include": "*.rs", "path": "src/tui/ui.rs", "pattern": "fn draw_pinned_diagram\""}}, {"t": 55900, "event": "tool_done", "name": "grep", "output": "Found 0 matches for 'fn draw_pinned_diagram\"'\n\n", "is_error": false}, {"t": 56400, "event": "thinking", "duration": 800}, {"t": 56600, "event": "token_usage", "input": 36, "output": 103, "cache_read": 75467, "cache_creation": 138}, {"t": 56600, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && grep -n \"fn draw_pinned\" src/tui/ui.rs", "description": "Find draw_pinned_diagram function"}}, {"t": 56700, "event": "tool_done", "name": "bash", "output": "3780:fn draw_pinned_diagram(\n", "is_error": false}, {"t": 57200, "event": "thinking", "duration": 800}, {"t": 57400, "event": "token_usage", "input": 31, "output": 97, "cache_read": 75605, "cache_creation": 139}, {"t": 57400, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/ui.rs", "limit": 120, "offset": 3780}}, {"t": 57500, "event": "tool_done", "name": "read", "output": " 3781\t frame: &mut Frame,\n 3782\t diagram: &info_widget::DiagramInfo,\n 3783\t area: Rect,\n 3784\t index: usize,\n 3785\t total: usize,\n 3786\t focused: bool,\n 3787\t scroll_x: i32,\n 3788\t scroll_y: i32,\n 3789\t zoom_percent: u8,\n 3790\t pane_position: crate::config::DiagramPanePosition,\n 3791\t) {\n 3792\t use ratatui::widgets::{BorderType, Clear, Paragraph, Wrap};\n 3793\t\n 3794\t if area.width < 5 || area.height < 3 {\n 3795\t return;\n 3796\t }\n 3797\t\n 3798\t l...", "is_error": false}, {"t": 58000, "event": "thinking", "duration": 800}, {"t": 58200, "event": "stream_text", "text": "Good, now I have a comprehensive understanding of the architecture. Let me also check how images from the mermaid module are stored, since we'll need to store arbitrary images there or add a parallel system:", "speed": 80}, {"t": 58400, "event": "token_usage", "input": 1737, "output": 141, "cache_read": 75744, "cache_creation": 128}, {"t": 58400, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/mermaid.rs", "limit": 50, "offset": 95}}, {"t": 58500, "event": "tool_done", "name": "read", "output": " 96\t\n 97\t/// Prevent unbounded growth when a long session contains many unique diagrams.\n 98\tconst ACTIVE_DIAGRAMS_MAX: usize = 128;\n 99\t\n 100\t/// Info about an active diagram (for info widget)\n 101\t#[derive(Clone)]\n 102\tstruct ActiveDiagram {\n 103\t hash: u64,\n 104\t width: u32,\n 105\t height: u32,\n 106\t label: Option,\n 107\t}\n 108\t\n 109\t/// State for a rendered image\n 110\tstruct ImageState {\n 111\t protocol: StatefulProtocol,\n 112\t source_path: Path...", "is_error": false}, {"t": 58700, "event": "thinking", "duration": 800}, {"t": 58900, "event": "stream_text", "text": "Now let me check the `get_active_diagrams` and `register` functions:", "speed": 80}, {"t": 59000, "event": "token_usage", "input": 616, "output": 118, "cache_read": 75872, "cache_creation": 1878}, {"t": 59000, "event": "tool_start", "name": "read", "input": {"file_path": "src/tui/mermaid.rs", "limit": 80, "offset": 600}}, {"t": 59100, "event": "tool_done", "name": "read", "output": " 601\t rss_bytes: parse_proc_status_value_bytes(&status, \"VmRSS:\"),\n 602\t peak_rss_bytes: parse_proc_status_value_bytes(&status, \"VmHWM:\"),\n 603\t virtual_bytes: parse_proc_status_value_bytes(&status, \"VmSize:\"),\n 604\t }\n 605\t}\n 606\t\n 607\t#[cfg(not(target_os = \"linux\"))]\n 608\tfn process_memory_snapshot() -> ProcessMemorySnapshot {\n 609\t ProcessMemorySnapshot::default()\n 610\t}\n 611\t\n 612\t/// Register a diagram as active (call during markdown rendering)\n 61...", "is_error": false}, {"t": 59900, "event": "thinking", "duration": 800}, {"t": 60100, "event": "stream_text", "text": "OK, I now have a thorough understanding of the whole system. This is a big set of changes. Let me break it into phases and start implementing:\n\n**Phase 1:** `ToolOutput` gets an `images` field + wiring through agent \u2192 ContentBlock::Image \n**Phase 2:** file_read returns base64 images in ToolOutput.images \n**Phase 3:** Rename diagram panel to \"pinned content\" panel, support arbitrary images \n**Phase 4:** Ctrl+1/2/3.. to switch pinned items, Ctrl+B for background \n**Phase 5:** DOCX, XLSX, archive, audio/video, EPUB support \n\nLet me start:", "speed": 80}, {"t": 60300, "event": "token_usage", "input": 1033, "output": 740, "cache_read": 15047, "cache_creation": 63434}, {"t": 60300, "event": "tool_start", "name": "todowrite", "input": {"todos": [{"content": "Add images field to ToolOutput so model can see images via vision", "id": "1", "priority": "high", "status": "in_progress"}, {"content": "Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation", "id": "2", "priority": "high", "status": "in_progress"}, {"content": "file_read: return base64 image data in ToolOutput.images for vision", "id": "2b", "priority": "high", "status": "pending"}, {"content": "Rename diagram side panel to pinned content panel + support arbitrary images", "id": "3", "priority": "high", "status": "pending"}, {"content": "Display file_read images in the pinned content panel", "id": "4", "priority": "high", "status": "pending"}, {"content": "Ctrl+1/2/3/4/5 to switch pinned panel entries", "id": "4b", "priority": "high", "status": "pending"}, {"content": "Ctrl+B for background tool (in addition to Alt+B)", "id": "4c", "priority": "high", "status": "pending"}, {"content": "Add DOCX support to file_read (docx-lite crate)", "id": "5", "priority": "medium", "status": "pending"}, {"content": "Add XLSX support to file_read (calamine crate)", "id": "6", "priority": "medium", "status": "pending"}, {"content": "Add archive listing (.zip, .tar.gz) to file_read", "id": "7", "priority": "medium", "status": "pending"}, {"content": "Add audio/video metadata to file_read (ffprobe)", "id": "8", "priority": "low", "status": "pending"}, {"content": "Add EPUB text extraction to file_read", "id": "9", "priority": "low", "status": "pending"}, {"content": "Add SVG rendering to file_read", "id": "10", "priority": "low", "status": "pending"}, {"content": "Build, test, commit", "id": "11", "priority": "high", "status": "pending"}]}}, {"t": 60400, "event": "tool_done", "name": "todowrite", "output": "[\n {\n \"content\": \"Add images field to ToolOutput so model can see images via vision\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"1\"\n },\n {\n \"content\": \"Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation\",\n \"status\": \"in_progress\",\n \"priority\": \"high\",\n \"id\": \"2\"\n },\n {\n \"content\": \"file_read: return base64 image data in ToolOutput.images for vision\",\n \"status\": \"pending\",\n \"priority\": \"high\",\n \"id\": \"2b\"\n },\n {\n ...", "is_error": false}, {"t": 61200, "event": "thinking", "duration": 800}, {"t": 61400, "event": "stream_text", "text": "### Step 1: Add `images` field to `ToolOutput`", "speed": 80}, {"t": 61500, "event": "token_usage", "input": 730, "output": 690, "cache_read": 78481, "cache_creation": 1777}, {"t": 61500, "event": "tool_start", "name": "edit", "input": {"file_path": "src/tool/mod.rs", "new_string": "#[derive(Debug, Clone)]\npub struct ToolOutput {\n pub output: String,\n pub title: Option,\n pub metadata: Option,\n pub images: Vec,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolImage {\n pub media_type: String,\n pub data: String,\n pub label: Option,\n}\n\nimpl ToolOutput {\n pub fn new(output: impl Into) -> Self {\n Self {\n output: output.into(),\n title: None,\n metadata: None,\n images: Vec::new(),\n }\n }\n\n pub fn with_title(mut self, title: impl Into) -> Self {\n self.title = Some(title.into());\n self\n }\n\n pub fn with_metadata(mut self, metadata: Value) -> Self {\n self.metadata = Some(metadata);\n self\n }\n\n pub fn with_image(mut self, media_type: impl Into, data: impl Into) -> Self {\n self.images.push(ToolImage {\n media_type: media_type.into(),\n data: data.into(),\n label: None,\n });\n self\n }\n\n pub fn with_labeled_image(\n mut self,\n media_type: impl Into,\n data: impl Into,\n label: impl Into,\n ) -> Self {\n self.images.push(ToolImage {\n media_type: media_type.into(),\n data: data.into(),\n label: Some(label.into()),\n });\n self\n }\n}", "old_string": "#[derive(Debug, Clone)]\npub struct ToolOutput {\n pub output: String,\n pub title: Option,\n pub metadata: Option,\n}\n\nimpl ToolOutput {\n pub fn new(output: impl Into) -> Self {\n Self {\n output: output.into(),\n title: None,\n metadata: None,\n }\n }\n\n pub fn with_title(mut self, title: impl Into) -> Self {\n self.title = Some(title.into());\n self\n }\n\n pub fn with_metadata(mut self, metadata: Value) -> Self {\n self.metadata = Some(metadata);\n self\n }\n}"}}, {"t": 61600, "event": "tool_done", "name": "edit", "output": "Edited src/tool/mod.rs: replaced 1 occurrence(s)\n51+ pub images: Vec,\n52+ }\n54+ #[derive(Debug, Clone)]\n55+ pub struct ToolImage {\n56+ pub media_type: String,\n57+ pub data: String,\n58+ pub label: Option,\n67+ images: Vec::new(),\n81+ pub fn with_image(mut self, media_type: impl Into, data: impl Into) -> Self {\n82+ self.images.push(ToolImage {\n83+ media_type: media_type.into(),\n84+ data: data.into(),\n85+ label: None,\n86+ });\n87+ self\n88+ }\n90+ pub fn with_label...", "is_error": false}, {"t": 62400, "event": "thinking", "duration": 800}, {"t": 62600, "event": "stream_text", "text": "### Step 2: Wire ToolOutput.images through agent \u2192 ContentBlock::Image\n\nNow I need to modify all the places in agent.rs where tool results are constructed. When `output.images` is non-empty, I need to add `ContentBlock::Image` blocks alongside the `ContentBlock::ToolResult`.\n\nLet me find all the places where `output.output` is used to create `ContentBlock::ToolResult`:", "speed": 80}, {"t": 62800, "event": "token_usage", "input": 991, "output": 196, "cache_read": 15047, "cache_creation": 66627}, {"t": 62800, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && grep -n \"content: output.output\" src/agent.rs", "description": "Find all output.output usages in agent"}}, {"t": 62900, "event": "tool_done", "name": "bash", "output": "1943: content: output.output,\n2480: content: output.output,\n3042: content: output.output,\n", "is_error": false}, {"t": 63400, "event": "thinking", "duration": 800}, {"t": 63600, "event": "stream_text", "text": "Let me look at each of these contexts:", "speed": 80}, {"t": 63700, "event": "token_usage", "input": 56, "output": 174, "cache_read": 81674, "cache_creation": 1187}, {"t": 63700, "event": "tool_start", "name": "batch", "input": {"tool_calls": [{"parameters": {"file_path": "src/agent.rs", "limit": 25, "offset": 1930}, "tool": "file_read"}, {"parameters": {"file_path": "src/agent.rs", "limit": 25, "offset": 2467}, "tool": "file_read"}, {"parameters": {"file_path": "src/agent.rs", "limit": 30, "offset": 3030}, "tool": "file_read"}]}}, {"t": 63800, "event": "tool_done", "name": "batch", "output": "--- [1] file_read ---\n 1931\t let preview = if output.output.len() > 200 {\n 1932\t format!(\"{}...\", crate::util::truncate_str(&output.output, 200))\n 1933\t } else {\n 1934\t output.output.clone()\n 1935\t };\n 1936\t println!(\"{}\", preview.lines().next().unwrap_or(\"(done)\"));\n 1937\t }\n 1938\t\n 1939\t ...", "is_error": false}, {"t": 64300, "event": "thinking", "duration": 800}, {"t": 64500, "event": "stream_text", "text": "I also need to check the native tool result path:", "speed": 80}, {"t": 64600, "event": "token_usage", "input": 866, "output": 113, "cache_read": 82861, "cache_creation": 230}, {"t": 64600, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && grep -n \"NativeToolResult::success.*output.output\" src/agent.rs", "description": "Find native result paths"}}, {"t": 64700, "event": "tool_done", "name": "bash", "output": "1675: Ok(output) => NativeToolResult::success(request_id, output.output),\n2238: Ok(output) => NativeToolResult::success(request_id, output.output),\n2767: Ok(output) => NativeToolResult::success(request_id, output.output),\n", "is_error": false}, {"t": 65200, "event": "thinking", "duration": 800}, {"t": 65400, "event": "stream_text", "text": "Now I need to create a helper function that builds the content blocks from ToolOutput, including images:", "speed": 80}, {"t": 65500, "event": "token_usage", "input": 97, "output": 115, "cache_read": 83091, "cache_creation": 979}, {"t": 65500, "event": "tool_start", "name": "read", "input": {"file_path": "src/agent.rs", "limit": 30, "offset": 1}}, {"t": 65600, "event": "tool_done", "name": "read", "output": " 2\t#![allow(unused_assignments)]\n 3\t\n 4\tuse crate::build;\n 5\tuse crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus};\n 6\tuse crate::cache_tracker::CacheTracker;\n 7\tuse crate::compaction::CompactionEvent;\n 8\tuse crate::id;\n 9\tuse crate::logging;\n 10\tuse crate::message::{\n 11\t ContentBlock, Message, Role, StreamEvent, ToolCall, ToolDefinition, TOOL_OUTPUT_MISSING_TEXT,\n 12\t};\n 13\tuse crate::protocol::{HistoryMessage, ServerEvent};\n 14\tuse crat...", "is_error": false}, {"t": 66100, "event": "thinking", "duration": 800}, {"t": 66300, "event": "stream_text", "text": "Now I'll add a helper function and modify the three locations. Let me first add a helper that builds content blocks from a ToolOutput:", "speed": 80}, {"t": 66400, "event": "token_usage", "input": 470, "output": 343, "cache_read": 84070, "cache_creation": 212}, {"t": 66400, "event": "tool_start", "name": "edit", "input": {"file_path": "src/agent.rs", "new_string": "const JCODE_NATIVE_TOOLS: &[&str] = &[\"selfdev\", \"communicate\"];\n\nfn tool_output_to_content_blocks(\n tool_use_id: String,\n output: crate::tool::ToolOutput,\n) -> Vec {\n let mut blocks = vec![ContentBlock::ToolResult {\n tool_use_id,\n content: output.output,\n is_error: None,\n }];\n for img in output.images {\n blocks.push(ContentBlock::Image {\n media_type: img.media_type,\n data: img.data,\n });\n }\n blocks\n}\n\n/// A soft interrupt message queued for injection at the next safe point", "old_string": "const JCODE_NATIVE_TOOLS: &[&str] = &[\"selfdev\", \"communicate\"];\n\n/// A soft interrupt message queued for injection at the next safe point"}}, {"t": 66500, "event": "tool_done", "name": "edit", "output": "Edited src/agent.rs: replaced 1 occurrence(s)\n31+ fn tool_output_to_content_blocks(\n32+ tool_use_id: String,\n33+ output: crate::tool::ToolOutput,\n34+ ) -> Vec {\n35+ let mut blocks = vec![ContentBlock::ToolResult {\n36+ tool_use_id,\n37+ content: output.output,\n38+ is_error: None,\n39+ }];\n40+ for img in output.images {\n41+ blocks.push(ContentBlock::Image {\n42+ media_type: img.media_type,\n43+ data: img.data,\n44+ });\n45+ }\n46+ blocks\n47+ }\n\nContext after edit (lines 26-52):\n 26\u2502 u...", "is_error": false}, {"t": 67000, "event": "thinking", "duration": 800}, {"t": 67200, "event": "stream_text", "text": "Now replace the three locations where `output.output` is used to build ToolResult. I need to use the helper instead. Let me get the exact context for each:", "speed": 80}, {"t": 67300, "event": "token_usage", "input": 528, "output": 131, "cache_read": 15047, "cache_creation": 70039}, {"t": 67300, "event": "tool_start", "name": "read", "input": {"file_path": "src/agent.rs", "limit": 20, "offset": 1942}}, {"t": 67400, "event": "tool_done", "name": "read", "output": " 1943\t eprintln!(\n 1944\t \"[trace] tool_exec_done name={} id={}\\n{}\",\n 1945\t tc.name, tc.id, output.output\n 1946\t );\n 1947\t }\n 1948\t if print_output {\n 1949\t let preview = if output.output.len() > 200 {\n 1950\t format!(\"{}...\", crate::util::truncate_str(&output.output, 200))\n 19...", "is_error": false}, {"t": 68200, "event": "thinking", "duration": 800}, {"t": 68400, "event": "token_usage", "input": 326, "output": 1028, "cache_read": 85086, "cache_creation": 659}, {"t": 68400, "event": "tool_start", "name": "edit", "input": {"file_path": "src/agent.rs", "new_string": " let blocks = tool_output_to_content_blocks(tc.id, output);\n self.add_message_with_duration(\n Role::User,\n blocks,\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n Err(e) => {\n Bus::global().publish(BusEvent::ToolUpdated(ToolEvent {\n session_id: self.session.id.clone(),\n message_id: message_id.clone(),\n tool_call_id: tc.id.clone(),\n tool_name: tc.name.clone(),\n status: ToolStatus::Error,\n title: None,\n }));\n\n let error_msg = format!(\"Error: {}\", e);\n if trace {\n eprintln!(\n \"[trace] tool_exec_error name={} id={} {}\",\n tc.name, tc.id, error_msg\n );\n }\n if print_output {\n println!(\"{}\", error_msg);\n }\n self.add_message_with_duration(\n Role::User,\n vec![ContentBlock::ToolResult {\n tool_use_id: tc.id,\n content: error_msg,\n is_error: Some(true),\n }],\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n }\n }\n\n if print_output {\n println!();\n }\n\n // Check for soft interrupts (e.g. Telegram messages) and inject as user messages\n if let Some(content) = self.inject_soft_interrupts()", "old_string": " self.add_message_with_duration(\n Role::User,\n vec![ContentBlock::ToolResult {\n tool_use_id: tc.id,\n content: output.output,\n is_error: None,\n }],\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n Err(e) => {\n Bus::global().publish(BusEvent::ToolUpdated(ToolEvent {\n session_id: self.session.id.clone(),\n message_id: message_id.clone(),\n tool_call_id: tc.id.clone(),\n tool_name: tc.name.clone(),\n status: ToolStatus::Error,\n title: None,\n }));\n\n let error_msg = format!(\"Error: {}\", e);\n if trace {\n eprintln!(\n \"[trace] tool_exec_error name={} id={} {}\",\n tc.name, tc.id, error_msg\n );\n }\n if print_output {\n println!(\"{}\", error_msg);\n }\n self.add_message_with_duration(\n Role::User,\n vec![ContentBlock::ToolResult {\n tool_use_id: tc.id,\n content: error_msg,\n is_error: Some(true),\n }],\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n }\n }\n\n if print_output {\n println!();\n }\n\n // Check for soft interrupts (e.g. Telegram messages) and inject as user messages\n if let Some(content) = self.inject_soft_interrupts()"}}, {"t": 68500, "event": "tool_done", "name": "edit", "output": "Edited src/agent.rs: replaced 1 occurrence(s)\n1957+ let blocks = tool_output_to_content_blocks(tc.id, output);\n1959- vec![ContentBlock::ToolResult {\n1960- tool_use_id: tc.id,\n1961- content: output.output,\n1962- is_error: None,\n1963- }],\n1960+ blocks,\n\nContext after edit (lines 1954-2007):\n1954\u2502 println!(\"{}\", preview.lines().next().unwrap_or(\"(done)\"));\n1955\u2502 }\n1956\u2502 \n1957\u2502 let blocks = tool_output_to_content_...", "is_error": false}, {"t": 69000, "event": "thinking", "duration": 800}, {"t": 69200, "event": "stream_text", "text": "Now the second location (~line 2480 area):", "speed": 80}, {"t": 69300, "event": "token_usage", "input": 827, "output": 112, "cache_read": 85745, "cache_creation": 1354}, {"t": 69300, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && grep -n \"content: output.output\" src/agent.rs", "description": "Find remaining output.output usages"}}, {"t": 69400, "event": "tool_done", "name": "bash", "output": "37: content: output.output,\n2495: content: output.output,\n3057: content: output.output,\n", "is_error": false}, {"t": 69600, "event": "thinking", "duration": 800}, {"t": 69800, "event": "token_usage", "input": 54, "output": 94, "cache_read": 87099, "cache_creation": 939}, {"t": 69800, "event": "tool_start", "name": "read", "input": {"file_path": "src/agent.rs", "limit": 20, "offset": 2482}}, {"t": 69900, "event": "tool_done", "name": "read", "output": " 2483\t Ok(output) => {\n 2484\t let _ = event_tx.send(ServerEvent::ToolDone {\n 2485\t id: tc.id.clone(),\n 2486\t name: tc.name.clone(),\n 2487\t output: output.output.clone(),\n 2488\t error: None,\n 2489\t });\n 2490\t\n 2491\t self.add_message_with_duration(\n 2492\t Role::User,\n 2493\t ...", "is_error": false}, {"t": 70400, "event": "thinking", "duration": 800}, {"t": 70600, "event": "token_usage", "input": 306, "output": 452, "cache_read": 88038, "cache_creation": 148}, {"t": 70600, "event": "tool_start", "name": "edit", "input": {"file_path": "src/agent.rs", "new_string": " Ok(output) => {\n let _ = event_tx.send(ServerEvent::ToolDone {\n id: tc.id.clone(),\n name: tc.name.clone(),\n output: output.output.clone(),\n error: None,\n });\n\n let blocks = tool_output_to_content_blocks(tc.id.clone(), output);\n self.add_message_with_duration(\n Role::User,\n blocks,\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n Err(e) => {", "old_string": " Ok(output) => {\n let _ = event_tx.send(ServerEvent::ToolDone {\n id: tc.id.clone(),\n name: tc.name.clone(),\n output: output.output.clone(),\n error: None,\n });\n\n self.add_message_with_duration(\n Role::User,\n vec![ContentBlock::ToolResult {\n tool_use_id: tc.id.clone(),\n content: output.output,\n is_error: None,\n }],\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n Err(e) => {"}}, {"t": 70700, "event": "tool_done", "name": "edit", "output": "Edited src/agent.rs: replaced 1 occurrence(s)\n2491+ let blocks = tool_output_to_content_blocks(tc.id.clone(), output);\n2493- vec![ContentBlock::ToolResult {\n2494- tool_use_id: tc.id.clone(),\n2495- content: output.output,\n2496- is_error: None,\n2497- }],\n2494+ blocks,\n\nContext after edit (lines 2480-2502):\n2480\u2502 ));\n2481\u2502 \n2482\u2502 match result {\n2483\u2502 Ok(output) => {\n2484\u2502 let _ = event_tx.send(ServerEvent::Tool...", "is_error": false}, {"t": 71200, "event": "thinking", "duration": 800}, {"t": 71400, "event": "stream_text", "text": "Now the third location:", "speed": 80}, {"t": 71471, "event": "token_usage", "input": 436, "output": 100, "cache_read": 88186, "cache_creation": 758}, {"t": 71471, "event": "tool_start", "name": "read", "input": {"file_path": "src/agent.rs", "limit": 20, "offset": 3043}}, {"t": 71571, "event": "tool_done", "name": "read", "output": " 3044\t id: tc.id.clone(),\n 3045\t name: tc.name.clone(),\n 3046\t output: output.output.clone(),\n 3047\t error: None,\n 3048\t });\n 3049\t\n 3050\t self.add_message_with_duration(\n 3051\t Role::User,\n 3052\t vec![ContentBlock::ToolResult {\n 3053\t ...", "is_error": false}, {"t": 72071, "event": "thinking", "duration": 800}, {"t": 72271, "event": "token_usage", "input": 317, "output": 397, "cache_read": 88944, "cache_creation": 536}, {"t": 72271, "event": "tool_start", "name": "edit", "input": {"file_path": "src/agent.rs", "new_string": " id: tc.id.clone(),\n name: tc.name.clone(),\n output: output.output.clone(),\n error: None,\n });\n\n let blocks = tool_output_to_content_blocks(tc.id.clone(), output);\n self.add_message_with_duration(\n Role::User,\n blocks,\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n Err(e) => {", "old_string": " id: tc.id.clone(),\n name: tc.name.clone(),\n output: output.output.clone(),\n error: None,\n });\n\n self.add_message_with_duration(\n Role::User,\n vec![ContentBlock::ToolResult {\n tool_use_id: tc.id.clone(),\n content: output.output,\n is_error: None,\n }],\n Some(tool_elapsed.as_millis() as u64),\n );\n self.session.save()?;\n }\n Err(e) => {"}}, {"t": 72371, "event": "tool_done", "name": "edit", "output": "Edited src/agent.rs: replaced 1 occurrence(s)\n3050+ let blocks = tool_output_to_content_blocks(tc.id.clone(), output);\n3052- vec![ContentBlock::ToolResult {\n3053- tool_use_id: tc.id.clone(),\n3054- content: output.output,\n3055- is_error: None,\n3056- }],\n3053+ blocks,\n\nContext after edit (lines 3041-3061):\n3041\u2502 match result {\n3042\u2502 Ok(output) => {\n3043\u2502 let _ = event_tx.send(ServerEvent::ToolDone {\n3044\u2502 ...", "is_error": false}, {"t": 73171, "event": "thinking", "duration": 800}, {"t": 73371, "event": "stream_text", "text": "### Step 3: Make file_read return base64 image data for vision", "speed": 80}, {"t": 73471, "event": "token_usage", "input": 430, "output": 1507, "cache_read": 89480, "cache_creation": 714}, {"t": 73471, "event": "tool_start", "name": "edit", "input": {"file_path": "src/tool/read.rs", "new_string": "/// Handle reading an image file - display in terminal if supported AND return base64 for model vision\nfn handle_image_file(path: &Path, file_path: &str) -> Result {\n let protocol = ImageProtocol::detect();\n\n let data = std::fs::read(path)?;\n let file_size = data.len() as u64;\n\n let dimensions = get_image_dimensions_from_data(&data);\n\n let dim_str = dimensions\n .map(|(w, h)| format!(\"{}x{}\", w, h))\n .unwrap_or_else(|| \"unknown\".to_string());\n\n let size_str = if file_size < 1024 {\n format!(\"{} bytes\", file_size)\n } else if file_size < 1024 * 1024 {\n format!(\"{:.1} KB\", file_size as f64 / 1024.0)\n } else {\n format!(\"{:.1} MB\", file_size as f64 / 1024.0 / 1024.0)\n };\n\n let mut terminal_displayed = false;\n if protocol.is_supported() {\n let params = ImageDisplayParams::from_terminal();\n match display_image(path, ¶ms) {\n Ok(true) => {\n terminal_displayed = true;\n }\n Ok(false) => {}\n Err(e) => {\n crate::logging::info(&format!(\"Warning: Failed to display image: {}\", e));\n }\n }\n }\n\n let ext = path\n .extension()\n .map(|e| e.to_string_lossy().to_lowercase())\n .unwrap_or_default();\n let media_type = match ext.as_str() {\n \"png\" => \"image/png\",\n \"jpg\" | \"jpeg\" => \"image/jpeg\",\n \"gif\" => \"image/gif\",\n \"webp\" => \"image/webp\",\n \"bmp\" => \"image/bmp\",\n \"ico\" => \"image/x-icon\",\n _ => \"image/png\",\n };\n\n const MAX_IMAGE_SIZE: u64 = 20 * 1024 * 1024;\n let mut output = if file_size <= MAX_IMAGE_SIZE {\n let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);\n let display_note = if terminal_displayed {\n \"Displayed in terminal. \"\n } else {\n \"\"\n };\n ToolOutput::new(format!(\n \"Image: {} ({})\\nDimensions: {}\\n{}Image sent to model for vision analysis.\",\n file_path, size_str, dim_str, display_note\n ))\n .with_labeled_image(media_type, b64, file_path.to_string())\n } else {\n let display_note = if terminal_displayed {\n \"\\nDisplayed in terminal.\"\n } else {\n \"\"\n };\n ToolOutput::new(format!(\n \"Image: {} ({})\\nDimensions: {}\\nImage too large for vision (max 20MB).{}\",\n file_path, size_str, dim_str, display_note\n ))\n };\n\n output = output.with_title(format!(\"\ud83d\udcf7 {}\", file_path));\n Ok(output)", "old_string": "/// Handle reading an image file - display in terminal if supported\nfn handle_image_file(path: &Path, file_path: &str) -> Result {\n let protocol = ImageProtocol::detect();\n\n // Get file size for metadata\n let metadata = std::fs::metadata(path)?;\n let file_size = metadata.len();\n\n // Try to get image dimensions\n let dimensions = if let Ok(data) = std::fs::read(path) {\n get_image_dimensions_from_data(&data)\n } else {\n None\n };\n\n let dim_str = dimensions\n .map(|(w, h)| format!(\"{}x{}\", w, h))\n .unwrap_or_else(|| \"unknown\".to_string());\n\n let size_str = if file_size < 1024 {\n format!(\"{} bytes\", file_size)\n } else if file_size < 1024 * 1024 {\n format!(\"{:.1} KB\", file_size as f64 / 1024.0)\n } else {\n format!(\"{:.1} MB\", file_size as f64 / 1024.0 / 1024.0)\n };\n\n // Display image in terminal if supported\n if protocol.is_supported() {\n let params = ImageDisplayParams::from_terminal();\n match display_image(path, ¶ms) {\n Ok(true) => {\n return Ok(ToolOutput::new(format!(\n \"Image: {} ({})\\nDimensions: {}\\nDisplayed in terminal using {:?} protocol\",\n file_path, size_str, dim_str, protocol\n )));\n }\n Ok(false) => {\n // Fall through to text output\n }\n Err(e) => {\n crate::logging::info(&format!(\"Warning: Failed to display image: {}\", e));\n // Fall through to text output\n }\n }\n }\n\n // Fallback: return metadata about the image\n Ok(ToolOutput::new(format!(\n \"Image: {} ({})\\nDimensions: {}\\nTerminal does not support inline images (try Kitty or iTerm2)\",\n file_path, size_str, dim_str\n )))"}}, {"t": 73571, "event": "tool_done", "name": "edit", "output": "Edited src/tool/read.rs: replaced 1 occurrence(s)\n215- /// Handle reading an image file - display in terminal if supported\n215+ /// Handle reading an image file - display in terminal if supported AND return base64 for model vision\n219- // Get file size for metadata\n220- let metadata = std::fs::metadata(path)?;\n221- let file_size = metadata.len();\n219+ let data = std::fs::read(path)?;\n220+ let file_size = data.len() as u64;\n223- // Try to get image dimensions\n224- let dimensions = if let Ok(da...", "is_error": false}, {"t": 74071, "event": "thinking", "duration": 800}, {"t": 74271, "event": "stream_text", "text": "Now add the base64 import to read.rs:", "speed": 80}, {"t": 74371, "event": "token_usage", "input": 2084, "output": 108, "cache_read": 15047, "cache_creation": 77076}, {"t": 74371, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 10, "offset": 0}}, {"t": 74471, "event": "tool_done", "name": "read", "output": " 1\tuse super::{Tool, ToolContext, ToolOutput};\n 2\tuse crate::bus::{Bus, BusEvent, FileOp, FileTouch};\n 3\tuse crate::tui::image::{display_image, ImageDisplayParams, ImageProtocol};\n 4\tuse anyhow::Result;\n 5\tuse async_trait::async_trait;\n 6\tuse serde::Deserialize;\n 7\tuse serde_json::{json, Value};\n 8\tuse std::path::Path;\n 9\t\n 10\tconst DEFAULT_LIMIT: usize = 2000;\n\n... 388 more lines (use offset=10 to continue)\n", "is_error": false}, {"t": 74971, "event": "thinking", "duration": 800}, {"t": 75171, "event": "token_usage", "input": 187, "output": 318, "cache_read": 92123, "cache_creation": 2192}, {"t": 75171, "event": "tool_start", "name": "edit", "input": {"file_path": "src/tool/read.rs", "new_string": "use super::{Tool, ToolContext, ToolOutput};\nuse base64::Engine as _;\nuse crate::bus::{Bus, BusEvent, FileOp, FileTouch};\nuse crate::tui::image::{display_image, ImageDisplayParams, ImageProtocol};\nuse anyhow::Result;\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::path::Path;", "old_string": "use super::{Tool, ToolContext, ToolOutput};\nuse crate::bus::{Bus, BusEvent, FileOp, FileTouch};\nuse crate::tui::image::{display_image, ImageDisplayParams, ImageProtocol};\nuse anyhow::Result;\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::path::Path;"}}, {"t": 75271, "event": "tool_done", "name": "edit", "output": "Edited src/tool/read.rs: replaced 1 occurrence(s)\n2+ use base64::Engine as _;\n\nContext after edit (lines 1-12):\n 1\u2502 use super::{Tool, ToolContext, ToolOutput};\n 2\u2502 use base64::Engine as _;\n 3\u2502 use crate::bus::{Bus, BusEvent, FileOp, FileTouch};\n 4\u2502 use crate::tui::image::{display_image, ImageDisplayParams, ImageProtocol};\n 5\u2502 use anyhow::Result;\n 6\u2502 use async_trait::async_trait;\n 7\u2502 use serde::Deserialize;\n 8\u2502 use serde_json::{json, Value};\n 9\u2502 use std::pat...", "is_error": false}, {"t": 75771, "event": "thinking", "duration": 800}, {"t": 75971, "event": "stream_text", "text": "Now let me do a quick build check to see if the core plumbing compiles:", "speed": 80}, {"t": 76071, "event": "token_usage", "input": 254, "output": 132, "cache_read": 94315, "cache_creation": 505}, {"t": 76071, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | tail -30", "description": "Build to check compilation", "timeout": 180000}}, {"t": 76871, "event": "tool_done", "name": "bash", "output": " |\n = help: maybe it is overwritten before being read?\n\nwarning: value assigned to `has_pinned_area` is never read\n --> src/tui/ui.rs:1845:31\n |\n1845 | let mut has_pinned_area = false;\n | ^^^^^\n |\n = help: maybe it is overwritten before being read?\n\nwarning: value assigned to `reconnect_attempts` is never read\n --> src/tui/client.rs:510:17\n |\n510 | reconnect_attempts = 0;\n | ^^^^^^^^^^^^^^^^^^^^...", "is_error": false}, {"t": 77371, "event": "thinking", "duration": 800}, {"t": 77571, "event": "token_usage", "input": 315, "output": 113, "cache_read": 94820, "cache_creation": 386}, {"t": 77571, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep \"^error\"", "description": "Get specific error", "timeout": 180000}}, {"t": 78371, "event": "tool_done", "name": "bash", "output": "error[E0063]: missing field `images` in initializer of `ToolOutput`\nerror: could not compile `jcode` (lib) due to 1 previous error; 29 warnings emitted\n", "is_error": false}, {"t": 78871, "event": "thinking", "duration": 800}, {"t": 79071, "event": "token_usage", "input": 66, "output": 115, "cache_read": 95206, "cache_creation": 428}, {"t": 79071, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep \"E0063\"", "description": "Get full error context", "timeout": 180000}}, {"t": 79871, "event": "tool_done", "name": "bash", "output": "error[E0063]: missing field `images` in initializer of `ToolOutput`\nFor more information about this error, try `rustc --explain E0063`.\n", "is_error": false}, {"t": 80371, "event": "thinking", "duration": 800}, {"t": 80571, "event": "token_usage", "input": 57, "output": 119, "cache_read": 95634, "cache_creation": 181}, {"t": 80571, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep -B5 \"E0063\"", "description": "Get error with context", "timeout": 180000}}, {"t": 81371, "event": "tool_done", "name": "bash", "output": " --> src/tool/read.rs:2:5\n |\n2 | use base64::Engine as _;\n | ^^^^^^^^^^^^^^^^^^^\n\nerror[E0063]: missing field `images` in initializer of `ToolOutput`\n--\n341 | disconnect_start = None;\n | ^^^^^^^^^^^^^^^^^^^^^^^\n |\n = help: maybe it is overwritten before being read?\n\nFor more information about this error, try `rustc --explain E0063`.\n", "is_error": false}, {"t": 82171, "event": "thinking", "duration": 800}, {"t": 82371, "event": "stream_text", "text": "There's a missing `images` field somewhere. Likely in the harness or test code:", "speed": 80}, {"t": 82471, "event": "token_usage", "input": 134, "output": 141, "cache_read": 15047, "cache_creation": 80945}, {"t": 82471, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep -A3 \"missing field\"", "description": "Find where images field is missing", "timeout": 180000}}, {"t": 83271, "event": "tool_done", "name": "bash", "output": "error[E0063]: missing field `images` in initializer of `ToolOutput`\n --> src/tool/mod.rs:511:9\n |\n511 | ToolOutput {\n", "is_error": false}, {"t": 83771, "event": "thinking", "duration": 800}, {"t": 83971, "event": "token_usage", "input": 65, "output": 95, "cache_read": 95992, "cache_creation": 275}, {"t": 83971, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/mod.rs", "limit": 15, "offset": 505}}, {"t": 84071, "event": "tool_done", "name": "read", "output": " 506\t current_tokens as f32 / 1000.0,\n 507\t budget / 1000,\n 508\t )\n 509\t };\n 510\t\n 511\t ToolOutput {\n 512\t output: truncated,\n 513\t title: output.title,\n 514\t metadata: output.metadata,\n 515\t }\n 516\t }\n 517\t\n 518\t /// Register a tool dynamically (for MCP tools, etc.)\n 519\t pub async fn register(&self, name: String, tool: Arc) {\n 520\t let mut tools = self.tool...", "is_error": false}, {"t": 84571, "event": "thinking", "duration": 800}, {"t": 84771, "event": "token_usage", "input": 198, "output": 167, "cache_read": 96267, "cache_creation": 160}, {"t": 84771, "event": "tool_start", "name": "edit", "input": {"file_path": "src/tool/mod.rs", "new_string": " ToolOutput {\n output: truncated,\n title: output.title,\n metadata: output.metadata,\n images: output.images,\n }", "old_string": " ToolOutput {\n output: truncated,\n title: output.title,\n metadata: output.metadata,\n }"}}, {"t": 84871, "event": "tool_done", "name": "edit", "output": "Edited src/tool/mod.rs: replaced 1 occurrence(s)\n515+ images: output.images,\n\nContext after edit (lines 508-519):\n 508\u2502 )\n 509\u2502 };\n 510\u2502 \n 511\u2502 ToolOutput {\n 512\u2502 output: truncated,\n 513\u2502 title: output.title,\n 514\u2502 metadata: output.metadata,\n 515\u2502 images: output.images,\n 516\u2502 }\n 517\u2502 }\n 518\u2502 \n 519\u2502 /// Register a tool dynamically (for MCP tools, etc.)", "is_error": false}, {"t": 85371, "event": "thinking", "duration": 800}, {"t": 85571, "event": "token_usage", "input": 163, "output": 114, "cache_read": 96427, "cache_creation": 365}, {"t": 85571, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep \"^error\"", "description": "Check build after fix", "timeout": 180000}}, {"t": 86371, "event": "tool_done", "name": "bash", "output": "\n\nExit code: 1", "is_error": false}, {"t": 87171, "event": "thinking", "duration": 800}, {"t": 87371, "event": "token_usage", "input": 25, "output": 119, "cache_read": 15047, "cache_creation": 82020}, {"t": 87371, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep -E \"^error|warning.*unused\"", "description": "Check for errors", "timeout": 180000}}, {"t": 88171, "event": "tool_done", "name": "bash", "output": "warning: unused import: `ResourceBudget`\nwarning: unused import: `tract_onnx::prelude::*`\nwarning: unused import: `EdgeKind`\nwarning: unused imports: `MemoryCategory` and `TrustLevel`\nwarning: unused import: `base64::Engine as _`\nwarning: unused import: `StreamExt`\nwarning: unused variable: `is_project`\nwarning: unused variable: `index`\nwarning: unused variable: `finish_reason`\nwarning: unused variable: `swarm_id`\nwarning: unused variable: `coord_id`\nwarning: unused variable: `skills`\nwarning...", "is_error": false}, {"t": 88671, "event": "thinking", "duration": 800}, {"t": 88871, "event": "stream_text", "text": "Build succeeded (just warnings). Let me also fix the unused import warning for base64:", "speed": 80}, {"t": 88971, "event": "token_usage", "input": 289, "output": 114, "cache_read": 15047, "cache_creation": 82104}, {"t": 88971, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 3, "offset": 265}}, {"t": 89071, "event": "tool_done", "name": "read", "output": " 266\t let mut output = if file_size <= MAX_IMAGE_SIZE {\n 267\t let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);\n 268\t let display_note = if terminal_displayed {\n\n... 131 more lines (use offset=268 to continue)\n", "is_error": false}, {"t": 89871, "event": "user_message", "text": "keep going "}, {"t": 90671, "event": "thinking", "duration": 800}, {"t": 90871, "event": "stream_text", "text": "\n\nLet me check the build status and continue with the remaining tasks:", "speed": 80}, {"t": 90971, "event": "token_usage", "input": 116, "output": 128, "cache_read": 9739, "cache_creation": 91113}, {"t": 90971, "event": "tool_start", "name": "bash", "input": {"command": "cd /home/jeremy/jcode && cargo build --release 2>&1 | grep \"error\"", "description": "Verify build passes", "timeout": 180000}}, {"t": 91771, "event": "tool_done", "name": "bash", "output": "warning: function `has_render_error` is never used\n1171 | fn has_render_error(hash: u64) -> bool {\nwarning: function `record_render_error` is never used\n1178 | fn record_render_error(hash: u64, message: String) {\nwarning: function `clear_render_error` is never used\n1184 | fn clear_render_error(hash: u64) {\nwarning: function `error_lines_for` is never used\n1190 | pub fn error_lines_for(hash: u64) -> Option>> {\n421 | pub fn add_error(&mut self, error: &str) {\n", "is_error": false}, {"t": 92571, "event": "thinking", "duration": 800}, {"t": 92771, "event": "stream_text", "text": "Build passes. Now let me continue with the remaining tasks. Let me update the todo and tackle them:", "speed": 80}, {"t": 92871, "event": "token_usage", "input": 183, "output": 493, "cache_read": 100852, "cache_creation": 245}, {"t": 92871, "event": "tool_start", "name": "todowrite", "input": {"todos": [{"content": "Add images field to ToolOutput so model can see images via vision", "id": "1", "priority": "high", "status": "completed"}, {"content": "Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation", "id": "2", "priority": "high", "status": "completed"}, {"content": "file_read: return base64 image data in ToolOutput.images for vision", "id": "2b", "priority": "high", "status": "completed"}, {"content": "Rename diagram side panel to pinned content panel + support arbitrary images", "id": "3", "priority": "high", "status": "in_progress"}, {"content": "Display file_read images in the pinned content panel", "id": "4", "priority": "high", "status": "pending"}, {"content": "Ctrl+1/2/3/4/5 to switch pinned panel entries", "id": "4b", "priority": "high", "status": "pending"}, {"content": "Ctrl+B for background tool (in addition to Alt+B)", "id": "4c", "priority": "high", "status": "pending"}, {"content": "Add DOCX support to file_read (docx-lite crate)", "id": "5", "priority": "medium", "status": "pending"}, {"content": "Add XLSX support to file_read (calamine crate)", "id": "6", "priority": "medium", "status": "pending"}, {"content": "Add archive listing (.zip, .tar.gz) to file_read", "id": "7", "priority": "medium", "status": "pending"}, {"content": "Add audio/video metadata to file_read (ffprobe)", "id": "8", "priority": "low", "status": "pending"}, {"content": "Add EPUB text extraction to file_read", "id": "9", "priority": "low", "status": "pending"}, {"content": "Add SVG rendering to file_read", "id": "10", "priority": "low", "status": "pending"}, {"content": "Build, test, commit", "id": "11", "priority": "high", "status": "pending"}]}}, {"t": 92971, "event": "tool_done", "name": "todowrite", "output": "[\n {\n \"content\": \"Add images field to ToolOutput so model can see images via vision\",\n \"status\": \"completed\",\n \"priority\": \"high\",\n \"id\": \"1\"\n },\n {\n \"content\": \"Wire ToolOutput.images through agent \u2192 ContentBlock::Image in conversation\",\n \"status\": \"completed\",\n \"priority\": \"high\",\n \"id\": \"2\"\n },\n {\n \"content\": \"file_read: return base64 image data in ToolOutput.images for vision\",\n \"status\": \"completed\",\n \"priority\": \"high\",\n \"id\": \"2b\"\n },\n {\n ...", "is_error": false}, {"t": 93771, "event": "thinking", "duration": 800}, {"t": 93971, "event": "stream_text", "text": "Now let me tackle the pinned content panel rename and the new file format support. Let me first add the deps to Cargo.toml, then do the file format handlers, then the UI changes.\n\n### Add dependencies for DOCX, XLSX, zip listing, EPUB", "speed": 80}, {"t": 94171, "event": "token_usage", "input": 728, "output": 317, "cache_read": 101097, "cache_creation": 787}, {"t": 94171, "event": "tool_start", "name": "edit", "input": {"file_path": "Cargo.toml", "new_string": "# PDF parsing (behind feature flag - 56 crates)\npdf-extract = { version = \"0.8\", optional = true }\n\n# Document format support (behind feature flags)\ndocx-lite = { version = \"0.2\", optional = true }\ncalamine = { version = \"0.26\", optional = true }\nzip = { version = \"2\", default-features = false, features = [\"deflate\"], optional = true }\nepub = { version = \"2\", optional = true }\n\n# Archive extraction (for auto-update)", "old_string": "# PDF parsing (behind feature flag - 56 crates)\npdf-extract = { version = \"0.8\", optional = true }\n\n# Archive extraction (for auto-update)"}}, {"t": 94271, "event": "tool_done", "name": "edit", "output": "Edited Cargo.toml: replaced 1 occurrence(s)\n98+ # Document format support (behind feature flags)\n99+ docx-lite = { version = \"0.2\", optional = true }\n100+ calamine = { version = \"0.26\", optional = true }\n101+ zip = { version = \"2\", default-features = false, features = [\"deflate\"], optional = true }\n102+ epub = { version = \"2\", optional = true }\n\nContext after edit (lines 92-107):\n 92\u2502 native-tls = \"0.2\"\n 93\u2502 mail-parser = \"0.9\"\n 94\u2502 \n 95\u2502 # PDF parsing (behind feature flag - 56 cr...", "is_error": false}, {"t": 95071, "event": "thinking", "duration": 800}, {"t": 95271, "event": "stream_text", "text": "Now update the features section:", "speed": 80}, {"t": 95371, "event": "token_usage", "input": 370, "output": 238, "cache_read": 18299, "cache_creation": 84638}, {"t": 95371, "event": "tool_start", "name": "edit", "input": {"file_path": "Cargo.toml", "new_string": "[features]\ndefault = [\"embeddings\", \"pdf\", \"documents\"]\njemalloc = [\"tikv-jemallocator\"]\nembeddings = [\"tract-onnx\", \"tract-hir\", \"tokenizers\"]\npdf = [\"pdf-extract\"]\ndocuments = [\"docx-lite\", \"calamine\", \"zip\", \"epub\"]", "old_string": "[features]\ndefault = [\"embeddings\", \"pdf\"]\njemalloc = [\"tikv-jemallocator\"]\nembeddings = [\"tract-onnx\", \"tract-hir\", \"tokenizers\"]\npdf = [\"pdf-extract\"]"}}, {"t": 95471, "event": "tool_done", "name": "edit", "output": "Edited Cargo.toml: replaced 1 occurrence(s)\n112- default = [\"embeddings\", \"pdf\"]\n112+ default = [\"embeddings\", \"pdf\", \"documents\"]\n115- pdf = [\"pdf-extract\"]\n115+ pdf = [\"pdf-extract\"]\n116+ documents = [\"docx-lite\", \"calamine\", \"zip\", \"epub\"]\n\nContext after edit (lines 108-119):\n 108\u2502 # Mermaid diagram rendering (stable v0.2.0 tag)\n 109\u2502 mermaid-rs-renderer = { git = \"ssh://git@github.com/1jehuang/mermaid-rs-renderer.git\", tag = \"v0.2.0\" }\n 110\u2502 \n 111\u2502 [features]\n 112\u2502 default = [\"e...", "is_error": false}, {"t": 95971, "event": "thinking", "duration": 800}, {"t": 96171, "event": "stream_text", "text": "Now let me add all the file format handlers to read.rs. Let me first see the full current state:", "speed": 80}, {"t": 96271, "event": "token_usage", "input": 324, "output": 115, "cache_read": 102937, "cache_creation": 608}, {"t": 96271, "event": "tool_start", "name": "read", "input": null}, {"t": 96371, "event": "tool_done", "name": "read", "output": "Error: invalid type: null, expected struct ReadInput", "is_error": true}, {"t": 96871, "event": "thinking", "duration": 800}, {"t": 97071, "event": "token_usage", "input": 37, "output": 95, "cache_read": 103545, "cache_creation": 384}, {"t": 97071, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 100, "offset": 60}}, {"t": 97171, "event": "tool_done", "name": "read", "output": " 61\t })\n 62\t }\n 63\t\n 64\t async fn execute(&self, input: Value, ctx: ToolContext) -> Result {\n 65\t let params: ReadInput = serde_json::from_value(input)?;\n 66\t\n 67\t let path = ctx.resolve_path(Path::new(¶ms.file_path));\n 68\t\n 69\t // Check if file exists\n 70\t if !path.exists() {\n 71\t // Try to find similar files\n 72\t let suggestions = find_similar_files(&path);\n 73\t if suggestions...", "is_error": false}, {"t": 97971, "event": "thinking", "duration": 800}, {"t": 98171, "event": "stream_text", "text": "Now I need to add detection functions and handlers for DOCX, XLSX, archives, EPUB, and audio/video. Let me insert the checks in the execute method and add the handlers. I'll also update the description and binary detection:", "speed": 80}, {"t": 98371, "event": "token_usage", "input": 1330, "output": 640, "cache_read": 103929, "cache_creation": 132}, {"t": 98371, "event": "tool_start", "name": "edit", "input": {"file_path": "src/tool/read.rs", "new_string": " // Check for image files and display in terminal if supported\n if is_image_file(&path) {\n return handle_image_file(&path, ¶ms.file_path);\n }\n\n // Check for PDF files and extract text\n if is_pdf_file(&path) {\n return handle_pdf_file(&path, ¶ms.file_path);\n }\n\n // Check for DOCX files and extract text\n #[cfg(feature = \"documents\")]\n if is_docx_file(&path) {\n return handle_docx_file(&path, ¶ms.file_path);\n }\n\n // Check for spreadsheet files (xlsx, xls, ods, csv)\n #[cfg(feature = \"documents\")]\n if is_spreadsheet_file(&path) {\n return handle_spreadsheet_file(&path, ¶ms.file_path);\n }\n\n // Check for archive files and list contents\n #[cfg(feature = \"documents\")]\n if is_archive_file(&path) {\n return handle_archive_file(&path, ¶ms.file_path);\n }\n\n // Check for EPUB files and extract text\n #[cfg(feature = \"documents\")]\n if is_epub_file(&path) {\n return handle_epub_file(&path, ¶ms.file_path);\n }\n\n // Check for audio/video files and show metadata\n if is_media_file(&path) {\n return handle_media_file(&path, ¶ms.file_path);\n }\n\n // Check for binary files\n if is_binary_file(&path) {", "old_string": " // Check for image files and display in terminal if supported\n if is_image_file(&path) {\n return handle_image_file(&path, ¶ms.file_path);\n }\n\n // Check for PDF files and extract text\n if is_pdf_file(&path) {\n return handle_pdf_file(&path, ¶ms.file_path);\n }\n\n // Check for binary files\n if is_binary_file(&path) {"}}, {"t": 98471, "event": "tool_done", "name": "edit", "output": "Edited src/tool/read.rs: replaced 1 occurrence(s)\n94+ // Check for DOCX files and extract text\n95+ #[cfg(feature = \"documents\")]\n96+ if is_docx_file(&path) {\n97+ return handle_docx_file(&path, ¶ms.file_path);\n98+ }\n100+ // Check for spreadsheet files (xlsx, xls, ods, csv)\n101+ #[cfg(feature = \"documents\")]\n102+ if is_spreadsheet_file(&path) {\n103+ return handle_spreadsheet_file(&path, ¶ms.file_path);\n104+ }\n106+ // Check for archive files and list contents\n107+ #[cfg(feature = \"docume...", "is_error": false}, {"t": 98971, "event": "thinking", "duration": 800}, {"t": 99171, "event": "stream_text", "text": "Now add all the handler functions at the end of the file. Let me see where the file ends:", "speed": 80}, {"t": 99271, "event": "token_usage", "input": 945, "output": 117, "cache_read": 18299, "cache_creation": 87742}, {"t": 99271, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 30, "offset": 380}}, {"t": 99371, "event": "tool_done", "name": "read", "output": " 381\t\n 382\t let size_str = if file_size < 1024 {\n 383\t format!(\"{} bytes\", file_size)\n 384\t } else if file_size < 1024 * 1024 {\n 385\t format!(\"{:.1} KB\", file_size as f64 / 1024.0)\n 386\t } else {\n 387\t format!(\"{:.1} MB\", file_size as f64 / 1024.0 / 1024.0)\n 388\t };\n 389\t\n 390\t // Extract text from PDF\n 391\t match pdf_extract::extract_text(path) {\n 392\t Ok(text) => {\n 393\t let mut output = String::new();\n 394\t o...", "is_error": false}, {"t": 99871, "event": "thinking", "duration": 800}, {"t": 100071, "event": "token_usage", "input": 539, "output": 95, "cache_read": 106041, "cache_creation": 1062}, {"t": 100071, "event": "tool_start", "name": "read", "input": {"file_path": "src/tool/read.rs", "limit": 30, "offset": 410}}, {"t": 100171, "event": "tool_done", "name": "read", "output": " 411\t } else {\n 412\t output.push_str(page_text);\n 413\t }\n 414\t output.push_str(\"\\n\\n\");\n 415\t }\n 416\t }\n 417\t\n 418\t Ok(ToolOutput::new(output))\n 419\t }\n 420\t Err(e) => {\n 421\t // Fall back to metadata only if text extraction fails\n 422\t Ok(ToolOutput::new(format!(\n 423\t \"PDF: {} ({})\\nCould not extract text: {}\\nTh...", "is_error": false}, {"t": 100171, "event": "done"}] \ No newline at end of file diff --git a/assets/demos/workflow.mp4 b/assets/demos/workflow.mp4 new file mode 100644 index 0000000..4f7a8ba Binary files /dev/null and b/assets/demos/workflow.mp4 differ diff --git a/assets/niri-screenshot.png b/assets/niri-screenshot.png new file mode 100644 index 0000000..a3cebb7 Binary files /dev/null and b/assets/niri-screenshot.png differ diff --git a/assets/readme/100-sessions-spawn-demo.gif b/assets/readme/100-sessions-spawn-demo.gif new file mode 100644 index 0000000..5929395 Binary files /dev/null and b/assets/readme/100-sessions-spawn-demo.gif differ diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000..d8c83b9 --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,56 @@ +# Changelog entries + +User-facing changelog entries for jcode releases. One JSON file per release, +written by the agent during `/cut-release`, reviewed in the release diff, and +consumed by jcode.sh and the GitHub release body. + +## Files + +- `v.json` - one entry per release (e.g. `v0.34.0.json`). +- `index.json` - newest-first list of released versions with dates, so + consumers can discover entries without directory listings. + +## Entry schema + +```json +{ + "version": "0.34.0", + "date": "2026-07-02", + "title": "Optional short release name", + "highlights": [ + "One-sentence, user-facing description of the most important change." + ], + "improvements": [ + "Smaller user-visible improvements." + ], + "fixes": [ + "Bug fixes described by their user-visible effect." + ] +} +``` + +## index.json schema + +```json +{ + "entries": [ + { "version": "0.34.0", "date": "2026-07-02" } + ] +} +``` + +## Writing guidelines + +- Write for users of jcode, not contributors. Describe the effect, not the + implementation ("swarm agents no longer lose retried commands", not + "close mutation-dedup races"). +- Skip internal-only changes entirely: refactors, CI, test-only, code moves. + If a release is purely internal, say so in a single `improvements` item like + "Internal reliability and performance work." +- One sentence per item. No trailing periods needed, but be consistent within + an entry. +- `highlights` is for the 1-3 changes a user would actually notice or care + about. Everything else goes in `improvements` or `fixes`. Omit empty arrays. +- `title` is optional. Use it only when a release has an obvious theme. +- Keep the full commit log as the source of truth; the changelog is a + user-facing layer over it, never a replacement. diff --git a/changelog/index.json b/changelog/index.json new file mode 100644 index 0000000..24d4894 --- /dev/null +++ b/changelog/index.json @@ -0,0 +1,18 @@ +{ + "entries": [ + { "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.34.0.json b/changelog/v0.34.0.json new file mode 100644 index 0000000..a2cee1b --- /dev/null +++ b/changelog/v0.34.0.json @@ -0,0 +1,25 @@ +{ + "version": "0.34.0", + "date": "2026-07-02", + "highlights": [ + "Swarm agents now spawn inline by default with a live gallery viewport, and the focused strip expands into a transcript view with todos", + "New /cut-release command commits, pushes, bumps the version, and publishes a GitHub release in one step", + "New /hotkeys command lists every chord along with your personal usage stats" + ], + "improvements": [ + "Inline images are now fully clickable and cycle through Fit, Large, and Full expand levels", + "Gmail drafts support file attachments, and reading messages surfaces attachment metadata", + "Session search is significantly faster with SIMD scanning and parallel source reads", + "Provider guardrail and refusal stops are surfaced instead of ending turns silently", + "Plan cards wrap long content instead of truncating at the border", + "Rare or unknown hotkey chords get inline feedback instead of doing nothing" + ], + "fixes": [ + "Queued follow-up messages are no longer lost across disconnects and reloads", + "Swarm task retries no longer replay stale results or race duplicate mutations", + "Scrolling no longer causes inline images to flicker", + "Corrupt protocol frames are skipped instead of killing the session", + "Clicks in kitty terminals are no longer swallowed by same-cell drag jitter", + "Fixed a startup panic in the info-widget cache and stale widget anchors after resizes" + ] +} diff --git a/changelog/v0.35.0.json b/changelog/v0.35.0.json new file mode 100644 index 0000000..1c6ef6b --- /dev/null +++ b/changelog/v0.35.0.json @@ -0,0 +1,35 @@ +{ + "version": "0.35.0", + "date": "2026-07-04", + "highlights": [ + "Mermaid diagrams now render inline in chat like images, on by default, with aspect-aware sizing and cached re-rasterization on resize", + "Swarm deep mode grows a live task graph with gate audits and renders it as a live mermaid plan diagram, plus a swarm dock widget with a live agent list", + "After provider guardrail refusals or terminal errors, jcode offers a one-keypress reroute to a fallback model instead of silently ending the turn" + ], + "improvements": [ + "Anthropic models gain a 'max' reasoning effort rung above xhigh", + "GitHub release notes are now generated from the human-readable changelog", + "Skills installed via Claude Code plugins and marketplaces are picked up automatically", + "MCP servers can be individually disabled with a per-server enabled flag", + "FreeBSD x86_64 release binaries are now shipped", + "/login guides you through provider filtering instead of instant-launching a login", + "Onboarding imports logins with a summary-first flow and a one-time resume picker", + "Session search got faster with incremental per-source indexes, and startup connect bursts use much less CPU", + "Rendered markdown blockquotes show a copy badge", + "New scripts/uninstall.sh performs a clean wipe", + "Swarm agent lifecycle transitions are surfaced as status notices, and swarm activity tails interleave live tool output" + ], + "fixes": [ + "Rapid Esc presses are no longer swallowed by a stale cancel reset", + "Project-local MCP config loads from the session working directory instead of the server's", + "Reasoning effort picked in the model picker is forwarded correctly in remote sessions", + "Real-world network outage errors are retried as transient instead of failing the turn", + "Stream stall detection now works across all providers, not just one", + "Scrolling no longer flips the scrollbar layout or causes flicker during overscroll", + "Webfetch URLs and search queries survive display compaction", + "Swarm spinners keep animating while the coordinator processes without streaming", + "The pinned diagram pane is capped so it never crushes the transcript", + "/rewind N now matches the numbered transcript entries shown in the TUI", + "Fixed border shear from ambiguous-width glyphs in info widgets" + ] +} diff --git a/changelog/v0.35.1.json b/changelog/v0.35.1.json new file mode 100644 index 0000000..2cacbbc --- /dev/null +++ b/changelog/v0.35.1.json @@ -0,0 +1,11 @@ +{ + "version": "0.35.1", + "date": "2026-07-04", + "title": "Complete the 0.35.0 release for macOS and FreeBSD", + "highlights": [ + "Restores macOS and FreeBSD release binaries, which were missing from 0.35.0 due to a platform-specific build error" + ], + "improvements": [ + "All 0.35.0 changes apply: inline mermaid diagrams, swarm deep-mode task graphs, and one-keypress model reroute after refusals" + ] +} diff --git a/changelog/v0.36.0.json b/changelog/v0.36.0.json new file mode 100644 index 0000000..35ee8b4 --- /dev/null +++ b/changelog/v0.36.0.json @@ -0,0 +1,31 @@ +{ + "version": "0.36.0", + "date": "2026-07-05", + "title": "Image pipeline hardening, big memory cuts, and swarm orchestration upgrades", + "highlights": [ + "Inline images are hardened end to end: payloads are released after decode (~8MB back per image-heavy session), evicted cache entries self-heal from disk, and undecodable images can no longer spin the render loop", + "Client memory drops sharply: syntect switches to the onig backend (~17MB), the session-search index is bloom-filtered (-24MB server-side), retained heap is trimmed on idle, and glibc mmap thresholds are pinned so large transients return to the OS", + "Swarm plans render as a live inline mermaid task graph in chat, with per-agent task labels, honest run_plan progress, and per-spawn model/effort selection" + ], + "improvements": [ + "Swarm: broadcasts and shared context are scoped to the sender's spawned subtree", + "Swarm: tasks stranded on dead workers are salvaged and reported to the coordinator; orphaned children are reparented when a mid-tree member leaves", + "Swarm: run_plan pauses on credential-failure waves and surfaces the fix instead of burning retries", + "Swarm: state persistence moved to a durable directory so it survives reboots", + "Swarm: long messages and reports require a tldr and collapse to it with an expand badge", + "Composed input text is mouse-selectable and copyable (issue #430)", + "Interrupts fan out to all active turn signals so cancel is immediate (issue #428)", + "Fable 5 defaults to low reasoning effort; effort=none now fully suppresses thinking", + "Provider runtimes (Anthropic, OpenAI, OpenRouter, Copilot, Cursor, Gemini, Antigravity) moved out of the base crate, so provider edits rebuild far less", + "Resumed sessions survive corrupt journal lines and keep the last prompt", + "agentgrep fixes common agent failure modes and truncates huge trace lines" + ], + "fixes": [ + "Scroll no longer hangs when older history is prepended to a live transcript (issue #344)", + "Sidebar context figure stays consistent with compaction accounting (issue #441)", + "Auto provider init registers external runtimes in every entry path, so OpenAI-compatible profile routes always reach the model picker", + "Resume no longer deadlocks when the live-attach target agent is busy", + "Auto-poke continuations no longer render as user prompts after reload", + "Copy badges no longer truncate blockquote content" + ] +} diff --git a/changelog/v0.37.0.json b/changelog/v0.37.0.json new file mode 100644 index 0000000..6b72090 --- /dev/null +++ b/changelog/v0.37.0.json @@ -0,0 +1,29 @@ +{ + "version": "0.37.0", + "date": "2026-07-07", + "title": "Sponsored tool discovery, inline todo card, and cache-aware notices", + "highlights": [ + "New discover_tools capability lets the agent browse and select sponsored third-party tools in two phases (browse a category, then fetch setup instructions), with clear (sponsored discovery) provenance tags, substantive selection reasons, and an opt-out config", + "A live todo card can be pinned inline in chat via /todos or Alt+P, and todos now track confidence history with a spike-aware validation gate", + "Claude Sonnet 5 supports reasoning effort selection and is classified as a native 1M-context model" + ], + "improvements": [ + "Named provider models from your config now appear in the model picker (issue #444)", + "Endorsed-but-not-installed skills are explained instead of reported as 'Unknown skill' (issue #445)", + "Cold prompt-cache notices are shorter, show how long ago the cache went cold, report effective prompt tokens, and the expiry countdown window scales with the cache TTL", + "Documented harness cache invalidations are attributed in the TUI so cache misses are explainable", + "Swarm plans render as a reliable, legible mermaid task graph and are labeled Plan in the todo widget", + "The initiative tool no longer auto-opens the side panel" + ], + "fixes": [ + "Explicit /login credentials now win over stale API-key environment variables inherited from the parent shell", + "Setting prevent_sleep_while_streaming=false is honored by the TUI sleep guard", + "Long silent reasoning phases no longer trip the client stall guard (issue #451)", + "OpenRouter: local llama.cpp context windows are read from meta.n_ctx (issue #447), and tool schemas are sanitized for strict OpenAI-compatible endpoints (issue #446)", + "default_provider pointing at an OpenAI-compatible catalog profile is honored (issue #448)", + "Server-path message hashing uses a cache-relevant projection, avoiding false KV-cache misses", + "Mermaid aspect-profile diagrams no longer draw outside their render scope, streaming previews clear on empty-buffer commits, and /clear fully re-scopes active diagrams", + "Inline-image marker text is never drawn to the terminal", + "Rewinding a session mid-stream drops stale streaming state instead of replaying it" + ] +} diff --git a/changelog/v0.38.0.json b/changelog/v0.38.0.json new file mode 100644 index 0000000..82d153b --- /dev/null +++ b/changelog/v0.38.0.json @@ -0,0 +1,17 @@ +{ + "version": "0.38.0", + "date": "2026-07-09", + "title": "Light terminal theme support and goal-level todo scoring", + "highlights": [ + "The TUI detects light terminal backgrounds and adapts its colors automatically, with a config override for manual control", + "Todos now carry goal-level hill-climbability scores with measurable objectives, and the agent is nudged to reframe taste-driven goals honestly instead of inventing proxy metrics" + ], + "improvements": [ + "New Anthropic models get optimistic reasoning-cap defaults instead of being pinned to conservative limits", + "The todo card hotkey default moved from Alt+P to Alt+X to avoid conflicts" + ], + "fixes": [ + "Reasoning effort is passed correctly for GPT-family models on OpenAI-compatible gateways", + "Phantom OpenRouter routes that pointed at unavailable models are no longer offered" + ] +} diff --git a/changelog/v0.39.0.json b/changelog/v0.39.0.json new file mode 100644 index 0000000..08a1a70 --- /dev/null +++ b/changelog/v0.39.0.json @@ -0,0 +1,16 @@ +{ + "version": "0.39.0", + "date": "2026-07-09", + "title": "Cursor provider restored, active sessions manager, and cross-desktop launch hotkeys", + "highlights": [ + "The Cursor provider works again: jcode now talks to Cursor's current agent service directly instead of a decommissioned endpoint that failed with \"Update Required\" errors", + "New active sessions manager (/active, or Left on an empty prompt when enabled) shows your live sessions with 'working'/'ready' badges so you can see which ones need attention" + ], + "improvements": [ + "Launch hotkey setup now covers GNOME, KDE Plasma, Cinnamon, MATE, XFCE, bspwm, Hyprland, sway, and i3 on Linux, plus config-driven hotkeys on Windows", + "The active sessions view refreshes live presence every couple of seconds and labels the session you opened it from" + ], + "fixes": [ + "Cursor chat no longer fails with resource-exhausted / payment-required errors caused by the retired ChatService endpoint" + ] +} diff --git a/changelog/v0.40.0.json b/changelog/v0.40.0.json new file mode 100644 index 0000000..1f203bf --- /dev/null +++ b/changelog/v0.40.0.json @@ -0,0 +1,24 @@ +{ + "version": "0.40.0", + "date": "2026-07-10", + "title": "Parallel browser control, clearer goals, and fresher model sessions", + "highlights": [ + "Browser automation sessions can now bind to individual browser windows, allowing parallel agents to work without interfering with each other's tabs", + "Todo widgets now show each goal's hill-climbability score or taste-driven status, making it easier to see whether progress has a measurable objective", + "Remote runtimes can use stable display names, and new server sessions now honor the latest saved provider and model defaults instead of stale server startup settings" + ], + "improvements": [ + "The model picker can be limited to configured provider families, presents reasoning-effort rows consistently, and uses clearer alignment and login guidance", + "Claude Fable 5 now defaults to high reasoning effort", + "Memory relevance and extraction now default to GPT-5.6 Luna with reasoning disabled for lower latency when OpenAI credentials are available", + "Tool transcript rows now retain useful summaries for scheduling, skills, MCP, discovery, Gmail, browser, and other tools across compaction and session reloads", + "The session picker derives useful names from active todo groups or goal objectives when no manual session title is set", + "Project-local skills are isolated to the workspace where they are defined so sessions in other repositories do not inherit them", + "The iOS app now has a production-ready icon and automated TestFlight delivery" + ], + "fixes": [ + "OpenAI model catalog refreshes recover automatically when an expired token initially returns 401", + "Revoked or unknown phone connection tokens are rejected during the handshake, and the iOS app stops reconnecting in a loop and prompts for re-pairing", + "New sessions created by a long-running shared server no longer display or use an outdated provider and model selection" + ] +} diff --git a/changelog/v0.41.0.json b/changelog/v0.41.0.json new file mode 100644 index 0000000..17e741f --- /dev/null +++ b/changelog/v0.41.0.json @@ -0,0 +1,19 @@ +{ + "version": "0.41.0", + "date": "2026-07-10", + "title": "Inline generated images and Gmail by default", + "highlights": [ + "Provider-generated images now render inline beside their transcript entries in the TUI", + "Gmail is now available by default in the full tool profile and can still be hidden with the tools disabled list", + "The new /swarm-prompt command opens the active swarm routing prompt for straightforward project or global customization" + ], + "improvements": [ + "Kitty image transfers are compressed to reduce terminal rendering overhead", + "Explicit swarm spawns now require short labels so parallel agents are easier to identify" + ], + "fixes": [ + "Anthropic API connections now honor their selected route and accept Jcode tools whose schemas previously used unsupported top-level combinators", + "Side-panel Mermaid diagrams no longer render duplicate images", + "Long inline image labels stay within one row instead of disrupting transcript layout" + ] +} diff --git a/changelog/v0.42.0.json b/changelog/v0.42.0.json new file mode 100644 index 0000000..bdd9c54 --- /dev/null +++ b/changelog/v0.42.0.json @@ -0,0 +1,21 @@ +{ + "version": "0.42.0", + "date": "2026-07-11", + "title": "Richer swarm status and resilient sessions", + "highlights": [ + "Inline swarm agent cards now show richer live status, expanded details, and clearer running animations", + "Fuzzy search now tolerates typos so models, sessions, and commands are easier to find", + "Running sessions are now animated in the resume picker and preserved more reliably during refreshes" + ], + "improvements": [ + "Memory status uses a quieter count-first display and stays hidden when memory is disabled", + "Mermaid rendering now follows one consistent feature toggle across configuration surfaces", + "Skill slash commands can include a prompt after the command instead of requiring a second message" + ], + "fixes": [ + "Detached and reloaded sessions now recover more reliably without lifecycle races, stale cleanup, or lost follow-up work", + "Model catalogs refresh correctly after login imports and session resumes", + "Provider retries now respect server delay hints, and interrupted OpenAI websocket responses start with a clean chain", + "TUI redraw and retry amplification has been reduced, while remote debug snapshots now capture the current frame" + ] +} diff --git a/changelog/v0.43.0.json b/changelog/v0.43.0.json new file mode 100644 index 0000000..ead9b75 --- /dev/null +++ b/changelog/v0.43.0.json @@ -0,0 +1,20 @@ +{ + "version": "0.43.0", + "date": "2026-07-11", + "title": "Terminal math and smoother swarm work", + "highlights": [ + "LaTeX now renders as readable terminal math, including common inline, display, fenced, and environment-based notation", + "Swarm waits now run asynchronously so the coordinator stays responsive while agents finish", + "The CLI can override the working directory used by remote sessions" + ], + "improvements": [ + "Swarm agent cards appear directly beneath spawn calls with clearer beta labeling", + "Tool discovery covers more integration categories", + "Todo guidance now emphasizes measurable goals and evidence-backed completion confidence" + ], + "fixes": [ + "Images with mismatched format labels are corrected before sending so providers no longer reject valid image payloads", + "Idle TUI sessions release retained heap memory more effectively", + "Jcode login directs users to the correct pricing path" + ] +} diff --git a/changelog/v0.44.0.json b/changelog/v0.44.0.json new file mode 100644 index 0000000..6f5d9c9 --- /dev/null +++ b/changelog/v0.44.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.44.0", + "date": "2026-07-12", + "title": "More reliable swarms across platforms", + "highlights": [ + "Swarm sessions use broader collision-resistant identities and clean up finished terminal members more reliably", + "Windows process handling now terminates detached task trees reliably and avoids startup and named-pipe failures", + "Amazon Bedrock now accepts tools whose top-level JSON Schema uses oneOf, anyOf, or allOf" + ], + "improvements": [ + "Swarm cards use calmer status indicators, stable worker identities, cleaner layouts, and tighter placement beneath spawn calls", + "Markdown and LaTeX rendering handles more notation forms and malformed input safely", + "Usage displays show reset countdowns, clear stale values correctly, and hide billing details when account authentication is unavailable", + "Project links and website integrations now use jcode.sh" + ], + "fixes": [ + "Remote server sessions initialize with the client's actual working directory", + "Command previews strip ANSI control sequences before rendering", + "Reload handoffs no longer issue unwanted terminal queries", + "Swarm gallery previews build correctly with current member metadata" + ] +} diff --git a/changelog/v0.45.0.json b/changelog/v0.45.0.json new file mode 100644 index 0000000..1e5e014 --- /dev/null +++ b/changelog/v0.45.0.json @@ -0,0 +1,22 @@ +{ + "version": "0.45.0", + "date": "2026-07-13", + "title": "Sharper planning and richer terminal workflows", + "highlights": [ + "Image files can be dragged directly into the composer and attach immediately", + "LaTeX can render as terminal images by default, including reliable multiline output", + "Todo and swarm activity now use compact borderless cards, clearer notifications, and denser progress indicators" + ], + "improvements": [ + "Todo goals now capture concrete feedback loops and enforce stronger measurable quality and end-to-end ownership gates", + "Terminal titles stay synchronized with todo session titles while preserving legacy title fallbacks", + "Startup avoids blocking terminal capability probes", + "Managed phone-server deployment guidance and defaults use stronger AWS access controls" + ], + "fixes": [ + "Agentgrep outline searches no longer duplicate relative file paths", + "Exact fuzzy-search matches rank correctly", + "Swarm cards resolve correctly outside gallery-filtered views", + "Dropped images bypass slash-command routing and attach without delay" + ] +} diff --git a/changelog/v0.46.0.json b/changelog/v0.46.0.json new file mode 100644 index 0000000..29e1031 --- /dev/null +++ b/changelog/v0.46.0.json @@ -0,0 +1,19 @@ +{ + "version": "0.46.0", + "date": "2026-07-13", + "title": "Clearer swarm hierarchies and steadier math rendering", + "highlights": [ + "Spawn cards now show nested agents and their active work directly in the chat transcript", + "Swarm prompts can be opened from inline controls without leaving the current workflow", + "Multiline LaTeX stays stable while responses stream and standalone equations render as complete math blocks" + ], + "improvements": [ + "LaTeX images scale to the terminal font size for more consistent visual proportions", + "OpenAI OAuth usage displays adapt to dynamically reported context and rate-limit windows" + ], + "fixes": [ + "Mouse capture remains enabled after restarting the TUI", + "Task graph seeding rejects collisions safely instead of partially applying conflicting nodes", + "Structured Markdown layouts preserve constrained widths when tables, lists, and blockquotes contain math" + ] +} diff --git a/crates/jcode-agent-runtime/Cargo.toml b/crates/jcode-agent-runtime/Cargo.toml new file mode 100644 index 0000000..ec79ded --- /dev/null +++ b/crates/jcode-agent-runtime/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "jcode-agent-runtime" +version = "0.1.0" +edition = "2024" + +[lib] +name = "jcode_agent_runtime" +path = "src/lib.rs" + +[dependencies] +thiserror = "1" +tokio = { version = "1", features = ["sync"] } + +[dev-dependencies] +tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "macros"] } diff --git a/crates/jcode-agent-runtime/src/lib.rs b/crates/jcode-agent-runtime/src/lib.rs new file mode 100644 index 0000000..be70183 --- /dev/null +++ b/crates/jcode-agent-runtime/src/lib.rs @@ -0,0 +1,282 @@ +use std::sync::Arc; + +/// A soft interrupt message queued for injection at the next safe point. +#[derive(Debug, Clone)] +pub struct SoftInterruptMessage { + pub content: String, + /// If true, can skip remaining tools when injected at point C. + pub urgent: bool, + pub source: SoftInterruptSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SoftInterruptSource { + User, + System, + BackgroundTask, +} + +/// Thread-safe soft interrupt queue that can be accessed without holding the agent lock. +pub type SoftInterruptQueue = Arc>>; + +/// Signal to move the currently executing tool to background. +/// Uses std::sync so it can be set without async from outside the agent lock. +pub type BackgroundToolSignal = Arc; + +/// Signal to gracefully stop generation. +pub type GracefulShutdownSignal = Arc; + +/// Async-aware interrupt signal that combines AtomicBool (sync read) with +/// tokio::Notify (async wake). Eliminates spin-loops during tool execution. +#[derive(Clone)] +pub struct InterruptSignal { + flag: Arc, + /// Monotonic fire counter. Lets owners of a timed/deferred reset detect + /// that a *newer* fire landed in the meantime and skip the reset instead + /// of erasing a cancel the target has not observed yet (issue #428). + epoch: Arc, + notify: Arc, +} + +impl InterruptSignal { + pub fn new() -> Self { + Self { + flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)), + notify: Arc::new(tokio::sync::Notify::new()), + } + } + + pub fn fire(&self) { + self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.notify.notify_waiters(); + } + + pub fn is_set(&self) -> bool { + self.flag.load(std::sync::atomic::Ordering::SeqCst) + } + + pub fn reset(&self) { + self.flag.store(false, std::sync::atomic::Ordering::SeqCst); + } + + /// Current fire epoch. Capture this right after a [`fire`](Self::fire) to + /// later reset only that specific fire via + /// [`reset_if_epoch`](Self::reset_if_epoch). + pub fn epoch(&self) -> u64 { + self.epoch.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Reset the signal only if no newer [`fire`](Self::fire) happened since + /// `epoch` was captured. Returns `true` when the reset was applied. + /// + /// If a racing fire lands between the epoch check and the reset, the + /// fire is restored (flag re-set and waiters re-notified) so no cancel + /// is ever silently erased. + pub fn reset_if_epoch(&self, epoch: u64) -> bool { + if self.epoch.load(std::sync::atomic::Ordering::SeqCst) != epoch { + return false; + } + self.flag.store(false, std::sync::atomic::Ordering::SeqCst); + if self.epoch.load(std::sync::atomic::Ordering::SeqCst) != epoch { + // A newer fire raced with the reset; restore it. + self.flag.store(true, std::sync::atomic::Ordering::SeqCst); + self.notify.notify_waiters(); + return false; + } + true + } + + pub async fn notified(&self) { + let mut notified = std::pin::pin!(self.notify.notified()); + // Explicitly register this waiter with the Notify before checking the + // flag. `notify_waiters()` (used by `fire()`) wakes only registered + // waiters; current tokio registers a `notified()` future at creation, + // but `enable()` makes the registration explicit rather than relying + // on that version-specific guarantee, since a lost wakeup here parks + // the cancel path (agent stream loop, tool-wait select) until an + // unrelated event arrives (issue #428). + notified.as_mut().enable(); + if self.is_set() { + return; + } + notified.await; + } + + pub fn as_atomic(&self) -> Arc { + Arc::clone(&self.flag) + } + + /// True when `other` is a clone of this signal (shares the same state). + /// Used by cancel fan-out to avoid double-firing the same signal and by + /// diagnostics that need to detect stale signal instances (issue #428). + pub fn same_instance(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.flag, &other.flag) + } +} + +impl Default for InterruptSignal { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct StreamError { + pub message: String, + pub retry_after_secs: Option, +} + +impl StreamError { + pub fn new(message: String, retry_after_secs: Option) -> Self { + Self { + message, + retry_after_secs, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// Documents the tokio semantics `InterruptSignal::notified()` relies on: + /// current tokio guarantees a `notified()` future receives wakeups from + /// `notify_waiters()` from the moment it is *created*, even before its + /// first poll. The explicit `enable()` in `notified()` makes that + /// registration explicit instead of relying on the version-specific + /// creation-time guarantee (hardening for issue #428). + #[tokio::test] + async fn notified_future_receives_notify_waiters_from_creation() { + let notify = tokio::sync::Notify::new(); + + // Created before the notification, not yet polled: must be woken. + let created_before = notify.notified(); + notify.notify_waiters(); + tokio::time::timeout(Duration::from_millis(100), created_before) + .await + .expect("a notified() future created before notify_waiters() must be woken"); + + // Created after the notification: must NOT be woken (notify_waiters + // stores no permit). This is why fire() also sets the atomic flag. + let created_after = notify.notified(); + assert!( + tokio::time::timeout(Duration::from_millis(50), created_after) + .await + .is_err(), + "notify_waiters() must not store a permit for future waiters" + ); + } + + /// Probabilistic race hammer for issue #428: `fire()` must never be lost + /// regardless of where the waiter is between creating the `notified()` + /// future and its first poll. The agent stream loop recreates this future + /// per stream event, so under fast token streams the pre-fix race made + /// Esc/Ctrl+C cancels appear to be ignored. + #[test] + fn fire_never_loses_wakeup_while_notified_races() { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_time() + .build() + .expect("runtime"); + rt.block_on(async { + for i in 0..2000 { + let signal = InterruptSignal::new(); + let waiter = { + let signal = signal.clone(); + tokio::spawn(async move { signal.notified().await }) + }; + // Fire concurrently: the waiter may be anywhere between + // future creation and first poll. + signal.fire(); + tokio::time::timeout(Duration::from_secs(2), waiter) + .await + .unwrap_or_else(|_| { + panic!( + "lost wakeup on iteration {i}: notified() missed fire() (issue #428)" + ) + }) + .expect("waiter task must not panic"); + } + }); + } + + /// A fire() that happened before notified() is observed immediately. + #[tokio::test] + async fn notified_returns_immediately_when_already_fired() { + let signal = InterruptSignal::new(); + signal.fire(); + tokio::time::timeout(Duration::from_millis(100), signal.notified()) + .await + .expect("pre-fired signal must resolve notified() immediately"); + } + + /// reset() clears the flag so subsequent notified() calls wait again. + #[tokio::test] + async fn reset_clears_fired_state() { + let signal = InterruptSignal::new(); + signal.fire(); + assert!(signal.is_set()); + signal.reset(); + assert!(!signal.is_set()); + let waited = tokio::time::timeout(Duration::from_millis(50), signal.notified()).await; + assert!(waited.is_err(), "reset signal must park notified() again"); + } + + /// reset_if_epoch() clears the flag only for the fire that captured the + /// epoch. A deferred reset (e.g. the server's 500ms timer for detached + /// turns) must not erase a newer cancel fired in the meantime, otherwise + /// rapid repeated Esc presses cancel each other out (issue #428). + #[test] + fn reset_if_epoch_skips_when_newer_fire_landed() { + let signal = InterruptSignal::new(); + signal.fire(); + let first_epoch = signal.epoch(); + + // A second cancel (repeated Esc) fires before the deferred reset runs. + signal.fire(); + assert!( + !signal.reset_if_epoch(first_epoch), + "stale deferred reset must be skipped" + ); + assert!( + signal.is_set(), + "newer cancel must survive the stale deferred reset" + ); + + // The reset scheduled for the latest fire still works. + let second_epoch = signal.epoch(); + assert!(signal.reset_if_epoch(second_epoch)); + assert!(!signal.is_set()); + + // And a reset for an already-consumed epoch stays a no-op. + assert!(!signal.reset_if_epoch(first_epoch)); + } + + /// A fire() racing the flag-clear inside reset_if_epoch() is restored + /// rather than silently erased. + #[test] + fn reset_if_epoch_never_erases_concurrent_fire() { + for _ in 0..2000 { + let signal = InterruptSignal::new(); + signal.fire(); + let epoch = signal.epoch(); + + let firer = { + let signal = signal.clone(); + std::thread::spawn(move || signal.fire()) + }; + let _ = signal.reset_if_epoch(epoch); + firer.join().expect("firer thread"); + + assert!( + signal.is_set(), + "a concurrent fire() must never be erased by reset_if_epoch()" + ); + } + } +} diff --git a/crates/jcode-ambient-types/Cargo.toml b/crates/jcode-ambient-types/Cargo.toml new file mode 100644 index 0000000..136208b --- /dev/null +++ b/crates/jcode-ambient-types/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "jcode-ambient-types" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } diff --git a/crates/jcode-ambient-types/src/lib.rs b/crates/jcode-ambient-types/src/lib.rs new file mode 100644 index 0000000..223ccde --- /dev/null +++ b/crates/jcode-ambient-types/src/lib.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum UsageSource { + User, + Ambient, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageRecord { + pub timestamp: DateTime, + pub source: UsageSource, + pub tokens_input: u32, + pub tokens_output: u32, + pub provider: String, +} + +impl UsageRecord { + pub fn total_tokens(&self) -> u64 { + self.tokens_input as u64 + self.tokens_output as u64 + } +} + +#[derive(Debug, Clone)] +pub struct RateLimitInfo { + pub limit_tokens: Option, + pub remaining_tokens: Option, + pub limit_requests: Option, + pub remaining_requests: Option, + pub reset_at: Option>, +} diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml new file mode 100644 index 0000000..1a53625 --- /dev/null +++ b/crates/jcode-app-core/Cargo.toml @@ -0,0 +1,141 @@ +[package] +name = "jcode-app-core" +version = "0.1.0" +edition = "2024" +publish = false + +# Application core for jcode: all non-presentation modules (server, agent, +# provider, auth, session, tool, config, etc.) extracted out of the monolithic +# root `jcode` crate so they compile as a separate rustc unit. The root `jcode` +# crate (cli + tui + bin) re-exports this crate's modules via `pub use +# jcode_app_core::*`, preserving every existing `crate::` path. + +[lib] +name = "jcode_app_core" +path = "src/lib.rs" + +[dependencies] +# NOTE: the jemalloc allocator stats live in `jcode-base` (process_memory) and +# local embeddings live in `jcode-base` (embedding); this crate forwards those +# features to jcode-base rather than depending on the backing crates directly. + +# Async runtime +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-util = { version = "0.7", features = ["rt"] } +futures = "0.3" +async-trait = "0.1" + +# HTTP client +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } + +# CLI + +# File operations +glob = "0.3" +similar = "2" # diffing for edits + +# Utilities +dirs = "5" # home directory +anyhow = "1" +libc = "0.2" # Unix system calls (flock) +chrono = { version = "0.4", features = ["serde"] } +regex = "1" +urlencoding = "2" # URL encoding for web search +uuid = { version = "1", features = ["v4", "v5"] } + +# Embeddings (local inference) live in jcode-base; this crate forwards the +# `embeddings` feature to jcode-base rather than depending on jcode-embedding. +jcode-import-core = { path = "../jcode-import-core" } + +# OAuth +base64 = "0.22" +sha2 = "0.10" +rand = "0.9.3" +url = "2" +open = "5" # Open URLs in browser +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" } +# 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. +jcode-update-core = { path = "../jcode-update-core" } +jcode-terminal-image = { path = "../jcode-terminal-image" } + +# Streaming +tokio-stream = "0.1" + +# TUI +ratatui = "0.30" +crossterm = { version = "0.29", features = ["event-stream"] } + +# Markdown & syntax highlighting +unicode-width = "0.2" # Unicode character display width + +# PDF parsing (behind feature flag) +jcode-pdf = { path = "../jcode-pdf", optional = true } +jcode-build-support = { path = "../jcode-build-support" } +jcode-build-meta = { path = "../jcode-build-meta" } + +# Foundational layer (provider, auth, config, session, message, memory, ...). +# 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-core = { path = "../jcode-core" } +jcode-message-types = { path = "../jcode-message-types" } +jcode-overnight-core = { path = "../jcode-overnight-core" } +jcode-plan = { path = "../jcode-plan" } +jcode-swarm-core = { path = "../jcode-swarm-core" } +jcode-selfdev-types = { path = "../jcode-selfdev-types" } +jcode-session-types = { path = "../jcode-session-types" } +jcode-setup-hints = { path = "../jcode-setup-hints" } +jcode-task-types = { path = "../jcode-task-types" } +jcode-tool-core = { path = "../jcode-tool-core" } +jcode-tool-types = { path = "../jcode-tool-types" } + +# Archive extraction (for auto-update) +flate2 = "1" +tar = "0.4" +tempfile = "3" +agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.6" } + +[features] +default = ["pdf", "embeddings", "bedrock"] +# jemalloc allocator stats live in jcode-base (process_memory); forward there. +jemalloc = ["jcode-base/jemalloc"] +jemalloc-prof = ["jcode-base/jemalloc-prof"] +# local embeddings live in jcode-base (embedding); forward there. +embeddings = ["jcode-base/embeddings"] +# live AWS Bedrock support lives in jcode-base (provider/bedrock); forward there. +bedrock = ["jcode-base/bedrock"] +# 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 +# ExternalAuthReviewCandidate read accessors) as `pub` and forwards +# jcode-base's `test-support` so downstream crates' test targets can reach the +# whole stack's helpers. Never enabled in normal (non-test) builds. +test-support = ["jcode-base/test-support"] + +[dev-dependencies] +# Used by async streaming tests (ambient/runner_tests, server/client_actions_tests). +async-stream = "0.3" +# Enables jcode-base's test-support helpers (storage::lock_test_env, +# auth::test_sandbox, bus::reset_models_updated_publish_state_for_tests) for +# *this* crate's own test target. Feature unification applies this only to +# test/bench/example builds, not to a normal `cargo build`. +jcode-base = { path = "../jcode-base", features = ["test-support"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + +[target.'cfg(target_os = "macos")'.dependencies] +global-hotkey = "0.7" +# Native desktop control for the `macos_computer_use` tool (synthetic mouse/keyboard +# events, cursor positioning, display bounds). `highsierra` enables the +# CGEventCreateScrollWheelEvent2 binding used for scroll. +core-graphics = { version = "0.23", features = ["highsierra"] } diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs new file mode 100644 index 0000000..70eb9a1 --- /dev/null +++ b/crates/jcode-app-core/src/agent.rs @@ -0,0 +1,997 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +mod compaction; +mod environment; +mod inline_tail; +mod interrupts; +mod messages; +mod prompting; +mod provider; +mod response_recovery; +mod status; +mod streaming; +mod tools; +mod turn_execution; +mod turn_loops; +mod turn_streaming_mpsc; +mod utils; + +use self::streaming::{send_stream_keepalive_mpsc, stream_keepalive_ticker}; +use self::tools::{ + cap_sdk_tool_content_for_history, cap_tool_output_for_history, print_tool_summary, + tool_output_side_pane_images, tool_output_to_content_blocks, +}; +use self::utils::trace_enabled; +use crate::build; +use crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus}; +use crate::cache_tracker::CacheTracker; +use crate::compaction::CompactionEvent; +use crate::id; +use crate::logging; +use crate::message::{ + ContentBlock, Message, Role, StreamEvent, TOOL_OUTPUT_MISSING_TEXT, ToolCall, ToolDefinition, +}; +use crate::protocol::{HistoryMessage, ServerEvent}; +use crate::provider::{NativeToolResult, Provider, ProviderRuntimeState}; +use crate::session::{GitState, Session, SessionStatus, StoredDisplayRole, StoredMessage}; +use crate::skill::SkillRegistry; +use crate::tool::{Registry, ToolContext, ToolExecutionMode}; +use anyhow::Result; +use futures::StreamExt; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +use interrupts::{NoToolCallOutcome, PostToolInterruptOutcome}; +pub use jcode_agent_runtime::{ + BackgroundToolSignal, GracefulShutdownSignal, InterruptSignal, SoftInterruptMessage, + SoftInterruptQueue, SoftInterruptSource, StreamError, +}; + +const JCODE_NATIVE_TOOLS: &[&str] = &["selfdev", "communicate"]; +static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); +static JCODE_REPO_SOURCE_STATE: LazyLock<(Option, Option)> = LazyLock::new(|| { + crate::build::get_repo_dir() + .map(|repo_dir| { + ( + build::current_git_hash(&repo_dir).ok(), + build::is_working_tree_dirty(&repo_dir).ok(), + ) + }) + .unwrap_or((None, None)) +}); +static WORKING_GIT_STATE_CACHE: LazyLock>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); +const STREAM_KEEPALIVE_PONG_ID: u64 = 0; + +fn stable_hash_str(value: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() +} + +fn stable_hash_json(value: &T) -> u64 { + let encoded = serde_json::to_string(value).unwrap_or_default(); + stable_hash_str(&encoded) +} + +fn stable_json_len(value: &T) -> usize { + serde_json::to_string(value) + .map(|encoded| encoded.len()) + .unwrap_or_default() +} + +fn message_hashes(messages: &[Message]) -> Vec { + // Hash the cache-relevant projection, not the raw Message. Raw hashing + // keys off non-transmitted metadata (timestamp, tool_duration_ms, + // ReasoningTrace blocks, cache_control markers), which triggers spurious + // harness:_prefix_changed KV-cache miss reports when the same message is + // re-serialized with backfilled metadata on the next turn. + crate::message::cache_relevant_message_hashes(messages) +} + +fn kv_cache_request_event( + messages: &[Message], + tools: &[ToolDefinition], + system_static: &str, + ephemeral_messages: &[Message], +) -> ServerEvent { + let ephemeral_hash = if ephemeral_messages.is_empty() { + None + } else { + Some(stable_hash_json(ephemeral_messages)) + }; + ServerEvent::KvCacheRequest { + system_static_hash: stable_hash_str(system_static), + tools_hash: stable_hash_json(tools), + messages_hash: stable_hash_json(&crate::message::cache_relevant_messages(messages)), + message_hashes: message_hashes(messages), + message_count: messages.len(), + tool_count: tools.len(), + system_static_chars: system_static.chars().count(), + tools_json_chars: stable_json_len(tools), + messages_json_chars: stable_json_len(messages), + ephemeral_hash, + ephemeral_chars: stable_json_len(ephemeral_messages), + ephemeral_message_count: ephemeral_messages.len(), + } +} + +fn log_agent_provider_stream_lifecycle( + level: logging::LogLevel, + agent: &Agent, + phase: &str, + api_start: Instant, + fields: Vec<(&str, String)>, +) { + let mut owned = vec![ + ("phase".to_string(), phase.to_string()), + ("provider".to_string(), agent.provider.name().to_string()), + ("model".to_string(), agent.provider.model()), + ("session_id".to_string(), agent.session.id.clone()), + ( + "provider_session_id".to_string(), + agent + .provider_session_id + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ( + "connection_type".to_string(), + agent + .last_connection_type + .clone() + .unwrap_or_else(|| "unknown".to_string()), + ), + ( + "elapsed_ms".to_string(), + api_start.elapsed().as_millis().to_string(), + ), + ]; + owned.extend( + fields + .into_iter() + .map(|(key, value)| (key.to_string(), value)), + ); + logging::event(level, "AGENT_PROVIDER_STREAM_LIFECYCLE", owned); +} + +/// Token usage from the last API request +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct TokenUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_input_tokens: Option, + pub cache_creation_input_tokens: Option, +} + +#[derive(Debug, Clone)] +struct RewindUndoSnapshot { + messages: Vec, + provider_session_id: Option, + session_provider_session_id: Option, + visible_message_count: usize, +} + +pub struct Agent { + provider: Arc, + registry: Registry, + skills: Arc, + session: Session, + active_skill: Option, + allowed_tools: Option>, + disabled_tools: HashSet, + /// 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 + last_upstream_provider: Option, + /// Last observed transport/connection type for this session + last_connection_type: Option, + /// Last provider-supplied human-readable transport detail for this session + last_status_detail: Option, + /// Pending swarm alerts to inject into the next turn + pending_alerts: Vec, + /// Transient reminder injected into provider requests for the current turn only. + /// Not persisted to session history. + current_turn_system_reminder: Option, + /// Tool call ids observed in the current session transcript. + tool_call_ids: HashSet, + /// Tool result ids observed in the current session transcript. + tool_result_ids: HashSet, + /// Number of stored session messages already indexed for missing tool-output repair. + tool_output_scan_index: usize, + /// Soft interrupt queue: messages to inject at next safe point without cancelling + /// Uses std::sync::Mutex so it can be accessed without async, even while agent is processing + soft_interrupt_queue: SoftInterruptQueue, + /// Signal from client to move the currently executing tool to background + background_tool_signal: InterruptSignal, + /// Signal to gracefully stop generation (checkpoint partial response and exit) + graceful_shutdown: InterruptSignal, + /// Client-side cache tracking for detecting append-only violations + cache_tracker: CacheTracker, + /// Last token usage from API request (for debug socket queries) + last_usage: TokenUsage, + /// Locked tool list: once the first API request is sent, freeze the tool list + /// to avoid cache invalidation when MCP tools arrive asynchronously. + /// Cleared on compaction/reset. + locked_tools: Option>, + /// One-shot guard for the async MCP-registration race (#206). + /// + /// MCP servers connect on a background task and register `mcp__*` tools + /// seconds after the session starts (we deliberately do NOT block the first + /// turn on MCP connection, so the user can talk to the agent immediately). + /// The first turn therefore locks a snapshot without MCP tools. We allow + /// exactly one rebuild to pick them up — an intentional, one-time provider + /// prompt-cache miss. Once that rebuild happens (or we confirm there are no + /// MCP tools to wait for), this is set so the per-turn registry scan stops. + /// Reset whenever the tool list is intentionally unlocked. + mcp_late_register_resolved: bool, + /// Override system prompt (used by ambient mode to inject a custom prompt) + system_prompt_override: Option, + /// Whether memory features are enabled for this session + memory_enabled: bool, + /// One-step undo snapshot captured before the most recent rewind. + rewind_undo_snapshot: Option, + /// Channel for tools to request stdin input from the user + stdin_request_tx: Option>, + /// Canonical reducer-backed view of runtime provider/model selection. + provider_runtime_state: ProviderRuntimeState, + /// When true, this session is an inline swarm worker: stream a throttled + /// output tail to the global bus so the coordinator's inline gallery can + /// render a live viewport. Off for normal sessions to avoid bus traffic. + inline_output_tap: bool, + /// Rolling activity tail (text + tool markers) for the inline output tap. + /// Persists across turns so the coordinator's viewport never blanks at + /// turn boundaries or freezes during long tool calls. + inline_tail: inline_tail::InlineTailBuffer, +} + +impl Agent { + fn should_track_client_cache(&self) -> bool { + match std::env::var("JCODE_TRACK_CLIENT_CACHE") { + Ok(value) => { + let value = value.trim(); + !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") + } + Err(_) => false, + } + } + + fn build_base( + provider: Arc, + registry: Registry, + session: Session, + allowed_tools: Option>, + disabled_tools: HashSet, + ) -> Self { + let skills = SkillRegistry::shared_snapshot(); + let initial_provider_model = provider.model(); + let agent = Self { + provider, + registry, + skills, + session, + active_skill: None, + allowed_tools, + disabled_tools, + provider_session_id: None, + last_upstream_provider: None, + last_connection_type: None, + last_status_detail: None, + pending_alerts: Vec::new(), + current_turn_system_reminder: None, + tool_call_ids: HashSet::new(), + tool_result_ids: HashSet::new(), + tool_output_scan_index: 0, + soft_interrupt_queue: Arc::new(std::sync::Mutex::new(Vec::new())), + background_tool_signal: InterruptSignal::new(), + graceful_shutdown: InterruptSignal::new(), + cache_tracker: CacheTracker::new(), + last_usage: TokenUsage::default(), + locked_tools: None, + mcp_late_register_resolved: false, + system_prompt_override: None, + 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 + } + + fn current_skills_snapshot(&self) -> Arc { + // Global skills come from the process-wide shared registry; the + // project-local overlay is composed fresh from this session's + // workspace root so per-repo skills are session-scoped, immediately + // visible, and never leak across sessions (issue #457). + let global = self + .registry + .skills() + .try_read() + .map(|skills| Arc::new(skills.clone())) + .unwrap_or_else(|_| self.skills.clone()); + let working_dir = self + .session + .working_dir + .as_deref() + .map(std::path::Path::new); + Arc::new(SkillRegistry::effective_for_working_dir( + &global, + working_dir, + )) + } + + pub fn available_skill_names(&self) -> Vec { + self.current_skills_snapshot() + .list() + .iter() + .map(|skill| skill.name.clone()) + .collect() + } + + pub fn new(provider: Arc, registry: Registry) -> Self { + Self::new_with_initial_working_dir(provider, registry, None) + } + + pub(crate) fn new_with_initial_working_dir( + provider: Arc, + registry: Registry, + working_dir: Option<&str>, + ) -> Self { + let tool_selection = crate::config::config().tools.selection(); + let mut session = Session::create(None, None); + if let Some(working_dir) = working_dir { + session.working_dir = Some(working_dir.to_string()); + } + let mut agent = Self::build_base( + provider, + registry, + session, + tool_selection.allowed_tools, + 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.ensure_initial_session_context_message(); + agent.seed_compaction_from_session(); + agent.log_env_snapshot("create"); + agent.fire_session_lifecycle_hook("session_start", "create"); + crate::telemetry::begin_session_with_parent( + agent.provider.name(), + &agent.provider.model(), + agent.session.parent_id.clone(), + false, + ); + agent + } + + pub fn new_with_session( + provider: Arc, + registry: Registry, + session: Session, + allowed_tools: Option>, + ) -> Self { + let tool_selection = if let Some(allowed_tools) = allowed_tools { + crate::config::ToolSelection { + allowed_tools: Some(allowed_tools), + disabled_tools: HashSet::new(), + } + } else { + crate::config::config().tools.selection() + }; + let mut agent = Self::build_base( + provider, + registry, + session, + tool_selection.allowed_tools, + tool_selection.disabled_tools, + ); + agent.session.mark_active(); + if agent.session.provider_key.is_none() { + agent.session.provider_key = + crate::session::derive_session_provider_key(agent.provider.name()); + } + if let Some(model) = agent.session.model.clone() { + let model_request = + crate::provider::MultiProvider::model_switch_request_for_session_route( + &model, + agent.session.provider_key.as_deref(), + agent.session.route_api_method.as_deref(), + ); + if let Err(e) = crate::provider::set_model_with_auth_refresh( + agent.provider.as_ref(), + &model_request, + ) { + logging::error(&format!( + "Failed to restore session model '{}' via '{}': {}", + model, model_request, e + )); + } + } else { + agent.session.model = Some(agent.provider.model()); + } + agent.restore_reasoning_effort_from_session(); + agent.session.ensure_initial_session_context_message(); + agent.sync_memory_dedup_state_from_session(); + agent.seed_compaction_from_session(); + agent.log_env_snapshot("attach"); + agent.fire_session_lifecycle_hook("session_start", "attach"); + crate::telemetry::begin_session_with_parent( + agent.provider.name(), + &agent.provider.model(), + agent.session.parent_id.clone(), + false, + ); + agent + } + + fn seed_compaction_from_session(&mut self) { + logging::info(&format!( + "seed_compaction_from_session: session has {} messages", + self.session.messages.len() + )); + let compaction = self.registry.compaction(); + let mut manager = match compaction.try_write() { + Ok(manager) => manager, + Err(_) => { + logging::warn( + "seed_compaction_from_session: compaction lock unavailable, skipping restore", + ); + return; + } + }; + manager.reset(); + let budget = self.provider.context_window(); + manager.set_budget(budget); + if let Some(state) = self.session.compaction.as_ref() { + manager.restore_persisted_stored_state_with(state, &self.session.messages); + } else { + manager.seed_restored_stored_messages_with(&self.session.messages); + } + let sanitized_state = if manager.discard_oversized_openai_native_compaction() { + Some(manager.persisted_state()) + } else { + None + }; + logging::info(&format!( + "seed_compaction_from_session: seeded compaction with {} messages", + self.session.messages.len() + )); + drop(manager); + if let Some(state) = sanitized_state { + self.session.compaction = state; + self.persist_session_best_effort("sanitized oversized OpenAI native compaction"); + } + } + + fn sync_memory_dedup_state_from_session(&self) { + crate::memory::sync_injected_memories( + &self.session.id, + &self.session.injected_memory_ids(), + ); + } + + fn record_memory_injection_in_session(&mut self, memory: &crate::memory::PendingMemory) { + let count = memory.count.max(1); + let age_ms = memory.computed_at.elapsed().as_millis() as u64; + let summary = if count == 1 { + "🧠 auto-recalled 1 memory".to_string() + } else { + format!("🧠 auto-recalled {} memories", count) + }; + let display_prompt = memory.display_prompt.clone().unwrap_or_else(|| { + if memory.prompt.trim().is_empty() { + "# Memory\n\n## Notes\n1. (empty injection payload)".to_string() + } else { + memory.prompt.clone() + } + }); + + self.session.record_memory_injection( + summary, + display_prompt, + count as u32, + age_ms, + memory.memory_ids.clone(), + ); + if let Err(err) = self.session.save() { + logging::warn(&format!( + "Failed to persist memory injection for session {}: {}", + self.session.id, err + )); + } + } + + fn memory_injection_message(memory: &crate::memory::PendingMemory) -> Message { + Message::user(&format!( + "\n{}\n", + memory.prompt + )) + } + + pub(super) fn prepare_memory_injection_message( + &mut self, + memory: &crate::memory::PendingMemory, + ) -> (Message, bool) { + let message = Self::memory_injection_message(memory); + let persist = crate::config::config().features.persist_memory_injections; + if persist { + self.add_message_with_display_role( + Role::User, + message.content.clone(), + Some(StoredDisplayRole::System), + ); + self.persist_session_best_effort("persisted memory injection message"); + } + (message, persist) + } + + fn persist_session_best_effort(&mut self, context: &str) { + if let Err(err) = self.session.save() { + logging::warn(&format!( + "Failed to persist {} for session {}: {}", + context, self.session.id, err + )); + } + } + + fn reset_runtime_state_for_session_change(&mut self) { + self.active_skill = None; + self.last_upstream_provider = None; + self.last_connection_type = None; + self.last_status_detail = None; + self.pending_alerts.clear(); + self.current_turn_system_reminder = None; + self.reset_tool_output_tracking(); + if let Ok(mut queue) = self.soft_interrupt_queue.lock() { + queue.clear(); + } + self.background_tool_signal.reset(); + self.graceful_shutdown.reset(); + self.cache_tracker.reset(); + self.last_usage = TokenUsage::default(); + self.locked_tools = None; + self.mcp_late_register_resolved = false; + self.rewind_undo_snapshot = None; + } + + fn sync_session_compaction_state_from_manager( + &mut self, + manager: &crate::compaction::CompactionManager, + ) { + let new_state = manager.persisted_state(); + if self.session.compaction != new_state { + self.session.compaction = new_state; + if let Err(err) = self.session.save() { + logging::error(&format!( + "Failed to persist compaction state for session {}: {}", + self.session.id, err + )); + } + } + } + + fn apply_openai_native_compaction( + &mut self, + encrypted_content: String, + compacted_count: usize, + ) -> Result<()> { + let encrypted_content_len = encrypted_content.len(); + let (summary_text, openai_encrypted_content) = + if crate::provider::openai_request::openai_encrypted_content_is_sendable( + &encrypted_content, + ) { + (String::new(), Some(encrypted_content)) + } else { + logging::warn(&format!( + "Discarding oversized OpenAI native compaction payload before persist ({} chars)", + encrypted_content_len, + )); + ( + crate::provider::openai_request::openai_encrypted_content_fallback_summary( + encrypted_content_len, + ), + None, + ) + }; + let state = crate::session::StoredCompactionState { + summary_text, + openai_encrypted_content, + covers_up_to_turn: compacted_count, + original_turn_count: compacted_count, + compacted_count, + }; + + self.session.compaction = Some(state.clone()); + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + manager.set_budget(self.provider.context_window()); + manager.restore_persisted_stored_state_with(&state, &self.session.messages); + } + + self.cache_tracker.reset(); + self.locked_tools = None; + self.mcp_late_register_resolved = false; + self.provider_session_id = None; + self.session.provider_session_id = None; + self.session.save()?; + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "native_compaction_applied", + "provider_native_compaction_persisted", + ) + .with_session_id(self.session.id.clone()) + .force_attribution(), + ); + Ok(()) + } + + fn messages_for_provider(&mut self) -> (Vec, Option) { + if self.provider.supports_compaction() || self.session.compaction.is_some() { + let compaction = self.registry.compaction(); + match compaction.try_write() { + Ok(mut manager) => { + let discarded_oversized_native = + manager.discard_oversized_openai_native_compaction(); + let messages = { + let all_messages = self.session.provider_messages(); + if self.provider.uses_jcode_compaction() { + let action = + manager.ensure_context_fits(all_messages, self.provider.clone()); + match action { + crate::compaction::CompactionAction::BackgroundStarted { + trigger, + } => { + logging::info(&format!( + "Background compaction started ({})", + trigger + )); + } + crate::compaction::CompactionAction::HardCompacted(dropped) => { + logging::warn(&format!( + "Emergency hard compact: dropped {} messages (context was critical)", + dropped + )); + } + crate::compaction::CompactionAction::None => {} + } + } + manager.messages_for_api_with(all_messages) + }; + let event = manager.take_compaction_event(); + if event.is_some() || discarded_oversized_native { + self.sync_session_compaction_state_from_manager(&manager); + } + if event.is_some() { + self.note_compaction_applied(); + self.persist_session_best_effort("compaction completion"); + } + let user_count = messages + .iter() + .filter(|message| matches!(message.role, Role::User)) + .count(); + let assistant_count = messages.len().saturating_sub(user_count); + logging::info(&format!( + "messages_for_provider (compaction): returning {} messages (user={}, assistant={})", + messages.len(), + user_count, + assistant_count, + )); + return (messages, event); + } + Err(_) => { + logging::info("messages_for_provider: compaction lock failed, using session"); + } + }; + } + + let all_messages = self.session.provider_messages(); + let messages = all_messages.to_vec(); + let user_count = messages + .iter() + .filter(|message| matches!(message.role, Role::User)) + .count(); + let assistant_count = messages.len().saturating_sub(user_count); + logging::info(&format!( + "messages_for_provider (session): returning {} messages (user={}, assistant={})", + messages.len(), + user_count, + assistant_count, + )); + (messages, None) + } + + fn record_client_cache_request(&mut self, messages: &[Message]) { + if !self.should_track_client_cache() { + return; + } + + let fast_snapshot = + if !self.provider.uses_jcode_compaction() && self.session.compaction.is_none() { + let previous_count = self.cache_tracker.previous_message_count(); + let prefix_hashes = self.session.provider_message_prefix_hashes(); + let current_count = prefix_hashes.len(); + let current_full_hash = prefix_hashes.last().copied(); + let prefix_hash_at_previous_count = + if previous_count == 0 || previous_count > current_count { + None + } else { + Some(prefix_hashes[previous_count - 1]) + }; + Some(( + current_count, + prefix_hash_at_previous_count, + current_full_hash, + )) + } else { + None + }; + + let violation = + if let Some((current_count, prefix_hash_at_previous_count, current_full_hash)) = + fast_snapshot + { + self.cache_tracker.record_prefix_hash_snapshot( + current_count, + prefix_hash_at_previous_count, + current_full_hash, + ) + } else { + self.cache_tracker.record_request(messages) + }; + + if let Some(violation) = violation { + logging::warn(&format!( + "CLIENT_CACHE_VIOLATION: {} | turn={} messages={}", + violation.reason, violation.turn, violation.message_count + )); + } + } + + fn repair_missing_tool_outputs(&mut self) -> usize { + if self.tool_output_scan_index > self.session.messages.len() { + self.reset_tool_output_tracking(); + } + + let scan_start = self.tool_output_scan_index; + let mut new_result_ids = Vec::new(); + let mut assistant_tool_uses: Vec<(usize, Vec)> = Vec::new(); + + for (index, msg) in self.session.messages.iter().enumerate().skip(scan_start) { + match msg.role { + Role::User => { + for block in &msg.content { + if let ContentBlock::ToolResult { tool_use_id, .. } = block { + new_result_ids.push(tool_use_id.clone()); + } + } + } + Role::Assistant => { + let tool_uses = msg + .content + .iter() + .filter_map(|block| match block { + ContentBlock::ToolUse { id, .. } => Some(id.clone()), + _ => None, + }) + .collect::>(); + if !tool_uses.is_empty() { + assistant_tool_uses.push((index, tool_uses)); + } + } + } + } + + self.tool_result_ids.extend(new_result_ids); + + let mut missing_repairs: Vec<(usize, Vec)> = Vec::new(); + for (index, tool_uses) in assistant_tool_uses { + 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 !missing_for_message.is_empty() { + missing_repairs.push((index, missing_for_message)); + } + } + + self.tool_output_scan_index = self.session.messages.len(); + + let mut repaired = 0usize; + let mut inserted = 0usize; + for (index, missing_for_message) in missing_repairs { + for (offset, id) in missing_for_message.iter().enumerate() { + let tool_block = ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: TOOL_OUTPUT_MISSING_TEXT.to_string(), + is_error: Some(true), + }; + let stored_message = StoredMessage { + id: id::new_id("message"), + role: Role::User, + content: vec![tool_block], + display_role: None, + timestamp: Some(chrono::Utc::now()), + tool_duration_ms: None, + token_usage: None, + }; + self.session + .insert_message(index + 1 + inserted + offset, stored_message); + self.tool_result_ids.insert(id.clone()); + repaired += 1; + } + inserted += missing_for_message.len(); + } + + self.tool_output_scan_index = self.session.messages.len(); + + if repaired > 0 { + self.persist_session_best_effort("missing tool-output repair"); + self.cache_tracker.reset(); + self.locked_tools = None; + self.mcp_late_register_resolved = false; + } + + repaired + } + + fn reset_tool_output_tracking(&mut self) { + self.tool_call_ids.clear(); + self.tool_result_ids.clear(); + self.tool_output_scan_index = 0; + } + + pub fn session_id(&self) -> &str { + &self.session.id + } + + pub(crate) fn set_working_dir_for_pending_context(&mut self, working_dir: Option) { + if working_dir.is_some() { + self.session.working_dir = working_dir; + self.session.refresh_initial_session_context_message(); + } + } + + /// 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.persist_soft_interrupt_snapshot(); + self.session.mark_closed(); + if !self.session.messages.is_empty() { + self.persist_session_best_effort("session close state"); + } + self.fire_session_lifecycle_hook("session_end", "close"); + } + + /// Fire a session lifecycle observer hook (`session_start`/`session_end`). + /// No-op when the hook is not configured. + pub(crate) fn fire_session_lifecycle_hook(&self, event_name: &'static str, source: &str) { + if !crate::hooks::hook_configured(event_name) { + return; + } + let mut event = crate::hooks::HookEvent::new(event_name) + .session_id(self.session.id.clone()) + .field("SOURCE", source) + .field("MODEL", self.provider_model()); + if let Some(cwd) = self.working_dir() { + event = event.cwd(cwd); + } + crate::hooks::dispatch_observer(event); + } + + pub fn mark_crashed(&mut self, message: Option) { + 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"); + } + } + + /// Get the last token usage from the most recent API request + pub fn last_usage(&self) -> &TokenUsage { + &self.last_usage + } + + pub fn token_usage_totals(&self) -> crate::protocol::TokenUsageTotals { + self.session.token_usage_totals() + } + + /// Export the full conversation as a markdown transcript. + pub fn export_conversation_markdown(&self) -> String { + let mut md = String::new(); + for msg in &self.session.messages { + let role_label = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + md.push_str(&format!("### {}\n\n", role_label)); + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } => { + md.push_str(text); + md.push_str("\n\n"); + } + ContentBlock::Reasoning { text } => { + md.push_str(&format!("*Thinking:* {}\n\n", text)); + } + ContentBlock::ReasoningTrace { text } => { + md.push_str(&format!("*Thinking:* {}\n\n", text)); + } + ContentBlock::AnthropicThinking { thinking, .. } => { + md.push_str(&format!("*Thinking:* {}\n\n", thinking)); + } + ContentBlock::OpenAIReasoning { summary, .. } => { + if !summary.is_empty() { + md.push_str(&format!("*Thinking:* {}\n\n", summary.join("\n"))); + } + } + ContentBlock::ToolUse { name, input, .. } => { + let input_str = serde_json::to_string_pretty(input) + .unwrap_or_else(|_| input.to_string()); + md.push_str(&format!( + "**Tool: `{}`**\n```json\n{}\n```\n\n", + name, input_str + )); + } + ContentBlock::ToolResult { + content, is_error, .. + } => { + let label = if is_error == &Some(true) { + "Error" + } else { + "Result" + }; + // Truncate very long results + let display = if content.len() > 2000 { + format!( + "{}... (truncated, {} chars total)", + crate::util::truncate_str(content, 2000), + content.len() + ) + } else { + content.clone() + }; + md.push_str(&format!("**{}:**\n```\n{}\n```\n\n", label, display)); + } + ContentBlock::Image { .. } => { + md.push_str("[Image]\n\n"); + } + ContentBlock::OpenAICompaction { .. } => { + md.push_str("[OpenAI native compaction]\n\n"); + } + } + } + } + md + } +} + +#[cfg(test)] +#[path = "agent_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/agent/compaction.rs b/crates/jcode-app-core/src/agent/compaction.rs new file mode 100644 index 0000000..c56c7a7 --- /dev/null +++ b/crates/jcode-app-core/src/agent/compaction.rs @@ -0,0 +1,344 @@ +use super::*; + +impl Agent { + pub(super) fn note_compaction_applied(&mut self) { + self.cache_tracker.reset(); + self.locked_tools = None; + self.provider_session_id = None; + self.session.provider_session_id = None; + } + + pub fn poll_compaction_completion_event(&mut self) -> Option { + let provider_messages = self.session.messages_for_provider(); + let compaction = self.registry.compaction(); + let event = match compaction.try_write() { + Ok(mut manager) => { + let event = manager.poll_compaction_event_with(&provider_messages); + if event.is_some() { + self.sync_session_compaction_state_from_manager(&manager); + } + event + } + Err(_) => return None, + }; + + if event.is_some() { + self.note_compaction_applied(); + self.persist_session_best_effort("compaction completion"); + } + + event + } + + pub fn request_manual_compaction(&mut self) -> (String, bool) { + if !self.provider.supports_compaction() { + return ( + "Manual compaction is not available for this provider.".to_string(), + false, + ); + } + + let provider = self.provider.fork(); + let messages = self.session.messages_for_provider(); + let compaction = self.registry.compaction(); + + match compaction.try_write() { + Ok(mut manager) => { + let stats = manager.stats_with(&messages); + let status_msg = format!( + "**Context Status:**\n\ + • Messages: {} (active), {} (total history)\n\ + • Token usage: ~{}k (estimate ~{}k) / {}k ({:.1}%)\n\ + • Has summary: {}\n\ + • Compacting: {}", + stats.active_messages, + stats.total_turns, + stats.effective_tokens / 1000, + stats.token_estimate / 1000, + manager.token_budget() / 1000, + stats.context_usage * 100.0, + if stats.has_summary { "yes" } else { "no" }, + if stats.is_compacting { + "in progress..." + } else { + "no" + } + ); + + match manager.force_compact_with(&messages, provider) { + Ok(()) => ( + format!( + "{}\n\n📦 **Compacting context** (manual) — summarizing older messages in the background to stay within the context window.\n\ + The summary will be applied automatically when ready.", + status_msg + ), + true, + ), + Err(reason) => ( + format!("{status_msg}\n\n⚠ **Cannot compact:** {reason}"), + false, + ), + } + } + Err(_) => ( + "⚠ Cannot access compaction manager (lock held)".to_string(), + false, + ), + } + } + + fn is_context_limit_error(error: &str) -> bool { + let lower = error.to_lowercase(); + lower.contains("context length") + || lower.contains("context window") + || lower.contains("maximum context") + || lower.contains("max context") + || lower.contains("token limit") + || lower.contains("too many tokens") + || lower.contains("prompt is too long") + || lower.contains("input is too long") + || lower.contains("request too large") + || lower.contains("length limit") + || lower.contains("maximum tokens") + || (lower.contains("exceeded") && lower.contains("tokens")) + } + + /// Best-effort emergency recovery after a context-limit error. + /// + /// Performs a synchronous hard compaction and resets provider session state, + /// allowing the caller to retry the same turn immediately. + pub(super) fn try_auto_compact_after_context_limit(&mut self, error: &str) -> bool { + if crate::provider::openai_request::is_openai_encrypted_content_too_large_error(error) + && self.try_recover_oversized_openai_native_compaction() + { + return true; + } + // A provider HTTP 413 ("request too large") is a *byte-size* failure + // driven by inline base64 images, not a token-context overflow. Token + // accounting deliberately undercounts images, so ordinary compaction + // would not shrink the payload and the retry would 413 again. Strip + // oversized images first. + if self.try_recover_after_payload_too_large(error) { + return true; + } + if !Self::is_context_limit_error(error) { + return false; + } + if !self.provider.supports_compaction() { + return false; + } + + let context_limit = self.provider.context_window() as u64; + let compaction = self.registry.compaction(); + + let (dropped, usage_pct) = match compaction.try_write() { + Ok(mut manager) => { + let (dropped, usage_pct) = { + let all_messages = self.session.provider_messages(); + manager.update_observed_input_tokens(context_limit); + let usage_pct = manager.context_usage_with(all_messages) * 100.0; + let dropped = match manager.hard_compact_with(all_messages) { + Ok(dropped) => dropped, + Err(reason) => { + logging::warn(&format!( + "Context-limit auto-recovery failed: hard compact failed ({})", + reason + )); + return false; + } + }; + (dropped, usage_pct) + }; + self.sync_session_compaction_state_from_manager(&manager); + (dropped, usage_pct) + } + Err(_) => { + logging::warn("Context-limit auto-recovery skipped: compaction manager lock busy"); + return false; + } + }; + + self.cache_tracker.reset(); + self.locked_tools = None; + self.provider_session_id = None; + self.session.provider_session_id = None; + + logging::warn(&format!( + "Context limit exceeded; auto-compacted and retrying (dropped {} messages, usage was {:.1}%)", + dropped, usage_pct + )); + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "auto_compaction_applied", + "context_limit_auto_compaction", + ) + .with_session_id(self.session.id.clone()) + .with_detail(format!( + "dropped_messages={dropped},usage_pct={usage_pct:.1}" + )) + .force_attribution(), + ); + + true + } + + /// Best-effort recovery after a provider HTTP 413 "request too large" error. + /// + /// This failure is caused by the serialized request body (dominated by inline + /// base64 images) exceeding the provider's size cap, which is independent of + /// the token context window. We strip oversized images from the persisted + /// transcript, oldest-first, down to a conservative byte budget and reset the + /// provider session/cache so the caller can retry the same turn immediately. + fn try_recover_after_payload_too_large(&mut self, error: &str) -> bool { + if !crate::compaction::is_request_payload_too_large_error(error) { + return false; + } + + let stripped = self + .session + .strip_oversized_images(crate::compaction::PAYLOAD_IMAGE_CHAR_BUDGET); + if stripped == 0 { + logging::warn( + "Request-too-large recovery skipped: no oversized inline images to strip", + ); + return false; + } + + // The transcript changed; reseed compaction bookkeeping and reset + // provider session/cache state so the retry sends the reduced payload. + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + let provider_messages = self.session.messages_for_provider(); + manager.reset(); + manager.set_budget(self.provider.context_window()); + if let Some(state) = self.session.compaction.as_ref() { + manager.restore_persisted_state_with(state, &provider_messages); + } else { + manager.seed_restored_messages_with(&provider_messages); + } + self.sync_session_compaction_state_from_manager(&manager); + } + + self.cache_tracker.reset(); + self.locked_tools = None; + self.provider_session_id = None; + self.session.provider_session_id = None; + + logging::warn(&format!( + "Request body exceeded provider size limit; stripped {} oversized inline image(s) and retrying", + stripped + )); + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "payload_too_large_recovered", + "request_payload_too_large", + ) + .with_session_id(self.session.id.clone()) + .with_detail(format!("images_stripped={stripped}")) + .force_attribution(), + ); + + true + } + + fn try_recover_oversized_openai_native_compaction(&mut self) -> bool { + let compaction = self.registry.compaction(); + let recovered = match compaction.try_write() { + Ok(mut manager) => { + if !manager.discard_oversized_openai_native_compaction() { + return false; + } + self.sync_session_compaction_state_from_manager(&manager); + true + } + Err(_) => { + logging::warn( + "OpenAI native compaction recovery skipped: compaction manager lock busy", + ); + false + } + }; + + if !recovered { + return false; + } + + self.cache_tracker.reset(); + self.locked_tools = None; + self.provider_session_id = None; + self.session.provider_session_id = None; + + logging::warn( + "OpenAI native compaction payload exceeded provider size limit; discarded native state and retrying with text fallback", + ); + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "native_compaction_payload_recovered", + "openai_encrypted_content_too_large", + ) + .with_session_id(self.session.id.clone()) + .force_attribution(), + ); + + true + } + + fn effective_context_tokens_from_usage( + &self, + input_tokens: u64, + cache_read_input_tokens: Option, + cache_creation_input_tokens: Option, + ) -> u64 { + // Shared heuristic (jcode-compaction-core): keeps the compaction + // manager's observed-token feed consistent with the client-side + // context display. + crate::compaction::effective_context_tokens_from_usage( + self.provider.name(), + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + ) + } + + pub(super) fn update_compaction_usage_from_stream( + &mut self, + input_tokens: u64, + cache_read_input_tokens: Option, + cache_creation_input_tokens: Option, + ) { + if !self.provider.uses_jcode_compaction() || input_tokens == 0 { + return; + } + let observed = self.effective_context_tokens_from_usage( + input_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + ); + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + manager.update_observed_input_tokens(observed); + manager.push_token_snapshot(observed); + }; + } + + /// Push an embedding snapshot for the semantic compaction mode. + /// Called after each assistant turn with a short text snippet. + /// No-op if the embedding model is unavailable or mode is not semantic. + pub(super) fn push_embedding_snapshot_if_semantic(&mut self, text: &str) { + use crate::config::CompactionMode; + let is_semantic = { + let compaction = self.registry.compaction(); + compaction + .try_read() + .map(|m| m.mode() == CompactionMode::Semantic) + .unwrap_or(false) + }; + if !is_semantic { + return; + } + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + manager.push_embedding_snapshot(text); + }; + } +} diff --git a/crates/jcode-app-core/src/agent/environment.rs b/crates/jcode-app-core/src/agent/environment.rs new file mode 100644 index 0000000..0aa009a --- /dev/null +++ b/crates/jcode-app-core/src/agent/environment.rs @@ -0,0 +1,98 @@ +use super::{Agent, JCODE_REPO_SOURCE_STATE, WORKING_GIT_STATE_CACHE}; +use crate::logging; +use crate::session::{EnvSnapshot, GitState}; +use chrono::Utc; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum EnvSnapshotDetail { + Minimal, + Full, +} + +pub(super) fn cached_git_state_for_dir( + dir: &Path, + git_state_for_dir: impl Fn(&Path) -> Option, +) -> Option { + let cache_key = dir.to_path_buf(); + if let Ok(cache) = WORKING_GIT_STATE_CACHE.lock() + && let Some(state) = cache.get(&cache_key) + { + return state.clone(); + } + + let state = git_state_for_dir(dir); + if let Ok(mut cache) = WORKING_GIT_STATE_CACHE.lock() { + cache.insert(cache_key, state.clone()); + } + state +} + +impl Agent { + /// Set logging context for this agent's session/provider + pub(super) fn set_log_context(&self) { + logging::set_session(&self.session.id); + logging::set_provider_info(self.provider.name(), &self.provider.model()); + } + + /// Record a lightweight environment snapshot for post-mortem debugging + pub(super) fn log_env_snapshot(&mut self, reason: &str) { + let snapshot = self.build_env_snapshot(reason, self.env_snapshot_detail()); + self.session.record_env_snapshot(snapshot.clone()); + if !self.session.messages.is_empty() { + self.persist_session_best_effort("environment snapshot"); + } + if let Ok(json) = serde_json::to_string(&snapshot) { + logging::info(&format!("ENV_SNAPSHOT {}", json)); + } else { + logging::info("ENV_SNAPSHOT {}"); + } + } + + pub(super) fn env_snapshot_detail(&self) -> EnvSnapshotDetail { + if self.session.visible_conversation_message_count() == 0 { + EnvSnapshotDetail::Minimal + } else { + EnvSnapshotDetail::Full + } + } + + pub(super) fn build_env_snapshot( + &self, + reason: &str, + detail: EnvSnapshotDetail, + ) -> EnvSnapshot { + let (jcode_git_hash, jcode_git_dirty) = match detail { + EnvSnapshotDetail::Full => JCODE_REPO_SOURCE_STATE.clone(), + EnvSnapshotDetail::Minimal => (None, None), + }; + + let working_dir = self.session.working_dir.clone(); + let working_git = match detail { + EnvSnapshotDetail::Full => working_dir.as_deref().and_then(|dir| { + cached_git_state_for_dir(Path::new(dir), super::utils::git_state_for_dir) + }), + EnvSnapshotDetail::Minimal => None, + }; + + EnvSnapshot { + captured_at: Utc::now(), + reason: reason.to_string(), + session_id: self.session.id.clone(), + working_dir, + provider: self.provider.name().to_string(), + model: self.provider.model().to_string(), + jcode_version: jcode_build_meta::VERSION.to_string(), + jcode_git_hash, + jcode_git_dirty, + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + pid: std::process::id(), + is_selfdev: self.session.is_self_dev(), + is_debug: self.session.is_debug, + is_canary: self.session.is_canary, + testing_build: self.session.testing_build.clone(), + working_git, + } + } +} diff --git a/crates/jcode-app-core/src/agent/inline_tail.rs b/crates/jcode-app-core/src/agent/inline_tail.rs new file mode 100644 index 0000000..5f13e17 --- /dev/null +++ b/crates/jcode-app-core/src/agent/inline_tail.rs @@ -0,0 +1,285 @@ +//! Rolling live-output tail for inline swarm workers. +//! +//! The coordinator's inline gallery/dock renders a small viewport of each +//! worker's recent activity. Streaming only the in-progress assistant text +//! had two failure modes: +//! +//! 1. Workers spend most wall-clock time inside tool calls, during which no +//! text streams, so the viewport froze on stale prose for the duration. +//! 2. `text_content` resets on every API call, so the viewport blanked at +//! each turn/continuation boundary. +//! +//! [`InlineTailBuffer`] fixes both: it keeps a rolling, capped buffer of +//! *committed* activity lines (finished text segments and tool markers) that +//! survives across turns, plus the current *live* streaming text segment. +//! Tool executions are interleaved as `⚙ name · summary` markers, updated in +//! place with a duration or error state on completion. + +use std::collections::VecDeque; + +/// Max committed lines retained (matches the gallery viewport budget). +const MAX_LINES: usize = 14; +/// Max total characters in the rendered tail (bus payload cap). +const MAX_CHARS: usize = 1400; +/// Max characters of a tool marker's input summary. +const MAX_SUMMARY_CHARS: usize = 60; + +/// Rolling tail of a worker's recent activity: committed lines (text + +/// tool markers) plus the live in-progress assistant text. +#[derive(Debug, Default)] +pub(crate) struct InlineTailBuffer { + /// Finished activity lines, oldest first, capped to [`MAX_LINES`]. + committed: VecDeque, + /// In-progress assistant text for the current stream (replaced wholesale + /// on every delta, committed at message end, discarded on rollback). + live: String, + /// Whether the last committed line is an in-flight tool marker that + /// [`Self::finish_tool`] should update in place. + pending_tool_marker: bool, +} + +impl InlineTailBuffer { + /// Replace the live streaming text with the accumulated `text` so far. + pub(crate) fn set_live(&mut self, text: &str) { + self.live.clear(); + self.live.push_str(text); + } + + /// Commit the live text into the rolling buffer (end of a message) and + /// clear it. Empty/whitespace-only live text is discarded. + pub(crate) fn commit_live(&mut self) { + let live = std::mem::take(&mut self.live); + for line in live.lines().filter(|l| !l.trim().is_empty()) { + self.push_committed(line.to_string()); + } + } + + /// Discard the live text (mid-stream retry rollback replays from the top). + pub(crate) fn clear_live(&mut self) { + self.live.clear(); + } + + /// Record a tool execution starting. Any live text is committed first so + /// ordering in the tail matches what actually happened. + pub(crate) fn start_tool(&mut self, name: &str, input: &serde_json::Value) { + self.commit_live(); + let summary = tool_marker_summary(name, input); + let marker = if summary.is_empty() { + format!("⚙ {name}") + } else { + format!("⚙ {name} · {summary}") + }; + self.push_committed(marker); + self.pending_tool_marker = true; + } + + /// Record the in-flight tool finishing, updating its marker in place with + /// a duration (and error flag). If the marker was already evicted by + /// buffer pressure, this is a no-op. + pub(crate) fn finish_tool(&mut self, elapsed_secs: f64, is_error: bool) { + if !self.pending_tool_marker { + return; + } + self.pending_tool_marker = false; + if let Some(last) = self.committed.back_mut() { + let status = if is_error { " ✗" } else { "" }; + last.push_str(&format!(" ({}){status}", humanize_secs(elapsed_secs))); + } + } + + /// Render the tail for the bus: committed lines then live lines, bounded + /// to the last [`MAX_LINES`] lines / [`MAX_CHARS`] chars. + pub(crate) fn render(&self) -> String { + let live_lines = self + .live + .lines() + .filter(|l| !l.trim().is_empty()) + .collect::>(); + let mut lines: Vec<&str> = self + .committed + .iter() + .map(String::as_str) + .chain(live_lines) + .collect(); + if lines.len() > MAX_LINES { + lines.drain(..lines.len() - MAX_LINES); + } + let mut tail = lines.join("\n"); + if tail.len() > MAX_CHARS { + let start = floor_char_boundary(&tail, tail.len() - MAX_CHARS); + tail = tail[start..].to_string(); + } + tail + } + + fn push_committed(&mut self, line: String) { + // A new committed line supersedes any pending in-place marker update + // ordering (finish_tool only touches the true last line). + self.pending_tool_marker = false; + self.committed.push_back(line); + while self.committed.len() > MAX_LINES { + self.committed.pop_front(); + } + } +} + +/// Compact one-line summary of a tool's input for the activity marker. +/// Prefers the model-provided `intent`, then well-known per-tool fields. +fn tool_marker_summary(name: &str, input: &serde_json::Value) -> String { + let raw = jcode_message_types::ToolCall::intent_from_input(input) + .or_else(|| { + let field = match name { + "bash" => "command", + "read" | "write" => "file_path", + "edit" | "multiedit" => "file_path", + "agentgrep" | "websearch" => "query", + "webfetch" => "url", + "task" | "subagent" => "description", + _ => return None, + }; + input + .get(field) + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .unwrap_or_default(); + let flat = raw.split_whitespace().collect::>().join(" "); + if flat.chars().count() > MAX_SUMMARY_CHARS { + let mut out: String = flat.chars().take(MAX_SUMMARY_CHARS - 1).collect(); + out.push('…'); + out + } else { + flat + } +} + +/// "3s" / "2m10s" style duration for tool markers. +fn humanize_secs(secs: f64) -> String { + if secs < 10.0 { + format!("{secs:.1}s") + } else if secs < 60.0 { + format!("{}s", secs as u64) + } else { + let total = secs as u64; + format!("{}m{}s", total / 60, total % 60) + } +} + +/// Largest byte index `<= index` that is a UTF-8 char boundary in `text`. +fn floor_char_boundary(text: &str, index: usize) -> usize { + if index >= text.len() { + return text.len(); + } + let mut boundary = index; + while boundary > 0 && !text.is_char_boundary(boundary) { + boundary -= 1; + } + boundary +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_text_renders_and_survives_commit() { + let mut tail = InlineTailBuffer::default(); + tail.set_live("thinking about the fix\nsecond line"); + assert_eq!(tail.render(), "thinking about the fix\nsecond line"); + tail.commit_live(); + // Next stream starts blank but the previous output is retained. + tail.set_live(""); + assert_eq!(tail.render(), "thinking about the fix\nsecond line"); + } + + #[test] + fn tool_markers_interleave_and_complete_in_place() { + let mut tail = InlineTailBuffer::default(); + tail.set_live("Let me check the render pipeline."); + tail.start_tool( + "bash", + &serde_json::json!({"command": "cargo build --profile selfdev"}), + ); + let mid = tail.render(); + assert!( + mid.contains("Let me check the render pipeline."), + "live text must commit before the marker: {mid}" + ); + assert!( + mid.contains("⚙ bash · cargo build --profile selfdev"), + "{mid}" + ); + + tail.finish_tool(47.2, false); + assert!(tail.render().contains("(47s)"), "{}", tail.render()); + + tail.start_tool("edit", &serde_json::json!({"file_path": "src/ui.rs"})); + tail.finish_tool(0.3, true); + let done = tail.render(); + assert!(done.contains("⚙ edit · src/ui.rs (0.3s) ✗"), "{done}"); + } + + #[test] + fn marker_prefers_intent_over_raw_input() { + let mut tail = InlineTailBuffer::default(); + tail.start_tool( + "bash", + &serde_json::json!({"command": "x", "intent": "run the ui tests"}), + ); + assert!(tail.render().contains("⚙ bash · run the ui tests")); + } + + #[test] + fn rollback_discards_live_but_keeps_committed() { + let mut tail = InlineTailBuffer::default(); + tail.start_tool("read", &serde_json::json!({"file_path": "a.rs"})); + tail.finish_tool(0.1, false); + tail.set_live("partial output that gets replayed"); + tail.clear_live(); + let out = tail.render(); + assert!(out.contains("⚙ read"), "{out}"); + assert!(!out.contains("partial output"), "{out}"); + } + + #[test] + fn caps_lines_and_chars_and_summary_length() { + let mut tail = InlineTailBuffer::default(); + for i in 0..40 { + tail.set_live(&format!("line number {i}")); + tail.commit_live(); + } + let out = tail.render(); + assert!(out.lines().count() <= MAX_LINES); + assert!(out.contains("line number 39")); + assert!(!out.contains("line number 0\n")); + + let huge = "x".repeat(5000); + tail.set_live(&huge); + assert!(tail.render().len() <= MAX_CHARS); + + let mut tail = InlineTailBuffer::default(); + let long_cmd = "cargo test ".repeat(30); + tail.start_tool("bash", &serde_json::json!({ "command": long_cmd })); + let line = tail.render(); + assert!(line.chars().count() < 80, "summary must truncate: {line}"); + assert!(line.contains('…'), "{line}"); + } + + #[test] + fn finish_without_pending_marker_is_noop() { + let mut tail = InlineTailBuffer::default(); + tail.set_live("hello"); + tail.commit_live(); + tail.finish_tool(1.0, false); + assert_eq!(tail.render(), "hello"); + // Committing new lines invalidates a pending marker: finish after + // commit must not append a duration to unrelated text. + let mut tail = InlineTailBuffer::default(); + tail.start_tool("bash", &serde_json::json!({"command": "ls"})); + tail.set_live("output line"); + tail.commit_live(); + tail.finish_tool(2.0, false); + let out = tail.render(); + assert!(!out.contains("output line (2.0s)"), "{out}"); + } +} diff --git a/crates/jcode-app-core/src/agent/interrupts.rs b/crates/jcode-app-core/src/agent/interrupts.rs new file mode 100644 index 0000000..386e6ea --- /dev/null +++ b/crates/jcode-app-core/src/agent/interrupts.rs @@ -0,0 +1,458 @@ +use super::Agent; +use crate::logging; +use crate::message::{ContentBlock, Role}; +use crate::protocol::ServerEvent; +use crate::session::StoredDisplayRole; +use anyhow::Result; +use jcode_agent_runtime::{ + InterruptSignal, SoftInterruptMessage, SoftInterruptQueue, SoftInterruptSource, +}; +use std::sync::Arc; + +fn soft_interrupt_session_display_role(source: SoftInterruptSource) -> Option { + match source { + SoftInterruptSource::User => None, + SoftInterruptSource::System => Some(StoredDisplayRole::System), + SoftInterruptSource::BackgroundTask => Some(StoredDisplayRole::BackgroundTask), + } +} + +fn soft_interrupt_protocol_display_role(source: SoftInterruptSource) -> Option { + match source { + SoftInterruptSource::User => None, + SoftInterruptSource::System => Some("system".to_string()), + SoftInterruptSource::BackgroundTask => Some("background_task".to_string()), + } +} + +#[derive(Debug, Clone)] +pub(super) struct InjectedSoftInterrupt { + pub(super) content: String, + pub(super) source: SoftInterruptSource, +} + +pub(super) enum NoToolCallOutcome { + Break, + ContinueWithoutEvent, + ContinueWithSoftInterrupt { + injected: Vec, + point: &'static str, + }, +} + +pub(super) enum PostToolInterruptOutcome { + NoInterrupt, + SoftInterrupt { + injected: Vec, + point: &'static str, + }, +} + +impl Agent { + pub fn restore_persisted_soft_interrupts(&self) -> usize { + let restored = match crate::soft_interrupt_store::take(self.session_id()) { + Ok(items) => items, + Err(err) => { + logging::warn(&format!( + "Failed to restore persisted soft interrupts for {}: {}", + self.session_id(), + err + )); + return 0; + } + }; + + if restored.is_empty() { + return 0; + } + + let restored_count = restored.len(); + if let Ok(mut queue) = self.soft_interrupt_queue.lock() { + queue.extend(restored); + } else { + logging::warn(&format!( + "Failed to restore persisted soft interrupts for {} because queue lock was poisoned", + self.session_id() + )); + return 0; + } + + logging::info(&format!( + "Restored {} persisted soft interrupt(s) for session {}", + restored_count, + self.session_id() + )); + restored_count + } + + pub fn persist_soft_interrupt_snapshot(&self) { + let pending = match self.soft_interrupt_queue.lock() { + Ok(queue) => queue.clone(), + Err(_) => { + logging::warn(&format!( + "Failed to snapshot soft interrupts for {} because queue lock was poisoned", + self.session_id() + )); + return; + } + }; + + if let Err(err) = crate::soft_interrupt_store::overwrite(self.session_id(), &pending) { + logging::warn(&format!( + "Failed to persist {} soft interrupt(s) for {}: {}", + pending.len(), + self.session_id(), + err + )); + } + } + + /// Add a swarm alert to be injected into the next turn + pub fn push_alert(&mut self, alert: String) { + self.pending_alerts.push(alert); + } + + /// Take all pending alerts (clears the queue) + pub fn take_alerts(&mut self) -> Vec { + std::mem::take(&mut self.pending_alerts) + } + + /// 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) { + let content_bytes = content.len(); + let content_chars = content.chars().count(); + if let Ok(mut queue) = self.soft_interrupt_queue.lock() { + let pending_before = queue.len(); + queue.push(SoftInterruptMessage { + content, + urgent, + source, + }); + logging::info(&format!( + "AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}", + self.session_id(), + source, + urgent, + content_bytes, + content_chars, + 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", + self.session_id(), + source, + urgent, + content_bytes, + content_chars + )); + } + } + + /// Get a handle to the soft interrupt queue. + /// The server can use this to queue interrupts without holding the agent lock. + pub fn soft_interrupt_queue(&self) -> SoftInterruptQueue { + Arc::clone(&self.soft_interrupt_queue) + } + + /// Get a handle to the background tool signal. + /// The server can use this to signal "move tool to background" without holding the agent lock. + pub fn background_tool_signal(&self) -> InterruptSignal { + self.background_tool_signal.clone() + } + + pub fn graceful_shutdown_signal(&self) -> InterruptSignal { + self.graceful_shutdown.clone() + } + + pub fn request_graceful_shutdown(&self) { + self.graceful_shutdown.fire(); + } + + pub(super) fn is_graceful_shutdown(&self) -> bool { + self.graceful_shutdown.is_set() + } + + /// Check if there are pending soft interrupts + pub fn has_soft_interrupts(&self) -> bool { + self.soft_interrupt_queue + .lock() + .map(|q| !q.is_empty()) + .unwrap_or(false) + } + + /// Check if there's an urgent soft interrupt that should skip remaining tools + pub fn has_urgent_interrupt(&self) -> bool { + self.soft_interrupt_queue + .lock() + .map(|q| q.iter().any(|m| m.urgent)) + .unwrap_or(false) + } + + /// Get count of queued soft interrupts + pub fn soft_interrupt_count(&self) -> usize { + self.soft_interrupt_queue + .lock() + .map(|q| q.len()) + .unwrap_or(0) + } + + /// Get count of pending alerts + pub fn pending_alert_count(&self) -> usize { + self.pending_alerts.len() + } + + /// Get pending alerts (for debug visibility) + pub fn pending_alerts_preview(&self) -> Vec { + self.pending_alerts + .iter() + .take(10) + .map(|s| { + if s.len() > 100 { + format!("{}...", crate::util::truncate_str(s, 100)) + } else { + s.clone() + } + }) + .collect() + } + + /// Get comprehensive debug info about agent internal state + pub fn debug_info(&self) -> serde_json::Value { + serde_json::json!({ + "provider": self.provider.name(), + "model": self.provider.model(), + "provider_session_id": self.provider_session_id, + "last_upstream_provider": self.last_upstream_provider, + "last_connection_type": self.last_connection_type, + "active_skill": self.active_skill, + "allowed_tools": self.allowed_tools, + "disabled_tools": self.disabled_tools, + "session": { + "id": self.session.id, + "is_canary": self.session.is_canary, + "model": self.session.model, + "working_dir": self.session.working_dir, + "message_count": self.session.messages.len(), + }, + "interrupts": { + "soft_interrupt_count": self.soft_interrupt_count(), + "has_urgent": self.has_urgent_interrupt(), + "pending_alert_count": self.pending_alert_count(), + "soft_interrupts": self.soft_interrupts_preview(), + "pending_alerts": self.pending_alerts_preview(), + }, + "cache_tracker": { + "turn_count": self.cache_tracker.turn_count(), + "had_violation": self.cache_tracker.had_violation(), + }, + "features": { + "memory_enabled": self.memory_enabled, + }, + "token_usage": { + "input": self.last_usage.input_tokens, + "output": self.last_usage.output_tokens, + "cache_read": self.last_usage.cache_read_input_tokens, + "cache_write": self.last_usage.cache_creation_input_tokens, + }, + }) + } + + pub fn debug_memory_profile(&self) -> serde_json::Value { + let process = crate::process_memory::snapshot_with_source("agent:memory"); + let soft_interrupt_text_bytes: usize = self + .soft_interrupt_queue + .lock() + .map(|queue| queue.iter().map(|msg| msg.content.len()).sum()) + .unwrap_or(0); + let pending_alert_text_bytes: usize = + self.pending_alerts.iter().map(|alert| alert.len()).sum(); + + serde_json::json!({ + "process": process, + "session": self.session.debug_memory_profile(), + "interrupts": { + "soft_interrupt_count": self.soft_interrupt_count(), + "soft_interrupt_text_bytes": soft_interrupt_text_bytes, + "pending_alert_count": self.pending_alert_count(), + "pending_alert_text_bytes": pending_alert_text_bytes, + }, + "agent": { + "memory_enabled": self.memory_enabled, + "allowed_tools_count": self.allowed_tools.as_ref().map(|tools| tools.len()), + "disabled_tools_count": self.disabled_tools.len(), + "tool_call_ids": self.tool_call_ids.len(), + "tool_result_ids": self.tool_result_ids.len(), + "last_usage": { + "input": self.last_usage.input_tokens, + "output": self.last_usage.output_tokens, + "cache_read": self.last_usage.cache_read_input_tokens, + "cache_write": self.last_usage.cache_creation_input_tokens, + }, + } + }) + } + + /// Get soft interrupt previews (for debug visibility) + pub fn soft_interrupts_preview(&self) -> Vec<(String, bool)> { + self.soft_interrupt_queue + .lock() + .map(|q| { + q.iter() + .take(10) + .map(|m| { + let preview = if m.content.len() > 100 { + format!("{}...", crate::util::truncate_str(&m.content, 100)) + } else { + m.content.clone() + }; + (preview, m.urgent) + }) + .collect() + }) + .unwrap_or_default() + } + + /// Inject all pending soft interrupt messages into the conversation. + /// Returns the combined message content and clears the queue. + pub(super) fn inject_soft_interrupts(&mut self) -> Vec { + let messages: Vec = { + let mut queue = match self.soft_interrupt_queue.lock() { + Ok(queue) => queue, + Err(_) => { + logging::warn(&format!( + "AGENT_SOFT_INTERRUPT_INJECT_LOCK_FAILED session={}", + self.session_id() + )); + return Vec::new(); + } + }; + if queue.is_empty() { + return Vec::new(); + } + logging::info(&format!( + "AGENT_SOFT_INTERRUPT_INJECT_DRAIN session={} pending_count={} urgent_count={} total_content_bytes={}", + self.session_id(), + queue.len(), + queue.iter().filter(|message| message.urgent).count(), + queue + .iter() + .map(|message| message.content.len()) + .sum::() + )); + queue.drain(..).collect() + }; + + let mut injected = Vec::new(); + let mut current_source: Option = None; + let mut current_parts: Vec = Vec::new(); + + let flush_group = |agent: &mut Self, + injected: &mut Vec, + source: SoftInterruptSource, + parts: &mut Vec| { + if parts.is_empty() { + return; + } + let content = parts.join("\n\n"); + parts.clear(); + agent.add_message_with_display_role( + Role::User, + vec![ContentBlock::Text { + text: content.clone(), + cache_control: None, + }], + soft_interrupt_session_display_role(source), + ); + injected.push(InjectedSoftInterrupt { content, source }); + }; + + for message in messages { + match current_source { + Some(source) if source != message.source => { + flush_group(self, &mut injected, source, &mut current_parts); + current_source = Some(message.source); + } + None => current_source = Some(message.source), + _ => {} + } + current_parts.push(message.content); + } + + if let Some(source) = current_source { + flush_group(self, &mut injected, source, &mut current_parts); + } + + self.persist_session_best_effort("soft interrupt injection"); + logging::info(&format!( + "AGENT_SOFT_INTERRUPT_INJECT_COMMIT session={} groups={} total_content_bytes={}", + self.session_id(), + injected.len(), + injected + .iter() + .map(|interrupt| interrupt.content.len()) + .sum::() + )); + injected + } + + pub(super) fn handle_streaming_no_tool_calls( + &mut self, + stop_reason: Option<&str>, + incomplete_continuations: &mut u32, + ) -> Result { + if self.maybe_continue_incomplete_response(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() { + return Ok(NoToolCallOutcome::ContinueWithSoftInterrupt { + injected, + point: "B", + }); + } + Ok(NoToolCallOutcome::Break) + } + + pub(super) fn take_post_tool_soft_interrupt(&mut self) -> PostToolInterruptOutcome { + let injected = self.inject_soft_interrupts(); + if !injected.is_empty() { + PostToolInterruptOutcome::SoftInterrupt { + injected, + point: "D", + } + } else { + PostToolInterruptOutcome::NoInterrupt + } + } + + pub(super) fn build_soft_interrupt_events( + injected: Vec, + point: &'static str, + tools_skipped: Option, + ) -> Vec { + logging::info(&format!( + "AGENT_SOFT_INTERRUPT_EVENTS_BUILD groups={} point={} tools_skipped={:?} total_content_bytes={}", + injected.len(), + point, + tools_skipped, + injected + .iter() + .map(|interrupt| interrupt.content.len()) + .sum::() + )); + injected + .into_iter() + .enumerate() + .map(|(idx, interrupt)| ServerEvent::SoftInterruptInjected { + content: interrupt.content, + display_role: soft_interrupt_protocol_display_role(interrupt.source), + point: point.to_string(), + tools_skipped: if idx == 0 { tools_skipped } else { None }, + }) + .collect() + } +} diff --git a/crates/jcode-app-core/src/agent/messages.rs b/crates/jcode-app-core/src/agent/messages.rs new file mode 100644 index 0000000..32bf45d --- /dev/null +++ b/crates/jcode-app-core/src/agent/messages.rs @@ -0,0 +1,77 @@ +use super::*; + +impl Agent { + pub(crate) fn add_message(&mut self, role: Role, content: Vec) -> String { + let id = self.session.add_message(role, content); + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + if let Some(message) = self.session.messages.last() { + manager.notify_message_added_blocks(&message.content); + } else { + manager.notify_message_added(); + } + } + id + } + + pub(crate) fn add_message_with_display_role( + &mut self, + role: Role, + content: Vec, + display_role: Option, + ) -> String { + let id = self + .session + .add_message_with_display_role(role, content, display_role); + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + if let Some(message) = self.session.messages.last() { + manager.notify_message_added_blocks(&message.content); + } else { + manager.notify_message_added(); + } + } + id + } + + pub(crate) fn add_message_with_duration( + &mut self, + role: Role, + content: Vec, + duration_ms: Option, + ) -> String { + let id = self + .session + .add_message_with_duration(role, content, duration_ms); + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + if let Some(message) = self.session.messages.last() { + manager.notify_message_added_blocks(&message.content); + } else { + manager.notify_message_added(); + } + } + id + } + + pub(crate) fn add_message_ext( + &mut self, + role: Role, + content: Vec, + duration_ms: Option, + token_usage: Option, + ) -> String { + let id = self + .session + .add_message_ext(role, content, duration_ms, token_usage); + let compaction = self.registry.compaction(); + if let Ok(mut manager) = compaction.try_write() { + if let Some(message) = self.session.messages.last() { + manager.notify_message_added_blocks(&message.content); + } else { + manager.notify_message_added(); + } + } + id + } +} diff --git a/crates/jcode-app-core/src/agent/prompting.rs b/crates/jcode-app-core/src/agent/prompting.rs new file mode 100644 index 0000000..dc0c705 --- /dev/null +++ b/crates/jcode-app-core/src/agent/prompting.rs @@ -0,0 +1,128 @@ +use super::Agent; +use crate::logging; +use crate::message::{Message, ToolDefinition}; + +impl Agent { + pub(super) fn log_prompt_prefix_accounting( + &self, + split: &crate::prompt::SplitSystemPrompt, + tools: &[ToolDefinition], + ) { + let system_tokens = split.estimated_tokens(); + let tool_tokens = ToolDefinition::aggregate_prompt_token_estimate(tools); + let prefix_tokens = system_tokens + tool_tokens; + logging::info(&format!( + "Prompt prefix estimate: total={} tokens (system={} tools={})", + prefix_tokens, system_tokens, tool_tokens + )); + } + + pub(super) fn build_memory_prompt_nonblocking_shared( + &self, + messages: std::sync::Arc<[Message]>, + _memory_event_tx: Option, + ) -> Option { + if !self.memory_enabled { + return None; + } + + let session_id = &self.session.id; + + let pending = if crate::message::ends_with_fresh_user_turn(&messages) { + crate::memory::take_pending_memory(session_id) + } else { + None + }; + + // Use the persistent memory-agent pipeline as the single source of truth. + // Running both this and the legacy MemoryManager background retrieval path + // can prepare overlapping pending prompts for the same turn, which makes + // memory injection feel overly aggressive. + crate::memory_agent::update_context_sync_with_dir( + session_id, + messages, + self.session.working_dir.clone(), + ); + + pending + } + + fn append_current_turn_system_reminder(&self, split: &mut crate::prompt::SplitSystemPrompt) { + let Some(reminder) = self + .current_turn_system_reminder + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + else { + return; + }; + + if !split.dynamic_part.is_empty() { + split.dynamic_part.push_str("\n\n"); + } + split.dynamic_part.push_str("# System Reminder\n\n"); + split.dynamic_part.push_str(reminder); + } + + /// Build split system prompt for better caching + /// Returns static (cacheable) and dynamic (not cached) parts separately + pub(super) fn build_system_prompt_split( + &self, + memory_prompt: Option<&str>, + ) -> crate::prompt::SplitSystemPrompt { + if let Some(ref override_prompt) = self.system_prompt_override { + return crate::prompt::SplitSystemPrompt { + static_part: override_prompt.clone(), + dynamic_part: String::new(), + }; + } + + let skills = self.current_skills_snapshot(); + let skill_prompt = self + .active_skill + .as_ref() + .and_then(|name| skills.get(name).map(|skill| skill.get_prompt().to_string())); + + let available_skills: Vec = self + .current_skills_snapshot() + .list() + .iter() + .map(|skill| crate::prompt::SkillInfo { + name: skill.name.clone(), + description: skill.description.clone(), + }) + .collect(); + + let working_dir = self + .session + .working_dir + .as_ref() + .map(std::path::PathBuf::from); + + let (mut split, _context_info) = crate::prompt::build_system_prompt_split( + skill_prompt.as_deref(), + &available_skills, + self.session.is_canary, + memory_prompt, + working_dir.as_deref(), + ); + + self.append_current_turn_system_reminder(&mut split); + crate::prompt::append_swarm_effort_directive( + &mut split, + self.provider.reasoning_effort().as_deref(), + ); + + split + } + + /// Non-blocking memory prompt - takes pending result and spawns check for next turn + #[cfg(test)] + pub(super) fn build_memory_prompt_nonblocking( + &self, + messages: &[Message], + _memory_event_tx: Option, + ) -> Option { + self.build_memory_prompt_nonblocking_shared(messages.to_vec().into(), _memory_event_tx) + } +} diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs new file mode 100644 index 0000000..24440bc --- /dev/null +++ b/crates/jcode-app-core/src/agent/provider.rs @@ -0,0 +1,240 @@ +use super::*; + +impl Agent { + pub fn set_premium_mode(&self, mode: crate::provider::copilot::PremiumMode) { + self.provider.set_premium_mode(mode); + } + + pub fn premium_mode(&self) -> crate::provider::copilot::PremiumMode { + self.provider.premium_mode() + } + + pub fn provider_fork(&self) -> Arc { + self.provider.fork() + } + + pub fn provider_handle(&self) -> Arc { + Arc::clone(&self.provider) + } + + pub fn available_models(&self) -> Vec<&'static str> { + self.provider.available_models() + } + + pub fn available_models_for_switching(&self) -> Vec { + self.provider.available_models_for_switching() + } + + pub fn available_models_display(&self) -> Vec { + self.provider.available_models_display() + } + + pub fn model_routes(&self) -> Vec { + self.provider.model_routes() + } + + pub fn model_catalog_snapshot(&self) -> jcode_provider_core::ModelCatalogSnapshot { + jcode_provider_core::ModelCatalogSnapshot::new( + Some(self.provider_name()), + Some(self.provider_model()), + self.available_models_display(), + self.model_routes(), + ) + } + + pub fn registry(&self) -> Registry { + self.registry.clone() + } + + pub async fn compaction_mode(&self) -> crate::config::CompactionMode { + self.registry.compaction().read().await.mode() + } + + pub async fn set_compaction_mode(&self, mode: crate::config::CompactionMode) -> Result<()> { + let compaction = self.registry.compaction(); + let mut manager = compaction.write().await; + manager.set_mode(mode); + Ok(()) + } + + pub fn provider_messages(&mut self) -> Vec { + self.session.messages_for_provider() + } + + pub fn set_model(&mut self, model: &str) -> Result<()> { + self.set_model_from_provider_state_event( + model, + crate::provider::ProviderModelSelectionSource::User, + ) + } + + pub fn set_route_selection( + &mut self, + selection: &crate::provider::RouteSelection, + ) -> Result<()> { + self.provider.set_route_selection(selection)?; + 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()); + let event = crate::provider::ProviderStateEvent::selected_model( + crate::provider::ProviderModelSelectionSource::User, + resolved_model, + ); + self.provider_runtime_state.apply(event); + self.persist_session_best_effort("route selection"); + self.log_env_snapshot("set_route_selection"); + Ok(()) + } + + pub(crate) fn set_model_from_auth(&mut self, model: &str) -> Result<()> { + self.set_model_from_provider_state_event( + model, + crate::provider::ProviderModelSelectionSource::Auth, + ) + } + + fn set_model_from_provider_state_event( + &mut self, + model: &str, + source: crate::provider::ProviderModelSelectionSource, + ) -> Result<()> { + crate::provider::set_model_with_auth_refresh(self.provider.as_ref(), model)?; + let resolved_model = self.provider.model(); + self.session.provider_key = + crate::provider::MultiProvider::session_provider_key_after_model_switch( + model, + self.provider.name(), + self.session.provider_key.as_deref(), + ); + self.session.model = Some(resolved_model.clone()); + let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model); + self.provider_runtime_state.apply(event); + self.persist_session_best_effort("model selection"); + self.log_env_snapshot("set_model"); + Ok(()) + } + + pub(crate) fn provider_model_selection_generation(&self) -> u64 { + self.provider_runtime_state.selection_generation() + } + + pub(crate) fn user_selected_provider_model_after(&self, generation: u64) -> bool { + self.provider_runtime_state.user_selected_after(generation) + } + + pub fn restore_reasoning_effort_from_session(&mut self) { + if let Some(effort) = self.session.reasoning_effort.clone() { + if let Err(e) = self.provider.set_reasoning_effort(&effort) { + crate::logging::error(&format!( + "Failed to restore session reasoning effort '{}': {}", + effort, e + )); + } + } else { + self.session.reasoning_effort = self.provider.reasoning_effort(); + } + // Mirror the effort into the deadlock-free side-table so server handlers + // (e.g. the swarm seed handler) can learn this session's effort without + // taking the agent lock. + crate::session_effort::record_session_effort( + &self.session.id, + self.session.reasoning_effort.as_deref(), + ); + } + + pub fn set_reasoning_effort(&mut self, effort: &str) -> Result> { + self.provider.set_reasoning_effort(effort)?; + let current = self.provider.reasoning_effort(); + self.session.reasoning_effort = current.clone(); + // Keep the side-table in sync (see `restore_reasoning_effort_from_session`). + crate::session_effort::record_session_effort(&self.session.id, current.as_deref()); + self.log_env_snapshot("set_reasoning_effort"); + self.session.save()?; + Ok(current) + } + + pub fn subagent_model(&self) -> Option { + self.session.subagent_model.clone() + } + + pub fn set_subagent_model(&mut self, model: Option) -> Result<()> { + self.session.subagent_model = model; + self.log_env_snapshot("set_subagent_model"); + self.session.save()?; + Ok(()) + } + + pub fn session_provider_key(&self) -> Option { + self.session.provider_key.clone() + } + + /// API method/runtime route used to select the active model (e.g. + /// "openai-api", "claude-oauth", "openai-compatible:nvidia-nim"). Spawned + /// swarm agents inherit this so they reconstruct the coordinator's exact + /// auth route instead of falling back to the config default. + pub fn session_route_api_method(&self) -> Option { + self.session.route_api_method.clone() + } + + /// The credential the active provider will use for the next request, when + /// the provider distinguishes OAuth (subscription) from API key (cost). + /// Resolved authoritatively here so remote clients can render billing/usage + /// without re-deriving it from the provider name. + pub fn active_resolved_credential(&self) -> Option { + self.provider.active_resolved_credential() + } + + pub fn set_session_provider_key(&mut self, provider_key: Option) { + self.session.provider_key = provider_key; + } + + pub fn rename_session_title(&mut self, title: Option) -> Result { + self.session.rename_title(title); + self.log_env_snapshot("rename_session"); + self.session.save()?; + Ok(self.session.display_title_or_name().to_string()) + } + + pub fn autoreview_enabled(&self) -> Option { + self.session.autoreview_enabled + } + + pub fn set_autoreview_enabled(&mut self, enabled: bool) -> Result<()> { + self.session.autoreview_enabled = Some(enabled); + self.log_env_snapshot("set_autoreview_enabled"); + self.session.save()?; + Ok(()) + } + + pub fn autojudge_enabled(&self) -> Option { + self.session.autojudge_enabled + } + + pub fn set_autojudge_enabled(&mut self, enabled: bool) -> Result<()> { + self.session.autojudge_enabled = Some(enabled); + self.log_env_snapshot("set_autojudge_enabled"); + self.session.save()?; + Ok(()) + } + + /// Set the working directory for this session + pub fn set_working_dir(&mut self, dir: &str) { + if self.session.working_dir.as_deref() == Some(dir) { + return; + } + self.session.working_dir = Some(dir.to_string()); + self.session.refresh_initial_session_context_message(); + self.log_env_snapshot("working_dir"); + } + + /// Get the working directory for this session + pub fn working_dir(&self) -> Option<&str> { + self.session.working_dir.as_deref() + } + + /// Get the stored messages (for transcript export) + pub fn messages(&self) -> &[StoredMessage] { + &self.session.messages + } +} diff --git a/crates/jcode-app-core/src/agent/response_recovery.rs b/crates/jcode-app-core/src/agent/response_recovery.rs new file mode 100644 index 0000000..a465de3 --- /dev/null +++ b/crates/jcode-app-core/src/agent/response_recovery.rs @@ -0,0 +1,248 @@ +use super::*; + +impl Agent { + fn parse_text_wrapped_tool_call( + text: &str, + ) -> Option<(String, String, serde_json::Value, String)> { + let marker = "to=functions."; + let marker_idx = text.find(marker)?; + let after_marker = &text[marker_idx + marker.len()..]; + + let mut tool_name_end = 0usize; + for (idx, ch) in after_marker.char_indices() { + if ch.is_ascii_alphanumeric() || ch == '_' { + tool_name_end = idx + ch.len_utf8(); + } else { + break; + } + } + if tool_name_end == 0 { + return None; + } + + let tool_name = after_marker[..tool_name_end].to_string(); + let remaining = &after_marker[tool_name_end..]; + let mut fallback: Option<(String, String, serde_json::Value, String)> = None; + + for (brace_idx, ch) in remaining.char_indices() { + if ch != '{' { + continue; + } + let slice = &remaining[brace_idx..]; + let mut stream = + serde_json::Deserializer::from_str(slice).into_iter::(); + let parsed = match stream.next() { + Some(Ok(value)) => value, + Some(Err(_)) | None => continue, + }; + let consumed = stream.byte_offset(); + if !parsed.is_object() { + continue; + } + + let prefix = text[..marker_idx].trim_end().to_string(); + let suffix = remaining[brace_idx + consumed..].trim().to_string(); + if suffix.is_empty() { + return Some((prefix, tool_name.clone(), parsed, suffix)); + } + if fallback.is_none() { + fallback = Some((prefix, tool_name.clone(), parsed, suffix)); + } + } + + fallback + } + + pub(super) fn recover_text_wrapped_tool_call( + &self, + text_content: &mut String, + tool_calls: &mut Vec, + ) -> bool { + if !tool_calls.is_empty() || text_content.trim().is_empty() { + return false; + } + + let Some((prefix, tool_name, arguments, suffix)) = + Self::parse_text_wrapped_tool_call(text_content) + else { + return false; + }; + + let mut sanitized = String::new(); + if !prefix.is_empty() { + sanitized.push_str(&prefix); + } + if !suffix.is_empty() { + if !sanitized.is_empty() { + sanitized.push('\n'); + } + sanitized.push_str(&suffix); + } + *text_content = sanitized; + + let call_id = format!("fallback_text_call_{}", id::new_id("call")); + let recovered_total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + logging::warn(&format!( + "[agent] Recovered text-wrapped tool call for '{}' ({}, total={})", + tool_name, call_id, recovered_total + )); + let intent = ToolCall::intent_from_input(&arguments); + tool_calls.push(ToolCall { + id: call_id, + name: tool_name, + input: arguments, + intent, + thought_signature: None, + }); + + true + } + + pub(super) fn should_continue_after_stop_reason(stop_reason: &str) -> bool { + let reason = stop_reason.trim().to_ascii_lowercase(); + if reason.is_empty() { + return false; + } + + if matches!(reason.as_str(), "stop" | "end_turn" | "tool_use") { + return false; + } + + reason.contains("incomplete") + || reason.contains("max_output_tokens") + || reason.contains("max_tokens") + || reason.contains("length") + || reason.contains("trunc") + || reason.contains("commentary") + } + + /// True when the provider's stop reason indicates a model-side + /// guardrail/safety stop (e.g. Anthropic `refusal`), as opposed to a + /// normal end-of-turn or truncation. + pub(crate) fn is_guardrail_stop_reason(stop_reason: Option<&str>) -> bool { + let Some(reason) = stop_reason else { + return false; + }; + let reason = reason.trim().to_ascii_lowercase(); + matches!(reason.as_str(), "refusal" | "content_filter" | "safety") + || reason.contains("guardrail") + || reason.contains("policy_violation") + } + + /// 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. + pub(crate) fn provider_guardrail_notice( + stop_reason: Option<&str>, + visible_text_empty: bool, + had_reasoning: bool, + ) -> Option { + let guardrail = Self::is_guardrail_stop_reason(stop_reason); + if !guardrail && !visible_text_empty { + return None; + } + let reason_label = stop_reason + .map(str::trim) + .filter(|r| !r.is_empty()) + .unwrap_or("unknown"); + if guardrail { + return Some(format!( + "Provider guardrail stopped the response (stop_reason: {}). The model declined to answer this request. Rephrasing, narrowing the request, or providing more context may help.", + reason_label + )); + } + // Empty visible output with a non-guardrail stop reason: still surface, + // since the user otherwise sees nothing at all. + 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.", + reasoning_hint, reason_label + )) + } + 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.]", + stop_reason.trim() + ) + } + + pub(crate) fn maybe_continue_incomplete_response( + &mut self, + stop_reason: Option<&str>, + attempts: &mut u32, + ) -> Result { + let Some(stop_reason) = stop_reason + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + return Ok(false); + }; + + if !Self::should_continue_after_stop_reason(stop_reason) { + return Ok(false); + } + + if *attempts >= Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS { + logging::warn(&format!( + "Response ended with stop_reason='{}' after {} continuation attempts; returning partial output", + stop_reason, attempts + )); + return Ok(false); + } + + *attempts += 1; + logging::warn(&format!( + "Response ended with stop_reason='{}'; requesting continuation (attempt {}/{})", + stop_reason, + attempts, + Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS + )); + + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: Self::continuation_prompt_for_stop_reason(stop_reason), + cache_control: None, + }], + ); + self.session.save()?; + Ok(true) + } + + pub(super) fn filter_truncated_tool_calls( + &mut self, + stop_reason: Option<&str>, + tool_calls: &mut Vec, + assistant_message_id: Option<&String>, + ) { + let stop_reason = stop_reason.unwrap_or(""); + if !Self::should_continue_after_stop_reason(stop_reason) { + return; + } + + let before = tool_calls.len(); + tool_calls.retain(|tc| !tc.input.is_null()); + let discarded = before - tool_calls.len(); + if discarded > 0 && tool_calls.is_empty() { + logging::warn(&format!( + "Discarded {} tool call(s) with null input (truncated by {}); requesting continuation", + discarded, + if stop_reason.is_empty() { + "unknown" + } else { + stop_reason + } + )); + if let Some(msg_id) = assistant_message_id { + self.session.remove_tool_use_blocks(msg_id); + self.persist_session_best_effort("truncated tool-call repair"); + } + } + } +} diff --git a/crates/jcode-app-core/src/agent/status.rs b/crates/jcode-app-core/src/agent/status.rs new file mode 100644 index 0000000..3f8aab2 --- /dev/null +++ b/crates/jcode-app-core/src/agent/status.rs @@ -0,0 +1,168 @@ +use super::*; + +impl Agent { + pub fn session_memory_profile_snapshot( + &mut self, + ) -> crate::session::SessionMemoryProfileSnapshot { + self.session.memory_profile_snapshot() + } + + pub fn message_count(&self) -> usize { + self.session.messages.len() + } + + /// Number of model-visible conversation messages (excludes the immutable + /// session-context header and internal system reminders). + pub fn visible_conversation_message_count(&self) -> usize { + self.session.visible_conversation_message_count() + } + + /// Role of the most recent model-visible conversation message, if any. + /// + /// When this is `User` and the agent is idle, the model still owes a + /// response for that turn (e.g. the turn errored or was interrupted before + /// the assistant replied). + pub fn last_visible_conversation_role(&self) -> Option { + self.session + .visible_conversation_messages() + .last() + .map(|message| message.role.clone()) + } + + pub fn last_message_role(&self) -> Option { + self.session.messages.last().map(|m| m.role.clone()) + } + + /// Get the text content of the last message (first Text block) + pub fn last_message_text(&self) -> Option<&str> { + self.session.messages.last().and_then(|m| { + m.content.iter().find_map(|block| { + if let ContentBlock::Text { text, .. } = block { + Some(text.as_str()) + } else { + None + } + }) + }) + } + + /// Build a transcript string for memory extraction + /// This is a independent method so it can be called before spawning async tasks + pub fn build_transcript_for_extraction(&self) -> String { + let mut transcript = String::new(); + for msg in &self.session.messages { + let role = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + transcript.push_str(&format!("**{}:**\n", role)); + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } => { + transcript.push_str(text); + transcript.push('\n'); + } + ContentBlock::ToolUse { name, .. } => { + transcript.push_str(&format!("[Used tool: {}]\n", name)); + } + ContentBlock::ToolResult { content, .. } => { + let preview = if content.len() > 200 { + format!("{}...", crate::util::truncate_str(content, 200)) + } else { + content.clone() + }; + transcript.push_str(&format!("[Result: {}]\n", preview)); + } + ContentBlock::Reasoning { .. } + | ContentBlock::ReasoningTrace { .. } + | ContentBlock::AnthropicThinking { .. } + | ContentBlock::OpenAIReasoning { .. } => {} + ContentBlock::Image { .. } => { + transcript.push_str("[Image]\n"); + } + ContentBlock::OpenAICompaction { .. } => { + transcript.push_str("[OpenAI native compaction]\n"); + } + } + } + transcript.push('\n'); + } + transcript + } + + pub fn last_assistant_text(&self) -> Option { + self.session + .messages + .iter() + .rev() + .find(|msg| msg.role == Role::Assistant) + .map(|msg| { + msg.content + .iter() + .filter_map(|c| { + if let ContentBlock::Text { text, .. } = c { + Some(text.clone()) + } else { + None + } + }) + .collect::>() + .join("\n") + }) + } + + /// Latest non-empty assistant text added at or after `start_index`. + pub fn latest_assistant_text_after(&self, start_index: usize) -> Option { + self.session + .messages + .iter() + .enumerate() + .rev() + .find_map(|(index, message)| { + if index < start_index || !matches!(&message.role, Role::Assistant) { + return None; + } + + let text = message + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) + }) + } + + pub fn last_upstream_provider(&self) -> Option { + self.last_upstream_provider + .clone() + .or_else(|| self.provider.preferred_provider()) + } + + pub fn last_connection_type(&self) -> Option { + self.last_connection_type.clone() + } + + pub fn last_status_detail(&self) -> Option { + self.last_status_detail.clone() + } + + pub fn provider_name(&self) -> String { + // `display_name()` resolves the active runtime profile (e.g. NVIDIA NIM) + // for the OpenRouter slot; for all other providers it equals `name()`. + self.provider.display_name() + } + + pub fn provider_model(&self) -> String { + self.provider.model().to_string() + } + + /// Get the short/friendly name for this session (e.g., "fox") + pub fn session_short_name(&self) -> Option<&str> { + self.session.short_name.as_deref() + } +} diff --git a/crates/jcode-app-core/src/agent/streaming.rs b/crates/jcode-app-core/src/agent/streaming.rs new file mode 100644 index 0000000..d5fa62a --- /dev/null +++ b/crates/jcode-app-core/src/agent/streaming.rs @@ -0,0 +1,26 @@ +use super::STREAM_KEEPALIVE_PONG_ID; +use crate::protocol::ServerEvent; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::{self, MissedTickBehavior}; + +fn stream_keepalive_interval() -> Duration { + if cfg!(test) { + Duration::from_millis(50) + } else { + Duration::from_secs(30) + } +} + +pub(super) fn stream_keepalive_ticker() -> time::Interval { + let interval = stream_keepalive_interval(); + let mut ticker = time::interval_at(time::Instant::now() + interval, interval); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + ticker +} + +pub(super) fn send_stream_keepalive_mpsc(event_tx: &mpsc::UnboundedSender) { + let _ = event_tx.send(ServerEvent::Pong { + id: STREAM_KEEPALIVE_PONG_ID, + }); +} diff --git a/crates/jcode-app-core/src/agent/tools.rs b/crates/jcode-app-core/src/agent/tools.rs new file mode 100644 index 0000000..70556c5 --- /dev/null +++ b/crates/jcode-app-core/src/agent/tools.rs @@ -0,0 +1,167 @@ +use crate::message::{ContentBlock, ToolCall}; +use crate::tool::ToolOutput; + +pub(super) const MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY: usize = 512 * 1024; + +pub(super) fn cap_tool_output_for_history(tool_name: &str, mut output: ToolOutput) -> ToolOutput { + if output.output.chars().count() <= MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY { + return output; + } + + let original_chars = output.output.chars().count(); + let kept = crate::util::truncate_str(&output.output, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY); + output.output = format!( + "{}\n\n[Tool output truncated by jcode: tool `{}` produced {} chars; kept first {} chars to protect the remote protocol, session history, and prompt cache. Redirect large logs to a file and read targeted sections.]", + kept, tool_name, original_chars, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY, + ); + output +} + +pub(super) fn cap_sdk_tool_content_for_history(tool_name: &str, content: String) -> String { + if content.chars().count() <= MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY { + return content; + } + let original_chars = content.chars().count(); + let kept = crate::util::truncate_str(&content, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY); + format!( + "{}\n\n[Tool output truncated by jcode: tool `{}` produced {} chars; kept first {} chars to protect the remote protocol, session history, and prompt cache. Redirect large logs to a file and read targeted sections.]", + kept, tool_name, original_chars, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY, + ) +} + +/// Build rendered side-pane images from a tool output's attached images. +/// +/// This mirrors how `render_messages_and_images` derives images from persisted +/// session history (source = ToolResult), so live-streamed images match what a +/// later History reload would produce. `tool_name` and `tool_input` provide the +/// label fallback (e.g. the `read` tool's `file_path`); `tool_call_id` anchors +/// the image to its tool message in the transcript. +pub(super) fn tool_output_side_pane_images( + tool_call_id: &str, + tool_name: &str, + tool_input: &serde_json::Value, + output: &ToolOutput, +) -> Vec { + if output.images.is_empty() { + return Vec::new(); + } + let fallback_label = tool_input + .get("file_path") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + output + .images + .iter() + .map(|img| jcode_session_types::RenderedImage { + media_type: img.media_type.clone(), + data: img.data.clone(), + label: img + .label + .as_ref() + .map(|label| label.trim().to_string()) + .filter(|label| !label.is_empty()) + .or_else(|| fallback_label.clone()), + source: jcode_session_types::RenderedImageSource::ToolResult { + tool_name: tool_name.to_string(), + }, + anchor: Some(jcode_session_types::RenderedImageAnchor::ToolCall { + id: tool_call_id.to_string(), + }), + }) + .collect() +} + +pub(super) fn tool_output_to_content_blocks( + tool_use_id: String, + output: ToolOutput, +) -> Vec { + let mut blocks = vec![ContentBlock::ToolResult { + tool_use_id, + content: output.output, + is_error: None, + }]; + for img in output.images { + blocks.push(ContentBlock::Image { + media_type: img.media_type, + data: img.data, + }); + if let Some(label) = img.label.filter(|label| !label.trim().is_empty()) { + blocks.push(ContentBlock::Text { + text: format!( + "[Attached image associated with the preceding tool result: {}]", + label + ), + cache_control: None, + }); + } + } + blocks +} + +pub(super) fn print_tool_summary(tool: &ToolCall) { + match tool.name.as_str() { + "bash" => { + if let Some(cmd) = tool.input.get("command").and_then(|v| v.as_str()) { + let short = if cmd.len() > 60 { + format!("{}...", crate::util::truncate_str(cmd, 60)) + } else { + cmd.to_string() + }; + println!("$ {}", short); + } + } + "read" | "write" | "edit" => { + if let Some(path) = tool.input.get("file_path").and_then(|v| v.as_str()) { + println!("{}", path); + } + } + "glob" | "grep" => { + if let Some(pattern) = tool.input.get("pattern").and_then(|v| v.as_str()) { + println!("'{}'", pattern); + } + } + "ls" => { + let path = tool + .input + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("."); + println!("{}", path); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cap_tool_output_leaves_small_output_unchanged() { + let output = ToolOutput::new("short output"); + let capped = cap_tool_output_for_history("bash", output.clone()); + assert_eq!(capped.output, output.output); + } + + #[test] + fn cap_tool_output_adds_visible_truncation_notice() { + let output = ToolOutput::new("x".repeat(MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 10)); + let capped = cap_tool_output_for_history("bash", output); + assert!(capped.output.len() < MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 1_000); + assert!(capped.output.contains("Tool output truncated by jcode")); + assert!(capped.output.contains("tool `bash` produced")); + assert!(capped.output.contains("Redirect large logs to a file")); + } + + #[test] + fn cap_sdk_tool_content_adds_same_notice() { + let capped = cap_sdk_tool_content_for_history( + "custom", + "y".repeat(MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 10), + ); + assert!(capped.contains("Tool output truncated by jcode")); + assert!(capped.contains("tool `custom` produced")); + } +} diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs new file mode 100644 index 0000000..a3b26e5 --- /dev/null +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -0,0 +1,917 @@ +use super::*; + +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( + Role::User, + vec![ContentBlock::Text { + text: user_message.to_string(), + cache_control: None, + }], + ); + self.session.save()?; + if trace_enabled() { + eprintln!("[trace] session_id {}", self.session.id); + } + let _ = self.run_turn(true).await?; + Ok(()) + } + + pub async fn run_once_capture(&mut self, user_message: &str) -> Result { + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: user_message.to_string(), + cache_control: None, + }], + ); + self.session.save()?; + if trace_enabled() { + eprintln!("[trace] session_id {}", self.session.id); + } + self.run_turn(false).await + } + + /// Run one conversation turn with streaming events via mpsc channel (per-client) + pub async fn run_once_streaming_mpsc( + &mut self, + user_message: &str, + images: Vec<(String, String)>, + system_reminder: Option, + event_tx: mpsc::UnboundedSender, + ) -> Result<()> { + // Inject any pending notifications before the user message + let alerts = self.take_alerts(); + if !alerts.is_empty() { + let alert_text = format!( + "[NOTIFICATION]\nYou received {} notification(s) from other agents working in this codebase:\n\n{}\n\nUse the communicate tool to coordinate with other agents (prefer dm; broadcast reaches only your spawned subtree).", + alerts.len(), + alerts.join("\n\n---\n\n") + ); + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: alert_text, + cache_control: None, + }], + ); + } + + self.current_turn_system_reminder = + system_reminder.filter(|value| !value.trim().is_empty()); + + let mut blocks: Vec = images + .into_iter() + .map(|(media_type, data)| ContentBlock::Image { media_type, data }) + .collect(); + blocks.push(ContentBlock::Text { + text: user_message.to_string(), + cache_control: None, + }); + + if blocks.len() > 1 { + crate::logging::info(&format!( + "Agent received message with {} image(s)", + blocks.len() - 1 + )); + } + + 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 + } + + /// Fire the `turn_start` observer hook when a turn begins, before the model + /// starts generating (and before the first `pre_tool`). This lets external + /// integrations (terminal multiplexers, status bars) detect that the agent + /// is actively working during the otherwise-invisible window between prompt + /// submission and the first tool call. No-op (without building the payload) + /// when the hook is not configured. + fn fire_turn_start_hook(&self, source: &str) { + if !crate::hooks::hook_configured("turn_start") { + return; + } + let mut event = crate::hooks::HookEvent::new("turn_start") + .session_id(self.session.id.clone()) + .field("MODEL", self.provider_model()) + .field("SOURCE", source.to_string()); + if let Some(cwd) = self.working_dir() { + event = event.cwd(cwd); + } + crate::hooks::dispatch_observer(event); + } + + /// Fire the `turn_end` observer hook with turn outcome metadata. + /// No-op (without building the payload) when the hook is not configured. + fn fire_turn_end_hook( + &self, + result: &Result<()>, + started_at: Instant, + start_message_index: usize, + ) { + if !crate::hooks::hook_configured("turn_end") { + return; + } + let status = if result.is_ok() { "ok" } else { "error" }; + let mut event = crate::hooks::HookEvent::new("turn_end") + .session_id(self.session.id.clone()) + .field("STATUS", status) + .field("DURATION_MS", started_at.elapsed().as_millis().to_string()) + .field("MODEL", self.provider_model()); + if let Some(cwd) = self.working_dir() { + event = event.cwd(cwd); + } + if let Some(text) = self.latest_assistant_text_after(start_message_index) { + const LAST_TEXT_LIMIT: usize = 4000; + let snippet: String = text.chars().take(LAST_TEXT_LIMIT).collect(); + event = event.field("LAST_ASSISTANT_TEXT", snippet); + } + if let Err(error) = result { + const ERROR_LIMIT: usize = 1000; + let message: String = error.to_string().chars().take(ERROR_LIMIT).collect(); + event = event.field("ERROR", message); + } + crate::hooks::dispatch_observer(event); + } + + /// Clear conversation history + pub fn clear(&mut self) { + let preserve_canary = self.session.is_canary; + let preserve_testing_build = self.session.testing_build.clone(); + let preserve_debug = self.session.is_debug; + let preserve_working_dir = self.session.working_dir.clone(); + + self.session.mark_closed(); + 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.is_canary = preserve_canary; + new_session.testing_build = preserve_testing_build; + new_session.is_debug = preserve_debug; + new_session.working_dir = preserve_working_dir; + new_session.ensure_initial_session_context_message(); + + self.session = new_session; + self.reset_runtime_state_for_session_change(); + self.provider_session_id = None; + self.seed_compaction_from_session(); + } + + /// Clear provider session so the next turn sends full context. + pub fn reset_provider_session(&mut self) { + self.provider_session_id = None; + self.session.provider_session_id = None; + self.persist_session_best_effort("provider session reset"); + } + + /// Rewind the conversation to a 1-based visible transcript message index. + /// + /// The index is interpreted against the same rendered transcript the TUI + /// numbers in `/rewind` (user/assistant entries only, tool cards and + /// system notices excluded). Mapping through raw stored messages instead + /// would count tool-result messages the UI never numbers, sending + /// `/rewind N` far earlier than the on-screen message N (issue #432). + /// + /// Provider-side resumable sessions are reset so the next request sends the + /// truncated context from scratch instead of continuing from a stale upstream + /// conversation. + pub fn rewind_to_message(&mut self, message_index: usize) -> Result { + let targets = self.session.rewind_target_stored_indices(); + let message_count = targets.len(); + if message_index == 0 || message_index > message_count { + return Err(format!( + "Invalid message number: {}. Valid range: 1-{}", + message_index, message_count + )); + } + let stored_len = targets[message_index - 1] + 1; + + let removed = message_count - message_index; + self.rewind_undo_snapshot = Some(RewindUndoSnapshot { + messages: self.session.messages.clone(), + provider_session_id: self.provider_session_id.clone(), + session_provider_session_id: self.session.provider_session_id.clone(), + visible_message_count: message_count, + }); + self.session.truncate_messages(stored_len); + self.session.updated_at = chrono::Utc::now(); + self.provider_session_id = None; + self.session.provider_session_id = None; + self.cache_tracker.reset(); + self.locked_tools = None; + self.reset_tool_output_tracking(); + self.persist_session_best_effort("conversation rewind"); + Ok(removed) + } + + pub fn undo_rewind(&mut self) -> Result { + let Some(snapshot) = self.rewind_undo_snapshot.take() else { + return Err("No rewind to undo.".to_string()); + }; + + let current_count = self.session.rewind_target_count(); + let restored = snapshot.visible_message_count.saturating_sub(current_count); + self.session.replace_messages(snapshot.messages); + self.provider_session_id = snapshot.provider_session_id; + self.session.provider_session_id = snapshot.session_provider_session_id; + self.session.updated_at = chrono::Utc::now(); + self.cache_tracker.reset(); + self.locked_tools = None; + self.reset_tool_output_tracking(); + self.persist_session_best_effort("conversation rewind undo"); + Ok(restored) + } + + /// Unlock the tool list so the next API request picks up any new tools. + /// Called after MCP reload or when the user explicitly wants new tools. + pub fn unlock_tools(&mut self) { + if self.locked_tools.is_some() { + logging::info("Tool list unlocked — next request will pick up current tools"); + self.locked_tools = None; + self.cache_tracker.reset(); + } + // Allow the late-MCP-registration recheck to fire once for the next + // snapshot (e.g. after an explicit `mcp` reload). + self.mcp_late_register_resolved = false; + } + + /// Unlock tools if a tool execution may have changed the registry + /// (e.g., mcp connect/disconnect/reload) + pub(super) fn unlock_tools_if_needed(&mut self, tool_name: &str) { + if tool_name == "mcp" { + self.unlock_tools(); + } + } + + pub fn is_canary(&self) -> bool { + self.session.is_canary + } + + pub fn is_debug(&self) -> bool { + self.session.is_debug + } + + pub fn set_canary(&mut self, build_hash: &str) { + self.session.set_canary(build_hash); + if let Err(err) = self.session.save() { + logging::error(&format!("Failed to persist canary session state: {}", err)); + } + } + + /// Mark this session as a debug/test session + /// Set a custom system prompt override (used by ambient mode). + /// When set, this replaces the normal system prompt entirely. + pub fn set_system_prompt(&mut self, prompt: &str) { + self.system_prompt_override = Some(prompt.to_string()); + } + + pub fn set_debug(&mut self, is_debug: bool) { + self.session.set_debug(is_debug); + if let Err(err) = self.session.save() { + logging::error(&format!("Failed to persist debug session state: {}", err)); + } + } + + /// Enable or disable memory features for this session. + pub fn set_memory_enabled(&mut self, enabled: bool) { + self.memory_enabled = enabled; + if !enabled { + crate::memory::clear_pending_memory(&self.session.id); + } + } + + /// Mark this session as an inline swarm worker. When enabled, the streaming + /// loop publishes a throttled output tail to the global bus so a + /// coordinator can render a live inline gallery viewport for it. + pub fn set_inline_output_tap(&mut self, enabled: bool) { + self.inline_output_tap = enabled; + } + + /// Whether this session streams an inline output tail to the bus. + pub(crate) fn inline_output_tap(&self) -> bool { + self.inline_output_tap + } + + /// Publish the current rolling activity tail to the bus for the + /// coordinator's inline gallery. No-op unless the inline tap is enabled. + pub(crate) fn publish_inline_tail(&self) { + if !self.inline_output_tap { + return; + } + crate::bus::Bus::global().publish(crate::bus::BusEvent::SwarmOutputTail( + crate::bus::SwarmOutputTail { + session_id: self.session.id.clone(), + tail: self.inline_tail.render(), + }, + )); + } + + /// Check whether memory features are enabled for this session. + pub fn memory_enabled(&self) -> bool { + self.memory_enabled + } + + /// Set the stdin request channel for interactive stdin forwarding + pub fn set_stdin_request_tx( + &mut self, + tx: tokio::sync::mpsc::UnboundedSender, + ) { + self.stdin_request_tx = Some(tx); + } + + pub(super) async fn tool_definitions(&mut self) -> Vec { + if self.session.is_canary { + self.registry.register_selfdev_tools().await; + } + + // Return locked tools if available (prevents cache invalidation from + // tools arriving asynchronously after the first API request). + // + // Exception: MCP servers connect on a background task and register + // `mcp__*` tools seconds after the session starts — typically *after* + // the first turn has already locked the snapshot. We deliberately do + // NOT block the first turn on MCP connection: servers can be slow or + // hang, and we want the user to be able to talk to the agent the moment + // the session spawns. The price is that the first locked snapshot is + // missing MCP tools, and the only other unlock path fires when the model + // calls the `mcp` management tool — which it cannot do without first + // seeing MCP tools (#206). + // + // So, exactly once per locked snapshot, if MCP tools have since appeared + // in the registry, we rebuild. This is a single intentional provider + // 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. + if let Some(ref locked) = self.locked_tools { + if self.mcp_late_register_resolved { + return locked.clone(); + } + if self.registry_has_new_mcp_tools(locked).await { + logging::info( + "MCP tools registered after first turn locked the tool snapshot — \ + rebuilding once to expose them. This is one intentional prompt-cache \ + miss; we accept it so the agent is reachable immediately at spawn \ + instead of blocking on MCP connection (#206).", + ); + // Latch the one-shot guard and drop the stale snapshot directly. + // We intentionally do NOT call `unlock_tools()` here, because that + // re-arms the guard (it is the explicit-reload path) and would let + // the recheck fire again on every later turn. + self.mcp_late_register_resolved = true; + self.locked_tools = None; + self.cache_tracker.reset(); + } else { + // No MCP tools have appeared. They may still be connecting, so + // leave the guard unset and re-check on the next turn. Once they + // appear (or never do, after the registry settles) we stop. + return locked.clone(); + } + } + + let tools = self.build_filtered_tool_definitions().await; + + // Lock the tool list to prevent cache invalidation when more tools + // arrive asynchronously mid-session. + logging::info(&format!( + "Locking tool list at {} tools for cache stability", + tools.len() + )); + self.locked_tools = Some(tools.clone()); + tools + } + + /// Build the agent's tool definitions from the registry, applying the + /// session's `allowed_tools`, `disabled_tools`, and self-dev filters. + 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)); + } + Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary); + tools + } + + /// Tailor the `selfdev` tool definition to the session 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) { + 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); + } + } + } + + /// Returns true if the registry contains `mcp__*` tools (subject to the + /// session's `allowed_tools` filter) that are not present in the currently + /// locked snapshot. Used to detect the async MCP-registration race (#206). + async fn registry_has_new_mcp_tools(&self, locked: &[ToolDefinition]) -> bool { + let registry_names = self.registry.tool_names().await; + 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) + && !locked.iter().any(|t| &t.name == name) + }) + } + + pub async fn tool_names(&self) -> Vec { + self.tool_definitions_for_debug() + .await + .into_iter() + .map(|tool| tool.name) + .collect() + } + + /// Get full tool definitions for debug introspection (bypasses lock) + pub async fn tool_definitions_for_debug(&self) -> Vec { + 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 + } + + pub async fn execute_tool( + &self, + name: &str, + input: serde_json::Value, + ) -> Result { + self.validate_tool_allowed(name)?; + + let call_id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("debug-{}", d.as_millis())) + .unwrap_or_else(|_| "debug".to_string()); + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: self.session.id.clone(), + tool_call_id: call_id, + working_dir: self.working_dir().map(PathBuf::from), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::Direct, + }; + self.registry.execute(name, input, ctx).await + } + + pub fn add_manual_tool_use( + &mut self, + tool_call_id: String, + tool_name: String, + input: serde_json::Value, + ) -> Result { + let message_id = self.add_message( + Role::Assistant, + vec![ContentBlock::ToolUse { + id: tool_call_id, + name: tool_name, + input, + thought_signature: None, + }], + ); + self.session.save()?; + Ok(message_id) + } + + pub fn add_manual_tool_result( + &mut self, + tool_call_id: String, + output: crate::tool::ToolOutput, + duration_ms: u64, + ) -> Result<()> { + let blocks = tool_output_to_content_blocks(tool_call_id, output); + self.add_message_with_duration(Role::User, blocks, Some(duration_ms)); + self.session.save()?; + Ok(()) + } + + pub fn add_manual_tool_error( + &mut self, + tool_call_id: String, + error: String, + duration_ms: u64, + ) -> Result<()> { + self.add_message_with_duration( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tool_call_id, + content: error, + is_error: Some(true), + }], + Some(duration_ms), + ); + self.session.save()?; + Ok(()) + } + + pub(super) fn validate_tool_allowed(&self, name: &str) -> Result<()> { + if let Some(allowed) = self.allowed_tools.as_ref() + && !allowed.contains(name) + { + return Err(anyhow::anyhow!("Tool '{}' is not allowed", name)); + } + if self.disabled_tools.contains(name) { + return Err(anyhow::anyhow!("Tool '{}' is disabled", name)); + } + Ok(()) + } + + /// Restore a session by ID (loads from disk) + pub fn restore_session(&mut self, session_id: &str) -> Result { + self.restore_session_with_working_dir(session_id, None) + } + + pub(crate) fn restore_session_with_working_dir( + &mut self, + session_id: &str, + working_dir: Option<&str>, + ) -> Result { + let restore_start = Instant::now(); + let load_start = Instant::now(); + let mut session = Session::load(session_id)?; + if let Some(working_dir) = working_dir { + session.working_dir = Some(working_dir.to_string()); + session.refresh_initial_session_context_message(); + } + let load_ms = load_start.elapsed().as_millis(); + logging::info(&format!( + "Restoring session '{}' with {} messages, provider_session_id: {:?}, status: {}", + session_id, + session.messages.len(), + session.provider_session_id, + session.status.display() + )); + let previous_status = session.status.clone(); + + let assign_start = Instant::now(); + let previous_session_id = self.session.id.clone(); + // 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.session.id, + self.allowed_tools.clone(), + self.disabled_tools.clone(), + ); + let assign_ms = assign_start.elapsed().as_millis(); + + let reset_start = Instant::now(); + self.reset_runtime_state_for_session_change(); + let restored_soft_interrupts = self.restore_persisted_soft_interrupts(); + let reset_ms = reset_start.elapsed().as_millis(); + + let model_start = Instant::now(); + if let Some(model) = self.session.model.clone() { + let model_request = + crate::provider::MultiProvider::model_switch_request_for_session_route( + &model, + self.session.provider_key.as_deref(), + self.session.route_api_method.as_deref(), + ); + if let Err(e) = + crate::provider::set_model_with_auth_refresh(self.provider.as_ref(), &model_request) + { + logging::error(&format!( + "Failed to restore session model '{}' via '{}': {}", + model, model_request, e + )); + } + } else { + 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(); + let mark_active_ms = mark_active_start.elapsed().as_millis(); + self.sync_memory_dedup_state_from_session(); + + logging::info(&format!( + "restore_session: loaded session {} with {} messages, calling seed_compaction", + session_id, + self.session.messages.len() + )); + let compaction_start = Instant::now(); + self.seed_compaction_from_session(); + let compaction_ms = compaction_start.elapsed().as_millis(); + + let env_snapshot_start = Instant::now(); + self.log_env_snapshot("resume"); + let env_snapshot_ms = env_snapshot_start.elapsed().as_millis(); + self.fire_session_lifecycle_hook("session_start", "resume"); + + let save_start = Instant::now(); + if let Err(err) = self.session.save() { + logging::error(&format!( + "Failed to persist resumed session state for {}: {}", + session_id, err + )); + } + let save_ms = save_start.elapsed().as_millis(); + + logging::info(&format!( + "[TIMING] restore_session: session={}, messages={}, restored_soft_interrupts={}, load={}ms, assign={}ms, reset={}ms, model={}ms, mark_active={}ms, compaction={}ms, env_snapshot={}ms, save={}ms, total={}ms", + session_id, + self.session.messages.len(), + restored_soft_interrupts, + load_ms, + assign_ms, + reset_ms, + model_ms, + mark_active_ms, + compaction_ms, + env_snapshot_ms, + save_ms, + restore_start.elapsed().as_millis(), + )); + logging::info(&format!( + "Session restored: {} messages in session", + self.session.messages.len() + )); + Ok(previous_status) + } + + /// Get conversation history for sync + pub fn get_history(&self) -> Vec { + crate::session::render_messages(&self.session) + .into_iter() + .map(|msg| HistoryMessage { + role: msg.role, + content: msg.content, + tool_calls: if msg.tool_calls.is_empty() { + None + } else { + Some(msg.tool_calls) + }, + tool_data: msg.tool_data, + }) + .collect() + } + + pub fn get_history_and_rendered_images( + &self, + ) -> (Vec, Vec) { + let (messages, images) = crate::session::render_messages_and_images(&self.session); + let history = messages + .into_iter() + .map(|msg| HistoryMessage { + role: msg.role, + content: msg.content, + tool_calls: if msg.tool_calls.is_empty() { + None + } else { + Some(msg.tool_calls) + }, + tool_data: msg.tool_data, + }) + .collect(); + (history, images) + } + + pub fn get_history_and_rendered_images_with_compacted_history( + &self, + compacted_history_visible: usize, + ) -> ( + Vec, + Vec, + Option, + ) { + let (messages, images, compacted_info) = + crate::session::render_messages_and_images_with_compacted_history( + &self.session, + compacted_history_visible, + ); + let history = messages + .into_iter() + .map(|msg| HistoryMessage { + role: msg.role, + content: msg.content, + tool_calls: if msg.tool_calls.is_empty() { + None + } else { + Some(msg.tool_calls) + }, + tool_data: msg.tool_data, + }) + .collect(); + (history, images, compacted_info) + } + + pub fn get_tool_call_summaries(&self, limit: usize) -> Vec { + crate::session::summarize_tool_calls(&self.session, limit) + } + + /// Start an interactive REPL + pub async fn repl(&mut self) -> Result<()> { + println!("J-Code - Coding Agent"); + println!("Type your message, or 'quit' to exit."); + + // Show available skills + let skills = self.current_skills_snapshot(); + let skill_list = skills.list(); + if !skill_list.is_empty() { + println!( + "Available skills: {}", + skill_list + .iter() + .map(|s| format!("/{}", s.name)) + .collect::>() + .join(", ") + ); + } + println!(); + + loop { + print!("> "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let input = input.trim(); + if input.is_empty() { + continue; + } + + if input == "quit" || input == "exit" { + break; + } + + if input == "clear" { + self.clear(); + println!("Conversation cleared."); + continue; + } + + // Check for skill invocation + if let Some(invocation) = SkillRegistry::parse_invocation(input) { + if let Some(skill) = skills.get(invocation.name) { + println!("Activating skill: {}", skill.name); + println!("{}\n", skill.description); + self.active_skill = Some(invocation.name.to_string()); + if let Some(prompt) = invocation.prompt { + if let Err(e) = self.run_once(prompt).await { + eprintln!("\nError: {}\n", e); + } + println!(); + } + continue; + } else { + println!("Unknown skill: /{}", invocation.name); + println!( + "Available: {}", + skills + .list() + .iter() + .map(|s| format!("/{}", s.name)) + .collect::>() + .join(", ") + ); + continue; + } + } + + if let Err(e) = self.run_once(input).await { + eprintln!("\nError: {}\n", e); + } + + println!(); + } + + // Extract memories from session before exiting + self.extract_session_memories().await; + + Ok(()) + } + + /// Extract memories from the session transcript + /// Returns the number of memories extracted, or 0 if none/skipped + pub async fn extract_session_memories(&self) -> usize { + if !self.memory_enabled { + return 0; + } + + // Need at least 4 messages for meaningful extraction + if self.session.messages.len() < 4 { + return 0; + } + + logging::info(&format!( + "Extracting memories from {} messages", + self.session.messages.len() + )); + + // Build transcript + let mut transcript = String::new(); + for msg in &self.session.messages { + let role = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + transcript.push_str(&format!("**{}:**\n", role)); + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } => { + transcript.push_str(text); + transcript.push('\n'); + } + ContentBlock::ToolUse { name, .. } => { + transcript.push_str(&format!("[Used tool: {}]\n", name)); + } + ContentBlock::ToolResult { content, .. } => { + let preview = if content.len() > 200 { + format!("{}...", crate::util::truncate_str(content, 200)) + } else { + content.clone() + }; + transcript.push_str(&format!("[Result: {}]\n", preview)); + } + ContentBlock::Reasoning { .. } + | ContentBlock::ReasoningTrace { .. } + | ContentBlock::AnthropicThinking { .. } + | ContentBlock::OpenAIReasoning { .. } => {} + ContentBlock::Image { .. } => { + transcript.push_str("[Image]\n"); + } + ContentBlock::OpenAICompaction { .. } => { + transcript.push_str("[OpenAI native compaction]\n"); + } + } + } + transcript.push('\n'); + } + + if !crate::memory::memory_llm_judge_available() { + logging::info("Memory extraction skipped: LLM judge unavailable"); + return 0; + } + + // Extract using sidecar + let sidecar = crate::sidecar::Sidecar::new(); + match sidecar.extract_memories(&transcript).await { + Ok(extracted) if !extracted.is_empty() => { + let manager = self + .session + .working_dir + .as_deref() + .map(|dir| crate::memory::MemoryManager::new().with_project_dir(dir)) + .unwrap_or_default(); + let mut stored_count = 0; + + for memory in &extracted { + let category = crate::memory::MemoryCategory::from_extracted(&memory.category); + + let trust = match memory.trust.as_str() { + "high" => crate::memory::TrustLevel::High, + "low" => crate::memory::TrustLevel::Low, + _ => crate::memory::TrustLevel::Medium, + }; + + let entry = crate::memory::MemoryEntry::new(category, &memory.content) + .with_source(&self.session.id) + .with_trust(trust); + + if manager.remember_project(entry).is_ok() { + stored_count += 1; + } + } + + if stored_count > 0 { + logging::info(&format!("Extracted {} memories from session", stored_count)); + } + stored_count + } + Ok(_) => 0, + Err(e) => { + logging::info(&format!("Memory extraction skipped: {}", e)); + 0 + } + } + } +} diff --git a/crates/jcode-app-core/src/agent/turn_loops.rs b/crates/jcode-app-core/src/agent/turn_loops.rs new file mode 100644 index 0000000..f0f917e --- /dev/null +++ b/crates/jcode-app-core/src/agent/turn_loops.rs @@ -0,0 +1,1173 @@ +use super::*; + +impl Agent { + /// 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; + + pub(super) async fn run_turn(&mut self, print_output: bool) -> Result { + self.set_log_context(); + 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. + let _streaming_guard = crate::session::StreamingGuard::new(self.session.id.clone()); + // Register this turn's cancel signal so session-level cancels reach + // this in-flight turn even through stale control handles (issue #428). + let _turn_cancel_guard = crate::turn_cancel_registry::register_active_turn( + &self.session.id, + self.graceful_shutdown.clone(), + ); + let mut final_text = String::new(); + let trace = trace_enabled(); + let mut context_limit_retries = 0u32; + let mut incomplete_continuations = 0u32; + let mut empty_post_tool_continuations = 0u32; + + loop { + let repaired = self.repair_missing_tool_outputs(); + if repaired > 0 { + logging::warn(&format!( + "Recovered {} missing tool output(s) before API call", + repaired + )); + } + 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 + self.cache_tracker.reset(); + self.locked_tools = None; + if print_output { + let tokens_str = event + .pre_tokens + .map(|t| format!(" ({} tokens)", t)) + .unwrap_or_default(); + println!("📦 Context compacted ({}){}", event.trigger, tokens_str); + } + } + + 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. + // Memory is an ephemeral suffix that changes each turn; tracking it would cause + // false-positive violations every turn (prior turn's memory ≠ current history prefix). + self.record_client_cache_request(&messages); + + // 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() { + let memory_count = memory.count.max(1); + let age_ms = memory.computed_at.elapsed().as_millis() as u64; + crate::memory::record_injected_prompt(&memory.prompt, memory_count, age_ms); + self.record_memory_injection_in_session(memory); + logging::info(&format!( + "Memory injected as message ({} chars)", + memory.prompt.len() + )); + let (memory_msg, _persisted) = self.prepare_memory_injection_message(memory); + messages_with_memory.push(memory_msg); + } + + logging::info(&format!( + "API call starting: {} messages, {} tools", + messages_with_memory.len(), + tools.len() + )); + let api_start = Instant::now(); + + // Publish status for TUI to show during Task execution + Bus::global().publish(BusEvent::SubagentStatus(SubagentStatus { + session_id: self.session.id.clone(), + status: "calling API".to_string(), + 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 prompt_has_recent_tool_result = Self::messages_end_with_tool_result(send_messages); + self.last_status_detail = None; + let mut stream = match self + .provider + .complete_split( + send_messages, + &tools, + &split_prompt.static_part, + &split_prompt.dynamic_part, + self.provider_session_id.as_deref(), + ) + .await + { + Ok(stream) => stream, + Err(e) => { + if self.try_auto_compact_after_context_limit(&e.to_string()) { + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + continue; + } + return Err(e); + } + }; + + // Successful API call - reset retry counter + context_limit_retries = 0; + + logging::info(&format!( + "API stream opened in {:.2}s", + api_start.elapsed().as_secs_f64() + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "stream_opened", + api_start, + vec![("mode", "blocking".to_string())], + ); + + Bus::global().publish(BusEvent::SubagentStatus(SubagentStatus { + session_id: self.session.id.clone(), + status: "streaming".to_string(), + model: Some(self.provider.model()), + })); + + let mut text_content = String::new(); + let mut tool_calls: Vec = Vec::new(); + let mut current_tool: Option = None; + let mut current_tool_input = String::new(); + let mut generated_image_contexts: Vec> = Vec::new(); + let mut usage_input: Option = None; + let mut usage_output: Option = None; + let mut usage_cache_read: Option = None; + let mut usage_cache_creation: Option = None; + let mut saw_message_end = false; + let mut stop_reason: Option = None; + let mut _thinking_start: Option = None; + let provider_name = self.provider.name().to_string(); + let store_reasoning_content = + crate::provider::stores_reasoning_content_for_context(&provider_name); + let mut reasoning_content = String::new(); + let mut reasoning_signature = String::new(); + let mut openai_reasoning_items: Vec = Vec::new(); + // Track tool results from provider (already executed by Claude Code CLI) + let mut sdk_tool_results: std::collections::HashMap = + std::collections::HashMap::new(); + let mut openai_native_compaction: Option<(String, usize)> = None; + + let mut retry_after_compaction = false; + while let Some(event) = stream.next().await { + let event = match event { + Ok(event) => event, + Err(e) => { + let err_str = e.to_string(); + if self.try_auto_compact_after_context_limit(&err_str) { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_error_retry_after_compaction", + api_start, + vec![ + ("mode", "blocking".to_string()), + ("error", err_str.clone()), + ( + "context_limit_retries", + (context_limit_retries + 1).to_string(), + ), + ], + ); + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + retry_after_compaction = true; + break; + } + log_agent_provider_stream_lifecycle( + logging::LogLevel::Error, + self, + "stream_error", + api_start, + vec![("mode", "blocking".to_string()), ("error", err_str)], + ); + return Err(e); + } + }; + + match event { + StreamEvent::ThinkingStart => { + // Track start but don't print - wait for ThinkingDone + _thinking_start = Some(Instant::now()); + } + StreamEvent::ThinkingDelta(thinking_text) => { + // Display reasoning content only if enabled + if print_output && crate::config::config().display.show_thinking { + println!("💭 {}", thinking_text); + } + // Always capture reasoning text so it can be persisted as a + // history-only trace, regardless of provider replay support. + reasoning_content.push_str(&thinking_text); + } + StreamEvent::ThinkingSignatureDelta(signature) => { + if store_reasoning_content { + reasoning_signature.push_str(&signature); + } + } + StreamEvent::ThinkingEnd => { + // Don't print here - ThinkingDone has accurate timing + _thinking_start = None; + } + StreamEvent::ThinkingDone { duration_secs } => { + // Bridge provides accurate wall-clock timing + if print_output { + println!("Thought for {:.1}s\n", duration_secs); + } + } + StreamEvent::TextDelta(text) => { + if print_output { + print!("{}", text); + io::stdout().flush()?; + } + text_content.push_str(&text); + } + StreamEvent::ToolUseStart { id, name } => { + if trace { + eprintln!("\n[trace] tool_use_start name={} id={}", name, id); + } + if print_output { + print!("\n[{}] ", name); + io::stdout().flush()?; + } + current_tool = Some(ToolCall { + id, + name, + input: serde_json::Value::Null, + intent: None, + thought_signature: None, + }); + current_tool_input.clear(); + } + StreamEvent::ToolInputDelta(delta) => { + current_tool_input.push_str(&delta); + } + StreamEvent::ToolUseEnd => { + if let Some(mut tool) = current_tool.take() { + // Parse the accumulated JSON + let tool_input = + ToolCall::parse_streamed_input_to_object(¤t_tool_input); + tool.input = tool_input.clone(); + tool.intent = ToolCall::intent_from_input(&tool_input); + + if trace { + if current_tool_input.trim().is_empty() { + eprintln!("[trace] tool_input {} (empty)", tool.name); + } else if tool_input == serde_json::Value::Null { + eprintln!( + "[trace] tool_input {} (raw) {}", + tool.name, current_tool_input + ); + } else { + let pretty = serde_json::to_string_pretty(&tool_input) + .unwrap_or_else(|_| tool_input.to_string()); + eprintln!("[trace] tool_input {} {}", tool.name, pretty); + } + } + + if print_output { + // Show brief tool info + print_tool_summary(&tool); + } + + tool_calls.push(tool); + current_tool_input.clear(); + } + } + StreamEvent::ToolUseSignature(signature) => { + // Attach Gemini 3 thought signature to the most recent + // tool call so it can be persisted and replayed. + if let Some(tool) = tool_calls.last_mut() + && !signature.is_empty() + { + tool.thought_signature = Some(signature); + } + } + StreamEvent::ToolResult { + tool_use_id, + content, + is_error, + } => { + // SDK already executed this tool, store the result + if trace { + eprintln!( + "[trace] sdk_tool_result id={} is_error={} content_len={}", + tool_use_id, + is_error, + content.len() + ); + } + sdk_tool_results.insert(tool_use_id, (content, is_error)); + } + StreamEvent::GeneratedImage { + id, + path, + metadata_path, + output_format, + revised_prompt, + } => { + if trace { + eprintln!( + "[trace] generated_image id={} format={} path={} metadata={}", + id, + output_format, + path, + metadata_path.as_deref().unwrap_or("none") + ); + } + if print_output { + let summary = crate::message::generated_image_summary( + &path, + metadata_path.as_deref(), + &output_format, + revised_prompt.as_deref(), + ); + eprintln!( + "\n[{}] {}", + crate::message::GENERATED_IMAGE_TOOL_NAME, + summary + ); + } + if self.provider.supports_image_input() { + if let Some(blocks) = + crate::message::generated_image_visual_context_blocks( + &path, + metadata_path.as_deref(), + &output_format, + revised_prompt.as_deref(), + ) + { + generated_image_contexts.push(blocks); + } else { + crate::logging::warn(&format!( + "Generated image was not attached as visual context: {}", + path + )); + } + } + } + StreamEvent::TokenUsage { + input_tokens, + output_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } => { + if let Some(input) = input_tokens { + usage_input = Some(input); + } + if let Some(output) = output_tokens { + usage_output = Some(output); + } + if cache_read_input_tokens.is_some() { + usage_cache_read = cache_read_input_tokens; + } + if cache_creation_input_tokens.is_some() { + usage_cache_creation = cache_creation_input_tokens; + } + if let Some(input) = usage_input { + self.update_compaction_usage_from_stream( + input, + usage_cache_read, + usage_cache_creation, + ); + } + if trace { + eprintln!( + "[trace] token_usage input={} output={} cache_read={} cache_write={}", + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read.unwrap_or(0), + usage_cache_creation.unwrap_or(0) + ); + } + } + StreamEvent::ConnectionType { connection } => { + if trace { + eprintln!("[trace] connection_type={}", connection); + } + crate::telemetry::record_connection_type(&connection); + self.last_connection_type = Some(connection); + } + StreamEvent::ConnectionPhase { phase } => { + if trace { + eprintln!("[trace] connection_phase={}", phase); + } + } + StreamEvent::StatusDetail { detail } => { + if trace { + eprintln!("[trace] status_detail={}", detail); + } + self.last_status_detail = Some(detail); + } + StreamEvent::RetryRollback { attempt, max } => { + // Transient transport fault mid-stream; the provider is + // replaying the request. Discard this attempt's partial + // output so the replay doesn't duplicate it in history. + logging::warn(&format!( + "Mid-stream retry rollback (attempt {}/{}): discarding partial output ({} text chars, {} tool calls)", + attempt, + max, + text_content.len(), + tool_calls.len(), + )); + if print_output && !text_content.is_empty() { + // Already-printed text can't be unprinted on a plain + // stdout stream; mark the discontinuity instead. + println!("\n[connection interrupted, retrying response from the top]"); + io::stdout().flush()?; + } + text_content.clear(); + tool_calls.clear(); + current_tool = None; + current_tool_input.clear(); + sdk_tool_results.clear(); + generated_image_contexts.clear(); + reasoning_content.clear(); + reasoning_signature.clear(); + openai_reasoning_items.clear(); + openai_native_compaction = None; + saw_message_end = false; + stop_reason = None; + } + StreamEvent::MessageEnd { + stop_reason: reason, + } => { + saw_message_end = true; + if reason.is_some() { + stop_reason = reason; + } + // Don't break yet - wait for SessionId which comes after MessageEnd + // (but stream close will also end the loop for providers without SessionId) + } + StreamEvent::SessionId(sid) => { + if trace { + eprintln!("[trace] session_id {}", sid); + } + self.provider_session_id = Some(sid.clone()); + self.session.provider_session_id = Some(sid); + // We've received session_id, can exit the loop now + if saw_message_end { + break; + } + } + StreamEvent::UpstreamProvider { provider } => { + // Log upstream provider for local trace output + if trace { + eprintln!("[trace] upstream_provider={}", provider); + } + self.last_upstream_provider = Some(provider); + } + StreamEvent::OpenAIReasoning { + id, + summary, + encrypted_content, + status, + } => { + if store_reasoning_content { + openai_reasoning_items.push(ContentBlock::OpenAIReasoning { + id, + summary, + encrypted_content, + status, + }); + } + } + StreamEvent::Compaction { + trigger, + 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())); + } + if print_output { + let tokens_str = pre_tokens + .map(|t| format!(" ({} tokens)", t)) + .unwrap_or_default(); + println!("📦 Context compacted ({}){}", trigger, tokens_str); + } + } + StreamEvent::NativeToolCall { + request_id, + tool_name, + input, + } => { + // Execute native tool and send result back to SDK bridge + if trace { + eprintln!( + "[trace] native_tool_call request_id={} tool={}", + request_id, tool_name + ); + } + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: self.session.id.clone(), + tool_call_id: request_id.clone(), + working_dir: self.working_dir().map(PathBuf::from), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::AgentTurn, + }; + crate::telemetry::record_tool_call(); + let tool_result = self + .registry + .execute(&tool_name, ToolCall::normalize_input_to_object(input), ctx) + .await; + if tool_result.is_err() { + crate::telemetry::record_tool_failure(); + } + let native_result = match tool_result { + Ok(output) => NativeToolResult::success(request_id, output.output), + Err(e) => NativeToolResult::error(request_id, e.to_string()), + }; + // Send result back to SDK bridge + if let Some(sender) = self.provider.native_result_sender() { + let _ = sender.send(native_result).await; + } + } + StreamEvent::Error { + message, + retry_after_secs, + } => { + if trace { + eprintln!("[trace] stream_error {}", message); + } + if self.try_auto_compact_after_context_limit(&message) { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_event_retry_after_compaction", + api_start, + vec![ + ("mode", "blocking".to_string()), + ("error", message.clone()), + ( + "context_limit_retries", + (context_limit_retries + 1).to_string(), + ), + ], + ); + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + retry_after_compaction = true; + break; + } + log_agent_provider_stream_lifecycle( + logging::LogLevel::Error, + self, + "stream_event_error", + api_start, + vec![ + ("mode", "blocking".to_string()), + ("error", message.clone()), + ( + "retry_after_secs", + retry_after_secs + .map(|seconds| seconds.to_string()) + .unwrap_or_else(|| "none".to_string()), + ), + ], + ); + return Err(StreamError::new(message, retry_after_secs).into()); + } + } + } + + if retry_after_compaction { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "retry_after_compaction", + api_start, + vec![("mode", "blocking".to_string())], + ); + continue; + } + + let api_elapsed = api_start.elapsed(); + logging::info(&format!( + "API call complete in {:.2}s (input={} output={} cache_read={} cache_write={})", + api_elapsed.as_secs_f64(), + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read.unwrap_or(0), + usage_cache_creation.unwrap_or(0), + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "stream_complete", + api_start, + vec![ + ("mode", "blocking".to_string()), + ("saw_message_end", saw_message_end.to_string()), + ("input_tokens", usage_input.unwrap_or(0).to_string()), + ("output_tokens", usage_output.unwrap_or(0).to_string()), + ("cache_read", usage_cache_read.unwrap_or(0).to_string()), + ("cache_write", usage_cache_creation.unwrap_or(0).to_string()), + ], + ); + + if usage_input.is_some() + || usage_output.is_some() + || usage_cache_read.is_some() + || usage_cache_creation.is_some() + { + crate::telemetry::record_token_usage( + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read, + usage_cache_creation, + ); + } + + if print_output + && (usage_input.is_some() + || usage_output.is_some() + || usage_cache_read.is_some() + || usage_cache_creation.is_some()) + { + let input = usage_input.unwrap_or(0); + let output = usage_output.unwrap_or(0); + let cache_read = usage_cache_read.unwrap_or(0); + let cache_creation = usage_cache_creation.unwrap_or(0); + let cache_str = if usage_cache_read.is_some() || usage_cache_creation.is_some() { + format!( + " cache_read: {} cache_write: {}", + cache_read, cache_creation + ) + } else { + String::new() + }; + print!( + "\n[Tokens] upload: {} download: {}{}\n", + input, output, cache_str + ); + io::stdout().flush()?; + } + + // Store usage for debug queries + self.last_usage = TokenUsage { + input_tokens: usage_input.unwrap_or(0), + output_tokens: usage_output.unwrap_or(0), + cache_read_input_tokens: usage_cache_read, + cache_creation_input_tokens: usage_cache_creation, + }; + + self.recover_text_wrapped_tool_call(&mut text_content, &mut tool_calls); + + let visible_text_is_empty = text_content.trim().is_empty(); + + // Add assistant message to history. Avoid persisting whitespace-only text as a + // successful visible answer: some OpenRouter/Kimi tool continuations can finish + // cleanly with only spaces despite non-zero output tokens. Persisting that makes the + // UI look like the agent stopped after tools with no explanation. + let mut content_blocks = Vec::new(); + if !text_content.is_empty() && !visible_text_is_empty { + content_blocks.push(ContentBlock::Text { + text: text_content.clone(), + cache_control: None, + }); + } + crate::message::push_reasoning_blocks( + &mut content_blocks, + &provider_name, + &reasoning_content, + Some(&reasoning_signature), + store_reasoning_content, + ); + if store_reasoning_content { + content_blocks.extend(openai_reasoning_items.iter().cloned()); + } + for tc in &tool_calls { + content_blocks.push(ContentBlock::ToolUse { + id: tc.id.clone(), + name: tc.name.clone(), + input: tc.input.clone(), + thought_signature: tc.thought_signature.clone(), + }); + } + + let assistant_message_id = if !content_blocks.is_empty() { + crate::telemetry::record_assistant_response(); + let token_usage = Some(crate::session::StoredTokenUsage { + input_tokens: self.last_usage.input_tokens, + output_tokens: self.last_usage.output_tokens, + cache_read_input_tokens: self.last_usage.cache_read_input_tokens, + cache_creation_input_tokens: self.last_usage.cache_creation_input_tokens, + }); + let message_id = + self.add_message_ext(Role::Assistant, content_blocks, None, token_usage); + self.push_embedding_snapshot_if_semantic(&text_content); + self.session.save()?; + Some(message_id) + } else { + None + }; + + if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() { + self.apply_openai_native_compaction(encrypted_content, compacted_count)?; + } + + // If stop_reason indicates truncation (e.g. max_tokens), discard tool calls + // with null/empty inputs since they were likely truncated mid-generation. + // This prevents executing broken tool calls and instead requests a continuation. + self.filter_truncated_tool_calls( + stop_reason.as_deref(), + &mut tool_calls, + assistant_message_id.as_ref(), + ); + + if tool_calls.is_empty() && !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + logging::info( + "Continuing turn so model can inspect generated image visual context", + ); + continue; + } + + // 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()?; + continue; + } + if self.maybe_continue_incomplete_response( + stop_reason.as_deref(), + &mut incomplete_continuations, + )? { + continue; + } + // Surface silent guardrail/refusal stops instead of returning + // an empty final answer with no explanation. + if let Some(notice) = Self::provider_guardrail_notice( + stop_reason.as_deref(), + visible_text_is_empty, + !reasoning_content.trim().is_empty(), + ) { + logging::warn(&format!( + "PROVIDER_GUARDRAIL: turn ended with no visible output (stop_reason={:?})", + stop_reason + )); + if print_output { + println!("\n[provider guardrail] {}", notice); + } + if text_content.trim().is_empty() { + text_content = format!("[provider guardrail] {}", notice); + } + } + logging::info("Turn complete - no tool calls, returning"); + if print_output { + println!(); + } + final_text = text_content; + break; + } + + logging::info(&format!( + "Turn has {} tool calls to execute", + tool_calls.len() + )); + + // If provider handles tools internally (like Claude Code CLI), only run native tools locally + if self.provider.handles_tools_internally() { + tool_calls.retain(|tc| JCODE_NATIVE_TOOLS.contains(&tc.name.as_str())); + if tool_calls.is_empty() { + if !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + logging::info( + "Continuing turn so model can inspect generated image visual context", + ); + continue; + } + logging::info("Provider handles tools internally - task complete"); + break; + } + logging::info("Provider handles tools internally - executing native tools locally"); + } + + // Execute tools and add results + let mut tool_results_dirty = false; + for tc in tool_calls { + let message_id = assistant_message_id + .clone() + .unwrap_or_else(|| self.session.id.clone()); + + if let Some(error_msg) = tc.validation_error() { + logging::warn(&error_msg); + Bus::global().publish(BusEvent::ToolUpdated(ToolEvent { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + tool_name: tc.name.clone(), + status: ToolStatus::Error, + intent: tc.intent.clone(), + title: None, + })); + if print_output { + println!("\n → {}", error_msg); + } + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id, + content: error_msg, + is_error: Some(true), + }], + ); + tool_results_dirty = true; + continue; + } + + self.validate_tool_allowed(&tc.name)?; + + let is_native_tool = JCODE_NATIVE_TOOLS.contains(&tc.name.as_str()); + + // Check if SDK already executed this tool + if let Some((sdk_content, sdk_is_error)) = sdk_tool_results.remove(&tc.id) { + // For native tools, ignore SDK errors and execute locally + if is_native_tool && sdk_is_error { + if trace { + eprintln!( + "[trace] sdk_error_for_native_tool name={} id={}, executing locally", + tc.name, tc.id + ); + } + // Fall through to local execution below + } else { + if trace { + eprintln!( + "[trace] using_sdk_result name={} id={} is_error={}", + tc.name, tc.id, sdk_is_error + ); + } + if print_output { + print!("\n → "); + let preview = if sdk_content.len() > 200 { + format!("{}...", crate::util::truncate_str(&sdk_content, 200)) + } else { + sdk_content.clone() + }; + println!("{}", preview.lines().next().unwrap_or("(done via SDK)")); + } + + Bus::global().publish(BusEvent::ToolUpdated(ToolEvent { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + tool_name: tc.name.clone(), + status: if sdk_is_error { + ToolStatus::Error + } else { + ToolStatus::Completed + }, + intent: tc.intent.clone(), + title: None, + })); + + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id, + content: sdk_content, + is_error: if sdk_is_error { Some(true) } else { None }, + }], + ); + tool_results_dirty = true; + continue; + } + } + + // SDK didn't execute this tool, run it locally + if print_output { + print!("\n → "); + io::stdout().flush()?; + } + + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + working_dir: self.working_dir().map(PathBuf::from), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::AgentTurn, + }; + + if trace { + eprintln!("[trace] tool_exec_start name={} id={}", tc.name, tc.id); + } + Bus::global().publish(BusEvent::ToolUpdated(ToolEvent { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + tool_name: tc.name.clone(), + status: ToolStatus::Running, + intent: tc.intent.clone(), + title: None, + })); + + logging::info(&format!("Tool starting: {}", tc.name)); + let tool_start = Instant::now(); + + // Publish status for TUI to show during Task execution + Bus::global().publish(BusEvent::SubagentStatus(SubagentStatus { + session_id: self.session.id.clone(), + status: format!("running {}", tc.name), + model: Some(self.provider.model()), + })); + + let result = self.registry.execute(&tc.name, tc.input.clone(), ctx).await; + crate::telemetry::record_tool_call(); + self.unlock_tools_if_needed(&tc.name); + let tool_elapsed = tool_start.elapsed(); + logging::info(&format!( + "Tool finished: {} in {:.2}s", + tc.name, + tool_elapsed.as_secs_f64() + )); + + match result { + Ok(output) => { + let output = cap_tool_output_for_history(&tc.name, output); + Bus::global().publish(BusEvent::ToolUpdated(ToolEvent { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + tool_name: tc.name.clone(), + status: ToolStatus::Completed, + intent: tc.intent.clone(), + title: output.title.clone(), + })); + + if trace { + eprintln!( + "[trace] tool_exec_done name={} id={}\n{}", + tc.name, tc.id, output.output + ); + } + if print_output { + let preview = if output.output.len() > 200 { + format!("{}...", crate::util::truncate_str(&output.output, 200)) + } else { + output.output.clone() + }; + println!("{}", preview.lines().next().unwrap_or("(done)")); + } + + let blocks = tool_output_to_content_blocks(tc.id, output); + self.add_message_with_duration( + Role::User, + blocks, + Some(tool_elapsed.as_millis() as u64), + ); + tool_results_dirty = true; + } + Err(e) => { + crate::telemetry::record_tool_failure(); + Bus::global().publish(BusEvent::ToolUpdated(ToolEvent { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + tool_name: tc.name.clone(), + status: ToolStatus::Error, + intent: tc.intent.clone(), + title: None, + })); + + let error_msg = format!("Error: {}", e); + if trace { + eprintln!( + "[trace] tool_exec_error name={} id={} {}", + tc.name, tc.id, error_msg + ); + } + if print_output { + println!("{}", error_msg); + } + self.add_message_with_duration( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id, + content: error_msg, + is_error: Some(true), + }], + Some(tool_elapsed.as_millis() as u64), + ); + tool_results_dirty = true; + } + } + } + + if tool_results_dirty { + self.session.save()?; + } + + if !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + } + + if print_output { + println!(); + } + + // Check for soft interrupts (e.g. Telegram messages) and inject them for the next turn + let injected = self.inject_soft_interrupts(); + if !injected.is_empty() { + let total_chars: usize = injected.iter().map(|item| item.content.len()).sum(); + logging::info(&format!( + "Soft interrupt injected into headless turn ({} message(s), {} chars)", + injected.len(), + total_chars + )); + } + } + + Ok(final_text) + } + + fn messages_end_with_tool_result(messages: &[Message]) -> bool { + messages.iter().rev().any(|message| { + if !matches!(message.role, Role::User) { + return false; + } + if message + .content + .iter() + .any(|block| matches!(block, ContentBlock::ToolResult { .. })) + { + return true; + } + message.content.iter().any(|block| match block { + ContentBlock::Text { text, .. } => text.trim().starts_with(""), + _ => false, + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user_text(text: &str) -> Message { + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + timestamp: None, + tool_duration_ms: None, + } + } + + fn tool_result(id: &str, content: &str) -> Message { + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: id.to_string(), + content: content.to_string(), + is_error: None, + }], + timestamp: None, + tool_duration_ms: Some(1), + } + } + + #[test] + fn messages_end_with_tool_result_detects_tool_continuation_context() { + let messages = vec![ + user_text("tell me about the desktop application"), + tool_result("functions.read:0", "desktop architecture docs"), + tool_result("functions.agentgrep:4", "desktop source summary"), + ]; + + assert!(Agent::messages_end_with_tool_result(&messages)); + } + + #[test] + fn messages_end_with_tool_result_allows_memory_after_tool_results() { + let messages = vec![ + user_text("tell me about the desktop application"), + tool_result("functions.read:0", "desktop architecture docs"), + user_text("Relevant memory"), + ]; + + assert!(Agent::messages_end_with_tool_result(&messages)); + } + + #[test] + fn messages_end_with_tool_result_ignores_plain_user_prompt() { + let messages = vec![user_text("hello")]; + + assert!(!Agent::messages_end_with_tool_result(&messages)); + } +} diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs new file mode 100644 index 0000000..60d6a6a --- /dev/null +++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs @@ -0,0 +1,1658 @@ +use super::*; + +/// Largest byte index `<= index` that is a UTF-8 char boundary in `text`. +/// Equivalent to the unstable `str::floor_char_boundary`, reimplemented so the +/// incremental marker scan can clamp its scan-window start onto a valid +/// boundary without re-scanning the whole accumulated response. +fn floor_char_boundary(text: &str, index: usize) -> usize { + if index >= text.len() { + return text.len(); + } + let mut boundary = index; + while boundary > 0 && !text.is_char_boundary(boundary) { + boundary -= 1; + } + boundary +} + +/// The wrapped-tool-call markers emitted by some models inside plain text. +const WRAP_TOOL_MARKERS: [&str; 2] = ["to=functions.", "+#+#"]; + +/// Find the first wrapped-tool-call marker in `accumulated`, scanning only the +/// newly appended `delta` plus a short overlap from the previous tail (so a +/// marker straddling the append boundary is still found). +/// +/// This avoids re-scanning the entire accumulated response on every streamed +/// delta, which was O(response) per token and O(response^2) over a full answer. +fn find_wrap_marker_incremental(accumulated: &str, appended_len: usize) -> Option { + let max_marker_len = WRAP_TOOL_MARKERS + .iter() + .map(|marker| marker.len()) + .max() + .unwrap_or(0); + let scan_start = accumulated + .len() + .saturating_sub(appended_len + max_marker_len.saturating_sub(1)); + let scan_start = floor_char_boundary(accumulated, scan_start); + let window = &accumulated[scan_start..]; + WRAP_TOOL_MARKERS + .iter() + .filter_map(|marker| window.find(marker)) + .min() + .map(|rel_idx| scan_start + rel_idx) +} + +fn reload_interrupted_tool_result(tc: &ToolCall, elapsed_secs: f64) -> (String, bool) { + if tc.name == "selfdev" { + return ("Reload initiated. Process restarting...".to_string(), false); + } + + let action = tc + .input + .get("action") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let is_wait_like = (tc.name == "bg" && action == "wait") + || (tc.name == "swarm" && matches!(action, "await_members" | "run_plan")); + + if is_wait_like { + let input = serde_json::to_string(&tc.input).unwrap_or_else(|_| "{}".to_string()); + return ( + format!( + "[Tool '{}' wait interrupted by server reload after {:.1}s. The underlying operation may still be running. Resume the wait by rerunning the same tool call with input: {}]", + tc.name, elapsed_secs, input + ), + false, + ); + } + + ( + format!( + "[Tool '{}' interrupted by server reload after {:.1}s]", + tc.name, elapsed_secs + ), + true, + ) +} + +impl Agent { + pub(super) async fn run_turn_streaming_mpsc( + &mut self, + event_tx: mpsc::UnboundedSender, + ) -> Result<()> { + self.set_log_context(); + // 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()); + // Register this turn's cancel signal in the process-global registry so + // a cancel routed through *any* control handle for this session (even a + // stale one built for a different agent object, e.g. after a + // reattach/reload) aborts this in-flight stream immediately (issue #428). + let _turn_cancel_guard = crate::turn_cancel_registry::register_active_turn( + &self.session.id, + self.graceful_shutdown.clone(), + ); + let trace = trace_enabled(); + let mut context_limit_retries = 0u32; + let mut incomplete_continuations = 0u32; + + loop { + let repaired = self.repair_missing_tool_outputs(); + if repaired > 0 { + logging::warn(&format!( + "Recovered {} missing tool output(s) before API call", + repaired + )); + } + 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 + self.cache_tracker.reset(); + self.locked_tools = None; + logging::info(&format!( + "Context compacted ({}{})", + event.trigger, + event + .pre_tokens + .map(|t| format!(" {} tokens", t)) + .unwrap_or_default() + )); + let _ = event_tx.send(ServerEvent::Compaction { + trigger: event.trigger.clone(), + pre_tokens: event.pre_tokens, + post_tokens: event.post_tokens, + tokens_saved: event.tokens_saved, + duration_ms: event.duration_ms, + messages_dropped: None, + messages_compacted: event.messages_compacted, + summary_chars: event.summary_chars, + active_messages: event.active_messages, + }); + } + + 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), + Some(std::sync::Arc::new({ + let event_tx = event_tx.clone(); + move |event| { + let _ = event_tx.send(event); + } + })), + ); + // 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. + // Memory is an ephemeral suffix that changes each turn; tracking it would cause + // false-positive violations every turn (prior turn's memory ≠ current history prefix). + self.record_client_cache_request(&messages); + + let mut cache_signature_messages = + if crate::config::config().features.message_timestamps { + Message::with_timestamps(&messages) + } else { + messages.iter().cloned().collect() + }; + let mut ephemeral_signature_messages = Vec::new(); + + // 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() { + let memory_count = memory.count.max(1); + let computed_age_ms = memory.computed_at.elapsed().as_millis() as u64; + crate::memory::record_injected_prompt( + &memory.prompt, + memory_count, + computed_age_ms, + ); + self.record_memory_injection_in_session(memory); + let _ = event_tx.send(ServerEvent::MemoryInjected { + count: memory_count, + prompt: memory.prompt.clone(), + display_prompt: memory.display_prompt.clone(), + prompt_chars: memory.prompt.chars().count(), + computed_age_ms, + }); + let (memory_msg, persisted) = self.prepare_memory_injection_message(memory); + if !persisted { + ephemeral_signature_messages.push(memory_msg.clone()); + } else { + cache_signature_messages.push(memory_msg.clone()); + } + messages_with_memory.push(memory_msg); + } + + logging::info(&format!( + "API call starting: {} messages, {} tools", + messages_with_memory.len(), + tools.len() + )); + 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 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 + // `claude-fable-5` falls back to `claude-opus-4-8`). When that + // happens the provider mutates its own model state, but the session + // and clients still believe they are on the originally requested + // model. Compare against this after the stream so we can emit a + // `ModelChanged` and resync the UI/context-limit. + let model_at_request_start = provider.model().to_string(); + let resume_session_id = self.provider_session_id.clone(); + self.last_status_detail = None; + let _ = event_tx.send(kv_cache_request_event( + &cache_signature_messages, + &tools, + &split_prompt.static_part, + &ephemeral_signature_messages, + )); + let mut keepalive = stream_keepalive_ticker(); + let mut stream = { + let mut complete_future = std::pin::pin!(provider.complete_split( + send_messages, + &tools, + &split_prompt.static_part, + &split_prompt.dynamic_part, + resume_session_id.as_deref(), + )); + loop { + tokio::select! { + _ = keepalive.tick() => { + send_stream_keepalive_mpsc(&event_tx); + } + _ = self.graceful_shutdown.notified() => { + logging::info( + "Graceful shutdown/cancel before API stream opened - stopping turn", + ); + return Ok(()); + } + result = &mut complete_future => { + match result { + Ok(stream) => break stream, + Err(e) => { + if self.try_auto_compact_after_context_limit(&e.to_string()) { + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "auto_recovery".to_string(), + pre_tokens: None, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: None, + summary_chars: None, + active_messages: None, + }); + continue; + } + return Err(e); + } + } + } + } + } + }; + + // Successful API call - reset retry counter + context_limit_retries = 0; + + logging::info(&format!( + "API stream opened in {:.2}s", + api_start.elapsed().as_secs_f64() + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "stream_opened", + api_start, + vec![("mode", "mpsc".to_string())], + ); + + let mut text_content = String::new(); + let mut text_wrapped_detected = false; + // Inline swarm worker output tap: publish a throttled tail of the + // in-progress assistant text to the bus so a coordinator can render + // a live inline gallery viewport. + let inline_output_tap = self.inline_output_tap(); + let mut inline_tap_last = Instant::now() + .checked_sub(std::time::Duration::from_millis(1000)) + .unwrap_or_else(Instant::now); + // Throttled "this session is alive" marks while tokens stream, so + // swarm status can distinguish a busy worker from a dead one + // without paying a registry lock per token. + let mut activity_mark_last = Instant::now() + .checked_sub(std::time::Duration::from_secs(10)) + .unwrap_or_else(Instant::now); + let mut tool_calls: Vec = Vec::new(); + let mut current_tool: Option = None; + let mut current_tool_input = String::new(); + let mut generated_image_contexts: Vec> = Vec::new(); + let mut usage_input: Option = None; + let mut usage_output: Option = None; + let mut usage_cache_read: Option = None; + let mut usage_cache_creation: Option = None; + let mut saw_message_end = false; + let mut stop_reason: Option = None; + let mut sdk_tool_results: std::collections::HashMap = + std::collections::HashMap::new(); + let provider_name = self.provider.name().to_string(); + let store_reasoning_content = + crate::provider::stores_reasoning_content_for_context(&provider_name); + let mut reasoning_content = String::new(); + let mut reasoning_signature = String::new(); + // Whether a live reasoning region is currently streaming to the client. + // Raw reasoning deltas are sent as `ReasoningDelta`; the client owns the + // dim/italic styling and live partial-line rendering. We close the region + // (via `ReasoningDone`) before real output or a tool call begins. + let mut reasoning_open = false; + // Last time hidden (non-displayed) reasoning activity was relayed + // 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 tool_id_to_name: std::collections::HashMap = + std::collections::HashMap::new(); + + let mut retry_after_compaction = false; + let mut keepalive = stream_keepalive_ticker(); + loop { + let next_event = std::pin::pin!(stream.next()); + let event = tokio::select! { + _ = keepalive.tick() => { + send_stream_keepalive_mpsc(&event_tx); + continue; + } + _ = self.graceful_shutdown.notified() => { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_cancelled", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("reason", "graceful_shutdown".to_string()), + ], + ); + logging::info( + "Graceful shutdown/cancel while waiting for API stream event - stopping stream", + ); + break; + } + event = next_event => event, + }; + + if activity_mark_last.elapsed() >= std::time::Duration::from_secs(2) { + activity_mark_last = Instant::now(); + crate::session_metrics::record_activity(&self.session.id); + } + let Some(event) = event else { + log_agent_provider_stream_lifecycle( + if saw_message_end { + logging::LogLevel::Info + } else { + logging::LogLevel::Warn + }, + self, + "stream_eof", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("saw_message_end", saw_message_end.to_string()), + ], + ); + break; + }; + let event = match event { + Ok(event) => event, + Err(e) => { + let err_str = e.to_string(); + if self.try_auto_compact_after_context_limit(&err_str) { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_error_retry_after_compaction", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("error", err_str.clone()), + ( + "context_limit_retries", + (context_limit_retries + 1).to_string(), + ), + ], + ); + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + retry_after_compaction = true; + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "auto_recovery".to_string(), + pre_tokens: None, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: None, + summary_chars: None, + active_messages: None, + }); + break; + } + log_agent_provider_stream_lifecycle( + logging::LogLevel::Error, + self, + "stream_error", + api_start, + vec![("mode", "mpsc".to_string()), ("error", err_str)], + ); + return Err(e); + } + }; + + match event { + StreamEvent::ThinkingStart => { + // Reasoning tokens are counted in provider output usage even when + // `display.show_thinking` hides the text. Let remote clients start + // their TPS timer without forcing hidden reasoning into the transcript. + let _ = event_tx.send(ServerEvent::ConnectionPhase { + phase: crate::message::ConnectionPhase::Streaming.to_string(), + }); + } + StreamEvent::ThinkingEnd => {} + StreamEvent::ThinkingSignatureDelta(signature) => { + if store_reasoning_content { + reasoning_signature.push_str(&signature); + } + } + StreamEvent::ThinkingDelta(thinking_text) => { + // Only send thinking content if enabled in config + if crate::config::config().display.show_thinking + && !thinking_text.is_empty() + { + reasoning_open = true; + let _ = event_tx.send(ServerEvent::ReasoningDelta { + text: thinking_text.clone(), + }); + } 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. + hidden_activity_last = Instant::now(); + send_stream_keepalive_mpsc(&event_tx); + } + // Always capture reasoning text so it can be persisted as a + // history-only trace, regardless of provider replay support. + reasoning_content.push_str(&thinking_text); + } + StreamEvent::ThinkingDone { duration_secs } => { + if reasoning_open { + reasoning_open = false; + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: Some(duration_secs), + }); + } + } + StreamEvent::TextDelta(text) => { + // Close any open reasoning region before real output so the + // answer renders as a normal paragraph rather than as reasoning. + if reasoning_open && !text.trim().is_empty() { + reasoning_open = false; + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); + } + text_content.push_str(&text); + if inline_output_tap { + self.inline_tail.set_live(&text_content); + if inline_tap_last.elapsed() >= std::time::Duration::from_millis(200) { + inline_tap_last = Instant::now(); + self.publish_inline_tail(); + } + } + if !text_wrapped_detected { + // Scan only the new delta (plus a short overlap for + // markers straddling the boundary) instead of the + // whole accumulated response on every token. + if let Some(marker_idx) = + find_wrap_marker_incremental(&text_content, text.len()) + { + text_wrapped_detected = true; + let clean_prefix = + text_content[..marker_idx].trim_end().to_string(); + let _ = + event_tx.send(ServerEvent::TextReplace { text: clean_prefix }); + } else { + let _ = + event_tx.send(ServerEvent::TextDelta { text: text.clone() }); + } + } + if self.is_graceful_shutdown() { + logging::info( + "Graceful shutdown during streaming - checkpointing partial response", + ); + let _ = event_tx.send(ServerEvent::TextDelta { + text: "\n\n[generation interrupted - server reloading]".to_string(), + }); + text_content + .push_str("\n\n[generation interrupted - server reloading]"); + break; + } + } + StreamEvent::ToolUseStart { id, name } => { + if reasoning_open { + reasoning_open = false; + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); + } + let _ = event_tx.send(ServerEvent::ToolStart { + id: id.clone(), + name: name.clone(), + }); + tool_id_to_name.insert(id.clone(), name.clone()); + current_tool = Some(ToolCall { + id, + name, + input: serde_json::Value::Null, + intent: None, + thought_signature: None, + }); + current_tool_input.clear(); + } + StreamEvent::ToolInputDelta(delta) => { + let _ = event_tx.send(ServerEvent::ToolInput { + delta: delta.clone(), + }); + current_tool_input.push_str(&delta); + } + StreamEvent::ToolUseEnd => { + if let Some(mut tool) = current_tool.take() { + tool.input = + ToolCall::parse_streamed_input_to_object(¤t_tool_input); + tool.refresh_intent_from_input(); + + let _ = event_tx.send(ServerEvent::ToolExec { + id: tool.id.clone(), + name: tool.name.clone(), + }); + + tool_calls.push(tool); + current_tool_input.clear(); + } + } + StreamEvent::ToolUseSignature(signature) => { + // Attach Gemini 3 thought signature to the most recent + // tool call so it can be persisted and replayed. + if let Some(tool) = tool_calls.last_mut() + && !signature.is_empty() + { + tool.thought_signature = Some(signature); + } + } + StreamEvent::ToolResult { + tool_use_id, + content, + is_error, + } => { + let tool_name = tool_id_to_name + .get(&tool_use_id) + .cloned() + .unwrap_or_default(); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tool_use_id.clone(), + name: tool_name, + output: content.clone(), + error: if is_error { + Some("Tool error".to_string()) + } else { + None + }, + }); + sdk_tool_results.insert(tool_use_id, (content, is_error)); + } + StreamEvent::GeneratedImage { + id, + path, + metadata_path, + output_format, + revised_prompt, + } => { + let rendered_image = crate::message::generated_image_rendered_image( + &id, + &path, + &output_format, + ); + if self.provider.supports_image_input() { + if let Some(blocks) = + crate::message::generated_image_visual_context_blocks( + &path, + metadata_path.as_deref(), + &output_format, + revised_prompt.as_deref(), + ) + { + generated_image_contexts.push(blocks); + } else { + crate::logging::warn(&format!( + "Generated image was not attached as visual context: {}", + path + )); + } + } + let _ = event_tx.send(ServerEvent::GeneratedImage { + id, + path, + metadata_path, + output_format, + revised_prompt, + }); + if let Some(image) = rendered_image { + let _ = event_tx.send(ServerEvent::SidePaneImages { + session_id: self.session.id.clone(), + images: vec![image], + }); + } + } + StreamEvent::TokenUsage { + input_tokens, + output_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } => { + if let Some(input) = input_tokens { + usage_input = Some(input); + } + if let Some(output) = output_tokens { + usage_output = Some(output); + } + if cache_read_input_tokens.is_some() { + usage_cache_read = cache_read_input_tokens; + } + if cache_creation_input_tokens.is_some() { + usage_cache_creation = cache_creation_input_tokens; + } + if let Some(input) = usage_input { + self.update_compaction_usage_from_stream( + input, + usage_cache_read, + usage_cache_creation, + ); + } + } + StreamEvent::ConnectionType { connection } => { + crate::telemetry::record_connection_type(&connection); + self.last_connection_type = Some(connection.clone()); + let _ = event_tx.send(ServerEvent::ConnectionType { connection }); + } + StreamEvent::ConnectionPhase { phase } => { + let _ = event_tx.send(ServerEvent::ConnectionPhase { + phase: phase.to_string(), + }); + } + StreamEvent::StatusDetail { detail } => { + self.last_status_detail = Some(detail.clone()); + let _ = event_tx.send(ServerEvent::StatusDetail { detail }); + } + StreamEvent::RetryRollback { attempt, max } => { + // A transient transport fault hit mid-stream after partial + // output was already emitted; the provider is replaying the + // request from the top. Discard everything accumulated for + // this attempt so the replay doesn't duplicate output, and + // tell the client to do the same. + logging::warn(&format!( + "Mid-stream retry rollback (attempt {}/{}): discarding partial output ({} text chars, {} tool calls)", + attempt, + max, + text_content.len(), + tool_calls.len(), + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "retry_rollback", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("attempt", attempt.to_string()), + ("max", max.to_string()), + ("text_chars", text_content.len().to_string()), + ("tool_calls", tool_calls.len().to_string()), + ], + ); + text_content.clear(); + if inline_output_tap { + // The provider replays from the top; drop the + // discarded partial from the live tail too. + self.inline_tail.clear_live(); + } + text_wrapped_detected = false; + tool_calls.clear(); + current_tool = None; + current_tool_input.clear(); + tool_id_to_name.clear(); + sdk_tool_results.clear(); + generated_image_contexts.clear(); + reasoning_content.clear(); + reasoning_signature.clear(); + reasoning_open = false; + openai_reasoning_items.clear(); + openai_native_compaction = None; + saw_message_end = false; + stop_reason = None; + let _ = event_tx.send(ServerEvent::RetryRollback { attempt, max }); + let _ = event_tx.send(ServerEvent::ConnectionPhase { + phase: crate::message::ConnectionPhase::Retrying { attempt, max } + .to_string(), + }); + } + StreamEvent::MessageEnd { + stop_reason: reason, + } => { + saw_message_end = true; + if inline_output_tap { + // Fold the finished text into the rolling tail so + // it survives the next turn/continuation. + self.inline_tail.set_live(&text_content); + self.inline_tail.commit_live(); + } + // Close any still-open reasoning region (e.g. a reasoning-only + // step) so the client flushes its live partial line. + if reasoning_open { + reasoning_open = false; + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); + } + if reason.is_some() { + stop_reason = reason; + } + let _ = event_tx.send(ServerEvent::MessageEnd); + } + StreamEvent::SessionId(sid) => { + self.provider_session_id = Some(sid.clone()); + self.session.provider_session_id = Some(sid.clone()); + let _ = event_tx.send(ServerEvent::SessionId { session_id: sid }); + } + StreamEvent::OpenAIReasoning { + id, + summary, + encrypted_content, + status, + } => { + if store_reasoning_content { + openai_reasoning_items.push(ContentBlock::OpenAIReasoning { + id, + summary, + encrypted_content, + status, + }); + } + } + StreamEvent::Compaction { + openai_encrypted_content, + .. + } => { + if let Some(encrypted_content) = openai_encrypted_content { + openai_native_compaction + .get_or_insert((encrypted_content, self.session.messages.len())); + } + } + StreamEvent::NativeToolCall { + request_id, + tool_name, + input, + } => { + // Execute native tool and send result back to SDK bridge + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: self.session.id.clone(), + tool_call_id: request_id.clone(), + working_dir: self.working_dir().map(PathBuf::from), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::AgentTurn, + }; + crate::telemetry::record_tool_call(); + let tool_result = self + .registry + .execute(&tool_name, ToolCall::normalize_input_to_object(input), ctx) + .await; + if tool_result.is_err() { + crate::telemetry::record_tool_failure(); + } + let native_result = match tool_result { + Ok(output) => NativeToolResult::success(request_id, output.output), + Err(e) => NativeToolResult::error(request_id, e.to_string()), + }; + if let Some(sender) = self.provider.native_result_sender() { + let _ = sender.send(native_result).await; + } + } + StreamEvent::UpstreamProvider { provider } => { + self.last_upstream_provider = Some(provider.clone()); + let _ = event_tx.send(ServerEvent::UpstreamProvider { provider }); + } + StreamEvent::Error { + message, + retry_after_secs, + } => { + if self.try_auto_compact_after_context_limit(&message) { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_event_retry_after_compaction", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("error", message.clone()), + ( + "context_limit_retries", + (context_limit_retries + 1).to_string(), + ), + ], + ); + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + retry_after_compaction = true; + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "auto_recovery".to_string(), + pre_tokens: None, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: None, + summary_chars: None, + active_messages: None, + }); + break; + } + log_agent_provider_stream_lifecycle( + logging::LogLevel::Error, + self, + "stream_event_error", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("error", message.clone()), + ( + "retry_after_secs", + retry_after_secs + .map(|seconds| seconds.to_string()) + .unwrap_or_else(|| "none".to_string()), + ), + ], + ); + return Err(StreamError::new(message, retry_after_secs).into()); + } + } + } + + if retry_after_compaction { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "retry_after_compaction", + api_start, + vec![("mode", "mpsc".to_string())], + ); + continue; + } + + let api_elapsed = api_start.elapsed(); + logging::info(&format!( + "API call complete in {:.2}s (input={} output={} cache_read={} cache_write={})", + api_elapsed.as_secs_f64(), + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read.unwrap_or(0), + usage_cache_creation.unwrap_or(0), + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "stream_complete", + api_start, + vec![ + ("mode", "mpsc".to_string()), + ("saw_message_end", saw_message_end.to_string()), + ( + "stop_reason", + stop_reason.clone().unwrap_or_else(|| "none".to_string()), + ), + ("input_tokens", usage_input.unwrap_or(0).to_string()), + ("output_tokens", usage_output.unwrap_or(0).to_string()), + ("cache_read", usage_cache_read.unwrap_or(0).to_string()), + ("cache_write", usage_cache_creation.unwrap_or(0).to_string()), + ], + ); + + if usage_input.is_some() + || usage_output.is_some() + || usage_cache_read.is_some() + || usage_cache_creation.is_some() + { + crate::telemetry::record_token_usage( + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read, + usage_cache_creation, + ); + + let input = usage_input.unwrap_or(0); + let output = usage_output.unwrap_or(0); + let total = input + .saturating_add(output) + .saturating_add(usage_cache_read.unwrap_or(0)) + .saturating_add(usage_cache_creation.unwrap_or(0)); + crate::session_metrics::record_token_usage(&self.session.id, total, output); + } + + if usage_input.is_some() + || usage_output.is_some() + || usage_cache_read.is_some() + || usage_cache_creation.is_some() + { + let _ = event_tx.send(ServerEvent::TokenUsage { + input: usage_input.unwrap_or(0), + output: usage_output.unwrap_or(0), + cache_read_input: usage_cache_read, + cache_creation_input: usage_cache_creation, + }); + } + + // Store usage for debug queries + self.last_usage = TokenUsage { + input_tokens: usage_input.unwrap_or(0), + output_tokens: usage_output.unwrap_or(0), + cache_read_input_tokens: usage_cache_read, + cache_creation_input_tokens: usage_cache_creation, + }; + + // Detect a transparent mid-request model switch (e.g. Anthropic's + // retired `claude-fable-5` falling back to `claude-opus-4-8`). The + // provider mutates its own model state during the stream, so the + // session and clients would otherwise keep showing the originally + // requested model with a stale context-limit. Resync the session and + // notify clients with a `ModelChanged` so the header, picker, and + // context budget all reflect the model that actually served. + let model_after_stream = self.provider.model(); + if model_after_stream != model_at_request_start { + let provider_name = self.provider.display_name(); + logging::warn(&format!( + "Provider switched model mid-request: '{}' -> '{}' (resyncing session/UI)", + model_at_request_start, model_after_stream + )); + self.session.model = Some(model_after_stream.clone()); + self.provider_runtime_state.apply( + crate::provider::ProviderStateEvent::RuntimeModelObserved { + model: model_after_stream.clone(), + }, + ); + self.persist_session_best_effort("model fallback"); + let _ = event_tx.send(ServerEvent::ModelChanged { + id: 0, + model: model_after_stream, + provider_name: Some(provider_name), + error: None, + }); + } + + let had_tool_calls_before = !tool_calls.is_empty(); + self.recover_text_wrapped_tool_call(&mut text_content, &mut tool_calls); + + if !had_tool_calls_before + && !tool_calls.is_empty() + && let Some(tc) = tool_calls.last() + && tc.id.starts_with("fallback_text_call_") + { + let _ = event_tx.send(ServerEvent::TextReplace { + text: text_content.clone(), + }); + let _ = event_tx.send(ServerEvent::ToolStart { + id: tc.id.clone(), + name: tc.name.clone(), + }); + tool_id_to_name.insert(tc.id.clone(), tc.name.clone()); + let _ = event_tx.send(ServerEvent::ToolInput { + delta: tc.input.to_string(), + }); + let _ = event_tx.send(ServerEvent::ToolExec { + id: tc.id.clone(), + name: tc.name.clone(), + }); + } + + // Add assistant message to history + let mut content_blocks = Vec::new(); + if !text_content.is_empty() { + content_blocks.push(ContentBlock::Text { + text: text_content.clone(), + cache_control: None, + }); + } + crate::message::push_reasoning_blocks( + &mut content_blocks, + &provider_name, + &reasoning_content, + Some(&reasoning_signature), + store_reasoning_content, + ); + if store_reasoning_content { + content_blocks.extend(openai_reasoning_items.iter().cloned()); + } + for tc in &tool_calls { + content_blocks.push(ContentBlock::ToolUse { + id: tc.id.clone(), + name: tc.name.clone(), + input: tc.input.clone(), + thought_signature: None, + }); + } + + let assistant_message_id = if !content_blocks.is_empty() { + crate::telemetry::record_assistant_response(); + let token_usage = Some(crate::session::StoredTokenUsage { + input_tokens: self.last_usage.input_tokens, + output_tokens: self.last_usage.output_tokens, + cache_read_input_tokens: self.last_usage.cache_read_input_tokens, + cache_creation_input_tokens: self.last_usage.cache_creation_input_tokens, + }); + let message_id = + self.add_message_ext(Role::Assistant, content_blocks, None, token_usage); + self.push_embedding_snapshot_if_semantic(&text_content); + self.session.save()?; + Some(message_id) + } else { + None + }; + + if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() { + self.apply_openai_native_compaction(encrypted_content, compacted_count)?; + } + + // If stop_reason indicates truncation (e.g. max_tokens), discard tool calls + // with null/empty inputs since they were likely truncated mid-generation. + self.filter_truncated_tool_calls( + stop_reason.as_deref(), + &mut tool_calls, + assistant_message_id.as_ref(), + ); + + if tool_calls.is_empty() && !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + logging::info( + "Continuing turn so model can inspect generated image visual context", + ); + continue; + } + + // If no tool calls, check for soft interrupt or exit + // NOTE: We only inject here (Point B) when there are no tools. + // Injecting before tool_results would break the API requirement that + // tool_use must be immediately followed by tool_result. + if tool_calls.is_empty() { + match self.handle_streaming_no_tool_calls( + stop_reason.as_deref(), + &mut incomplete_continuations, + )? { + NoToolCallOutcome::Break => { + // Surface silent guardrail/refusal stops: the provider + // ended the turn with no visible output (e.g. Anthropic + // stop_reason "refusal", or a reasoning-only response). + // Only when the provider actually finished the message + // (saw_message_end) and the user did not cancel, so + // interrupted turns never show a spurious notice. + if saw_message_end + && !self.is_graceful_shutdown() + && let Some(notice) = Self::provider_guardrail_notice( + stop_reason.as_deref(), + text_content.trim().is_empty(), + !reasoning_content.trim().is_empty(), + ) + { + logging::warn(&format!( + "PROVIDER_GUARDRAIL: turn ended with no visible output (stop_reason={:?}, reasoning_chars={})", + stop_reason, + reasoning_content.len() + )); + let _ = event_tx.send(ServerEvent::ProviderGuardrail { + stop_reason: stop_reason.clone(), + message: notice, + }); + } + break; + } + NoToolCallOutcome::ContinueWithoutEvent => continue, + NoToolCallOutcome::ContinueWithSoftInterrupt { injected, point } => { + for event in Self::build_soft_interrupt_events(injected, point, None) { + let _ = event_tx.send(event); + } + continue; + } + } + } + + // If graceful shutdown was signaled during streaming and we have tool calls, + // we need to provide tool results for them (API requires tool_use -> tool_result) + // then exit cleanly + if self.is_graceful_shutdown() { + logging::info(&format!( + "Graceful shutdown - skipping {} tool call(s)", + tool_calls.len() + )); + for tc in &tool_calls { + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: "[Skipped - server reloading]".to_string(), + is_error: Some(true), + }], + ); + } + self.session.save()?; + break; + } + + logging::info(&format!( + "Turn has {} tool calls to execute", + tool_calls.len() + )); + + if self.provider.handles_tools_internally() { + tool_calls.retain(|tc| JCODE_NATIVE_TOOLS.contains(&tc.name.as_str())); + if tool_calls.is_empty() { + // === INJECTION POINT D: After provider-handled tools, before next API call === + let injected = self.inject_soft_interrupts(); + if !injected.is_empty() { + for event in Self::build_soft_interrupt_events(injected, "D", None) { + let _ = event_tx.send(event); + } + // Don't break - continue loop to process injected message + continue; + } + break; + } + } + + // Execute tools and add results + let tool_count = tool_calls.len(); + let mut tool_results_dirty = false; + for tool_index in 0..tool_count { + // === INJECTION POINT C (before): Check for urgent abort before each tool (except first) === + if tool_index > 0 && self.has_urgent_interrupt() { + crate::telemetry::record_user_cancelled(); + // Add tool_results for all remaining skipped tools to maintain valid history + for skipped_tc in &tool_calls[tool_index..] { + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: skipped_tc.id.clone(), + content: "[Skipped: user interrupted]".to_string(), + is_error: Some(true), + }], + ); + } + let tools_remaining = tool_count - tool_index; + let injected = self.inject_soft_interrupts(); + if !injected.is_empty() { + for event in + Self::build_soft_interrupt_events(injected, "C", Some(tools_remaining)) + { + let _ = event_tx.send(event); + } + // Add note about skipped tools for the AI + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: format!( + "[User interrupted: {} remaining tool(s) skipped]", + tools_remaining + ), + cache_control: None, + }], + ); + } + self.persist_session_best_effort("streamed tool output"); + break; // Skip remaining tools + } + let tc = &tool_calls[tool_index]; + + let message_id = assistant_message_id + .clone() + .unwrap_or_else(|| self.session.id.clone()); + + if let Some(error_msg) = tc.validation_error() { + logging::warn(&error_msg); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: error_msg.clone(), + error: Some(error_msg.clone()), + }); + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: error_msg, + is_error: Some(true), + }], + ); + tool_results_dirty = true; + continue; + } + + self.validate_tool_allowed(&tc.name)?; + + let is_native_tool = JCODE_NATIVE_TOOLS.contains(&tc.name.as_str()); + + if let Some((sdk_content, sdk_is_error)) = sdk_tool_results.remove(&tc.id) { + // For native tools, ignore SDK errors and execute locally + if !(is_native_tool && sdk_is_error) { + let sdk_content = cap_sdk_tool_content_for_history(&tc.name, sdk_content); + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: sdk_content, + is_error: if sdk_is_error { Some(true) } else { None }, + }], + ); + tool_results_dirty = true; + + // NOTE: No injection here - wait for Point D after all tools + + continue; + } + // Fall through to local execution for native tools with SDK errors + } + + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + working_dir: self.working_dir().map(PathBuf::from), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::AgentTurn, + }; + + if trace { + eprintln!("[trace] tool_exec_start name={} id={}", tc.name, tc.id); + } + + logging::info(&format!("Tool starting: {}", tc.name)); + crate::session_metrics::record_activity(&self.session.id); + if inline_output_tap { + // Surface the tool execution on the coordinator's inline + // viewport immediately: workers spend most wall-clock time + // here, where no assistant text streams. + self.inline_tail.start_tool(&tc.name, &tc.input); + self.publish_inline_tail(); + } + let tool_start = Instant::now(); + + // Spawn tool in its own task so we can detach it to background on Alt+B + let registry_clone = self.registry.clone(); + let tool_name_for_spawn = tc.name.clone(); + let tool_input_for_spawn = tc.input.clone(); + let tool_handle = tokio::spawn(async move { + registry_clone + .execute(&tool_name_for_spawn, tool_input_for_spawn, ctx) + .await + }); + + // Reset background signal before waiting + self.background_tool_signal.reset(); + + // Wait for tool completion OR background signal from user (Alt+B) + // OR graceful shutdown signal from server reload + let bg_signal = self.background_tool_signal.clone(); + let shutdown_signal = self.graceful_shutdown.clone(); + let allow_reload_handoff = tc.name == "bash"; + let tool_result; + let mut tool_handle = tool_handle; + tokio::select! { + biased; + res = &mut tool_handle => { + tool_result = Some(match res { + Ok(r) => r, + Err(e) => Err(anyhow::anyhow!("Tool task panicked: {}", e)), + }); + } + _ = async { + tokio::select! { + _ = bg_signal.notified() => {} + _ = shutdown_signal.notified() => {} + } + } => { + if self.is_graceful_shutdown() && allow_reload_handoff { + tool_result = match tokio::time::timeout( + Duration::from_millis(750), + &mut tool_handle, + ) + .await + { + Ok(res) => Some(match res { + Ok(r) => r, + Err(e) => Err(anyhow::anyhow!("Tool task panicked: {}", e)), + }), + Err(_) => None, + }; + } else { + tool_result = None; + } + } + }; + + self.unlock_tools_if_needed(&tc.name); + let tool_elapsed = tool_start.elapsed(); + crate::session_metrics::record_activity(&self.session.id); + + if let Some(result) = tool_result { + // Normal tool completion + logging::info(&format!( + "Tool finished: {} in {:.2}s", + tc.name, + tool_elapsed.as_secs_f64() + )); + if inline_output_tap { + // Update the tool marker in place with duration/error. + self.inline_tail + .finish_tool(tool_elapsed.as_secs_f64(), result.is_err()); + self.publish_inline_tail(); + } + + match result { + Ok(output) => { + let output = cap_tool_output_for_history(&tc.name, output); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: output.output.clone(), + error: None, + }); + + let side_pane_images = + tool_output_side_pane_images(&tc.id, &tc.name, &tc.input, &output); + if !side_pane_images.is_empty() { + logging::info(&format!( + "SidePaneImages: emitting {} image(s) from tool '{}' (session={})", + side_pane_images.len(), + tc.name, + self.session.id + )); + let _ = event_tx.send(ServerEvent::SidePaneImages { + session_id: self.session.id.clone(), + images: side_pane_images, + }); + } + + let blocks = tool_output_to_content_blocks(tc.id.clone(), output); + self.add_message_with_duration( + Role::User, + blocks, + Some(tool_elapsed.as_millis() as u64), + ); + tool_results_dirty = true; + } + Err(e) => { + let error_msg = format!("Error: {}", e); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: error_msg.clone(), + error: Some(error_msg.clone()), + }); + + self.add_message_with_duration( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: error_msg, + is_error: Some(true), + }], + Some(tool_elapsed.as_millis() as u64), + ); + tool_results_dirty = true; + } + } + } else if self.is_graceful_shutdown() { + // Server reload - abort tool and save interrupted result + logging::info(&format!( + "Tool '{}' interrupted by server reload after {:.1}s", + tc.name, + tool_elapsed.as_secs_f64() + )); + tool_handle.abort(); + + // For selfdev reload and wait-like tools, the interruption is expected: + // selfdev initiated the restart, while wait-like tools should be resumed + // after reload rather than treated as failed work. + let (interrupted_msg, is_error) = + reload_interrupted_tool_result(tc, tool_elapsed.as_secs_f64()); + + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: interrupted_msg.clone(), + error: if is_error { + Some("interrupted by reload".to_string()) + } else { + None + }, + }); + + self.add_message_with_duration( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: interrupted_msg, + is_error: Some(is_error), + }], + Some(tool_elapsed.as_millis() as u64), + ); + self.session.save()?; + + // Add results for any remaining tools too + for remaining_tc in &tool_calls[(tool_index + 1)..] { + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: remaining_tc.id.clone(), + content: "[Skipped - server reloading]".to_string(), + is_error: Some(true), + }], + ); + } + self.session.save()?; + return Ok(()); + } else { + // User pressed Alt+B — move tool to background + logging::info(&format!( + "Tool '{}' moved to background after {:.1}s", + tc.name, + tool_elapsed.as_secs_f64() + )); + + let bg_info = crate::background::global() + .adopt(&tc.name, &self.session.id, tool_handle) + .await; + + let bg_msg = format!( + "Tool '{}' was moved to background by the user (task_id: {}). \ + Use the `bg` tool with action 'wait' to wait for completion/checkpoints, \ + or action 'status'/'output' to inspect it.", + tc.name, bg_info.task_id + ); + + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: bg_msg.clone(), + error: None, + }); + + self.add_message_with_duration( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: bg_msg, + is_error: None, + }], + Some(tool_elapsed.as_millis() as u64), + ); + self.session.save()?; + + self.background_tool_signal.reset(); + } + + // NOTE: We do NOT inject between tools (non-urgent) because that would + // place user text between tool_results, which may violate API constraints. + // All non-urgent injection happens at Point D after all tools are done. + } + + if tool_results_dirty { + self.session.save()?; + } + + if !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + } + + // === INJECTION POINT D: All tools done, before next API call === + // This is the safest point for non-urgent injection since all tool_results + // have been added and the conversation is in a valid state. + if let PostToolInterruptOutcome::SoftInterrupt { injected, point } = + self.take_post_tool_soft_interrupt() + { + for event in Self::build_soft_interrupt_events(injected, point, None) { + let _ = event_tx.send(event); + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tool_call(name: &str, input: serde_json::Value) -> ToolCall { + ToolCall { + id: "toolu_test".to_string(), + name: name.to_string(), + input, + intent: None, + thought_signature: None, + } + } + + #[test] + fn reload_interrupted_bg_wait_is_non_error_and_resumable() { + let tc = tool_call( + "bg", + json!({"action": "wait", "task_id": "bg-123", "max_wait_seconds": 300}), + ); + + let (message, is_error) = reload_interrupted_tool_result(&tc, 1.2); + + assert!(!is_error); + assert!(message.contains("Resume the wait")); + assert!(message.contains("\"task_id\":\"bg-123\"")); + } + + #[test] + fn reload_interrupted_non_wait_tool_remains_error() { + let tc = tool_call("bash", json!({"command": "sleep 10"})); + + let (message, is_error) = reload_interrupted_tool_result(&tc, 1.2); + + assert!(is_error); + assert!(message.contains("interrupted by server reload")); + } + + /// Reference O(n) full scan, preserving the original precedence: the + /// `to=functions.` marker is checked before `+#+#`. + fn find_wrap_marker_full(text: &str) -> Option { + text.find("to=functions.").or_else(|| text.find("+#+#")) + } + + /// Simulate streaming `full` in arbitrary deltas and assert the incremental + /// scan finds the first marker position, matching a full rescan each step. + fn assert_incremental_matches(full: &str, chunk: usize) { + let mut acc = String::new(); + let mut incremental_hit: Option = None; + let bytes = full.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let mut end = (i + chunk).min(bytes.len()); + while end < bytes.len() && !full.is_char_boundary(end) { + end += 1; + } + let delta = &full[i..end]; + acc.push_str(delta); + if incremental_hit.is_none() { + incremental_hit = find_wrap_marker_incremental(&acc, delta.len()); + } + i = end; + } + // The earliest of either marker in the full text. + let fn_pos = full.find("to=functions."); + let plus_pos = full.find("+#+#"); + let expected = match (fn_pos, plus_pos) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + assert_eq!( + incremental_hit, expected, + "incremental scan mismatch for {full:?} chunk={chunk}" + ); + } + + #[test] + fn wrap_marker_incremental_detects_markers_across_chunk_sizes() { + let cases = [ + "plain answer with no marker at all", + "answer then to=functions.foo({})", + "answer then +#+# wrapped", + "prefix +#+# and later to=functions.bar", + "unicode 🔄 résumé then to=functions.baz", + "", + "to=functions.first", + "+#+#", + ]; + for case in cases { + for chunk in [1usize, 2, 3, 5, 7, 100] { + assert_incremental_matches(case, chunk); + } + } + } + + #[test] + fn wrap_marker_incremental_finds_marker_straddling_delta_boundary() { + // Feed "to=functions." split right in the middle so the marker only + // exists once both halves are appended; the overlap window must catch it. + let mut acc = String::new(); + acc.push_str("answer to=fun"); + assert_eq!( + find_wrap_marker_incremental(&acc, "answer to=fun".len()), + None + ); + acc.push_str("ctions.tool"); + let hit = find_wrap_marker_incremental(&acc, "ctions.tool".len()); + assert_eq!(hit, find_wrap_marker_full(&acc)); + assert_eq!(hit, Some("answer ".len())); + } +} diff --git a/crates/jcode-app-core/src/agent/utils.rs b/crates/jcode-app-core/src/agent/utils.rs new file mode 100644 index 0000000..f1b851d --- /dev/null +++ b/crates/jcode-app-core/src/agent/utils.rs @@ -0,0 +1,39 @@ +use crate::session::GitState; +use std::path::Path; +use std::process::Command; + +pub(super) fn trace_enabled() -> bool { + match std::env::var("JCODE_TRACE") { + Ok(value) => { + let value = value.trim(); + !value.is_empty() && value != "0" && value.to_lowercase() != "false" + } + Err(_) => false, + } +} + +pub(super) fn git_state_for_dir(dir: &Path) -> Option { + let root = git_output(dir, &["rev-parse", "--show-toplevel"])?; + let head = git_output(dir, &["rev-parse", "HEAD"]); + let branch = git_output(dir, &["rev-parse", "--abbrev-ref", "HEAD"]); + let dirty = git_output(dir, &["status", "--porcelain"]).map(|out| !out.is_empty()); + + Some(GitState { + root, + head, + branch, + dirty, + }) +} + +fn git_output(dir: &Path, args: &[&str]) -> Option { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs new file mode 100644 index 0000000..8871b44 --- /dev/null +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -0,0 +1,1236 @@ +use super::*; +use crate::agent::environment::EnvSnapshotDetail; +use crate::message::{Message, StreamEvent, ToolDefinition}; +use crate::provider::{EventStream, Provider}; +use crate::tool::Registry; +use crate::tool::ToolOutput; +use async_trait::async_trait; +use tokio::sync::mpsc as tokio_mpsc; +use tokio_stream::wrappers::ReceiverStream; + +struct DelayedProvider { + open_delay: Duration, + first_event_delay: Duration, +} + +struct NativeAutoCompactionProvider; + +fn content_text(content: &[ContentBlock]) -> &str { + match content.first() { + Some(ContentBlock::Text { text, .. }) => text, + _ => "", + } +} + +fn message_text(message: &Message) -> &str { + content_text(&message.content) +} + +#[async_trait] +impl Provider for DelayedProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + tokio::time::sleep(self.open_delay).await; + + let first_event_delay = self.first_event_delay; + let (tx, rx) = tokio_mpsc::channel::>(8); + tokio::spawn(async move { + tokio::time::sleep(first_event_delay).await; + let _ = tx + .send(Ok(StreamEvent::TextDelta("hello".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 { + "delayed" + } + + fn fork(&self) -> Arc { + Arc::new(Self { + open_delay: self.open_delay, + first_event_delay: self.first_event_delay, + }) + } +} + +#[async_trait] +impl Provider for NativeAutoCompactionProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let (_tx, rx) = tokio_mpsc::channel::>(1); + 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 context_window(&self) -> usize { + 1_000 + } + + fn fork(&self) -> Arc { + Arc::new(Self) + } + + async fn complete_simple(&self, _prompt: &str, _system: &str) -> Result { + Ok("manual summary from native-auto provider".to_string()) + } +} + +#[test] +fn tool_output_to_content_blocks_preserves_labeled_images() { + let output = ToolOutput::new("Image ready").with_labeled_image( + "image/png", + "ZmFrZQ==", + "screenshots/example.png", + ); + + let blocks = tool_output_to_content_blocks("call_1".to_string(), output); + assert_eq!(blocks.len(), 3); + + match &blocks[0] { + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } => { + assert_eq!(tool_use_id, "call_1"); + assert_eq!(content, "Image ready"); + assert_eq!(*is_error, None); + } + other => panic!("expected tool result, got {other:?}"), + } + + match &blocks[1] { + ContentBlock::Image { media_type, data } => { + assert_eq!(media_type, "image/png"); + assert_eq!(data, "ZmFrZQ=="); + } + other => panic!("expected image block, got {other:?}"), + } + + match &blocks[2] { + ContentBlock::Text { text, .. } => { + assert!(text.contains("screenshots/example.png")); + assert!(text.contains("preceding tool result")); + } + other => panic!("expected trailing label text, got {other:?}"), + } +} + +#[tokio::test] +async fn run_turn_streaming_mpsc_emits_keepalive_while_provider_is_quiet() { + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(DelayedProvider { + open_delay: Duration::from_secs(2), + first_event_delay: Duration::from_secs(2), + }); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "test".to_string(), + cache_control: None, + }], + ); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let task = tokio::spawn(async move { agent.run_turn_streaming_mpsc(tx).await }); + + let mut saw_keepalive = false; + 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 })) => { + assert_eq!(id, STREAM_KEEPALIVE_PONG_ID); + saw_keepalive = true; + break; + } + Ok(Some(ServerEvent::TextDelta { text })) => { + panic!("expected keepalive before text delta, got: {text}"); + } + Ok(Some(_)) => {} + Ok(None) => panic!("channel closed before keepalive"), + Err(_) => { + assert!( + !task.is_finished(), + "streaming task finished before keepalive arrived" + ); + } + } + } + assert!(saw_keepalive, "expected keepalive before provider response"); + + let mut saw_text = false; + let text_deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < text_deadline { + match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await { + Ok(Some(ServerEvent::TextDelta { text })) => { + assert_eq!(text, "hello"); + saw_text = true; + break; + } + Ok(Some(ServerEvent::Pong { id })) => { + assert_eq!(id, STREAM_KEEPALIVE_PONG_ID); + } + Ok(Some(_)) => {} + Ok(None) => panic!("channel closed before text delta"), + Err(_) => { + assert!( + !task.is_finished(), + "streaming task finished before text delta arrived" + ); + } + } + } + + assert!(saw_text, "expected delayed provider text after keepalive"); + task.await.unwrap().unwrap(); +} + +/// Provider that transparently switches its model mid-stream, mimicking the +/// Anthropic retired-model fallback (`claude-fable-5` -> `claude-opus-4-8`). +struct MidStreamModelSwitchProvider { + model: std::sync::Mutex, + switch_to: String, +} + +#[async_trait] +impl Provider for MidStreamModelSwitchProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + // Emulate the provider switching its own model state during the request. + *self.model.lock().unwrap() = self.switch_to.clone(); + let (tx, rx) = tokio_mpsc::channel::>(8); + tokio::spawn(async move { + let _ = tx + .send(Ok(StreamEvent::TextDelta("hello".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 { + "claude" + } + + fn model(&self) -> String { + self.model.lock().unwrap().clone() + } + + fn fork(&self) -> Arc { + Arc::new(Self { + model: std::sync::Mutex::new(self.model.lock().unwrap().clone()), + switch_to: self.switch_to.clone(), + }) + } +} + +#[tokio::test] +async fn run_turn_streaming_mpsc_emits_model_changed_on_midstream_switch() { + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(MidStreamModelSwitchProvider { + model: std::sync::Mutex::new("claude-fable-5".to_string()), + switch_to: "claude-opus-4-8".to_string(), + }); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "test".to_string(), + cache_control: None, + }], + ); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let task = tokio::spawn(async move { agent.run_turn_streaming_mpsc(tx).await }); + + let mut switched_model = None; + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + match tokio::time::timeout(Duration::from_secs(1), rx.recv()).await { + Ok(Some(ServerEvent::ModelChanged { model, error, .. })) => { + assert!(error.is_none(), "unexpected model-change error: {error:?}"); + switched_model = Some(model); + break; + } + Ok(Some(_)) => {} + Ok(None) => break, + Err(_) => { + if task.is_finished() { + break; + } + } + } + } + + task.await.unwrap().unwrap(); + assert_eq!( + switched_model.as_deref(), + Some("claude-opus-4-8"), + "expected a ModelChanged event resyncing to the served model" + ); +} + +#[tokio::test] +async fn messages_for_provider_replays_persisted_native_compaction_in_auto_mode() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "first".to_string(), + cache_control: None, + }], + ); + agent.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "second".to_string(), + cache_control: None, + }], + ); + + agent + .apply_openai_native_compaction("enc_auto".to_string(), 1) + .expect("persist native compaction"); + + let (messages, event) = agent.messages_for_provider(); + assert!(event.is_none()); + assert!(!messages.is_empty()); + match &messages[0].content[0] { + ContentBlock::OpenAICompaction { encrypted_content } => { + assert_eq!(encrypted_content, "enc_auto"); + } + other => panic!("expected OpenAI compaction block, got {other:?}"), + } + assert!( + messages + .iter() + .any(|message| message.role == Role::Assistant) + ); +} + +#[tokio::test] +async fn oversized_openai_native_compaction_is_persisted_as_text_fallback() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "first".to_string(), + cache_control: None, + }], + ); + agent.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "second".to_string(), + cache_control: None, + }], + ); + + let oversized = + "x".repeat(crate::provider::openai_request::OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS + 1); + agent + .apply_openai_native_compaction(oversized, 1) + .expect("persist fallback compaction"); + + let state = agent + .session + .compaction + .as_ref() + .expect("compaction should be persisted"); + assert!(state.openai_encrypted_content.is_none()); + assert!( + state + .summary_text + .contains("OpenAI native compaction state was discarded") + ); + + let (messages, event) = agent.messages_for_provider(); + assert!(event.is_none()); + assert!(!messages.is_empty()); + assert!(messages.iter().all(|message| { + message + .content + .iter() + .all(|block| !matches!(block, ContentBlock::OpenAICompaction { .. })) + })); + match &messages[0].content[0] { + ContentBlock::Text { text, .. } => { + assert!(text.contains("Previous Conversation Summary")); + assert!(text.contains("OpenAI native compaction state was discarded")); + } + other => panic!("expected text fallback summary, got {other:?}"), + } + assert!( + messages + .iter() + .any(|message| message.role == Role::Assistant) + ); +} + +#[tokio::test] +async fn messages_for_provider_applies_manual_compaction_in_native_auto_mode() { + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + for i in 0..30 { + agent.add_message( + Role::User, + vec![ContentBlock::Text { + text: format!("turn {i} {}", "x".repeat(120)), + cache_control: None, + }], + ); + } + + agent.provider_session_id = Some("stale-provider-session".to_string()); + agent.session.provider_session_id = Some("stale-provider-session".to_string()); + + let provider_messages = agent.provider_messages(); + let (message, success) = agent.request_manual_compaction(); + assert!(success, "manual compaction should start: {message}"); + + let deadline = Instant::now() + Duration::from_secs(2); + let mut event = None; + let mut compacted_messages = Vec::new(); + while Instant::now() < deadline { + let (messages, maybe_event) = agent.messages_for_provider(); + if maybe_event.is_some() { + event = maybe_event; + compacted_messages = messages; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let event = event.expect("manual compaction event should be applied"); + assert_eq!(event.trigger, "manual"); + assert!(agent.session.compaction.is_some()); + assert!(agent.provider_session_id.is_none()); + assert!(agent.session.provider_session_id.is_none()); + assert!(compacted_messages.len() < provider_messages.len()); + match &compacted_messages[0].content[0] { + ContentBlock::Text { text, .. } => { + assert!(text.contains("Previous Conversation Summary")); + assert!(text.contains("manual summary from native-auto provider")); + } + other => panic!("expected text summary block, got {other:?}"), + } +} + +// ── InterruptSignal tests ──────────────────────────────────────────────── + +#[tokio::test] +async fn interrupt_signal_fire_before_notified_does_not_hang() { + // Regression test: fire() called BEFORE notified().await must not hang. + // The old code called notify_waiters() which drops the notification if + // nobody is waiting yet. The flag is still set so the fast path catches it, + // but only if the future is created before the flag check. + let sig = InterruptSignal::new(); + sig.fire(); // fire before anyone is waiting + tokio::time::timeout(std::time::Duration::from_millis(100), sig.notified()) + .await + .expect("notified() hung when signal was already set before call"); +} + +#[tokio::test] +async fn interrupt_signal_fire_concurrent_with_notified() { + // Regression test for the race window: fire() is called concurrently while + // notified() is being set up. The fix (create future before flag check) ensures + // the notify_waiters() in fire() wakes the registered future. + let sig = Arc::new(InterruptSignal::new()); + let sig2 = Arc::clone(&sig); + + // Spawn a task that fires after a tiny delay, giving the main task time to + // enter notified() but before it reaches notified().await. + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + sig2.fire(); + }); + + tokio::time::timeout(std::time::Duration::from_millis(500), sig.notified()) + .await + .expect("notified() hung during concurrent fire()"); +} + +#[tokio::test] +async fn interrupt_signal_is_set_false_initially() { + let sig = InterruptSignal::new(); + assert!(!sig.is_set()); +} + +#[tokio::test] +async fn interrupt_signal_is_set_true_after_fire() { + let sig = InterruptSignal::new(); + sig.fire(); + assert!(sig.is_set()); +} + +#[tokio::test] +async fn interrupt_signal_reset_clears_flag() { + let sig = InterruptSignal::new(); + sig.fire(); + assert!(sig.is_set()); + sig.reset(); + assert!(!sig.is_set()); +} + +#[tokio::test] +async fn interrupt_signal_notified_completes_after_fire() { + let sig = Arc::new(InterruptSignal::new()); + let sig2 = Arc::clone(&sig); + + let handle = tokio::spawn(async move { + sig2.notified().await; + }); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + sig.fire(); + + tokio::time::timeout(std::time::Duration::from_millis(200), handle) + .await + .expect("notified() task timed out after fire()") + .expect("task panicked"); +} + +#[tokio::test] +async fn new_agent_registers_active_pid_and_clear_swaps_it() { + 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 first_session_id = agent.session_id().to_string(); + assert!( + crate::session::active_session_ids().contains(&first_session_id), + "fresh agent session should be tracked as active" + ); + + agent.clear(); + + let second_session_id = agent.session_id().to_string(); + let active = crate::session::active_session_ids(); + assert_ne!(first_session_id, second_session_id); + assert!( + active.contains(&second_session_id), + "replacement session should be tracked as active" + ); + assert!( + !active.contains(&first_session_id), + "cleared session should no longer be tracked as active" + ); +} + +#[tokio::test] +async fn gmail_is_exposed_by_default_and_can_be_explicitly_disabled() { + let _guard = crate::storage::lock_test_env(); + let prev_home = std::env::var_os("JCODE_HOME"); + let prev_tools = std::env::var_os("JCODE_TOOLS"); + let prev_disabled_tools = std::env::var_os("JCODE_DISABLED_TOOLS"); + let prev_tool_profile = std::env::var_os("JCODE_TOOL_PROFILE"); + let prev_disable_base_tools = std::env::var_os("JCODE_DISABLE_BASE_TOOLS"); + let temp_home = tempfile::TempDir::new().expect("temp home"); + + crate::env::set_var("JCODE_HOME", temp_home.path()); + crate::env::remove_var("JCODE_TOOLS"); + crate::env::remove_var("JCODE_DISABLED_TOOLS"); + crate::env::remove_var("JCODE_TOOL_PROFILE"); + crate::env::remove_var("JCODE_DISABLE_BASE_TOOLS"); + crate::config::Config::invalidate_cache(); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + let definitions = agent.tool_definitions().await; + let tool_names = agent.tool_names().await; + let tool_name = "gmail"; + + assert!( + definitions + .iter() + .any(|definition| definition.name == tool_name), + "{tool_name} must be sent in model-visible tool definitions by default" + ); + assert!( + tool_names.iter().any(|name| name == tool_name), + "{tool_name} must be listed as model-visible by default" + ); + agent + .validate_tool_allowed(tool_name) + .expect("gmail must be executable by default"); + + crate::env::set_var("JCODE_DISABLED_TOOLS", tool_name); + crate::config::Config::invalidate_cache(); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + let definitions = agent.tool_definitions().await; + let tool_names = agent.tool_names().await; + + assert!( + !definitions + .iter() + .any(|definition| definition.name == tool_name), + "explicitly disabled {tool_name} must not be sent in model-visible tool definitions" + ); + assert!( + !tool_names.iter().any(|name| name == tool_name), + "explicitly disabled {tool_name} must not be listed as model-visible" + ); + let err = agent + .validate_tool_allowed(tool_name) + .expect_err("explicitly disabled gmail must not be executable"); + assert!(err.to_string().contains("disabled")); + + if let Some(previous) = prev_home { + crate::env::set_var("JCODE_HOME", previous); + } else { + crate::env::remove_var("JCODE_HOME"); + } + if let Some(previous) = prev_tools { + crate::env::set_var("JCODE_TOOLS", previous); + } else { + crate::env::remove_var("JCODE_TOOLS"); + } + if let Some(previous) = prev_disabled_tools { + crate::env::set_var("JCODE_DISABLED_TOOLS", previous); + } else { + crate::env::remove_var("JCODE_DISABLED_TOOLS"); + } + if let Some(previous) = prev_tool_profile { + crate::env::set_var("JCODE_TOOL_PROFILE", previous); + } else { + crate::env::remove_var("JCODE_TOOL_PROFILE"); + } + if let Some(previous) = prev_disable_base_tools { + crate::env::set_var("JCODE_DISABLE_BASE_TOOLS", previous); + } else { + crate::env::remove_var("JCODE_DISABLE_BASE_TOOLS"); + } + crate::config::Config::invalidate_cache(); +} + +fn seed_transient_session_state(agent: &mut Agent) { + agent.push_alert("pending alert".to_string()); + agent.queue_soft_interrupt( + "queued interrupt".to_string(), + true, + SoftInterruptSource::User, + ); + agent.background_tool_signal.fire(); + agent.request_graceful_shutdown(); + agent.tool_call_ids.insert("tool_call_old".to_string()); + agent.tool_result_ids.insert("tool_result_old".to_string()); + agent.tool_output_scan_index = 7; + agent.last_upstream_provider = Some("upstream_old".to_string()); + agent.last_connection_type = Some("websocket".to_string()); + agent.current_turn_system_reminder = Some("reminder".to_string()); + agent.last_usage = TokenUsage { + input_tokens: 11, + output_tokens: 17, + cache_read_input_tokens: Some(3), + cache_creation_input_tokens: Some(5), + }; + agent.locked_tools = Some(vec![ToolDefinition { + name: "test_tool".to_string(), + description: "test tool".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }]); +} + +#[tokio::test] +async fn clear_resets_runtime_interrupt_and_queue_state() { + 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); + + seed_transient_session_state(&mut agent); + assert_eq!(agent.soft_interrupt_count(), 1); + assert!(agent.background_tool_signal().is_set()); + assert!(agent.graceful_shutdown_signal().is_set()); + + agent.clear(); + + assert_eq!(agent.soft_interrupt_count(), 0); + assert!(!agent.background_tool_signal().is_set()); + assert!(!agent.graceful_shutdown_signal().is_set()); + assert_eq!(agent.pending_alert_count(), 0); + assert!(agent.tool_call_ids.is_empty()); + assert!(agent.tool_result_ids.is_empty()); + assert_eq!(agent.tool_output_scan_index, 0); + assert!(agent.last_upstream_provider.is_none()); + assert!(agent.last_connection_type.is_none()); + assert!(agent.current_turn_system_reminder.is_none()); + assert_eq!(agent.last_usage.input_tokens, 0); + assert_eq!(agent.last_usage.output_tokens, 0); + assert!(agent.locked_tools.is_none()); +} + +#[tokio::test] +async fn restore_session_resets_runtime_interrupt_and_queue_state() { + 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 restored_session = crate::session::Session::create_with_id( + "session_restore_resets_runtime_state".to_string(), + None, + None, + ); + restored_session.save().expect("save restored session"); + + seed_transient_session_state(&mut agent); + assert_eq!(agent.soft_interrupt_count(), 1); + assert!(agent.background_tool_signal().is_set()); + assert!(agent.graceful_shutdown_signal().is_set()); + + let status = agent + .restore_session(&restored_session.id) + .expect("restore session should succeed"); + + assert_eq!(status, crate::session::SessionStatus::Active); + assert_eq!(agent.session_id(), restored_session.id); + assert_eq!(agent.soft_interrupt_count(), 0); + assert!(!agent.background_tool_signal().is_set()); + assert!(!agent.graceful_shutdown_signal().is_set()); + assert_eq!(agent.pending_alert_count(), 0); + assert!(agent.tool_call_ids.is_empty()); + assert!(agent.tool_result_ids.is_empty()); + assert_eq!(agent.tool_output_scan_index, 0); + assert!(agent.last_upstream_provider.is_none()); + assert!(agent.last_connection_type.is_none()); + assert!(agent.current_turn_system_reminder.is_none()); + assert_eq!(agent.last_usage.input_tokens, 0); + assert_eq!(agent.last_usage.output_tokens, 0); + assert!(agent.locked_tools.is_none()); +} + +#[tokio::test] +async fn restore_session_rehydrates_injected_memory_ids() { + let _guard = crate::storage::lock_test_env(); + crate::memory::clear_all_pending_memory(); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + + let mut restored_session = crate::session::Session::create_with_id( + "session_restore_memory_dedup".to_string(), + None, + None, + ); + restored_session.record_memory_injection( + "🧠 auto-recalled 1 memory".to_string(), + "persisted memory".to_string(), + 1, + 5, + vec!["memory-persisted".to_string()], + ); + restored_session.save().expect("save restored session"); + + crate::memory::mark_memories_injected(&restored_session.id, &["memory-stale".to_string()]); + + agent + .restore_session(&restored_session.id) + .expect("restore session should succeed"); + + assert!(crate::memory::is_memory_injected( + &restored_session.id, + "memory-persisted" + )); + assert!( + !crate::memory::is_memory_injected(&restored_session.id, "memory-stale"), + "restore should replace stale in-memory dedup state with persisted session data" + ); + + crate::memory::clear_all_pending_memory(); +} + +#[tokio::test] +async fn build_memory_prompt_nonblocking_defers_pending_memory_during_tool_loop() { + let _guard = crate::storage::lock_test_env(); + crate::memory::clear_all_pending_memory(); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Agent::new(provider, registry); + let session_id = agent.session.id.clone(); + + crate::memory::set_pending_memory_with_ids( + &session_id, + "remember this later".to_string(), + 1, + vec!["memory-deferred".to_string()], + ); + + let tool_loop_messages = vec![ + Message::user("hello"), + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call_1".to_string(), + name: "bash".to_string(), + input: serde_json::json!({}), + thought_signature: None, + }], + timestamp: Some(chrono::Utc::now()), + tool_duration_ms: None, + }, + Message::tool_result("call_1", "ok", false), + ]; + + let pending = agent.build_memory_prompt_nonblocking(&tool_loop_messages, None); + assert!(pending.is_none(), "memory should not inject mid tool loop"); + assert!(crate::memory::has_pending_memory(&session_id)); + + let next_turn_messages = vec![Message::user("follow up")]; + let pending = agent.build_memory_prompt_nonblocking(&next_turn_messages, None); + assert!( + pending.is_some(), + "memory should inject on the next real user turn" + ); + assert!(!crate::memory::has_pending_memory(&session_id)); + + crate::memory::clear_all_pending_memory(); +} + +#[tokio::test] +async fn memory_injection_message_defaults_to_ephemeral_history() { + let _guard = crate::storage::lock_test_env(); + let previous = std::env::var_os("JCODE_PERSIST_MEMORY_INJECTIONS"); + crate::env::set_var("JCODE_PERSIST_MEMORY_INJECTIONS", "false"); + crate::config::invalidate_config_cache(); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + let before = agent.session.messages.len(); + let memory = crate::memory::PendingMemory { + prompt: "# Memory\n\n## Facts\n1. Use ephemeral mode".to_string(), + display_prompt: None, + computed_at: Instant::now(), + count: 1, + memory_ids: vec!["mem-ephemeral".to_string()], + }; + + let (message, persisted) = agent.prepare_memory_injection_message(&memory); + + assert!(!persisted); + assert_eq!(agent.session.messages.len(), before); + assert!(matches!(message.role, Role::User)); + assert!(message_text(&message).contains("Use ephemeral mode")); + + match previous { + Some(value) => crate::env::set_var("JCODE_PERSIST_MEMORY_INJECTIONS", value), + None => crate::env::remove_var("JCODE_PERSIST_MEMORY_INJECTIONS"), + } + crate::config::invalidate_config_cache(); +} + +#[tokio::test] +async fn memory_injection_message_can_persist_to_history() { + let _guard = crate::storage::lock_test_env(); + let previous = std::env::var_os("JCODE_PERSIST_MEMORY_INJECTIONS"); + crate::env::set_var("JCODE_PERSIST_MEMORY_INJECTIONS", "true"); + crate::config::invalidate_config_cache(); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider, registry); + let before = agent.session.messages.len(); + let memory = crate::memory::PendingMemory { + prompt: "# Memory\n\n## Facts\n1. Persist for cache".to_string(), + display_prompt: None, + computed_at: Instant::now(), + count: 1, + memory_ids: vec!["mem-persisted".to_string()], + }; + + let (message, persisted) = agent.prepare_memory_injection_message(&memory); + + assert!(persisted); + assert_eq!(agent.session.messages.len(), before + 1); + assert_eq!( + content_text(&agent.session.messages.last().unwrap().content), + message_text(&message) + ); + assert!( + content_text(&agent.session.messages.last().unwrap().content).contains("Persist for cache") + ); + + match previous { + Some(value) => crate::env::set_var("JCODE_PERSIST_MEMORY_INJECTIONS", value), + None => crate::env::remove_var("JCODE_PERSIST_MEMORY_INJECTIONS"), + } + crate::config::invalidate_config_cache(); +} + +#[tokio::test] +async fn mark_closed_persists_soft_interrupts_for_restore_after_reload() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let provider: Arc = Arc::new(NativeAutoCompactionProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = Agent::new(provider.clone(), registry.clone()); + let session_id = agent.session_id().to_string(); + agent.session.save().expect("save active session"); + agent.queue_soft_interrupt( + "resume me after reload".to_string(), + true, + SoftInterruptSource::System, + ); + + agent.mark_closed(); + + let mut restored = Agent::new(provider, registry); + restored + .restore_session(&session_id) + .expect("restore session with persisted interrupts"); + + assert_eq!(restored.soft_interrupt_count(), 1); + assert!(restored.has_urgent_interrupt()); + assert!( + crate::soft_interrupt_store::load(&session_id) + .expect("store should be readable after restore") + .is_empty() + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +async fn env_snapshot_detail_is_minimal_for_empty_sessions_and_full_after_history() { + 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); + + assert_eq!(agent.env_snapshot_detail(), EnvSnapshotDetail::Minimal); + let minimal = agent.build_env_snapshot("create", agent.env_snapshot_detail()); + assert!(minimal.jcode_git_hash.is_none()); + assert!(minimal.jcode_git_dirty.is_none()); + assert!(minimal.working_git.is_none()); + + agent + .session + .append_stored_message(crate::session::StoredMessage { + id: "msg_env_snapshot_detail".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::Text { + text: "hello".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }); + + assert_eq!(agent.env_snapshot_detail(), EnvSnapshotDetail::Full); +} + +/// A trivial tool used to simulate an MCP tool registering on the registry +/// after the agent has already locked its tool snapshot. +struct FakeMcpTool { + name: String, +} + +#[async_trait] +impl crate::tool::Tool for FakeMcpTool { + fn name(&self) -> &str { + &self.name + } + fn description(&self) -> &str { + "fake mcp tool" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn execute( + &self, + _input: serde_json::Value, + _ctx: crate::tool::ToolContext, + ) -> anyhow::Result { + Ok(ToolOutput::new("ok")) + } +} + +/// 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 +/// unlock path (`unlock_tools_if_needed`) fires solely when the LLM invokes the +/// `"mcp"` management tool — which it never does, since it cannot see the +/// `mcp__*` tools it would need to trigger that unlock. +#[tokio::test] +async fn mcp_tools_registered_after_lock_are_visible_to_agent() { + 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); + + // First turn locks the snapshot (this is what happens before the async MCP + // registration spawn completes). + let before = agent.tool_definitions().await; + let before_len = before.len(); + assert!( + !before.iter().any(|t| t.name.starts_with("mcp__")), + "precondition: no mcp tools before async registration completes" + ); + + // Simulate the spawned MCP registration task finishing: a new mcp__* tool + // lands on the shared registry. + agent + .registry + .register( + "mcp__test__write_memory".to_string(), + Arc::new(FakeMcpTool { + name: "mcp__test__write_memory".to_string(), + }) as Arc, + ) + .await; + + // The next turn should now advertise the MCP tool to the provider. + let after = agent.tool_definitions().await; + assert!( + after.iter().any(|t| t.name == "mcp__test__write_memory"), + "regression #206: MCP tool registered after the first turn never reaches \ + the agent's tool surface (locked snapshot of {} tools is reused forever)", + before_len + ); + + // Once MCP tools are present in the locked snapshot, subsequent turns must + // return the *same* stable snapshot so provider prompt-cache hits stay warm + // (the whole point of locked_tools). The #206 fix must not flap. + let names = + |defs: &[ToolDefinition]| -> Vec { defs.iter().map(|t| t.name.clone()).collect() }; + let stable_a = agent.tool_definitions().await; + let stable_b = agent.tool_definitions().await; + assert_eq!( + names(&stable_a), + names(&stable_b), + "tool snapshot must be stable across turns once MCP tools are present" + ); + assert_eq!( + names(&stable_a), + names(&after), + "snapshot must not change after MCP tools are already included" + ); +} + +/// The intentional, MCP-driven prompt-cache miss must happen at most ONCE per +/// locked snapshot. After the first late-registered `mcp__*` tool is picked up +/// (the one accepted miss), a *second* MCP tool that registers even later must +/// NOT trigger another rebuild — otherwise a server that connects in waves would +/// thrash the provider prompt cache. Guards the `mcp_late_register_resolved` +/// one-shot flag (#206 follow-up). +#[tokio::test] +async fn mcp_late_registration_rebuild_happens_at_most_once() { + 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); + + // First turn locks the snapshot with no MCP tools yet. + let _ = agent.tool_definitions().await; + + // First MCP tool arrives -> one accepted rebuild exposes it. + agent + .registry + .register( + "mcp__test__first".to_string(), + Arc::new(FakeMcpTool { + name: "mcp__test__first".to_string(), + }) as Arc, + ) + .await; + let after_first = agent.tool_definitions().await; + assert!( + after_first.iter().any(|t| t.name == "mcp__test__first"), + "first late MCP tool must be picked up by the one accepted rebuild" + ); + assert!( + agent.mcp_late_register_resolved, + "one-shot guard must latch after the accepted rebuild" + ); + + // A SECOND MCP tool registers even later (server connected in a second + // wave). The one-shot guard means we do NOT rebuild again, so the snapshot + // stays cache-stable and this tool is intentionally not surfaced until the + // tool list is explicitly unlocked. + agent + .registry + .register( + "mcp__test__second".to_string(), + Arc::new(FakeMcpTool { + name: "mcp__test__second".to_string(), + }) as Arc, + ) + .await; + let after_second = agent.tool_definitions().await; + let names: Vec = after_second.iter().map(|t| t.name.clone()).collect(); + assert!( + names.iter().any(|n| n == "mcp__test__first"), + "previously surfaced MCP tool must remain" + ); + assert!( + !names.iter().any(|n| n == "mcp__test__second"), + "second-wave MCP tool must NOT trigger a second cache-busting rebuild" + ); + + // An explicit unlock (e.g. the `mcp` reload tool) re-arms the one-shot guard + // and lets the next snapshot pick up everything currently registered. + agent.unlock_tools(); + assert!( + !agent.mcp_late_register_resolved, + "explicit unlock must re-arm the one-shot guard" + ); + let after_unlock = agent.tool_definitions().await; + let unlocked_names: Vec = after_unlock.iter().map(|t| t.name.clone()).collect(); + assert!( + unlocked_names.iter().any(|n| n == "mcp__test__second"), + "after explicit unlock, the second-wave MCP tool must finally surface" + ); +} + +/// Without any newly-registered MCP tools, the locked snapshot must be returned +/// verbatim on every turn (no rebuild, no cache invalidation). Guards the #206 +/// fix against re-snapshotting on turns where nothing changed. +#[tokio::test] +async fn tool_snapshot_is_stable_without_new_mcp_tools() { + 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 first = agent.tool_definitions().await; + // Register a NON-mcp tool after locking — this should NOT trigger a rebuild, + // because the cache-stability optimization only yields to MCP arrival. + agent + .registry + .register( + "not_an_mcp_tool".to_string(), + Arc::new(FakeMcpTool { + name: "not_an_mcp_tool".to_string(), + }) as Arc, + ) + .await; + let second = agent.tool_definitions().await; + let first_names: Vec = first.iter().map(|t| t.name.clone()).collect(); + let second_names: Vec = second.iter().map(|t| t.name.clone()).collect(); + assert_eq!( + first_names, second_names, + "non-MCP registry changes must not invalidate the locked tool snapshot" + ); + assert!( + !second_names.iter().any(|n| n == "not_an_mcp_tool"), + "non-MCP tool registered after lock must not leak into the snapshot" + ); +} + +#[test] +fn guardrail_stop_reason_detection() { + assert!(Agent::is_guardrail_stop_reason(Some("refusal"))); + assert!(Agent::is_guardrail_stop_reason(Some("REFUSAL"))); + assert!(Agent::is_guardrail_stop_reason(Some(" content_filter "))); + assert!(Agent::is_guardrail_stop_reason(Some("safety"))); + assert!(Agent::is_guardrail_stop_reason(Some("model_guardrail"))); + assert!(Agent::is_guardrail_stop_reason(Some("policy_violation_x"))); + assert!(!Agent::is_guardrail_stop_reason(Some("end_turn"))); + assert!(!Agent::is_guardrail_stop_reason(Some("max_tokens"))); + assert!(!Agent::is_guardrail_stop_reason(Some("tool_use"))); + assert!(!Agent::is_guardrail_stop_reason(Some("stop"))); + assert!(!Agent::is_guardrail_stop_reason(None)); +} + +#[test] +fn guardrail_notice_for_refusal_stop() { + let notice = Agent::provider_guardrail_notice(Some("refusal"), true, true) + .expect("refusal with empty text must produce a notice"); + assert!( + notice.contains("refusal"), + "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()); +} + +#[test] +fn guardrail_notice_for_silent_empty_turn() { + // end_turn with zero visible output and reasoning-only content: surface it. + let notice = Agent::provider_guardrail_notice(Some("end_turn"), true, true) + .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"); + assert!(notice.contains("unknown"), "{notice}"); + assert!(!notice.contains("internal reasoning"), "{notice}"); +} + +#[test] +fn guardrail_notice_absent_for_normal_turns() { + // Normal turn with visible text: no notice. + assert!(Agent::provider_guardrail_notice(Some("end_turn"), false, false).is_none()); + assert!(Agent::provider_guardrail_notice(None, false, true).is_none()); +} diff --git a/crates/jcode-app-core/src/ambient.rs b/crates/jcode-app-core/src/ambient.rs new file mode 100644 index 0000000..20cbba8 --- /dev/null +++ b/crates/jcode-app-core/src/ambient.rs @@ -0,0 +1,197 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +mod directives; +mod manager; +mod paths; +mod persistence; +mod prompt; +pub mod runner; +pub mod scheduler; + +pub use directives::{ + UserDirective, add_directive, has_pending_directives, load_directives, take_pending_directives, +}; +pub use manager::AmbientManager; +pub use persistence::{AmbientLock, ScheduledQueue}; +#[cfg(test)] +pub(crate) use prompt::format_duration_rough; +pub use prompt::{ + MemoryGraphHealth, RecentSessionInfo, ResourceBudget, build_ambient_system_prompt, + format_minutes_human, format_scheduled_session_message, gather_feedback_memories, + gather_memory_graph_health, gather_recent_sessions, +}; + +use crate::storage; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Context passed from the ambient runner to a visible TUI cycle. +/// Saved to `~/.jcode/ambient/visible_cycle.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VisibleCycleContext { + pub system_prompt: String, + pub initial_message: String, +} + +impl VisibleCycleContext { + pub fn context_path() -> Result { + Ok(storage::jcode_dir()? + .join("ambient") + .join("visible_cycle.json")) + } + + pub fn save(&self) -> Result<()> { + let path = Self::context_path()?; + if let Some(parent) = path.parent() { + storage::ensure_dir(parent)?; + } + storage::write_json(&path, self) + } + + pub fn load() -> Result { + let path = Self::context_path()?; + storage::read_json(&path) + } + + pub fn result_path() -> Result { + Ok(storage::jcode_dir()? + .join("ambient") + .join("cycle_result.json")) + } +} + +/// Ambient mode status +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum AmbientStatus { + #[default] + Idle, + Running { + detail: String, + }, + Scheduled { + next_wake: DateTime, + }, + Paused { + reason: String, + }, + Disabled, +} + +/// Priority for scheduled items +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum Priority { + Low, + Normal, + High, +} + +/// Where a scheduled task should be delivered when it becomes due. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScheduleTarget { + /// Wake the ambient agent and hand it the queued task. + #[default] + Ambient, + /// Deliver the reminder back into a specific interactive session. + Session { session_id: String }, + /// Spawn a single new session derived from the originating session. + Spawn { parent_session_id: String }, +} + +impl ScheduleTarget { + pub fn is_direct_delivery(&self) -> bool { + matches!(self, Self::Session { .. } | Self::Spawn { .. }) + } +} + +/// A scheduled ambient task +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledItem { + pub id: String, + pub scheduled_for: DateTime, + pub context: String, + pub priority: Priority, + #[serde(default)] + pub target: ScheduleTarget, + pub created_by_session: String, + pub created_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relevant_files: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_context: Option, +} + +/// Persistent ambient state +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AmbientState { + pub status: AmbientStatus, + pub last_run: Option>, + pub last_summary: Option, + pub last_compactions: Option, + pub last_memories_modified: Option, + pub total_cycles: u64, +} + +/// Result from an ambient cycle +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AmbientCycleResult { + pub summary: String, + pub memories_modified: u32, + pub compactions: u32, + pub proactive_work: Option, + pub next_schedule: Option, + pub started_at: DateTime, + pub ended_at: DateTime, + pub status: CycleStatus, + /// Full conversation transcript (markdown) for email notifications + #[serde(default, skip_serializing_if = "Option::is_none")] + pub conversation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CycleStatus { + Complete, + Interrupted, + Incomplete, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduleRequest { + pub wake_in_minutes: Option, + pub wake_at: Option>, + pub context: String, + pub priority: Priority, + #[serde(default)] + pub target: ScheduleTarget, + #[serde(default)] + pub created_by_session: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relevant_files: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_context: Option, +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[path = "ambient_tests.rs"] +mod ambient_tests; diff --git a/crates/jcode-app-core/src/ambient/directives.rs b/crates/jcode-app-core/src/ambient/directives.rs new file mode 100644 index 0000000..b90dd67 --- /dev/null +++ b/crates/jcode-app-core/src/ambient/directives.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use super::paths::ambient_dir; +use crate::storage; + +// --------------------------------------------------------------------------- +// User Directives (from email replies) +// --------------------------------------------------------------------------- + +/// A user directive received via email reply to an ambient cycle notification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserDirective { + pub id: String, + pub text: String, + pub received_at: DateTime, + pub in_reply_to_cycle: String, + pub consumed: bool, +} + +fn directives_path() -> Result { + Ok(ambient_dir()?.join("directives.json")) +} + +pub fn load_directives() -> Vec { + directives_path() + .ok() + .and_then(|p| { + if p.exists() { + storage::read_json(&p).ok() + } else { + None + } + }) + .unwrap_or_default() +} + +fn save_directives(directives: &[UserDirective]) -> Result<()> { + storage::write_json(&directives_path()?, directives) +} + +/// Store a new directive from an email reply. +pub fn add_directive(text: String, in_reply_to: String) -> Result<()> { + let mut directives = load_directives(); + directives.push(UserDirective { + id: format!("dir_{:08x}", rand::random::()), + text, + received_at: Utc::now(), + in_reply_to_cycle: in_reply_to, + consumed: false, + }); + save_directives(&directives) +} + +/// Take all unconsumed directives, marking them as consumed. +pub fn take_pending_directives() -> Vec { + let mut all = load_directives(); + let pending: Vec<_> = all.iter().filter(|d| !d.consumed).cloned().collect(); + if pending.is_empty() { + return pending; + } + for d in &mut all { + if !d.consumed { + d.consumed = true; + } + } + let _ = save_directives(&all); + pending +} + +/// Check if there are any unconsumed directives. +pub fn has_pending_directives() -> bool { + load_directives().iter().any(|d| !d.consumed) +} diff --git a/crates/jcode-app-core/src/ambient/manager.rs b/crates/jcode-app-core/src/ambient/manager.rs new file mode 100644 index 0000000..8755789 --- /dev/null +++ b/crates/jcode-app-core/src/ambient/manager.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use chrono::Utc; + +use super::paths::{ambient_dir, queue_path, transcripts_dir}; +use super::{ + AmbientCycleResult, AmbientState, AmbientStatus, ScheduleRequest, ScheduledItem, ScheduledQueue, +}; +use crate::config::config; + +// --------------------------------------------------------------------------- +// AmbientManager +// --------------------------------------------------------------------------- + +pub struct AmbientManager { + state: AmbientState, + queue: ScheduledQueue, +} + +impl AmbientManager { + pub fn new() -> Result { + // Ensure storage layout exists + let _ = ambient_dir()?; + let _ = transcripts_dir()?; + + let state = AmbientState::load()?; + let queue = ScheduledQueue::load(queue_path()?); + + Ok(Self { state, queue }) + } + + pub fn is_enabled() -> bool { + config().ambient.enabled + } + + /// Check whether it's time to run a cycle based on current state and queue. + pub fn should_run(&self) -> bool { + if !Self::is_enabled() { + return false; + } + + match &self.state.status { + AmbientStatus::Disabled | AmbientStatus::Paused { .. } => false, + AmbientStatus::Running { .. } => false, // already running + AmbientStatus::Idle => true, + AmbientStatus::Scheduled { next_wake } => Utc::now() >= *next_wake, + } + } + + pub fn record_cycle_result(&mut self, result: AmbientCycleResult) -> Result<()> { + self.state.record_cycle(&result); + self.state.save()?; + + // If the cycle produced a schedule request, enqueue it + if let Some(ref req) = result.next_schedule { + self.schedule(req.clone())?; + } + + Ok(()) + } + + /// Remove and return all ready scheduled items. + pub fn take_ready_items(&mut self) -> Vec { + self.queue.pop_ready() + } + + /// Remove and return only ready items targeted at direct delivery into a + /// specific resumed or spawned session. + pub fn take_ready_direct_items(&mut self) -> Vec { + self.queue.take_ready_direct_items() + } + + /// Add a schedule request to the queue. Returns the item ID. + pub fn schedule(&mut self, request: ScheduleRequest) -> Result { + let id = format!("sched_{:08x}", rand::random::()); + let scheduled_for = request.wake_at.unwrap_or_else(|| { + Utc::now() + chrono::Duration::minutes(request.wake_in_minutes.unwrap_or(30) as i64) + }); + + let item = ScheduledItem { + id: id.clone(), + scheduled_for, + context: request.context, + priority: request.priority, + target: request.target, + created_by_session: request.created_by_session, + created_at: Utc::now(), + working_dir: request.working_dir, + task_description: request.task_description, + relevant_files: request.relevant_files, + git_branch: request.git_branch, + additional_context: request.additional_context, + }; + + self.queue.push(item); + Ok(id) + } + + /// Cancel a queued scheduled item by ID. + pub fn cancel_schedule(&mut self, id: &str) -> Result> { + self.queue.remove_by_id(id) + } + + pub fn state(&self) -> &AmbientState { + &self.state + } + + pub fn queue(&self) -> &ScheduledQueue { + &self.queue + } +} diff --git a/crates/jcode-app-core/src/ambient/paths.rs b/crates/jcode-app-core/src/ambient/paths.rs new file mode 100644 index 0000000..6a937f2 --- /dev/null +++ b/crates/jcode-app-core/src/ambient/paths.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use std::path::PathBuf; + +use crate::storage; + +// --------------------------------------------------------------------------- +// Storage paths +// --------------------------------------------------------------------------- + +pub(super) fn ambient_dir() -> Result { + let dir = storage::jcode_dir()?.join("ambient"); + storage::ensure_dir(&dir)?; + Ok(dir) +} + +pub(super) fn state_path() -> Result { + Ok(ambient_dir()?.join("state.json")) +} + +pub(super) fn queue_path() -> Result { + Ok(ambient_dir()?.join("queue.json")) +} + +pub(super) fn lock_path() -> Result { + Ok(ambient_dir()?.join("ambient.lock")) +} + +pub(super) fn transcripts_dir() -> Result { + let dir = ambient_dir()?.join("transcripts"); + storage::ensure_dir(&dir)?; + Ok(dir) +} diff --git a/crates/jcode-app-core/src/ambient/persistence.rs b/crates/jcode-app-core/src/ambient/persistence.rs new file mode 100644 index 0000000..a923cac --- /dev/null +++ b/crates/jcode-app-core/src/ambient/persistence.rs @@ -0,0 +1,217 @@ +use anyhow::Result; +use chrono::Utc; +use std::path::PathBuf; + +use super::paths::{lock_path, state_path}; +use super::{AmbientCycleResult, AmbientState, AmbientStatus, CycleStatus, ScheduledItem}; +use crate::storage; + +// --------------------------------------------------------------------------- +// AmbientState persistence +// --------------------------------------------------------------------------- + +impl AmbientState { + pub fn load() -> Result { + let path = state_path()?; + if path.exists() { + storage::read_json(&path) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self) -> Result<()> { + storage::write_json(&state_path()?, self) + } + + pub fn record_cycle(&mut self, result: &AmbientCycleResult) { + self.last_run = Some(result.ended_at); + self.last_summary = Some(result.summary.clone()); + self.last_compactions = Some(result.compactions); + self.last_memories_modified = Some(result.memories_modified); + self.total_cycles += 1; + + match result.status { + CycleStatus::Complete => { + if let Some(ref req) = result.next_schedule { + let next = req.wake_at.unwrap_or_else(|| { + Utc::now() + + chrono::Duration::minutes(req.wake_in_minutes.unwrap_or(30) as i64) + }); + self.status = AmbientStatus::Scheduled { next_wake: next }; + } else { + self.status = AmbientStatus::Idle; + } + } + CycleStatus::Interrupted | CycleStatus::Incomplete => { + self.status = AmbientStatus::Idle; + } + } + } +} + +// --------------------------------------------------------------------------- +// ScheduledQueue +// --------------------------------------------------------------------------- + +pub struct ScheduledQueue { + items: Vec, + path: PathBuf, +} + +impl ScheduledQueue { + pub fn load(path: PathBuf) -> Self { + let items: Vec = if path.exists() { + storage::read_json(&path).unwrap_or_default() + } else { + Vec::new() + }; + Self { items, path } + } + + pub fn save(&self) -> Result<()> { + storage::write_json(&self.path, &self.items) + } + + pub fn push(&mut self, item: ScheduledItem) { + self.items.push(item); + let _ = self.save(); + } + + /// Remove a scheduled item by ID, persisting the queue when found. + pub fn remove_by_id(&mut self, id: &str) -> Result> { + let Some(index) = self.items.iter().position(|item| item.id == id) else { + return Ok(None); + }; + + let item = self.items.remove(index); + self.save()?; + Ok(Some(item)) + } + + /// Pop items whose `scheduled_for` is in the past, sorted by priority + /// (highest first) then by time (earliest first). + pub fn pop_ready(&mut self) -> Vec { + let now = Utc::now(); + let (ready, remaining): (Vec<_>, Vec<_>) = + self.items.drain(..).partition(|i| i.scheduled_for <= now); + + self.items = remaining; + + let mut ready = ready; + // Sort: highest priority first, then earliest scheduled_for + ready.sort_by(|a, b| { + b.priority + .cmp(&a.priority) + .then_with(|| a.scheduled_for.cmp(&b.scheduled_for)) + }); + + if !ready.is_empty() { + let _ = self.save(); + } + + ready + } + + /// Remove and return ready items targeted at a specific direct-delivery session, + /// leaving ambient-targeted queue items intact for the ambient agent to process. + pub fn take_ready_direct_items(&mut self) -> Vec { + let now = Utc::now(); + let mut ready_direct = Vec::new(); + let mut remaining = Vec::with_capacity(self.items.len()); + + for item in self.items.drain(..) { + let is_ready = item.scheduled_for <= now; + let is_direct_target = item.target.is_direct_delivery(); + if is_ready && is_direct_target { + ready_direct.push(item); + } else { + remaining.push(item); + } + } + + self.items = remaining; + + if !ready_direct.is_empty() { + let _ = self.save(); + } + + ready_direct.sort_by(|a, b| { + b.priority + .cmp(&a.priority) + .then_with(|| a.scheduled_for.cmp(&b.scheduled_for)) + }); + + ready_direct + } + + pub fn peek_next(&self) -> Option<&ScheduledItem> { + self.items.iter().min_by_key(|i| i.scheduled_for) + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn items(&self) -> &[ScheduledItem] { + &self.items + } +} + +// --------------------------------------------------------------------------- +// AmbientLock (single-instance guard) +// --------------------------------------------------------------------------- + +pub struct AmbientLock { + pub(crate) lock_path: PathBuf, +} + +impl AmbientLock { + /// Try to acquire the ambient lock. + /// Returns `Ok(Some(lock))` if acquired, `Ok(None)` if another instance + /// already holds it, or `Err` on I/O failure. + pub fn try_acquire() -> Result> { + let path = lock_path()?; + + // Check existing lock + if path.exists() { + if let Ok(contents) = std::fs::read_to_string(&path) + && let Ok(pid) = contents.trim().parse::() + && is_pid_alive(pid) + { + return Ok(None); // Another instance is running + } + let _ = std::fs::remove_file(&path); + } + + // Write our PID + let pid = std::process::id(); + if let Some(parent) = path.parent() { + storage::ensure_dir(parent)?; + } + std::fs::write(&path, pid.to_string())?; + + Ok(Some(Self { lock_path: path })) + } + + pub fn release(self) -> Result<()> { + let _ = std::fs::remove_file(&self.lock_path); + // Drop runs, but we already cleaned up + std::mem::forget(self); + Ok(()) + } +} + +impl Drop for AmbientLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.lock_path); + } +} + +fn is_pid_alive(pid: u32) -> bool { + crate::platform::is_process_running(pid) +} diff --git a/crates/jcode-app-core/src/ambient/prompt.rs b/crates/jcode-app-core/src/ambient/prompt.rs new file mode 100644 index 0000000..ed3f592 --- /dev/null +++ b/crates/jcode-app-core/src/ambient/prompt.rs @@ -0,0 +1,597 @@ +use chrono::{DateTime, Utc}; + +use super::{AmbientState, Priority, ScheduleTarget, ScheduledItem, take_pending_directives}; + +// --------------------------------------------------------------------------- +// Ambient System Prompt Builder +// --------------------------------------------------------------------------- + +/// Health stats for the memory graph, used in the ambient system prompt. +#[derive(Debug, Clone, Default)] +pub struct MemoryGraphHealth { + pub total: usize, + pub active: usize, + pub inactive: usize, + pub low_confidence: usize, + pub contradictions: usize, + pub missing_embeddings: usize, + pub duplicate_candidates: usize, + pub last_consolidation: Option>, +} + +/// Summary of a recent session for the ambient prompt. +#[derive(Debug, Clone)] +pub struct RecentSessionInfo { + pub id: String, + pub status: String, + pub topic: Option, + pub duration_secs: i64, + pub extraction_status: String, +} + +/// Resource budget info for the ambient prompt. +#[derive(Debug, Clone, Default)] +pub struct ResourceBudget { + pub provider: String, + pub tokens_remaining_desc: String, + pub window_resets_desc: String, + pub user_usage_rate_desc: String, + pub cycle_budget_desc: String, +} + +/// Gather memory graph health stats from the MemoryManager. +pub fn gather_memory_graph_health( + memory_manager: &crate::memory::MemoryManager, +) -> MemoryGraphHealth { + let mut health = MemoryGraphHealth::default(); + + // Accumulate stats from project + global graphs + for graph in [ + memory_manager.load_project_graph(), + memory_manager.load_global_graph(), + ] + .into_iter() + .flatten() + { + let active_count = graph.memories.values().filter(|m| m.active).count(); + let inactive_count = graph.memories.values().filter(|m| !m.active).count(); + health.total += graph.memories.len(); + health.active += active_count; + health.inactive += inactive_count; + + // Low confidence: effective confidence < 0.1 + health.low_confidence += graph + .memories + .values() + .filter(|m| m.active && m.effective_confidence() < 0.1) + .count(); + + // Missing embeddings + health.missing_embeddings += graph + .memories + .values() + .filter(|m| m.active && m.embedding.is_none()) + .count(); + + // Count contradiction edges + for edges in graph.edges.values() { + for edge in edges { + if matches!(edge.kind, crate::memory_graph::EdgeKind::Contradicts) { + health.contradictions += 1; + } + } + } + + // Use last_cluster_update as a proxy for last consolidation + if let Some(ts) = graph.metadata.last_cluster_update { + match health.last_consolidation { + Some(existing) if ts > existing => health.last_consolidation = Some(ts), + None => health.last_consolidation = Some(ts), + _ => {} + } + } + } + + // Contradicts edges are bidirectional, so divide by 2 + health.contradictions /= 2; + + // Duplicate candidates would require embedding similarity scan; + // placeholder for now — ambient agent will discover them during its cycle. + health.duplicate_candidates = 0; + + health +} + +/// Gather feedback memories relevant to ambient mode. +/// +/// Pulls from two sources: +/// 1. Recent ambient transcripts (summaries of past cycles) +/// 2. Memory graph entries tagged "ambient" or "system" +/// +/// Returns formatted strings for inclusion in the ambient system prompt. +pub fn gather_feedback_memories(memory_manager: &crate::memory::MemoryManager) -> Vec { + let mut feedback = Vec::new(); + + // --- Source 1: Recent ambient transcripts --- + let transcripts_dir = match crate::storage::jcode_dir() { + Ok(d) => d.join("ambient").join("transcripts"), + Err(_) => return feedback, + }; + + if transcripts_dir.exists() + && let Ok(dir) = std::fs::read_dir(&transcripts_dir) + { + let mut files: Vec<_> = dir.flatten().collect(); + // Sort by filename descending (most recent first) + files.sort_by_key(|entry| std::cmp::Reverse(entry.file_name())); + // Only look at the last 5 transcripts + files.truncate(5); + + for entry in files { + if let Ok(content) = std::fs::read_to_string(entry.path()) + && let Ok(transcript) = + serde_json::from_str::(&content) + { + let status = format!("{:?}", transcript.status); + let summary = transcript.summary.as_deref().unwrap_or("no summary"); + let age = format_duration_rough(Utc::now() - transcript.started_at); + feedback.push(format!( + "Past cycle ({} ago, {}): {} memories modified, {} compactions — {}", + age, + status.to_lowercase(), + transcript.memories_modified, + transcript.compactions, + summary, + )); + } + } + } + + // --- Source 2: Memory graph entries tagged "ambient" or "system" --- + for graph in [ + memory_manager.load_project_graph(), + memory_manager.load_global_graph(), + ] + .into_iter() + .flatten() + { + for memory in graph.memories.values() { + if !memory.active { + continue; + } + let has_ambient_tag = memory.tags.iter().any(|t| t == "ambient" || t == "system"); + if has_ambient_tag { + feedback.push(format!("Memory [{}]: {}", memory.id, memory.content)); + } + } + } + + feedback +} + +/// Gather recent sessions since a given timestamp. +pub fn gather_recent_sessions(since: Option>) -> Vec { + let sessions_dir = match crate::storage::jcode_dir() { + Ok(d) => d.join("sessions"), + Err(_) => return Vec::new(), + }; + if !sessions_dir.exists() { + return Vec::new(); + } + + let cutoff = since.unwrap_or_else(|| Utc::now() - chrono::Duration::hours(24)); + + // Pre-filter candidate session files by filesystem mtime BEFORE loading and + // parsing them. The sessions directory can hold tens of thousands of files; + // fully parsing every one via Session::load just to drop those older than + // the cutoff is O(all_sessions * parse). A session updated after the cutoff + // has a recent mtime, so we keep only files whose mtime is at or after the + // cutoff (minus a small margin for clock/write skew), then load newest-first + // and stop once we have enough recent sessions. + const RECENT_SESSION_LIMIT: usize = 20; + let mtime_cutoff = cutoff - chrono::Duration::hours(1); + + let mut candidates: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&sessions_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.extension().map(|e| e == "json").unwrap_or(false) { + continue; + } + let Ok(modified) = entry.metadata().and_then(|meta| meta.modified()) else { + // If we can't read mtime, keep the file as a candidate so we + // don't silently drop a possibly-recent session. + candidates.push((path, std::time::SystemTime::UNIX_EPOCH)); + continue; + }; + let modified_dt: DateTime = modified.into(); + if modified_dt < mtime_cutoff { + continue; + } + candidates.push((path, modified)); + } + } + // Newest files first so we can stop early once we have enough. + candidates.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut recent = Vec::new(); + // Load somewhat more than the final limit by mtime so the subsequent + // id-based sort/truncate picks the true most-recent set even when file + // mtime order and id (timestamp) order disagree near the boundary, while + // still bounding work far below "load every session file". + let load_budget = RECENT_SESSION_LIMIT + .saturating_mul(4) + .max(RECENT_SESSION_LIMIT); + let mut loaded = 0usize; + for (path, _modified) in candidates { + if loaded >= load_budget { + break; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + && let Ok(session) = crate::session::Session::load(stem) + { + loaded += 1; + // Skip debug sessions + if session.is_debug { + continue; + } + // Only include sessions updated after cutoff + if session.updated_at < cutoff { + continue; + } + let duration = (session.updated_at - session.created_at) + .num_seconds() + .max(0); + let extraction = if session.messages.is_empty() { + "no messages" + } else { + // Heuristic: if session closed normally, assume extracted + match &session.status { + crate::session::SessionStatus::Closed => "extracted", + crate::session::SessionStatus::Crashed { .. } => "missed", + crate::session::SessionStatus::Active => "in progress", + _ => "unknown", + } + }; + recent.push(RecentSessionInfo { + id: session.id.clone(), + status: session.status.display().to_string(), + topic: session.display_title().map(ToOwned::to_owned), + duration_secs: duration, + extraction_status: extraction.to_string(), + }); + } + } + + // Sort by most recent first (id embeds a timestamp). + recent.sort_by(|a, b| b.id.cmp(&a.id)); + recent.truncate(RECENT_SESSION_LIMIT); + recent +} + +/// Build the dynamic system prompt for an ambient cycle. +/// +/// Populates the template from AMBIENT_MODE.md with real data from the +/// current state, queue, memory graph, sessions, and resource budget. +pub fn build_ambient_system_prompt( + state: &AmbientState, + queue: &[ScheduledItem], + graph_health: &MemoryGraphHealth, + recent_sessions: &[RecentSessionInfo], + feedback_memories: &[String], + budget: &ResourceBudget, + active_user_sessions: usize, +) -> String { + let mut prompt = String::with_capacity(4096); + + prompt.push_str( + "You are the ambient agent for jcode. You operate autonomously without \ + user prompting. Your job is to maintain and improve the user's \ + development environment.\n\n", + ); + + // --- Current State --- + prompt.push_str("## Current State\n"); + if let Some(last_run) = state.last_run { + let ago = Utc::now() - last_run; + let ago_str = format_duration_rough(ago); + prompt.push_str(&format!( + "- Last ambient cycle: {} ({} ago)\n", + last_run.format("%Y-%m-%d %H:%M UTC"), + ago_str, + )); + } else { + prompt.push_str("- Last ambient cycle: never (first run)\n"); + } + if active_user_sessions > 0 { + prompt.push_str(&format!( + "- Active user sessions: {}\n", + active_user_sessions + )); + } else { + prompt.push_str("- Active user sessions: none\n"); + } + prompt.push_str(&format!( + "- Total cycles completed: {}\n", + state.total_cycles + )); + prompt.push('\n'); + + // --- Scheduled Queue --- + prompt.push_str("## Scheduled Queue\n"); + if queue.is_empty() { + prompt.push_str("Empty -- do general ambient work.\n"); + } else { + for item in queue { + let age = Utc::now() - item.created_at; + let priority = match item.priority { + Priority::Low => "low", + Priority::Normal => "normal", + Priority::High => "HIGH", + }; + prompt.push_str(&format!( + "- [{}] {} (scheduled {} ago, priority: {})\n", + item.id, + item.context, + format_duration_rough(age), + priority, + )); + match &item.target { + ScheduleTarget::Ambient => {} + ScheduleTarget::Session { session_id } => { + prompt.push_str(&format!(" Target session: {}\n", session_id)); + } + ScheduleTarget::Spawn { parent_session_id } => { + prompt.push_str(&format!(" Spawn from session: {}\n", parent_session_id)); + } + } + if let Some(ref dir) = item.working_dir { + prompt.push_str(&format!(" Working dir: {}\n", dir)); + } + if let Some(ref desc) = item.task_description { + prompt.push_str(&format!(" Details: {}\n", desc)); + } + if !item.relevant_files.is_empty() { + prompt.push_str(&format!(" Files: {}\n", item.relevant_files.join(", "))); + } + if let Some(ref branch) = item.git_branch { + prompt.push_str(&format!(" Branch: {}\n", branch)); + } + if let Some(ref ctx) = item.additional_context { + for line in ctx.lines() { + prompt.push_str(&format!(" {}\n", line)); + } + } + } + } + prompt.push('\n'); + + // --- Recent Sessions --- + prompt.push_str("## Recent Sessions (since last cycle)\n"); + if recent_sessions.is_empty() { + prompt.push_str("No sessions since last cycle.\n"); + } else { + for s in recent_sessions { + let topic = s.topic.as_deref().unwrap_or("(no title)"); + let dur = format_duration_rough(chrono::Duration::seconds(s.duration_secs)); + prompt.push_str(&format!( + "- {} | {} | {} | {} | extraction: {}\n", + s.id, s.status, dur, topic, s.extraction_status, + )); + } + } + prompt.push('\n'); + + // --- Memory Graph Health --- + prompt.push_str("## Memory Graph Health\n"); + prompt.push_str(&format!( + "- Total memories: {} ({} active, {} inactive)\n", + graph_health.total, graph_health.active, graph_health.inactive, + )); + prompt.push_str(&format!( + "- Memories with confidence < 0.1: {}\n", + graph_health.low_confidence, + )); + prompt.push_str(&format!( + "- Unresolved contradictions: {}\n", + graph_health.contradictions, + )); + prompt.push_str(&format!( + "- Memories without embeddings: {}\n", + graph_health.missing_embeddings, + )); + if graph_health.duplicate_candidates > 0 { + prompt.push_str(&format!( + "- Duplicate candidates (similarity > 0.95): {}\n", + graph_health.duplicate_candidates, + )); + } else { + prompt.push_str("- Duplicate candidates: run embedding scan to detect\n"); + } + if let Some(ts) = graph_health.last_consolidation { + let ago = format_duration_rough(Utc::now() - ts); + prompt.push_str(&format!("- Last consolidation: {} ago\n", ago)); + } else { + prompt.push_str("- Last consolidation: never\n"); + } + prompt.push('\n'); + + // --- User Feedback History --- + prompt.push_str("## User Feedback History\n"); + if feedback_memories.is_empty() { + prompt.push_str("No feedback memories found about ambient mode yet.\n"); + } else { + for mem in feedback_memories { + prompt.push_str(&format!("- {}\n", mem)); + } + } + prompt.push('\n'); + + // --- Resource Budget --- + prompt.push_str("## Resource Budget\n"); + prompt.push_str(&format!("- Provider: {}\n", budget.provider)); + prompt.push_str(&format!( + "- Tokens remaining in window: {}\n", + budget.tokens_remaining_desc, + )); + prompt.push_str(&format!("- Window resets: {}\n", budget.window_resets_desc)); + prompt.push_str(&format!( + "- User usage rate: {}\n", + budget.user_usage_rate_desc, + )); + prompt.push_str(&format!( + "- Budget for this cycle: {}\n", + budget.cycle_budget_desc, + )); + prompt.push('\n'); + + // --- User Directives (from email/Telegram replies) --- + let pending_directives = take_pending_directives(); + if !pending_directives.is_empty() { + prompt.push_str("## User Directives (from replies)\n"); + prompt.push_str( + "The user replied to ambient notifications with these instructions. \ + Address them as your **top priority** this cycle.\n\n", + ); + for dir in &pending_directives { + let ago = format_duration_rough(Utc::now() - dir.received_at); + prompt.push_str(&format!( + "- [reply to cycle {}] ({} ago): {}\n", + dir.in_reply_to_cycle, ago, dir.text, + )); + } + prompt.push('\n'); + } + + // --- Instructions --- + prompt.push_str( + "## Instructions\n\n\ + Use the tools that are already available to you in this session. Do \ + not search for tools — there is no tool-search/discovery tool, and \ + the tools you need are listed below and in your tool definitions.\n\n\ + Key tools for this cycle (use these exact names):\n\ + - `todo` — plan and track what you'll do this cycle.\n\ + - `end_ambient_cycle` — REQUIRED to finish the cycle (see below).\n\ + - `schedule_ambient` — schedule your next wake time.\n\ + - `request_permission` — get approval before any code change.\n\ + - `send_message` — keep the user informed.\n\ + Standard tools (`bash`, `read`, `write`, `edit`, `memory`, etc.) are \ + also available.\n\n\ + Start by using the `todo` tool to plan what you'll do this cycle.\n\n\ + Priority order:\n\ + 1. Execute any scheduled queue items first.\n\ + 2. Garden the memory graph -- consolidate duplicates, resolve \ + contradictions, prune dead memories, verify stale facts, \ + extract from missed sessions.\n\ + 3. Scout for proactive work (only if enabled and past cold start) -- \ + look at recent sessions and git history to identify useful work \ + the user would appreciate.\n\n\ + For gardening: focus on highest-value maintenance first. Duplicates \ + and contradictions before pruning. Verify stale facts only if you \ + have budget left.\n\n\ + For proactive work: be conservative. A bad surprise is worse than \ + no surprise. Check the user feedback memories -- if they've rejected \ + similar work before, don't do it. Code changes must go on a worktree \ + branch with a PR via request_permission.\n\n\ + Every request_permission call must be reviewer-ready. Include:\n\ + - description: concise summary of what you are about to do\n\ + - rationale: why approval is needed right now\n\ + - context.summary: what you are working on in this cycle\n\ + - context.why_permission_needed: explicit justification for permission\n\ + - context.planned_steps, context.files, context.commands (if known)\n\ + - context.risks and context.rollback_plan (if relevant)\n\n\ + Good sources for scouting proactive work:\n\ + - Todoist (via MCP) — check for relevant tasks and deadlines\n\ + - Canvas (via MCP) — check for upcoming assignments or deadlines\n\ + - Git history — recent commits, open branches, stale PRs\n\ + - Session history — patterns in what the user works on\n\n\ + When done, you MUST call end_ambient_cycle with a summary of \ + everything you did, including compaction count. Always schedule \ + your next wake time with context for what you plan to do next.\n\n\ + ## Messaging Check-ins\n\n\ + You have a `send_message` tool. Use it to keep the user informed \ + about what you're doing. Send a brief message when you start a cycle \ + and when you finish significant work. Keep messages short and useful — \ + the user should be able to glance at their messages and know what's happening \ + without opening jcode. You can optionally target a specific channel \ + (e.g. telegram, discord) or omit channel to send to all.\n", + ); + + prompt +} + +pub fn format_scheduled_session_message(item: &ScheduledItem) -> String { + let mut lines = vec![ + "[Scheduled task]".to_string(), + "A scheduled task for this session is now due.".to_string(), + String::new(), + format!( + "Task: {}", + item.task_description.as_deref().unwrap_or(&item.context) + ), + ]; + + if let Some(ref dir) = item.working_dir { + lines.push(format!("Working directory: {}", dir)); + } + if !item.relevant_files.is_empty() { + lines.push(format!( + "Relevant files: {}", + item.relevant_files.join(", ") + )); + } + if let Some(ref branch) = item.git_branch { + lines.push(format!("Branch: {}", branch)); + } + if let Some(ref ctx) = item.additional_context { + lines.push(String::new()); + lines.push(ctx.clone()); + } + + lines.join("\n") +} + +/// Format a chrono::Duration into a rough human-readable string. +pub(crate) fn format_duration_rough(d: chrono::Duration) -> String { + let secs = d.num_seconds().max(0); + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86400 { + let h = secs / 3600; + let m = (secs % 3600) / 60; + if m > 0 { + format!("{}h {}m", h, m) + } else { + format!("{}h", h) + } + } else { + let days = secs / 86400; + format!("{}d", days) + } +} + +/// Format a number of minutes into a human-friendly string. +/// E.g. 5 → "5m", 90 → "1h 30m", 370 → "6h 10m", 1500 → "1d 1h" +pub fn format_minutes_human(mins: u32) -> String { + if mins < 60 { + format!("{}m", mins) + } else if mins < 1440 { + let h = mins / 60; + let m = mins % 60; + if m > 0 { + format!("{}h {}m", h, m) + } else { + format!("{}h", h) + } + } else { + let d = mins / 1440; + let h = (mins % 1440) / 60; + if h > 0 { + format!("{}d {}h", d, h) + } else { + format!("{}d", d) + } + } +} diff --git a/crates/jcode-app-core/src/ambient/runner.rs b/crates/jcode-app-core/src/ambient/runner.rs new file mode 100644 index 0000000..4c49ffc --- /dev/null +++ b/crates/jcode-app-core/src/ambient/runner.rs @@ -0,0 +1,1074 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +//! Background ambient mode runner. +//! +//! Spawned by the server when ambient mode is enabled. Manages the lifecycle of +//! ambient cycles: scheduling, spawning agent sessions, handling results, and +//! providing status for the TUI widget and debug socket. + +use crate::agent::Agent; +use crate::ambient::{ + self, AmbientCycleResult, AmbientLock, AmbientManager, AmbientState, AmbientStatus, + CycleStatus, ScheduleTarget, ScheduledItem, +}; +use crate::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig}; +use crate::config::config; +use crate::logging; +use crate::memory::MemoryManager; +use crate::notifications::NotificationDispatcher; +use crate::provider::Provider; +use crate::safety::SafetySystem; +use crate::session::Session; +use crate::tool; +use crate::tool::ambient as ambient_tools; +use chrono::Utc; +use jcode_agent_runtime::{SoftInterruptMessage, SoftInterruptQueue, SoftInterruptSource}; +use std::sync::Arc; +use tokio::sync::{Notify, RwLock}; + +const MAX_IDLE_POLL_SECS: u64 = 30; + +/// Shared ambient runner state, accessible from the server, debug socket, and TUI. +#[derive(Clone)] +pub struct AmbientRunnerHandle { + inner: Arc, +} + +struct AmbientRunnerInner { + /// Current snapshot of ambient state (for queries) + state: RwLock, + /// Queue item count for widget + queue_count: RwLock, + /// Next queue item context preview + next_queue_preview: RwLock>, + /// Wake notify (nudge the loop to re-check sooner) + wake_notify: Notify, + /// Whether the runner loop is active + running: RwLock, + /// Safety system shared with ambient tools + safety: Arc, + /// Notification dispatcher for push/email/desktop alerts + notifier: NotificationDispatcher, + /// Number of active user sessions (for pause logic) + active_user_sessions: RwLock, + /// Soft interrupt queue for the currently-running ambient agent (if any). + /// Telegram replies push messages here so they arrive mid-cycle. + active_cycle_queue: RwLock>, +} + +impl AmbientRunnerHandle { + pub fn new(safety: Arc) -> Self { + let state = AmbientState::load().unwrap_or_default(); + Self { + inner: Arc::new(AmbientRunnerInner { + state: RwLock::new(state), + queue_count: RwLock::new(0), + next_queue_preview: RwLock::new(None), + wake_notify: Notify::new(), + running: RwLock::new(false), + safety, + notifier: NotificationDispatcher::new(), + active_user_sessions: RwLock::new(0), + active_cycle_queue: RwLock::new(None), + }), + } + } + + /// Nudge the ambient loop to check sooner (e.g., after session close/crash). + pub fn nudge(&self) { + self.inner.wake_notify.notify_one(); + } + + /// Check if the runner loop is active. + pub async fn is_running(&self) -> bool { + *self.inner.running.read().await + } + + /// Get current ambient state snapshot. + pub async fn state(&self) -> AmbientState { + self.inner.state.read().await.clone() + } + + /// Get a reference to the safety system (for debug socket permission commands). + pub fn safety(&self) -> &Arc { + &self.inner.safety + } + + /// Inject a message from an external channel (Telegram, Discord, etc.) + /// into the active ambient cycle as a user message. + /// If a cycle is running, the message goes in via soft interrupt (immediate). + /// If no cycle is running, the message is saved as a directive and a cycle is triggered. + /// Returns true if injected into active cycle, false if queued as directive. + pub async fn inject_message(&self, text: &str, source: &str) -> bool { + let queue = self.inner.active_cycle_queue.read().await; + if let Some(ref q) = *queue + && let Ok(mut q) = q.lock() + { + q.push(SoftInterruptMessage { + content: format!("[{} message from user]\n{}", source, text), + urgent: false, + source: SoftInterruptSource::User, + }); + logging::info(&format!( + "{} message injected into active ambient cycle: {}", + source, + crate::util::truncate_str(text, 60) + )); + return true; + } + drop(queue); + + // No active cycle — save as directive and trigger a wake + let source_id = format!("{}_{}", source, chrono::Utc::now().timestamp()); + if let Err(e) = ambient::add_directive(text.to_string(), source_id) { + logging::error(&format!("Failed to save {} directive: {}", source, e)); + } + self.trigger().await; + false + } + + /// Manually trigger an ambient cycle (returns immediately, cycle runs async). + pub async fn trigger(&self) { + // Set status to idle so should_run returns true + let mut state = self.inner.state.write().await; + if matches!( + state.status, + AmbientStatus::Scheduled { .. } | AmbientStatus::Idle + ) { + state.status = AmbientStatus::Idle; + } + drop(state); + self.inner.wake_notify.notify_one(); + } + + /// Stop the ambient loop. + pub async fn stop(&self) { + let mut state = self.inner.state.write().await; + state.status = AmbientStatus::Disabled; + let _ = state.save(); + drop(state); + self.inner.wake_notify.notify_one(); + } + + /// Start (or restart) the ambient loop. If the loop exited due to Disabled + /// status, this resets the state to Idle and spawns a new loop task. + pub async fn start(&self, provider: Arc) -> bool { + let already_running = *self.inner.running.read().await; + if already_running { + return false; + } + { + let mut state = self.inner.state.write().await; + state.status = AmbientStatus::Idle; + let _ = state.save(); + } + let handle = self.clone(); + tokio::spawn(async move { + handle.run_loop(provider).await; + }); + true + } + + /// Get status JSON for debug socket. + pub async fn status_json(&self) -> String { + let state = self.state().await; + let running = self.is_running().await; + let active_sessions = *self.inner.active_user_sessions.read().await; + + let ( + queue_count, + next_preview, + next_due, + overdue_queue_count, + reminder_count, + next_reminder_preview, + next_reminder_due, + overdue_reminder_count, + ) = match AmbientManager::new() { + Ok(mgr) => { + let now = Utc::now(); + let items = mgr.queue().items(); + let queue_count = items.len(); + let next_item = items.iter().min_by_key(|item| item.scheduled_for); + let overdue_queue_count = items + .iter() + .filter(|item| item.scheduled_for <= now) + .count(); + let reminder_items: Vec<_> = items + .iter() + .filter(|item| item.target.is_direct_delivery()) + .collect(); + let reminder_count = reminder_items.len(); + let next_reminder = reminder_items + .iter() + .min_by_key(|item| item.scheduled_for) + .copied(); + let overdue_reminder_count = reminder_items + .iter() + .filter(|item| item.scheduled_for <= now) + .count(); + + ( + queue_count, + next_item.map(|item| { + item.task_description + .as_deref() + .unwrap_or(&item.context) + .to_string() + }), + next_item.map(|item| item.scheduled_for.to_rfc3339()), + overdue_queue_count, + reminder_count, + next_reminder.map(|item| { + item.task_description + .as_deref() + .unwrap_or(&item.context) + .to_string() + }), + next_reminder.map(|item| item.scheduled_for.to_rfc3339()), + overdue_reminder_count, + ) + } + Err(_) => (0, None, None, 0, 0, None, None, 0), + }; + + let status_str = match &state.status { + AmbientStatus::Idle => "idle".to_string(), + AmbientStatus::Running { detail } => format!("running: {}", detail), + AmbientStatus::Scheduled { next_wake } => { + let until = *next_wake - Utc::now(); + let mins = until.num_minutes().max(0) as u32; + format!( + "scheduled (in {})", + crate::ambient::format_minutes_human(mins) + ) + } + AmbientStatus::Paused { reason } => format!("paused: {}", reason), + AmbientStatus::Disabled => "disabled".to_string(), + }; + + serde_json::json!({ + "enabled": config().ambient.enabled, + "status": status_str, + "loop_running": running, + "total_cycles": state.total_cycles, + "last_run": state.last_run.map(|t| t.to_rfc3339()), + "last_summary": state.last_summary, + "last_memories_modified": state.last_memories_modified, + "last_compactions": state.last_compactions, + "queue_count": queue_count, + "next_queue_preview": next_preview, + "next_queue_due": next_due, + "overdue_queue_count": overdue_queue_count, + "reminder_count": reminder_count, + "next_reminder_preview": next_reminder_preview, + "next_reminder_due": next_reminder_due, + "overdue_reminder_count": overdue_reminder_count, + "scheduled_task_count": reminder_count, + "next_scheduled_task_preview": next_reminder_preview, + "next_scheduled_task_due": next_reminder_due, + "overdue_scheduled_task_count": overdue_reminder_count, + "active_user_sessions": active_sessions, + }) + .to_string() + } + + /// Get queue items JSON for debug socket. + pub async fn queue_json(&self) -> String { + match AmbientManager::new() { + Ok(mgr) => { + let items: Vec = mgr + .queue() + .items() + .iter() + .map(|item| { + let (target_kind, target_session_id, target_parent_session_id) = + match &item.target { + ScheduleTarget::Ambient => ("ambient", None, None), + ScheduleTarget::Session { session_id } => { + ("session", Some(session_id.clone()), None) + } + ScheduleTarget::Spawn { parent_session_id } => { + ("spawn", None, Some(parent_session_id.clone())) + } + }; + let overdue_seconds = + (Utc::now() - item.scheduled_for).num_seconds().max(0); + serde_json::json!({ + "id": item.id, + "scheduled_for": item.scheduled_for.to_rfc3339(), + "context": item.context, + "task_description": item.task_description, + "priority": format!("{:?}", item.priority), + "created_at": item.created_at.to_rfc3339(), + "target_kind": target_kind, + "target_session_id": target_session_id, + "target_parent_session_id": target_parent_session_id, + "working_dir": item.working_dir, + "relevant_files": item.relevant_files, + "git_branch": item.git_branch, + "overdue": item.scheduled_for <= Utc::now(), + "overdue_seconds": overdue_seconds, + }) + }) + .collect(); + serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string()) + } + Err(e) => format!("{{\"error\": \"{}\"}}", e), + } + } + + /// Get recent transcript log summaries. + pub async fn log_json(&self) -> String { + let transcripts_dir = match crate::storage::jcode_dir() { + Ok(d) => d.join("ambient").join("transcripts"), + Err(e) => return format!("{{\"error\": \"{}\"}}", e), + }; + + if !transcripts_dir.exists() { + return "[]".to_string(); + } + + let mut entries: Vec = Vec::new(); + if let Ok(dir) = std::fs::read_dir(&transcripts_dir) { + let mut files: Vec<_> = dir.flatten().collect(); + files.sort_by_key(|entry| std::cmp::Reverse(entry.file_name())); + files.truncate(20); + + for entry in files { + if let Ok(content) = std::fs::read_to_string(entry.path()) + && let Ok(transcript) = + serde_json::from_str::(&content) + { + entries.push(serde_json::json!({ + "session_id": transcript.session_id, + "started_at": transcript.started_at.to_rfc3339(), + "ended_at": transcript.ended_at.map(|t| t.to_rfc3339()), + "status": format!("{:?}", transcript.status), + "summary": transcript.summary, + "memories_modified": transcript.memories_modified, + "compactions": transcript.compactions, + })); + } + } + } + + serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()) + } + + async fn wait_for_request_done( + client: &mut crate::server::Client, + request_id: u64, + ) -> anyhow::Result<()> { + loop { + match client.read_event().await? { + crate::protocol::ServerEvent::Done { id } if id == request_id => return Ok(()), + crate::protocol::ServerEvent::Error { id, message, .. } if id == request_id => { + anyhow::bail!(message) + } + _ => continue, + } + } + } + + async fn notify_live_session(&self, session_id: &str, message: &str) -> anyhow::Result<()> { + let mut client = crate::server::Client::connect().await?; + let request_id = client.notify_session(session_id, message).await?; + Self::wait_for_request_done(&mut client, request_id).await + } + + async fn resume_dead_session_with_reminder( + &self, + provider: &Arc, + item: &ScheduledItem, + session_id: &str, + ) -> anyhow::Result<()> { + let session = Session::load(session_id)?; + let cycle_provider = provider.fork(); + let registry = tool::Registry::new(cycle_provider.clone()).await; + if session.is_canary { + registry.register_selfdev_tools().await; + } + + let mut agent = Agent::new(cycle_provider, registry); + agent.set_debug(session.is_debug); + agent.restore_session(session_id)?; + + let reminder = ambient::format_scheduled_session_message(item); + let _ = agent.run_once_capture(&reminder).await?; + agent.mark_closed(); + Ok(()) + } + + async fn spawn_session_for_scheduled_item( + &self, + provider: &Arc, + item: &ScheduledItem, + parent_session_id: &str, + ) -> anyhow::Result { + let mut child = match Session::load(parent_session_id) { + Ok(parent) => { + let mut child = Session::create( + Some(parent_session_id.to_string()), + Some( + item.task_description + .clone() + .unwrap_or_else(|| "Scheduled task".to_string()), + ), + ); + child.replace_messages(parent.messages.clone()); + child.compaction = parent.compaction.clone(); + child.provider_key = parent.provider_key.clone(); + child.route_api_method = parent.route_api_method.clone(); + child.model = parent.model.clone(); + child.subagent_model = parent.subagent_model.clone(); + child.improve_mode = parent.improve_mode; + child.autoreview_enabled = parent.autoreview_enabled; + child.autojudge_enabled = parent.autojudge_enabled; + child.is_canary = parent.is_canary; + child.testing_build = parent.testing_build.clone(); + child.is_debug = parent.is_debug; + child.memory_injections = parent.memory_injections.clone(); + child.replay_events = parent.replay_events.clone(); + child.working_dir = item.working_dir.clone().or(parent.working_dir.clone()); + child + } + Err(err) => { + logging::warn(&format!( + "Ambient runner: failed to load parent session {} for spawned scheduled task {}; creating a fresh child instead: {}", + parent_session_id, item.id, err + )); + let mut child = Session::create( + Some(parent_session_id.to_string()), + Some( + item.task_description + .clone() + .unwrap_or_else(|| "Scheduled task".to_string()), + ), + ); + child.working_dir = item.working_dir.clone(); + child + } + }; + child.status = crate::session::SessionStatus::Closed; + child.save()?; + + let child_session_id = child.id.clone(); + let child_is_canary = child.is_canary; + let child_is_debug = child.is_debug; + let cycle_provider = provider.fork(); + let registry = tool::Registry::new(cycle_provider.clone()).await; + if child_is_canary { + registry.register_selfdev_tools().await; + } + + let mut agent = Agent::new_with_session(cycle_provider, registry, child, None); + agent.set_debug(child_is_debug); + if item.working_dir.is_some() { + agent.set_working_dir_for_pending_context(item.working_dir.clone()); + } + + let reminder = ambient::format_scheduled_session_message(item); + let _ = agent.run_once_capture(&reminder).await?; + agent.mark_closed(); + Ok(child_session_id) + } + + async fn deliver_scheduled_direct_item( + &self, + provider: &Arc, + item: &ScheduledItem, + ) -> anyhow::Result<()> { + match &item.target { + ScheduleTarget::Ambient => Ok(()), + ScheduleTarget::Session { session_id } => { + let reminder = ambient::format_scheduled_session_message(item); + match self.notify_live_session(session_id, &reminder).await { + Ok(()) => { + logging::info(&format!( + "Ambient runner: delivered scheduled task {} to live session {}", + item.id, session_id + )); + Ok(()) + } + Err(err) => { + logging::info(&format!( + "Ambient runner: live delivery for {} fell back to headless resume: {}", + session_id, err + )); + self.resume_dead_session_with_reminder(provider, item, session_id) + .await + } + } + } + ScheduleTarget::Spawn { parent_session_id } => { + let spawned_session_id = self + .spawn_session_for_scheduled_item(provider, item, parent_session_id) + .await?; + logging::info(&format!( + "Ambient runner: spawned scheduled task {} into child session {} from {}", + item.id, spawned_session_id, parent_session_id + )); + Ok(()) + } + } + } + + async fn deliver_ready_direct_items( + &self, + provider: &Arc, + items: Vec, + ) { + for item in items { + if let Err(e) = self.deliver_scheduled_direct_item(provider, &item).await { + logging::error(&format!( + "Ambient runner: failed to deliver scheduled direct item {}: {}", + item.id, e + )); + } + } + } + + /// Start the background ambient loop. Call from a tokio::spawn. + pub async fn run_loop(self, provider: Arc) { + { + let mut running = self.inner.running.write().await; + *running = true; + } + logging::info("Ambient runner: starting background loop"); + + let ambient_enabled = config().ambient.enabled; + + // Spawn reply pollers only when ambient mode is enabled; scheduled + // session-targeted scheduled tasks should still work without the ambient-only reply + // infrastructure. + if ambient_enabled { + let safety_config = config().safety.clone(); + if safety_config.email_reply_enabled + && safety_config.email_imap_host.is_some() + && safety_config.email_enabled + { + let imap_config = safety_config.clone(); + tokio::spawn(async move { + crate::notifications::imap_reply_loop(imap_config).await; + }); + logging::info("Ambient runner: IMAP reply poller spawned"); + } + + // Spawn reply pollers for all configured message channels + // (Telegram, Discord, etc.) + let channel_registry = crate::channel::ChannelRegistry::from_config(&safety_config); + channel_registry.spawn_reply_loops(&self); + } + + let amb_config = &config().ambient; + let scheduler_config = AmbientSchedulerConfig { + min_interval_minutes: amb_config.min_interval_minutes, + max_interval_minutes: amb_config.max_interval_minutes, + pause_on_active_session: amb_config.pause_on_active_session, + ..Default::default() + }; + let mut scheduler = AdaptiveScheduler::new(scheduler_config); + + // Initialize safety system for ambient tools + ambient_tools::init_safety_system(Arc::clone(&self.inner.safety)); + + loop { + // Check state + let state = { self.inner.state.read().await.clone() }; + + let ambient_allowed = + ambient_enabled && !matches!(state.status, AmbientStatus::Disabled); + + if ambient_allowed { + // Update scheduler's user-active state + let active_sessions = *self.inner.active_user_sessions.read().await; + scheduler.set_user_active(active_sessions > 0); + + // Check if we should pause + if scheduler.should_pause() { + let mut s = self.inner.state.write().await; + s.status = AmbientStatus::Paused { + reason: "user session active".to_string(), + }; + drop(s); + + // Sleep until nudged or 60s + tokio::select! { + _ = self.inner.wake_notify.notified() => {}, + _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {}, + } + continue; + } + + // Drop stale permission requests whose originating session is no longer active. + match self + .inner + .safety + .expire_dead_session_requests("ambient_runner_gc") + { + Ok(expired) if !expired.is_empty() => { + logging::info(&format!( + "Ambient runner: expired {} stale permission request(s)", + expired.len() + )); + } + Ok(_) => {} + Err(e) => { + logging::warn(&format!( + "Ambient runner: failed to expire stale permission requests: {}", + e + )); + } + } + } + + // Load manager to check should_run and update queue info + let (should_run, ready_direct_items, next_direct_due) = match AmbientManager::new() { + Ok(mut mgr) => { + let ready_direct_items = mgr.take_ready_direct_items(); + let next_direct_due = mgr + .queue() + .items() + .iter() + .filter(|item| item.target.is_direct_delivery()) + .map(|item| item.scheduled_for) + .min(); + // Update queue info for widget + { + let mut qc = self.inner.queue_count.write().await; + *qc = mgr.queue().len(); + } + { + let mut qp = self.inner.next_queue_preview.write().await; + *qp = mgr.queue().peek_next().map(|i| i.context.clone()); + } + // Also run if there are pending email reply directives + ( + ambient_allowed && (mgr.should_run() || ambient::has_pending_directives()), + ready_direct_items, + next_direct_due, + ) + } + Err(e) => { + logging::error(&format!("Ambient runner: failed to load manager: {}", e)); + (false, Vec::new(), None) + } + }; + + if !ready_direct_items.is_empty() { + self.deliver_ready_direct_items(&provider, ready_direct_items) + .await; + } + + if !should_run { + let sleep_secs = if ambient_allowed { + let interval = scheduler + .calculate_interval(None) + .as_secs() + .max(MAX_IDLE_POLL_SECS); + let next_direct_secs = next_direct_due + .map(|next| (next - Utc::now()).num_seconds().max(0) as u64) + .unwrap_or(interval); + interval.min(next_direct_secs.max(1)) + } else { + next_direct_due + .map(|next| (next - Utc::now()).num_seconds().max(0) as u64) + .map(|secs| secs.clamp(1, MAX_IDLE_POLL_SECS)) + .unwrap_or(MAX_IDLE_POLL_SECS) + }; + + logging::info(&format!( + "Ambient runner: not time to run, sleeping {}s", + sleep_secs + )); + + tokio::select! { + _ = self.inner.wake_notify.notified() => { + logging::info("Ambient runner: nudged awake"); + }, + _ = tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)) => {}, + } + continue; + } + + // Try to acquire lock + let lock = match AmbientLock::try_acquire() { + Ok(Some(lock)) => lock, + Ok(None) => { + logging::info("Ambient runner: another instance holds the lock, waiting"); + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + continue; + } + Err(e) => { + logging::error(&format!("Ambient runner: lock error: {}", e)); + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + continue; + } + }; + + // Run a cycle + logging::info("Ambient runner: starting ambient cycle"); + self.set_running_detail("starting cycle").await; + + let cycle_result = self.run_cycle(&provider).await; + + // Clear the soft interrupt queue — cycle is done + { + let mut aq = self.inner.active_cycle_queue.write().await; + *aq = None; + } + + match cycle_result { + Ok(result) => { + logging::info(&format!( + "Ambient cycle complete: {} memories modified, {} compactions", + result.memories_modified, result.compactions + )); + + // Update state + if let Ok(mut mgr) = AmbientManager::new() { + let _ = mgr.record_cycle_result(result.clone()); + } + let mut s = self.inner.state.write().await; + s.record_cycle(&result); + let _ = s.save(); + + scheduler.on_successful_cycle(); + + // Save transcript + let transcript = crate::safety::AmbientTranscript { + session_id: format!("ambient_{}", Utc::now().format("%Y%m%d_%H%M%S")), + started_at: result.started_at, + ended_at: Some(result.ended_at), + status: match result.status { + CycleStatus::Complete => crate::safety::TranscriptStatus::Complete, + CycleStatus::Interrupted => { + crate::safety::TranscriptStatus::Interrupted + } + CycleStatus::Incomplete => crate::safety::TranscriptStatus::Incomplete, + }, + provider: provider.name().to_string(), + model: provider.model(), + actions: Vec::new(), + pending_permissions: self.inner.safety.pending_requests().len(), + summary: Some(result.summary.clone()), + compactions: result.compactions, + memories_modified: result.memories_modified, + conversation: result.conversation.clone(), + }; + let _ = self.inner.safety.save_transcript(&transcript); + + // Send notifications (fire-and-forget) + self.inner.notifier.dispatch_cycle_summary(&transcript); + + // Post-cycle memory consolidation (fire-and-forget) + tokio::spawn(async move { + let manager = MemoryManager::new(); + match manager.backfill_embeddings() { + Ok((backfilled, _failed)) => { + if backfilled > 0 { + logging::info(&format!( + "Ambient: backfilled {} embeddings", + backfilled + )); + } + } + Err(e) => { + logging::error(&format!( + "Ambient: embedding backfill failed: {}", + e + )); + } + } + }); + } + Err(e) => { + logging::error(&format!("Ambient cycle failed: {}", e)); + scheduler.on_rate_limit_hit(); + + let mut s = self.inner.state.write().await; + s.status = AmbientStatus::Idle; + let _ = s.save(); + } + } + + // Release lock + let _ = lock.release(); + + // Calculate next sleep interval + let interval = scheduler.calculate_interval(None); + let sleep_secs = interval.as_secs().max(30); + + // Update state with scheduled wake + { + let mut s = self.inner.state.write().await; + if matches!( + s.status, + AmbientStatus::Running { .. } | AmbientStatus::Idle + ) { + s.status = AmbientStatus::Scheduled { + next_wake: Utc::now() + chrono::Duration::seconds(sleep_secs as i64), + }; + let _ = s.save(); + } + } + + logging::info(&format!("Ambient runner: next cycle in {}s", sleep_secs)); + + tokio::select! { + _ = self.inner.wake_notify.notified() => { + logging::info("Ambient runner: nudged awake after cycle"); + }, + _ = tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)) => {}, + } + } + } + + /// Update the running status detail and persist to disk for waybar. + async fn set_running_detail(&self, detail: &str) { + let mut s = self.inner.state.write().await; + s.status = AmbientStatus::Running { + detail: detail.to_string(), + }; + let _ = s.save(); + } + + /// Build the ambient system prompt and initial message for a cycle. + async fn build_cycle_context( + &self, + provider: &Arc, + ) -> anyhow::Result<(String, String)> { + let state = self.inner.state.read().await.clone(); + + let mgr = AmbientManager::new()?; + let queue_items: Vec<_> = mgr.queue().items().to_vec(); + + let memory_manager = MemoryManager::new(); + let graph_health = ambient::gather_memory_graph_health(&memory_manager); + let recent_sessions = ambient::gather_recent_sessions(state.last_run); + let feedback_memories = ambient::gather_feedback_memories(&memory_manager); + + let budget = ambient::ResourceBudget { + provider: provider.name().to_string(), + tokens_remaining_desc: "unknown (adaptive)".to_string(), + window_resets_desc: "unknown".to_string(), + user_usage_rate_desc: "estimated from history".to_string(), + cycle_budget_desc: "stay under 50k tokens".to_string(), + }; + + let active_sessions = *self.inner.active_user_sessions.read().await; + + let system_prompt = ambient::build_ambient_system_prompt( + &state, + &queue_items, + &graph_health, + &recent_sessions, + &feedback_memories, + &budget, + active_sessions, + ); + + let initial_message = "Begin your ambient cycle. Check the scheduled queue, assess memory graph health, and plan your work using the `todo` tool.".to_string(); + + Ok((system_prompt, initial_message)) + } + + /// Run a single ambient cycle. Returns the cycle result. + async fn run_cycle(&self, provider: &Arc) -> anyhow::Result { + 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; + } + + // Headless mode: run agent directly + self.set_running_detail("setting up tools").await; + + let cycle_provider = provider.fork(); + let registry = tool::Registry::new(cycle_provider.clone()).await; + registry.register_ambient_tools().await; + + let mut agent = Agent::new(cycle_provider.clone(), registry); + agent.set_debug(true); + agent.set_system_prompt(&system_prompt); + let ambient_session_id = agent.session_id().to_string(); + ambient_tools::register_ambient_session(ambient_session_id.clone()); + + // Clear any previous cycle result + ambient_tools::take_cycle_result(); + + // Expose the agent's soft interrupt queue so Telegram replies can be injected mid-cycle + { + let mut aq = self.inner.active_cycle_queue.write().await; + *aq = Some(agent.soft_interrupt_queue()); + } + + self.set_running_detail("running agent").await; + + let run_result = agent.run_once_capture(&initial_message).await; + + // Check if end_ambient_cycle was called + if let Some(result) = ambient_tools::take_cycle_result() { + ambient_tools::unregister_ambient_session(&ambient_session_id); + let conversation = agent.export_conversation_markdown(); + agent.mark_closed(); + return Ok(AmbientCycleResult { + started_at, + ended_at: Utc::now(), + conversation: Some(conversation), + ..result + }); + } + + // Agent didn't call end_ambient_cycle - try continuation + if run_result.is_err() { + logging::warn("Ambient cycle: agent error without calling end_ambient_cycle"); + } + + self.set_running_detail("continuation turn").await; + logging::info("Ambient cycle: sending continuation message (no end_ambient_cycle called)"); + let continuation = "You stopped unexpectedly without calling end_ambient_cycle. \ + If you are done with your work, call end_ambient_cycle with a summary of \ + what you accomplished and schedule your next wake. \ + If you are not done, continue what you were doing."; + + let _ = agent.run_once_capture(continuation).await; + + // Check again + if let Some(result) = ambient_tools::take_cycle_result() { + ambient_tools::unregister_ambient_session(&ambient_session_id); + let conversation = agent.export_conversation_markdown(); + agent.mark_closed(); + return Ok(AmbientCycleResult { + started_at, + ended_at: Utc::now(), + conversation: Some(conversation), + ..result + }); + } + + // Forced end + ambient_tools::unregister_ambient_session(&ambient_session_id); + logging::warn("Ambient cycle: forced end after 2 attempts without end_ambient_cycle"); + let forced = AmbientCycleResult { + summary: "Cycle ended without calling end_ambient_cycle (forced end after 2 attempts)" + .to_string(), + memories_modified: 0, + compactions: 0, + proactive_work: None, + next_schedule: None, + started_at, + ended_at: Utc::now(), + status: CycleStatus::Incomplete, + conversation: Some(agent.export_conversation_markdown()), + }; + agent.mark_closed(); + Ok(forced) + } + + /// Run a visible ambient cycle by spawning a full TUI in a kitty window. + async fn run_cycle_visible( + &self, + started_at: chrono::DateTime, + system_prompt: String, + initial_message: String, + ) -> anyhow::Result { + use crate::ambient::VisibleCycleContext; + + self.set_running_detail("launching visible TUI").await; + + // Save context for the spawned process + let context = VisibleCycleContext { + system_prompt, + initial_message, + }; + context.save()?; + + // Clear any previous result file + if let Ok(result_path) = VisibleCycleContext::result_path() { + 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(); + + match child { + Ok(mut child) => { + self.set_running_detail("waiting for TUI cycle").await; + + // Wait for the kitty process to exit (user closes window or cycle completes) + let status = tokio::task::spawn_blocking(move || child.wait()).await?; + match status { + Ok(s) => logging::info(&format!("Ambient visible: TUI exited with {}", s)), + Err(e) => logging::warn(&format!("Ambient visible: wait error: {}", e)), + } + + // Try to read the cycle result from the file + if let Ok(result_path) = VisibleCycleContext::result_path() + && result_path.exists() + && let Ok(result) = + crate::storage::read_json::(&result_path) + { + let _ = std::fs::remove_file(&result_path); + return Ok(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, + }) + } + Err(e) => { + logging::warn(&format!( + "Ambient visible: failed to spawn kitty ({}), falling back to headless", + e + )); + // Fall back to headless mode + Err(anyhow::anyhow!("Failed to spawn visible TUI: {}", e)) + } + } + } +} + +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[path = "runner_tests.rs"] +mod runner_tests; diff --git a/crates/jcode-app-core/src/ambient/runner_tests.rs b/crates/jcode-app-core/src/ambient/runner_tests.rs new file mode 100644 index 0000000..94146aa --- /dev/null +++ b/crates/jcode-app-core/src/ambient/runner_tests.rs @@ -0,0 +1,184 @@ +use super::AmbientRunnerHandle; +use crate::ambient::{Priority, ScheduleTarget, ScheduledItem}; +use crate::message::{Message, Role, StreamEvent, ToolDefinition}; +use crate::provider::{EventStream, Provider}; +use crate::session::Session; +use anyhow::Result; +use async_stream::stream; +use async_trait::async_trait; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; + +struct EnvVarGuard { + key: &'static str, + prev: Option, +} + +impl EnvVarGuard { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let prev = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, prev } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(prev) = self.prev.take() { + crate::env::set_var(self.key, prev); + } else { + crate::env::remove_var(self.key); + } + } +} + +struct TestProvider; + +#[derive(Clone, Default)] +struct StreamingTestProvider { + responses: Arc>>>, +} + +impl StreamingTestProvider { + fn queue_response(&self, events: Vec) { + self.responses.lock().unwrap().push_back(events); + } +} + +#[async_trait] +impl Provider for TestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + Err(anyhow::anyhow!( + "TestProvider should not be used for streaming completions in ambient runner tests" + )) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc { + Arc::new(TestProvider) + } +} + +#[async_trait] +impl Provider for StreamingTestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + let events = self + .responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_default(); + let stream = stream! { + for event in events { + yield Ok(event); + } + }; + Ok(Box::pin(stream)) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc { + Arc::new(self.clone()) + } +} + +#[tokio::test] +async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() { + 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(TestProvider); + let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new())); + let task = tokio::spawn(runner.clone().run_loop(provider)); + + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + runner.is_running().await, + "runner should remain active for scheduled tasks even with ambient disabled" + ); + + task.abort(); + let _ = task.await; +} + +#[tokio::test] +async fn spawn_target_creates_one_child_session_and_runs_task() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let provider = StreamingTestProvider::default(); + provider.queue_response(vec![ + StreamEvent::TextDelta("Spawned session handled task.".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider: Arc = Arc::new(provider); + + let mut parent = Session::create_with_id( + "session_parent_spawn_test".to_string(), + None, + Some("Parent".to_string()), + ); + parent.working_dir = Some(temp.path().display().to_string()); + parent.save().expect("save parent session"); + + let item = ScheduledItem { + id: "sched_spawn_test".to_string(), + scheduled_for: chrono::Utc::now(), + context: "Follow up later".to_string(), + priority: Priority::Normal, + target: ScheduleTarget::Spawn { + parent_session_id: parent.id.clone(), + }, + created_by_session: parent.id.clone(), + created_at: chrono::Utc::now(), + working_dir: parent.working_dir.clone(), + task_description: Some("Follow up later".to_string()), + relevant_files: vec!["src/lib.rs".to_string()], + git_branch: None, + additional_context: Some("Background: spawned schedule test".to_string()), + }; + + let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new())); + let child_session_id = runner + .spawn_session_for_scheduled_item(&provider, &item, &parent.id) + .await + .expect("spawned scheduled task should succeed"); + + assert_ne!(child_session_id, parent.id); + + let child = Session::load(&child_session_id).expect("load spawned child session"); + assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str())); + assert_eq!(child.working_dir, parent.working_dir); + assert!(child.messages.iter().any(|message| { + message.role == Role::User + && message.content_preview().contains("[Scheduled task]") + && message.content_preview().contains("Follow up later") + })); + assert!(child.messages.iter().any(|message| { + message.role == Role::Assistant + && message + .content_preview() + .contains("Spawned session handled task.") + })); +} diff --git a/crates/jcode-app-core/src/ambient/scheduler.rs b/crates/jcode-app-core/src/ambient/scheduler.rs new file mode 100644 index 0000000..e65d7eb --- /dev/null +++ b/crates/jcode-app-core/src/ambient/scheduler.rs @@ -0,0 +1,497 @@ +//! Adaptive usage calculator for ambient mode scheduling. +//! +//! Tracks per-call token usage (user vs ambient), maintains a rolling usage log, +//! and computes adaptive intervals for ambient cycles based on rate limit headroom. +use crate::storage; +use chrono::{Duration as ChronoDuration, Utc}; +use std::path::PathBuf; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Usage record types +// --------------------------------------------------------------------------- + +pub use jcode_ambient_types::{RateLimitInfo, UsageRecord, UsageSource}; + +// --------------------------------------------------------------------------- +// Usage log — rolling, persisted to disk +// --------------------------------------------------------------------------- + +/// How often to auto-save (every N records added). +const SAVE_INTERVAL: usize = 10; + +/// Records older than this are pruned on save. +const PRUNE_AGE_HOURS: i64 = 24; + +pub struct UsageLog { + records: Vec, + path: PathBuf, + unsaved_count: usize, +} + +impl UsageLog { + /// Load (or create) the usage log from the default path. + pub fn load() -> Self { + let path = Self::default_path(); + let records: Vec = if path.exists() { + storage::read_json(&path).unwrap_or_default() + } else { + Vec::new() + }; + UsageLog { + records, + path, + unsaved_count: 0, + } + } + + fn default_path() -> PathBuf { + storage::jcode_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join("ambient") + .join("usage.json") + } + + /// Add a record and periodically save. + pub fn record(&mut self, record: UsageRecord) { + self.records.push(record); + self.unsaved_count += 1; + if self.unsaved_count >= SAVE_INTERVAL + && let Err(err) = self.save() + { + crate::logging::warn(&format!( + "Failed to persist ambient usage log '{}': {}", + self.path.display(), + err + )); + } + } + + /// Rolling average of *user* token usage per minute over `window`. + pub fn user_rate_per_minute(&self, window: Duration) -> f32 { + self.rate_per_minute(UsageSource::User, window) + } + + /// Rolling average of *ambient* token usage per minute over `window`. + pub fn ambient_rate_per_minute(&self, window: Duration) -> f32 { + self.rate_per_minute(UsageSource::Ambient, window) + } + + /// Total tokens for a given source within a window. + pub fn total_tokens_in_window(&self, source: &UsageSource, window: Duration) -> u64 { + let cutoff = Utc::now() - ChronoDuration::from_std(window).unwrap_or_default(); + self.records + .iter() + .filter(|r| r.source == *source && r.timestamp >= cutoff) + .map(|r| r.total_tokens()) + .sum() + } + + /// Average tokens per ambient cycle (last N cycles). + pub fn avg_tokens_per_ambient_cycle(&self, last_n: usize) -> Option { + let ambient: Vec = self + .records + .iter() + .rev() + .filter(|r| r.source == UsageSource::Ambient) + .take(last_n) + .map(|r| r.total_tokens()) + .collect(); + if ambient.is_empty() { + return None; + } + let sum: u64 = ambient.iter().sum(); + Some(sum as f64 / ambient.len() as f64) + } + + /// Persist to disk, pruning old records. + pub fn save(&mut self) -> anyhow::Result<()> { + self.prune(); + storage::write_json(&self.path, &self.records)?; + self.unsaved_count = 0; + Ok(()) + } + + // -- internal helpers --------------------------------------------------- + + fn rate_per_minute(&self, source: UsageSource, window: Duration) -> f32 { + let cutoff = Utc::now() - ChronoDuration::from_std(window).unwrap_or_default(); + let total: u64 = self + .records + .iter() + .filter(|r| r.source == source && r.timestamp >= cutoff) + .map(|r| r.total_tokens()) + .sum(); + let minutes = window.as_secs_f32() / 60.0; + if minutes > 0.0 { + total as f32 / minutes + } else { + 0.0 + } + } + + fn prune(&mut self) { + let cutoff = Utc::now() - ChronoDuration::hours(PRUNE_AGE_HOURS); + self.records.retain(|r| r.timestamp >= cutoff); + } +} + +// --------------------------------------------------------------------------- +// Scheduler config +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct AmbientSchedulerConfig { + pub min_interval_minutes: u32, + pub max_interval_minutes: u32, + pub pause_on_active_session: bool, + /// Fraction of remaining budget reserved for user. 0.8 means ambient gets + /// at most 20% of headroom. + pub user_budget_reserve: f32, +} + +impl Default for AmbientSchedulerConfig { + fn default() -> Self { + AmbientSchedulerConfig { + min_interval_minutes: 5, + max_interval_minutes: 120, + pause_on_active_session: true, + user_budget_reserve: 0.8, + } + } +} + +// --------------------------------------------------------------------------- +// Adaptive scheduler +// --------------------------------------------------------------------------- + +pub struct AdaptiveScheduler { + pub usage_log: UsageLog, + pub config: AmbientSchedulerConfig, + /// Exponential backoff multiplier (doubles on rate limit hits). + backoff_multiplier: u32, + /// Whether a user session is currently active. + user_active: bool, +} + +impl AdaptiveScheduler { + pub fn new(config: AmbientSchedulerConfig) -> Self { + AdaptiveScheduler { + usage_log: UsageLog::load(), + config, + backoff_multiplier: 1, + user_active: false, + } + } + + /// Core interval calculation following the algorithm in AMBIENT_MODE.md. + pub fn calculate_interval(&self, rate_limit_info: Option<&RateLimitInfo>) -> Duration { + let max = Duration::from_secs(self.config.max_interval_minutes as u64 * 60); + let min = Duration::from_secs(self.config.min_interval_minutes as u64 * 60); + + // If no rate limit info, fall back to max interval. + let info = match rate_limit_info { + Some(i) => i, + None => return self.apply_backoff(max), + }; + + // window_remaining = reset_time - now + let window_remaining_secs = info + .reset_at + .map(|r| { + let diff = r - Utc::now(); + diff.num_seconds().max(0) as f64 + }) + .unwrap_or(3600.0); // default 1 hour if unknown + + let tokens_remaining = info.remaining_tokens.unwrap_or(0) as f64; + + if tokens_remaining <= 0.0 || window_remaining_secs <= 0.0 { + return self.apply_backoff(max); + } + + // Estimate user consumption from rolling history (last hour). + let user_rate = self + .usage_log + .user_rate_per_minute(Duration::from_secs(3600)) as f64; + + // Project user usage for rest of window. + let window_remaining_minutes = window_remaining_secs / 60.0; + let user_projected = user_rate * window_remaining_minutes; + + // Ambient budget = (remaining - user_projected) * (1 - reserve) + let ambient_fraction = 1.0 - self.config.user_budget_reserve as f64; + let ambient_budget = (tokens_remaining - user_projected) * ambient_fraction; + + if ambient_budget <= 0.0 { + // No headroom — wait until window resets. + return self.apply_backoff(max); + } + + // Estimate cost per ambient cycle from recent cycles. + let tokens_per_cycle = self + .usage_log + .avg_tokens_per_ambient_cycle(5) + .unwrap_or(10_000.0); // conservative default + + let cycles_available = ambient_budget / tokens_per_cycle; + + let interval_secs = if cycles_available > 0.0 { + window_remaining_secs / cycles_available + } else { + window_remaining_secs + }; + + let interval = Duration::from_secs_f64(interval_secs); + self.apply_backoff(interval.clamp(min, max)) + } + + /// Returns `true` if the scheduler thinks ambient should pause (user active). + pub fn should_pause(&self) -> bool { + self.config.pause_on_active_session && self.user_active + } + + /// Mark user session state. + pub fn set_user_active(&mut self, active: bool) { + self.user_active = active; + } + + /// Called when a provider rate limit error occurs. + pub fn on_rate_limit_hit(&mut self) { + self.backoff_multiplier = self.backoff_multiplier.saturating_mul(2).min(64); + } + + /// Called after a successful ambient cycle. + pub fn on_successful_cycle(&mut self) { + self.backoff_multiplier = 1; + } + + // -- internal -- + + fn apply_backoff(&self, interval: Duration) -> Duration { + let min = Duration::from_secs(self.config.min_interval_minutes as u64 * 60); + let max = Duration::from_secs(self.config.max_interval_minutes as u64 * 60); + let adjusted = interval.saturating_mul(self.backoff_multiplier); + adjusted.clamp(min, max) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn make_record(source: UsageSource, tokens: u32, mins_ago: i64) -> UsageRecord { + UsageRecord { + timestamp: Utc::now() - ChronoDuration::minutes(mins_ago), + source, + tokens_input: tokens / 2, + tokens_output: tokens / 2, + provider: "test".to_string(), + } + } + + #[test] + fn test_usage_log_rate_per_minute() { + let mut log = UsageLog { + records: Vec::new(), + path: PathBuf::from("/tmp/test_usage.json"), + unsaved_count: 0, + }; + + // Add 3 user records in the last 30 minutes, 1000 tokens each. + for i in 0..3 { + log.records + .push(make_record(UsageSource::User, 1000, i * 10)); + } + + let rate = log.user_rate_per_minute(Duration::from_secs(3600)); + // 3000 tokens over 60 minutes = 50 tokens/min + assert!((rate - 50.0).abs() < 1.0, "got {}", rate); + } + + #[test] + fn test_total_tokens_in_window() { + let mut log = UsageLog { + records: Vec::new(), + path: PathBuf::from("/tmp/test_usage2.json"), + unsaved_count: 0, + }; + + log.records.push(make_record(UsageSource::User, 500, 10)); + log.records.push(make_record(UsageSource::Ambient, 300, 5)); + log.records.push(make_record(UsageSource::User, 200, 2)); + + let user_total = log.total_tokens_in_window(&UsageSource::User, Duration::from_secs(3600)); + assert_eq!(user_total, 700); + + let ambient_total = + log.total_tokens_in_window(&UsageSource::Ambient, Duration::from_secs(3600)); + assert_eq!(ambient_total, 300); + } + + #[test] + fn test_avg_tokens_per_ambient_cycle() { + let mut log = UsageLog { + records: Vec::new(), + path: PathBuf::from("/tmp/test_usage3.json"), + unsaved_count: 0, + }; + + // No ambient records => None. + assert!(log.avg_tokens_per_ambient_cycle(5).is_none()); + + log.records + .push(make_record(UsageSource::Ambient, 1000, 30)); + log.records + .push(make_record(UsageSource::Ambient, 2000, 20)); + log.records + .push(make_record(UsageSource::Ambient, 3000, 10)); + + let avg = log.avg_tokens_per_ambient_cycle(5).unwrap(); + assert!((avg - 2000.0).abs() < 1.0, "got {}", avg); + } + + #[test] + fn test_scheduler_no_rate_limit_returns_max() { + let config = AmbientSchedulerConfig { + min_interval_minutes: 5, + max_interval_minutes: 120, + ..Default::default() + }; + let scheduler = AdaptiveScheduler::new(config); + let interval = scheduler.calculate_interval(None); + assert_eq!(interval, Duration::from_secs(120 * 60)); + } + + #[test] + fn test_scheduler_no_remaining_tokens_returns_max() { + let config = AmbientSchedulerConfig::default(); + let scheduler = AdaptiveScheduler::new(config); + + let info = RateLimitInfo { + limit_tokens: Some(100_000), + remaining_tokens: Some(0), + limit_requests: None, + remaining_requests: None, + reset_at: Some(Utc::now() + ChronoDuration::hours(1)), + }; + let interval = scheduler.calculate_interval(Some(&info)); + assert_eq!(interval, Duration::from_secs(120 * 60)); + } + + #[test] + fn test_scheduler_plenty_of_headroom() { + let config = AmbientSchedulerConfig { + min_interval_minutes: 5, + max_interval_minutes: 120, + user_budget_reserve: 0.8, + ..Default::default() + }; + let scheduler = AdaptiveScheduler::new(config); + + let info = RateLimitInfo { + limit_tokens: Some(1_000_000), + remaining_tokens: Some(500_000), + limit_requests: None, + remaining_requests: None, + reset_at: Some(Utc::now() + ChronoDuration::hours(1)), + }; + + let interval = scheduler.calculate_interval(Some(&info)); + // With 500k remaining, 0 user rate, 20% for ambient = 100k budget. + // Default 10k per cycle => 10 cycles in 60 min => 6 min per cycle. + let mins = interval.as_secs() as f64 / 60.0; + assert!( + (5.0..=10.0).contains(&mins), + "expected 5-10 min, got {:.1}", + mins + ); + } + + #[test] + fn test_backoff_doubles() { + let config = AmbientSchedulerConfig { + min_interval_minutes: 5, + max_interval_minutes: 120, + ..Default::default() + }; + let mut scheduler = AdaptiveScheduler::new(config); + + let info = RateLimitInfo { + limit_tokens: Some(1_000_000), + remaining_tokens: Some(500_000), + limit_requests: None, + remaining_requests: None, + reset_at: Some(Utc::now() + ChronoDuration::hours(1)), + }; + + let before = scheduler.calculate_interval(Some(&info)); + scheduler.on_rate_limit_hit(); + let after = scheduler.calculate_interval(Some(&info)); + + // After one hit, interval should roughly double (clamped). + assert!( + after >= before, + "after backoff should be >= before: {:?} vs {:?}", + after, + before + ); + } + + #[test] + fn test_backoff_resets_on_success() { + let config = AmbientSchedulerConfig::default(); + let mut scheduler = AdaptiveScheduler::new(config); + + scheduler.on_rate_limit_hit(); + scheduler.on_rate_limit_hit(); + assert!(scheduler.backoff_multiplier > 1); + + scheduler.on_successful_cycle(); + assert_eq!(scheduler.backoff_multiplier, 1); + } + + #[test] + fn test_should_pause() { + let config = AmbientSchedulerConfig { + pause_on_active_session: true, + ..Default::default() + }; + let mut scheduler = AdaptiveScheduler::new(config); + + assert!(!scheduler.should_pause()); + scheduler.set_user_active(true); + assert!(scheduler.should_pause()); + scheduler.set_user_active(false); + assert!(!scheduler.should_pause()); + } + + #[test] + fn test_prune_removes_old_records() { + let mut log = UsageLog { + records: Vec::new(), + path: PathBuf::from("/tmp/test_prune.json"), + unsaved_count: 0, + }; + + // Record from 25 hours ago (should be pruned). + log.records.push(UsageRecord { + timestamp: Utc::now() - ChronoDuration::hours(25), + source: UsageSource::User, + tokens_input: 100, + tokens_output: 100, + provider: "test".to_string(), + }); + + // Recent record (should survive). + log.records.push(make_record(UsageSource::User, 200, 5)); + + log.prune(); + assert_eq!(log.records.len(), 1); + assert_eq!(log.records[0].total_tokens(), 200); + } +} diff --git a/crates/jcode-app-core/src/ambient_runner.rs b/crates/jcode-app-core/src/ambient_runner.rs new file mode 100644 index 0000000..b621b49 --- /dev/null +++ b/crates/jcode-app-core/src/ambient_runner.rs @@ -0,0 +1,3 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +pub use crate::ambient::runner::AmbientRunnerHandle; diff --git a/crates/jcode-app-core/src/ambient_scheduler.rs b/crates/jcode-app-core/src/ambient_scheduler.rs new file mode 100644 index 0000000..e74d2a3 --- /dev/null +++ b/crates/jcode-app-core/src/ambient_scheduler.rs @@ -0,0 +1,3 @@ +pub use crate::ambient::scheduler::{ + AdaptiveScheduler, AmbientSchedulerConfig, RateLimitInfo, UsageLog, UsageRecord, UsageSource, +}; diff --git a/crates/jcode-app-core/src/ambient_tests.rs b/crates/jcode-app-core/src/ambient_tests.rs new file mode 100644 index 0000000..99d5324 --- /dev/null +++ b/crates/jcode-app-core/src/ambient_tests.rs @@ -0,0 +1,457 @@ +use super::*; +use chrono::Duration; + +#[test] +fn test_ambient_status_default() { + let status = AmbientStatus::default(); + assert_eq!(status, AmbientStatus::Idle); +} + +#[test] +fn test_priority_ordering() { + assert!(Priority::High > Priority::Normal); + assert!(Priority::Normal > Priority::Low); +} + +#[test] +fn test_scheduled_queue_push_and_pop() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + + let mut queue = ScheduledQueue::load(path); + assert!(queue.is_empty()); + + let past = Utc::now() - Duration::minutes(5); + let future = Utc::now() + Duration::hours(1); + + queue.push(ScheduledItem { + id: "s1".into(), + scheduled_for: past, + context: "past item".into(), + priority: Priority::Low, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + queue.push(ScheduledItem { + id: "s2".into(), + scheduled_for: future, + context: "future item".into(), + priority: Priority::High, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + assert_eq!(queue.len(), 2); + + let ready = queue.pop_ready(); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].id, "s1"); + + // Future item still in queue + assert_eq!(queue.len(), 1); + assert_eq!(queue.peek_next().unwrap().id, "s2"); +} + +#[test] +fn test_scheduled_queue_remove_by_id_persists_remaining_items() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + + let mut queue = ScheduledQueue::load(path.clone()); + let future = Utc::now() + Duration::hours(1); + + queue.push(ScheduledItem { + id: "keep".into(), + scheduled_for: future, + context: "keep item".into(), + priority: Priority::Normal, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + queue.push(ScheduledItem { + id: "cancel".into(), + scheduled_for: future, + context: "cancel item".into(), + priority: Priority::High, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + let removed = queue.remove_by_id("cancel").unwrap().unwrap(); + assert_eq!(removed.id, "cancel"); + assert!(queue.remove_by_id("missing").unwrap().is_none()); + + let reloaded = ScheduledQueue::load(path); + assert_eq!(reloaded.len(), 1); + assert_eq!(reloaded.items()[0].id, "keep"); +} + +#[test] +fn test_pop_ready_sorts_by_priority_then_time() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + + let mut queue = ScheduledQueue::load(path); + let past1 = Utc::now() - Duration::minutes(10); + let past2 = Utc::now() - Duration::minutes(5); + + queue.push(ScheduledItem { + id: "low_early".into(), + scheduled_for: past1, + context: "low early".into(), + priority: Priority::Low, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + queue.push(ScheduledItem { + id: "high_late".into(), + scheduled_for: past2, + context: "high late".into(), + priority: Priority::High, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + let ready = queue.pop_ready(); + assert_eq!(ready.len(), 2); + // High priority should come first + assert_eq!(ready[0].id, "high_late"); + assert_eq!(ready[1].id, "low_early"); +} + +#[test] +fn test_take_ready_direct_items_only_removes_direct_targets() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + + let mut queue = ScheduledQueue::load(path); + let past = Utc::now() - Duration::minutes(5); + + queue.push(ScheduledItem { + id: "session_due".into(), + scheduled_for: past, + context: "scheduled session task".into(), + priority: Priority::Normal, + target: ScheduleTarget::Session { + session_id: "session_123".into(), + }, + created_by_session: "session_123".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + queue.push(ScheduledItem { + id: "spawn_due".into(), + scheduled_for: past, + context: "spawned session task".into(), + priority: Priority::High, + target: ScheduleTarget::Spawn { + parent_session_id: "session_123".into(), + }, + created_by_session: "session_123".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + queue.push(ScheduledItem { + id: "ambient_due".into(), + scheduled_for: past, + context: "scheduled ambient task".into(), + priority: Priority::High, + target: ScheduleTarget::Ambient, + created_by_session: "ambient".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + let ready_direct = queue.take_ready_direct_items(); + assert_eq!(ready_direct.len(), 2); + assert_eq!(ready_direct[0].id, "spawn_due"); + assert_eq!(ready_direct[1].id, "session_due"); + assert_eq!(queue.len(), 1); + assert_eq!(queue.items()[0].id, "ambient_due"); +} + +#[test] +fn test_ambient_state_record_cycle() { + let mut state = AmbientState::default(); + assert_eq!(state.total_cycles, 0); + + let result = AmbientCycleResult { + summary: "Merged 2 duplicates".into(), + memories_modified: 3, + compactions: 1, + proactive_work: None, + next_schedule: None, + started_at: Utc::now() - Duration::seconds(30), + ended_at: Utc::now(), + status: CycleStatus::Complete, + conversation: None, + }; + + state.record_cycle(&result); + assert_eq!(state.total_cycles, 1); + assert_eq!(state.last_summary.as_deref(), Some("Merged 2 duplicates")); + assert_eq!(state.last_compactions, Some(1)); + assert_eq!(state.last_memories_modified, Some(3)); + assert_eq!(state.status, AmbientStatus::Idle); +} + +#[test] +fn test_ambient_state_record_cycle_with_schedule() { + let mut state = AmbientState::default(); + + let result = AmbientCycleResult { + summary: "Done".into(), + memories_modified: 0, + compactions: 0, + proactive_work: None, + next_schedule: Some(ScheduleRequest { + wake_in_minutes: Some(15), + wake_at: None, + context: "check CI".into(), + priority: Priority::Normal, + target: ScheduleTarget::Ambient, + created_by_session: "ambient_test".into(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }), + started_at: Utc::now() - Duration::seconds(10), + ended_at: Utc::now(), + status: CycleStatus::Complete, + conversation: None, + }; + + state.record_cycle(&result); + assert!(matches!(state.status, AmbientStatus::Scheduled { .. })); +} + +#[test] +fn test_ambient_lock_release() { + // Use a temp dir so we don't conflict with real state + let tmp_dir = tempfile::tempdir().unwrap(); + let lock_file = tmp_dir.path().join("test.lock"); + + // Manually create a lock to test release/drop + std::fs::write(&lock_file, std::process::id().to_string()).unwrap(); + let lock = AmbientLock { + lock_path: lock_file.clone(), + }; + lock.release().unwrap(); + assert!(!lock_file.exists()); +} + +#[test] +fn test_schedule_id_format() { + let id = format!("sched_{:08x}", rand::random::()); + assert!(id.starts_with("sched_")); + assert_eq!(id.len(), 6 + 8); // "sched_" + 8 hex chars +} + +#[test] +fn test_format_duration_rough() { + assert_eq!(format_duration_rough(Duration::seconds(30)), "30s"); + assert_eq!(format_duration_rough(Duration::minutes(5)), "5m"); + assert_eq!(format_duration_rough(Duration::hours(2)), "2h"); + assert_eq!( + format_duration_rough(Duration::hours(2) + Duration::minutes(30)), + "2h 30m" + ); + assert_eq!(format_duration_rough(Duration::days(3)), "3d"); + assert_eq!(format_duration_rough(Duration::seconds(-5)), "0s"); +} + +#[test] +fn test_build_ambient_system_prompt_minimal() { + let state = AmbientState::default(); + let queue = vec![]; + let health = MemoryGraphHealth::default(); + let sessions = vec![]; + let feedback: Vec = vec![]; + let budget = ResourceBudget { + provider: "anthropic-oauth".into(), + tokens_remaining_desc: "unknown".into(), + window_resets_desc: "unknown".into(), + user_usage_rate_desc: "0 tokens/min".into(), + cycle_budget_desc: "stay under 50k tokens".into(), + }; + + let prompt = + build_ambient_system_prompt(&state, &queue, &health, &sessions, &feedback, &budget, 0); + + assert!(prompt.contains("ambient agent for jcode")); + assert!(prompt.contains("## Current State")); + assert!(prompt.contains("never (first run)")); + assert!(prompt.contains("Active user sessions: none")); + assert!(prompt.contains("## Scheduled Queue")); + assert!(prompt.contains("Empty")); + assert!(prompt.contains("## Memory Graph Health")); + assert!(prompt.contains("Total memories: 0")); + assert!(prompt.contains("## User Feedback History")); + assert!(prompt.contains("No feedback memories")); + assert!(prompt.contains("## Resource Budget")); + assert!(prompt.contains("anthropic-oauth")); + assert!(prompt.contains("## Instructions")); + assert!(prompt.contains("end_ambient_cycle")); + assert!(prompt.contains("reviewer-ready")); + assert!(prompt.contains("context.why_permission_needed")); +} + +#[test] +fn test_build_ambient_system_prompt_with_data() { + let state = AmbientState { + last_run: Some(Utc::now() - Duration::minutes(15)), + total_cycles: 7, + ..Default::default() + }; + + let queue = vec![ScheduledItem { + id: "sched_001".into(), + scheduled_for: Utc::now(), + context: "Check CI status".into(), + priority: Priority::High, + target: ScheduleTarget::Ambient, + created_by_session: "session_abc".into(), + created_at: Utc::now() - Duration::minutes(10), + working_dir: Some("/home/user/project".into()), + task_description: Some("Check CI status for the main branch".into()), + relevant_files: vec!["src/main.rs".into()], + git_branch: Some("main".into()), + additional_context: Some("Background: Tests were flaky yesterday".into()), + }]; + + let health = MemoryGraphHealth { + total: 42, + active: 38, + inactive: 4, + low_confidence: 3, + contradictions: 1, + missing_embeddings: 5, + duplicate_candidates: 0, + last_consolidation: Some(Utc::now() - Duration::hours(2)), + }; + + let sessions = vec![RecentSessionInfo { + id: "session_fox_123".into(), + status: "closed".into(), + topic: Some("Fix auth bug".into()), + duration_secs: 900, + extraction_status: "extracted".into(), + }]; + + let feedback = vec![ + "User approved ambient fixing typos in docs".into(), + "User rejected ambient refactoring tests".into(), + ]; + + let budget = ResourceBudget { + provider: "openai-oauth".into(), + tokens_remaining_desc: "~85k".into(), + window_resets_desc: "in 3h 20m".into(), + user_usage_rate_desc: "120 tokens/min".into(), + cycle_budget_desc: "stay under 15k tokens".into(), + }; + + let prompt = + build_ambient_system_prompt(&state, &queue, &health, &sessions, &feedback, &budget, 2); + + assert!(prompt.contains("15m ago")); + assert!(prompt.contains("Active user sessions: 2")); + assert!(prompt.contains("Total cycles completed: 7")); + assert!(prompt.contains("Check CI status")); + assert!(prompt.contains("HIGH")); + assert!(prompt.contains("42")); + assert!(prompt.contains("38 active")); + assert!(prompt.contains("confidence < 0.1: 3")); + assert!(prompt.contains("contradictions: 1")); + assert!(prompt.contains("without embeddings: 5")); + assert!(prompt.contains("Fix auth bug")); + assert!(prompt.contains("approved ambient fixing typos")); + assert!(prompt.contains("rejected ambient refactoring")); + assert!(prompt.contains("openai-oauth")); + assert!(prompt.contains("~85k")); + assert!(prompt.contains("Working dir: /home/user/project")); + assert!(prompt.contains("Details: Check CI status for the main branch")); + assert!(prompt.contains("Files: src/main.rs")); + assert!(prompt.contains("Branch: main")); + assert!(prompt.contains("Tests were flaky yesterday")); +} + +#[test] +fn test_scheduled_queue_items_accessor() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + let mut queue = ScheduledQueue::load(path); + + queue.push(ScheduledItem { + id: "s1".into(), + scheduled_for: Utc::now(), + context: "test item".into(), + priority: Priority::Normal, + target: ScheduleTarget::Ambient, + created_by_session: "test".into(), + created_at: Utc::now(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + let items = queue.items(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "s1"); +} diff --git a/crates/jcode-app-core/src/build.rs b/crates/jcode-app-core/src/build.rs new file mode 100644 index 0000000..42d4a6e --- /dev/null +++ b/crates/jcode-app-core/src/build.rs @@ -0,0 +1,27 @@ +pub use jcode_build_support::{ + BinaryChoice, BinaryVersionReport, BuildInfo, BuildManifest, CanaryStatus, CrashInfo, + DevBinarySourceMetadata, MigrationContext, PendingActivation, PublishedBuild, + SELFDEV_CARGO_PROFILE, SelfDevBuildCommand, SelfDevBuildTarget, SharedServerRepair, + SourceState, advance_shared_server_if_tracking_stable, binary_name, binary_stem, + build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress, + clear_migration_context, client_update_candidate, complete_pending_activation_for_session, + current_binary_build_time_string, current_binary_built_at, current_binary_path, + current_build_info, current_git_diff, current_git_hash, current_git_hash_full, + current_source_state, current_version_file, ensure_source_state_matches, find_dev_binary, + find_repo_in_ancestors, get_commit_message, get_repo_dir, install_binary_at_version, + install_local_release, install_version, is_jcode_repo, is_working_tree_dirty, + launcher_binary_path, launcher_dir, load_migration_context, manifest_path, + migration_context_path, preferred_reload_candidate, promote_version_to_shared_server, + publish_local_current_build, publish_local_current_build_for_source, read_build_progress, + read_current_version, read_shared_server_version, read_stable_version, release_binary_path, + repair_stale_shared_server_channel, repo_build_version, repo_scope_key, resolve_binary_payload, + rollback_pending_activation_for_session, run_selfdev_build, save_migration_context, + selfdev_binary_path, selfdev_build_command, selfdev_build_command_for_target, + shared_server_binary_path, shared_server_tracks_stable, shared_server_update_candidate, + shared_server_version_file, smoke_test_binary, smoke_test_server_binary, stable_binary_path, + stable_version_file, update_canary_symlink, update_current_symlink, + update_launcher_symlink_to_current, update_launcher_symlink_to_stable, + update_shared_server_symlink, update_stable_symlink, version_binary_path, + version_matches_installed_channel, worktree_scope_key, write_build_progress, + write_current_dev_binary_source_metadata, write_dev_binary_source_metadata, +}; diff --git a/crates/jcode-app-core/src/catchup.rs b/crates/jcode-app-core/src/catchup.rs new file mode 100644 index 0000000..cd6643b --- /dev/null +++ b/crates/jcode-app-core/src/catchup.rs @@ -0,0 +1,651 @@ +use crate::message::ContentBlock; +use crate::session::{Session, SessionStatus}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use std::collections::{BTreeMap, HashSet}; + +pub use jcode_task_types::{CatchupBrief, PersistedCatchupState}; + +const CATCHUP_STATE_FILE: &str = "catchup_seen.json"; + +pub fn needs_catchup(session_id: &str, updated_at: DateTime, status: &SessionStatus) -> bool { + if !is_attention_status(status) { + return false; + } + let seen = load_seen_state() + .seen_at_ms_by_session + .get(session_id) + .copied(); + needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status) +} + +/// Snapshot of the persisted catch-up "seen" state, so callers that need to +/// evaluate many sessions at once (e.g. the session picker building its list) +/// can avoid re-reading and re-parsing `catchup_seen.json` once per session. +#[derive(Clone, Default)] +pub struct CatchupSeenSnapshot { + state: PersistedCatchupState, +} + +impl CatchupSeenSnapshot { + /// Load the persisted seen-state once from disk. + pub fn load() -> Self { + Self { + state: load_seen_state(), + } + } + + /// Same semantics as [`needs_catchup`] but uses this preloaded snapshot + /// instead of re-reading the state file for every call. + pub fn needs_catchup( + &self, + session_id: &str, + updated_at: DateTime, + status: &SessionStatus, + ) -> bool { + if !is_attention_status(status) { + return false; + } + let seen = self.state.seen_at_ms_by_session.get(session_id).copied(); + needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status) + } +} + +pub(crate) fn needs_catchup_with_seen( + updated_at_ms: i64, + seen_at_ms: Option, + status: &SessionStatus, +) -> bool { + is_attention_status(status) && seen_at_ms.unwrap_or_default() < updated_at_ms +} + +pub fn mark_seen(session_id: &str, updated_at: DateTime) -> Result<()> { + let mut state = load_seen_state(); + state + .seen_at_ms_by_session + .insert(session_id.to_string(), updated_at.timestamp_millis()); + save_seen_state(&state) +} + +pub fn build_brief(session: &Session) -> CatchupBrief { + let rendered = crate::session::render_messages(session); + let last_user_prompt = rendered + .iter() + .rev() + .find(|msg| msg.role == "user" && !msg.content.trim().is_empty()) + .map(|msg| msg.content.trim().to_string()); + let latest_agent_response = rendered + .iter() + .rev() + .find(|msg| msg.role == "assistant" && !msg.content.trim().is_empty()) + .map(|msg| msg.content.trim().to_string()); + + let files_touched = collect_touched_files(session); + let tool_counts = collect_tool_counts(session); + let validation_notes = collect_validation_notes(&rendered); + let activity_steps = collect_activity_steps(session); + let (reason, tags) = reason_and_tags(&session.status); + let needs_from_user = infer_needs_from_user(&session.status, latest_agent_response.as_deref()); + + CatchupBrief { + reason, + tags, + last_user_prompt, + activity_steps, + files_touched, + tool_counts, + validation_notes, + latest_agent_response, + needs_from_user, + updated_at: session.updated_at, + } +} + +pub fn render_markdown( + session: &Session, + source_session_id: Option<&str>, + queue_position: Option<(usize, usize)>, + brief: &CatchupBrief, +) -> String { + let display_name = session.display_name().to_string(); + let icon = crate::id::session_icon(&display_name); + let status_icon = status_icon(&session.status); + let status_label = status_label(&session.status); + let updated_ago = format_time_ago(brief.updated_at); + let source_label = source_session_id + .and_then(crate::id::extract_session_name) + .unwrap_or("previous session"); + + let mut out = String::new(); + out.push_str("# Catch Up\n\n"); + out.push_str(&format!( + "**{} {}** · {} **{}** · updated {}\n\n", + icon, display_name, status_icon, status_label, updated_ago + )); + + if let Some((index, total)) = queue_position { + out.push_str(&format!("- Queue: **{} of {}**\n", index, total)); + } + if source_session_id.is_some() { + out.push_str(&format!("- From: **{}**\n", source_label)); + } + out.push_str(&format!("- Session: `{}`\n\n", session.id)); + + if crate::config::config().features.mermaid && !brief.activity_steps.is_empty() { + out.push_str("```mermaid\nflowchart TD\n"); + out.push_str(&format!( + " A[\"Why
{}\"]:::status --> B[\"Your last prompt\"]:::user\n", + mermaid_escape(&brief.reason) + )); + let mut prev = 'B'; + for (idx, step) in brief.activity_steps.iter().take(4).enumerate() { + let node = ((b'C' + idx as u8) as char).to_string(); + out.push_str(&format!( + " {}[\"{}\"]:::step\n", + node, + mermaid_escape(step) + )); + out.push_str(&format!(" {} --> {}\n", prev, node)); + prev = node.chars().next().unwrap_or('B'); + } + out.push_str(&format!( + " {} --> Z[\"Need from you
{}\"]:::decision\n", + prev, + mermaid_escape(&brief.needs_from_user) + )); + out.push_str(" classDef status fill:#18331f,stroke:#4caf50,color:#d6ffd9;\n"); + out.push_str(" classDef user fill:#1f3659,stroke:#7fb3ff,color:#e8f1ff;\n"); + out.push_str(" classDef step fill:#2b2b33,stroke:#9090a0,color:#f0f0f5;\n"); + out.push_str(" classDef decision fill:#43284f,stroke:#d38cff,color:#fdefff;\n"); + out.push_str("```\n\n"); + } + + out.push_str("## Why this needs attention\n\n"); + out.push_str(&format!("> {}\n\n", brief.reason)); + if !brief.tags.is_empty() { + out.push_str(&format!( + "{}\n\n", + brief + .tags + .iter() + .map(|tag| format!("`{}`", tag)) + .collect::>() + .join(" ") + )); + } + + out.push_str("## Your last prompt\n\n"); + if let Some(prompt) = brief.last_user_prompt.as_deref() { + out.push_str(&format!("> {}\n\n", markdown_quote(prompt))); + } else { + out.push_str("> No user prompt found in the restored transcript.\n\n"); + } + + out.push_str("## What happened\n\n"); + if brief.activity_steps.is_empty() { + out.push_str("- No tool activity was reconstructed from the stored transcript.\n\n"); + } else { + for step in &brief.activity_steps { + out.push_str(&format!("- {}\n", step)); + } + out.push('\n'); + } + + out.push_str("## What changed\n\n"); + if brief.files_touched.is_empty() { + out.push_str("- Files: _no explicit file paths captured_\n"); + } else { + out.push_str(&format!( + "- Files: {}\n", + brief + .files_touched + .iter() + .take(5) + .map(|path| format!("`{}`", path)) + .collect::>() + .join(", ") + )); + } + if brief.tool_counts.is_empty() { + out.push_str("- Tools: _none captured_\n"); + } else { + out.push_str(&format!( + "- Tools: {}\n", + brief + .tool_counts + .iter() + .take(6) + .map(|(name, count)| format!("`{}`×{}", name, count)) + .collect::>() + .join(" · ") + )); + } + if brief.validation_notes.is_empty() { + out.push_str("- Validation: _no test/build validation detected_\n\n"); + } else { + out.push_str("- Validation:\n"); + for note in &brief.validation_notes { + out.push_str(&format!(" - {}\n", note)); + } + out.push('\n'); + } + + out.push_str("## Latest agent response\n\n"); + if let Some(response) = brief.latest_agent_response.as_deref() { + out.push_str(&format!("> {}\n\n", markdown_quote(response))); + } else { + out.push_str("> No final assistant response was found.\n\n"); + } + + out.push_str("## Needs from you\n\n"); + out.push_str(&format!("> {}\n\n", brief.needs_from_user)); + + out.push_str("## Actions\n\n"); + out.push_str("- **Enter** — continue in this session\n"); + out.push_str("- **/back** — return to the previous session\n"); + out.push_str("- **/catchup next** — jump to the next unfinished handoff\n"); + out.push_str("- **/resume** — browse all sessions normally\n"); + + out +} + +fn state_path() -> Result { + Ok(crate::storage::jcode_dir()?.join(CATCHUP_STATE_FILE)) +} + +fn load_seen_state() -> PersistedCatchupState { + let Ok(path) = state_path() else { + return PersistedCatchupState::default(); + }; + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() +} + +fn save_seen_state(state: &PersistedCatchupState) -> Result<()> { + let path = state_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(state)?)?; + Ok(()) +} + +fn is_attention_status(status: &SessionStatus) -> bool { + matches!( + status, + SessionStatus::Closed + | SessionStatus::Reloaded + | SessionStatus::Compacted + | SessionStatus::RateLimited + | SessionStatus::Crashed { .. } + | SessionStatus::Error { .. } + ) +} + +fn reason_and_tags(status: &SessionStatus) -> (String, Vec) { + match status { + SessionStatus::Closed => ( + "Finished and is ready for your next instruction.".to_string(), + vec!["completed".to_string(), "decision needed".to_string()], + ), + SessionStatus::Reloaded => ( + "Resumed after a reload and may need confirmation before continuing.".to_string(), + vec!["reloaded".to_string(), "review".to_string()], + ), + SessionStatus::Compacted => ( + "Compacted older context; review the latest result before continuing.".to_string(), + vec!["compacted".to_string(), "review".to_string()], + ), + SessionStatus::RateLimited => ( + "Paused by rate limiting; decide whether to retry here or move on.".to_string(), + vec!["waiting".to_string(), "rate limited".to_string()], + ), + SessionStatus::Crashed { message } => ( + message + .as_deref() + .map(|msg| format!("Failed and needs attention: {}", msg.trim())) + .unwrap_or_else(|| { + "Failed and may need intervention before continuing.".to_string() + }), + vec!["failed".to_string(), "intervention".to_string()], + ), + SessionStatus::Error { message } => ( + format!( + "Stopped with an error and needs attention: {}", + message.trim() + ), + vec!["failed".to_string(), "error".to_string()], + ), + SessionStatus::Active => ("Still active.".to_string(), vec!["active".to_string()]), + } +} + +fn infer_needs_from_user(status: &SessionStatus, latest_response: Option<&str>) -> String { + if matches!( + status, + SessionStatus::Error { .. } | SessionStatus::Crashed { .. } + ) { + return "Inspect the failure, decide whether to retry, and redirect follow-up work if needed." + .to_string(); + } + + let latest_lower = latest_response.unwrap_or_default().to_lowercase(); + if [ + "decide", + "choose", + "approve", + "which", + "option", + "what do you want", + ] + .iter() + .any(|needle| latest_lower.contains(needle)) + { + return "Review the proposed options and decide the next step for this session." + .to_string(); + } + + if matches!(status, SessionStatus::RateLimited) { + return "Decide whether to retry this session now or move on to the next catch-up." + .to_string(); + } + + "Continue here if you want to direct follow-up work, or jump to the next catch-up.".to_string() +} + +fn collect_touched_files(session: &Session) -> Vec { + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for msg in &session.messages { + for block in &msg.content { + if let ContentBlock::ToolUse { input, .. } = block { + for key in ["file_path", "path"] { + let Some(value) = input.get(key).and_then(|value| value.as_str()) else { + continue; + }; + let trimmed = value.trim(); + if trimmed.is_empty() || !seen.insert(trimmed.to_string()) { + continue; + } + files.push(trimmed.to_string()); + if files.len() >= 12 { + return files; + } + } + } + } + } + files +} + +fn collect_tool_counts(session: &Session) -> Vec<(String, usize)> { + let mut counts: BTreeMap = BTreeMap::new(); + for msg in &session.messages { + for block in &msg.content { + if let ContentBlock::ToolUse { name, .. } = block { + *counts.entry(name.clone()).or_default() += 1; + } + } + } + let mut counts: Vec<(String, usize)> = counts.into_iter().collect(); + counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + counts +} + +fn collect_activity_steps(session: &Session) -> Vec { + let mut steps = Vec::new(); + let mut last = String::new(); + for msg in &session.messages { + for block in &msg.content { + let Some(step) = tool_use_step(block) else { + continue; + }; + if step == last { + continue; + } + last = step.clone(); + steps.push(step); + if steps.len() >= 6 { + return steps; + } + } + } + steps +} + +fn tool_use_step(block: &ContentBlock) -> Option { + let ContentBlock::ToolUse { name, input, .. } = block else { + return None; + }; + let obj = input.as_object(); + match name.as_str() { + "agentgrep" | "grep" | "glob" | "ls" | "codesearch" | "session_search" => { + Some("Searched code and session context".to_string()) + } + "read" => Some( + obj.and_then(|map| map.get("file_path").and_then(|v| v.as_str())) + .map(|path| format!("Inspected `{}`", path.trim())) + .unwrap_or_else(|| "Inspected files".to_string()), + ), + "edit" | "multiedit" | "write" | "patch" | "apply_patch" => Some( + obj.and_then(|map| map.get("file_path").and_then(|v| v.as_str())) + .map(|path| format!("Updated `{}`", path.trim())) + .unwrap_or_else(|| "Edited files".to_string()), + ), + "bash" => { + let command = obj + .and_then(|map| map.get("command").and_then(|v| v.as_str())) + .unwrap_or_default() + .trim(); + let lower = command.to_lowercase(); + if lower.contains("cargo test") + || lower.contains("pytest") + || lower.contains("npm test") + || lower.contains("pnpm test") + || lower.contains("go test") + { + Some(format!("Ran tests{}", summarize_shell_suffix(command))) + } else if lower.contains("cargo build") + || lower.contains("npm run build") + || lower.contains("pnpm build") + || lower.contains("go build") + { + Some(format!( + "Built the project{}", + summarize_shell_suffix(command) + )) + } else { + Some(format!( + "Ran shell command{}", + summarize_shell_suffix(command) + )) + } + } + "communicate" => Some("Coordinated with other agents".to_string()), + "subagent" => Some("Spawned a subagent".to_string()), + "memory" => Some("Queried memory context".to_string()), + "side_panel" | "todo" | "todoread" | "todowrite" | "initiative" => None, + other => Some(format!("Used `{}`", other)), + } +} + +fn summarize_shell_suffix(command: &str) -> String { + let trimmed = command.trim(); + if trimmed.is_empty() { + String::new() + } else { + format!(" · `{}`", truncate(trimmed, 56)) + } +} + +fn collect_validation_notes(rendered: &[crate::session::RenderedMessage]) -> Vec { + let mut notes = Vec::new(); + for msg in rendered.iter().rev() { + if msg.role != "tool" { + continue; + } + let Some(tool) = msg.tool_data.as_ref() else { + continue; + }; + if tool.name != "bash" { + continue; + } + let command = tool + .input + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim(); + if command.is_empty() { + continue; + } + let lower = command.to_lowercase(); + let label = if lower.contains("test") { + "tests" + } else if lower.contains("build") { + "build" + } else { + continue; + }; + let ok = !looks_like_error(&msg.content); + notes.push(format!( + "{} {}: `{}`", + if ok { "✓" } else { "✗" }, + label, + truncate(command, 64) + )); + if notes.len() >= 3 { + break; + } + } + notes.reverse(); + notes +} + +fn looks_like_error(text: &str) -> bool { + let trimmed = text.trim_start().to_lowercase(); + trimmed.starts_with("error:") || trimmed.starts_with("failed:") || trimmed.contains("exit 1") +} + +fn status_icon(status: &SessionStatus) -> &'static str { + match status { + SessionStatus::Closed => "🟢", + SessionStatus::Reloaded => "🔄", + SessionStatus::Compacted => "🟠", + SessionStatus::RateLimited => "⏳", + SessionStatus::Crashed { .. } | SessionStatus::Error { .. } => "🔴", + SessionStatus::Active => "▶", + } +} + +fn status_label(status: &SessionStatus) -> &'static str { + match status { + SessionStatus::Closed => "completed", + SessionStatus::Reloaded => "reloaded", + SessionStatus::Compacted => "compacted", + SessionStatus::RateLimited => "waiting", + SessionStatus::Crashed { .. } | SessionStatus::Error { .. } => "failed", + SessionStatus::Active => "active", + } +} + +fn format_time_ago(updated_at: DateTime) -> String { + let delta = Utc::now().signed_duration_since(updated_at); + if delta.num_seconds() < 60 { + format!("{}s ago", delta.num_seconds().max(0)) + } else if delta.num_minutes() < 60 { + format!("{}m ago", delta.num_minutes()) + } else if delta.num_hours() < 24 { + format!("{}h ago", delta.num_hours()) + } else { + format!("{}d ago", delta.num_days()) + } +} + +fn truncate(text: &str, max_chars: usize) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + let mut out = trimmed + .chars() + .take(max_chars.saturating_sub(1)) + .collect::(); + out.push('…'); + out +} + +fn markdown_quote(text: &str) -> String { + truncate(text.replace('\n', " ").trim(), 600) +} + +fn mermaid_escape(text: &str) -> String { + text.replace('"', "'") + .replace('\n', "
") + .replace(':', " -") +} + +#[cfg(test)] +mod tests { + use super::{build_brief, needs_catchup_with_seen, render_markdown}; + use crate::message::{ContentBlock, Role}; + use crate::session::{Session, SessionStatus}; + + #[test] + fn needs_catchup_requires_attention_status_and_newer_than_seen() { + assert!(needs_catchup_with_seen(10, Some(9), &SessionStatus::Closed)); + assert!(!needs_catchup_with_seen( + 10, + Some(10), + &SessionStatus::Closed + )); + assert!(!needs_catchup_with_seen(10, None, &SessionStatus::Active)); + } + + #[test] + fn render_markdown_includes_key_sections() { + let mut session = Session::create(None, Some("catchup".to_string())); + session.short_name = Some("fox".to_string()); + session.status = SessionStatus::Closed; + session.add_message( + Role::User, + vec![ContentBlock::Text { + text: "Implement the catch up side panel".to_string(), + cache_control: None, + }], + ); + session.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "Searched the relevant session picker code.".to_string(), + cache_control: None, + }], + ); + session.add_message( + Role::Assistant, + vec![ContentBlock::ToolUse { + id: "tool_1".to_string(), + name: "read".to_string(), + input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}), + thought_signature: None, + }], + ); + session.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "Implemented the first pass and left a few follow-up notes.".to_string(), + cache_control: None, + }], + ); + let brief = build_brief(&session); + let markdown = render_markdown(&session, Some("session_otter"), Some((1, 3)), &brief); + assert!(markdown.contains("# Catch Up")); + assert!(markdown.contains("## Your last prompt")); + assert!(markdown.contains("## What happened")); + assert!(markdown.contains("## Latest agent response")); + assert!(markdown.contains("## Needs from you")); + assert!(markdown.contains("```mermaid")); + } +} diff --git a/crates/jcode-app-core/src/channel.rs b/crates/jcode-app-core/src/channel.rs new file mode 100644 index 0000000..d88893b --- /dev/null +++ b/crates/jcode-app-core/src/channel.rs @@ -0,0 +1,953 @@ +use crate::ambient_runner::AmbientRunnerHandle; +use crate::config::SafetyConfig; +use crate::logging; +use async_trait::async_trait; +use std::sync::Arc; + +#[async_trait] +pub trait MessageChannel: Send + Sync { + fn name(&self) -> &str; + + fn is_send_enabled(&self) -> bool; + + fn is_reply_enabled(&self) -> bool; + + async fn send(&self, text: &str) -> anyhow::Result<()>; + + async fn reply_loop(&self, runner: AmbientRunnerHandle); +} + +#[derive(Clone)] +pub struct ChannelRegistry { + channels: Vec>, +} + +impl ChannelRegistry { + pub fn from_config(config: &SafetyConfig) -> Self { + let mut channels: Vec> = Vec::new(); + + if config.telegram_enabled + && let (Some(token), Some(chat_id)) = ( + config.telegram_bot_token.clone(), + config.telegram_chat_id.clone(), + ) + { + logging::info(&format!( + "registering telegram notification channel reply_enabled={}", + config.telegram_reply_enabled + )); + channels.push(Arc::new(TelegramChannel::new( + token, + chat_id, + config.telegram_reply_enabled, + ))); + } + + if config.discord_enabled + && let (Some(token), Some(channel_id)) = ( + config.discord_bot_token.clone(), + config.discord_channel_id.clone(), + ) + { + logging::info(&format!( + "registering discord notification channel reply_enabled={}", + config.discord_reply_enabled + )); + channels.push(Arc::new(DiscordChannel::new( + token, + channel_id, + config.discord_reply_enabled, + config.discord_bot_user_id.clone(), + ))); + } + + if config.jade_relay_enabled { + match ( + config.jade_relay_api_base.clone(), + config.jade_relay_token.clone(), + config.jade_relay_session_id.clone(), + ) { + (Some(api_base), Some(token), Some(session_id)) => { + // user_id defaults to the token id when not explicitly set. + let user_id = config + .jade_relay_user_id + .clone() + .or_else(|| config.jade_relay_token_id.clone()) + .unwrap_or_else(|| "default".to_string()); + logging::info(&format!( + "registering jade relay channel user={} session={} reply_enabled={}", + user_id, session_id, config.jade_relay_reply_enabled + )); + channels.push(Arc::new(JadeRelayChannel::new( + api_base, + token, + config.jade_relay_token_id.clone(), + user_id, + session_id, + config.jade_relay_reply_enabled, + ))); + } + _ => { + logging::warn( + "jade_relay_enabled but api_base/token/session_id incomplete; skipping", + ); + } + } + } + + logging::debug(&format!( + "channel registry initialized channel_count={}", + channels.len() + )); + Self { channels } + } + + pub fn send_all(&self, text: &str) { + if tokio::runtime::Handle::try_current().is_err() { + logging::warn("skipping channel send_all because no Tokio runtime is active"); + return; + } + for ch in self.channels.iter().filter(|c| c.is_send_enabled()) { + let ch = Arc::clone(ch); + let text = text.to_string(); + tokio::spawn(async move { + logging::debug(&format!("sending notification via {}", ch.name())); + if let Err(e) = ch.send(&text).await { + logging::error(&format!("{} notification failed: {}", ch.name(), e)); + } + }); + } + } + + pub fn spawn_reply_loops(&self, runner: &AmbientRunnerHandle) { + for ch in self.channels.iter().filter(|c| c.is_reply_enabled()) { + let ch = Arc::clone(ch); + let runner = runner.clone(); + tokio::spawn(async move { + logging::info(&format!("{} reply loop spawned", ch.name())); + ch.reply_loop(runner).await; + }); + } + } + + pub fn channel_names(&self) -> Vec { + self.channels.iter().map(|c| c.name().to_string()).collect() + } + + pub fn find_by_name(&self, name: &str) -> Option> { + let channel = self.channels.iter().find(|c| c.name() == name).cloned(); + if channel.is_none() { + logging::debug(&format!("channel lookup missed name={name}")); + } + channel + } + + pub fn send_enabled(&self) -> Vec> { + self.channels + .iter() + .filter(|c| c.is_send_enabled()) + .cloned() + .collect() + } +} + +// --------------------------------------------------------------------------- +// Telegram channel +// --------------------------------------------------------------------------- + +pub struct TelegramChannel { + token: String, + chat_id: String, + reply_enabled: bool, + client: reqwest::Client, +} + +impl TelegramChannel { + pub fn new(token: String, chat_id: String, reply_enabled: bool) -> Self { + Self { + token, + chat_id, + reply_enabled, + client: crate::provider::shared_http_client(), + } + } +} + +#[async_trait] +impl MessageChannel for TelegramChannel { + fn name(&self) -> &str { + "telegram" + } + + fn is_send_enabled(&self) -> bool { + true + } + + fn is_reply_enabled(&self) -> bool { + self.reply_enabled + } + + async fn send(&self, text: &str) -> anyhow::Result<()> { + logging::debug(&format!( + "sending telegram notification bytes={}", + text.len() + )); + crate::telegram::send_message(&self.client, &self.token, &self.chat_id, text).await + } + + async fn reply_loop(&self, runner: AmbientRunnerHandle) { + let mut offset: Option = None; + + loop { + match crate::telegram::get_updates(&self.client, &self.token, offset, 30).await { + Ok(updates) => { + if !updates.is_empty() { + logging::debug(&format!( + "telegram reply loop received update_count={}", + updates.len() + )); + } + for update in updates { + offset = Some(update.update_id + 1); + + let msg = match update.message { + Some(m) => m, + None => continue, + }; + + if msg.chat.id.to_string() != self.chat_id { + continue; + } + + let text = match msg.text { + Some(t) => t, + None => continue, + }; + + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) { + let (approved, message) = + crate::notifications::parse_permission_reply(trimmed); + if let Err(e) = crate::safety::record_permission_via_file( + &req_id, + approved, + "telegram_reply", + message, + ) { + logging::error(&format!( + "Failed to record permission from Telegram for {}: {}", + req_id, e + )); + } else { + logging::info(&format!( + "Permission {} via Telegram: {}", + if approved { "approved" } else { "denied" }, + req_id + )); + let _ = self + .send(&format!( + "✅ Permission {} for `{}`", + if approved { "approved" } else { "denied" }, + req_id + )) + .await; + } + } else { + let injected = runner.inject_message(trimmed, "telegram").await; + logging::info(&format!( + "telegram reply injected into session injected={}", + injected + )); + let ack = if injected { + format!("💬 Message sent to active session: _{}_", trimmed) + } else { + format!("📋 Message queued, waking agent: _{}_", trimmed) + }; + let _ = self.send(&ack).await; + } + } + } + Err(e) => { + logging::error(&format!("Telegram poll error: {}", e)); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Discord channel +// --------------------------------------------------------------------------- + +pub struct DiscordChannel { + token: String, + channel_id: String, + reply_enabled: bool, + bot_user_id: Option, + client: reqwest::Client, +} + +impl DiscordChannel { + pub fn new( + token: String, + channel_id: String, + reply_enabled: bool, + bot_user_id: Option, + ) -> Self { + Self { + token, + channel_id, + reply_enabled, + bot_user_id, + client: crate::provider::shared_http_client(), + } + } + + async fn poll_messages(&self, after: Option<&str>) -> anyhow::Result> { + logging::debug(&format!( + "polling discord messages after_present={}", + after.is_some() + )); + let mut url = format!( + "https://discord.com/api/v10/channels/{}/messages?limit=10", + self.channel_id + ); + if let Some(after_id) = after { + url.push_str(&format!("&after={}", after_id)); + } + + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bot {}", self.token)) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + logging::warn(&format!("discord message poll returned status={status}")); + anyhow::bail!("Discord messages error ({}): {}", status, body); + } + + let messages: Vec = resp.json().await?; + logging::debug(&format!( + "discord message poll returned count={}", + messages.len() + )); + Ok(messages) + } +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct DiscordMessage { + pub id: String, + pub content: String, + pub author: DiscordAuthor, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct DiscordAuthor { + pub id: String, + pub bot: Option, +} + +#[async_trait] +impl MessageChannel for DiscordChannel { + fn name(&self) -> &str { + "discord" + } + + fn is_send_enabled(&self) -> bool { + true + } + + fn is_reply_enabled(&self) -> bool { + self.reply_enabled + } + + async fn send(&self, text: &str) -> anyhow::Result<()> { + let url = format!( + "https://discord.com/api/v10/channels/{}/messages", + self.channel_id + ); + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bot {}", self.token)) + .json(&serde_json::json!({ "content": text })) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Discord API error ({}): {}", status, body); + } + + logging::info("Discord notification sent"); + Ok(()) + } + + async fn reply_loop(&self, runner: AmbientRunnerHandle) { + let mut last_seen_id: Option = None; + + // Get the latest message ID on startup so we don't replay old messages + match self.poll_messages(None).await { + Ok(msgs) => { + if let Some(latest) = msgs.first() { + last_seen_id = Some(latest.id.clone()); + } + } + Err(e) => { + logging::error(&format!("Discord initial poll error: {}", e)); + } + } + + loop { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + match self.poll_messages(last_seen_id.as_deref()).await { + Ok(msgs) => { + // Discord returns newest first, reverse for chronological order + let mut msgs = msgs; + msgs.reverse(); + + for msg in msgs { + last_seen_id = Some(msg.id.clone()); + + // Skip messages from bots (including ourselves) + if msg.author.bot.unwrap_or(false) { + continue; + } + + // If we know our bot user ID, also skip our own messages + if let Some(ref bot_id) = self.bot_user_id + && msg.author.id == *bot_id + { + continue; + } + + let trimmed = msg.content.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) { + let (approved, message) = + crate::notifications::parse_permission_reply(trimmed); + if let Err(e) = crate::safety::record_permission_via_file( + &req_id, + approved, + "discord_reply", + message, + ) { + logging::error(&format!( + "Failed to record permission from Discord for {}: {}", + req_id, e + )); + } else { + logging::info(&format!( + "Permission {} via Discord: {}", + if approved { "approved" } else { "denied" }, + req_id + )); + let _ = self + .send(&format!( + "✅ Permission {} for `{}`", + if approved { "approved" } else { "denied" }, + req_id + )) + .await; + } + } else { + let injected = runner.inject_message(trimmed, "discord").await; + logging::info(&format!( + "discord reply injected into session injected={}", + injected + )); + let ack = if injected { + format!("💬 Message sent to active session: *{}*", trimmed) + } else { + format!("📋 Message queued, waking agent: *{}*", trimmed) + }; + let _ = self.send(&ack).await; + } + } + } + Err(e) => { + logging::error(&format!("Discord poll error: {}", e)); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Jade cloud relay channel +// --------------------------------------------------------------------------- + +/// Remote control via the Jade cloud relay (an append-only per-session event +/// log in AWS). Unlike the WebSocket gateway, nothing listens on this machine: +/// the laptop only makes outbound long-poll requests, so there is no inbound +/// port to attack. A cloud client posts `prompt` events; this channel injects +/// them into the live session and posts the agent's reply back as a `response` +/// event for the cloud client to read. +pub struct JadeRelayChannel { + /// API base URL, normalized to end with a single '/'. + api_base: String, + token: String, + token_id: Option, + user_id: String, + session_id: String, + reply_enabled: bool, + client: reqwest::Client, +} + +impl JadeRelayChannel { + pub fn new( + api_base: String, + token: String, + token_id: Option, + user_id: String, + session_id: String, + reply_enabled: bool, + ) -> Self { + let api_base = if api_base.ends_with('/') { + api_base + } else { + format!("{}/", api_base) + }; + Self { + api_base, + token, + token_id, + user_id, + session_id, + reply_enabled, + client: crate::provider::shared_http_client(), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.api_base, path.trim_start_matches('/')) + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let mut req = req.header("Authorization", format!("Bearer {}", self.token)); + if let Some(id) = &self.token_id { + req = req.header("x-jade-token-id", id); + } + req + } + + /// Register/heartbeat this device so the cloud can show it as online. + async fn heartbeat(&self, device_id: &str) { + let body = serde_json::json!({ + "user_id": self.user_id, + "device_id": device_id, + "label": device_id, + "platform": std::env::consts::OS, + }); + let req = self.auth(self.client.post(self.url("v1/devices")).json(&body)); + if let Err(e) = req.send().await { + logging::debug(&format!("jade relay heartbeat failed: {}", e)); + } + } + + /// Long-poll for new prompt events after `after`. Returns (events, next_after). + /// `wait` is the server-side long-poll window in seconds (capped at 25 by the relay). + async fn poll_prompts(&self, after: i64, wait: u32) -> anyhow::Result<(Vec, i64)> { + let session = urlencoding_encode(&self.session_id); + let url = self.url(&format!( + "v1/sessions/{}/events?user_id={}&after={}&types=prompt&wait={}", + session, + urlencoding_encode(&self.user_id), + after, + wait + )); + let resp = self.auth(self.client.get(&url)).send().await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("jade relay poll error ({}): {}", status, body); + } + let parsed: RelayEventsResponse = resp.json().await?; + Ok((parsed.events, parsed.next_after)) + } + + /// Post a response event back to the relay for the cloud client to read. + async fn post_response(&self, text: &str, request_seq: i64) -> anyhow::Result<()> { + let session = urlencoding_encode(&self.session_id); + let body = serde_json::json!({ + "user_id": self.user_id, + "type": "response", + "text": text, + "request_seq": request_seq, + "origin": "jcode", + }); + let resp = self + .auth( + self.client + .post(self.url(&format!("v1/sessions/{}/events", session))) + .json(&body), + ) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let detail = resp.text().await.unwrap_or_default(); + anyhow::bail!("jade relay post error ({}): {}", status, detail); + } + Ok(()) + } +} + +#[derive(Debug, serde::Deserialize)] +struct RelayEventsResponse { + #[serde(default)] + events: Vec, + #[serde(default)] + next_after: i64, +} + +#[derive(Debug, serde::Deserialize)] +struct RelayEvent { + #[serde(default)] + seq: i64, + #[serde(default)] + text: Option, +} + +/// Minimal percent-encoding for path/query segments (alnum and -_.~ pass through). +fn urlencoding_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +#[async_trait] +impl MessageChannel for JadeRelayChannel { + fn name(&self) -> &str { + "jade_relay" + } + + fn is_send_enabled(&self) -> bool { + true + } + + fn is_reply_enabled(&self) -> bool { + // Inbound Jade relay prompts are delivered by server::jade_relay so they + // work even when ambient mode is disabled and target the configured live + // Jcode session directly. Keep this channel for outbound notifications + // only; otherwise ambient mode would start a second poller. + let _configured_for_server_listener = self.reply_enabled; + false + } + + async fn send(&self, text: &str) -> anyhow::Result<()> { + // Cloud notifications (e.g. ambient cycle summaries) are posted as a + // response event with request_seq=0 (not tied to a specific prompt). + self.post_response(text, 0).await + } + + async fn reply_loop(&self, runner: AmbientRunnerHandle) { + let host = std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "laptop".to_string()); + let device_id = format!("jcode-{}", host); + logging::info(&format!( + "jade relay reply loop started channel={}/{}", + self.user_id, self.session_id + )); + // Start after the latest existing prompt so we don't replay history. + let mut after: i64 = match self.poll_prompts(0, 0).await { + Ok((_, next)) => next, + Err(e) => { + logging::error(&format!("jade relay init poll failed: {}", e)); + 0 + } + }; + let mut last_heartbeat = std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(60)) + .unwrap_or_else(std::time::Instant::now); + + loop { + if last_heartbeat.elapsed() >= std::time::Duration::from_secs(30) { + self.heartbeat(&device_id).await; + last_heartbeat = std::time::Instant::now(); + } + match self.poll_prompts(after, 20).await { + Ok((events, next_after)) => { + after = next_after; + for ev in events { + let text = ev.text.unwrap_or_default(); + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) { + let (approved, message) = + crate::notifications::parse_permission_reply(trimmed); + if let Err(e) = crate::safety::record_permission_via_file( + &req_id, + approved, + "jade_relay", + message, + ) { + logging::error(&format!( + "Failed to record permission from jade relay for {}: {}", + req_id, e + )); + } else { + let _ = self + .post_response( + &format!( + "Permission {} for {}", + if approved { "approved" } else { "denied" }, + req_id + ), + ev.seq, + ) + .await; + } + continue; + } + let injected = runner.inject_message(trimmed, "jade_relay").await; + logging::info(&format!( + "jade relay prompt injected seq={} injected={}", + ev.seq, injected + )); + let ack = if injected { + "Message delivered to active session." + } else { + "Message queued; waking agent." + }; + if let Err(e) = self.post_response(ack, ev.seq).await { + logging::error(&format!("jade relay ack post failed: {}", e)); + } + } + } + Err(e) => { + logging::error(&format!("jade relay poll error: {}", e)); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discord_message_parse() { + let json = r#"{ + "id": "123456", + "content": "hello agent", + "author": {"id": "789", "bot": false} + }"#; + let msg: DiscordMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.id, "123456"); + assert_eq!(msg.content, "hello agent"); + assert!(!msg.author.bot.unwrap()); + } + + #[test] + fn test_discord_bot_message_parse() { + let json = r#"{ + "id": "999", + "content": "bot response", + "author": {"id": "111", "bot": true} + }"#; + let msg: DiscordMessage = serde_json::from_str(json).unwrap(); + assert!(msg.author.bot.unwrap()); + } + + #[test] + fn test_relay_events_parse() { + let json = r#"{ + "events": [ + {"seq": 5, "type": "prompt", "text": "run the tests"}, + {"seq": 6, "type": "prompt", "text": "now lint"} + ], + "next_after": 6 + }"#; + let parsed: RelayEventsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.events.len(), 2); + assert_eq!(parsed.events[0].seq, 5); + assert_eq!(parsed.events[0].text.as_deref(), Some("run the tests")); + assert_eq!(parsed.next_after, 6); + } + + #[test] + fn test_relay_events_empty() { + let json = r#"{"events": [], "next_after": 0}"#; + let parsed: RelayEventsResponse = serde_json::from_str(json).unwrap(); + assert!(parsed.events.is_empty()); + assert_eq!(parsed.next_after, 0); + } + + #[test] + fn test_relay_url_encoding() { + assert_eq!(urlencoding_encode("sess-relay-test"), "sess-relay-test"); + assert_eq!(urlencoding_encode("a/b c"), "a%2Fb%20c"); + assert_eq!(urlencoding_encode("user.name~1_2"), "user.name~1_2"); + } + + #[test] + fn test_relay_url_join() { + let ch = JadeRelayChannel::new( + "https://example.com/api".to_string(), + "tok".to_string(), + Some("jeremy".to_string()), + "jeremy".to_string(), + "sess-1".to_string(), + true, + ); + assert_eq!(ch.url("v1/devices"), "https://example.com/api/v1/devices"); + assert_eq!(ch.url("/v1/devices"), "https://example.com/api/v1/devices"); + } + + #[test] + fn test_relay_registry_wiring() { + // Disabled: not registered. + let cfg = SafetyConfig::default(); + let reg = ChannelRegistry::from_config(&cfg); + assert!(!reg.channel_names().iter().any(|n| n == "jade_relay")); + + // Enabled but incomplete: skipped with a warning. + let mut cfg = SafetyConfig { + jade_relay_enabled: true, + ..SafetyConfig::default() + }; + let reg = ChannelRegistry::from_config(&cfg); + assert!(!reg.channel_names().iter().any(|n| n == "jade_relay")); + + // Enabled and complete: registered. + cfg.jade_relay_api_base = Some("https://example.com/".to_string()); + cfg.jade_relay_token = Some("tok".to_string()); + cfg.jade_relay_session_id = Some("sess-1".to_string()); + let reg = ChannelRegistry::from_config(&cfg); + assert!(reg.channel_names().iter().any(|n| n == "jade_relay")); + } + + /// Live end-to-end test against the real Jade relay. Ignored by default; + /// run with the relay env vars set: + /// JADE_RELAY_API_BASE, JADE_RELAY_TOKEN, JADE_RELAY_TOKEN_ID, + /// JADE_RELAY_USER_ID, JADE_RELAY_SESSION_ID + /// cargo test -p jcode-app-core relay_live -- --ignored --nocapture + #[tokio::test] + #[ignore = "requires live Jade relay credentials"] + async fn test_relay_live_roundtrip() { + let api_base = match std::env::var("JADE_RELAY_API_BASE") { + Ok(v) => v, + Err(_) => { + eprintln!("skipping: JADE_RELAY_API_BASE not set"); + return; + } + }; + let token = std::env::var("JADE_RELAY_TOKEN").expect("JADE_RELAY_TOKEN"); + let token_id = std::env::var("JADE_RELAY_TOKEN_ID").ok(); + let user_id = std::env::var("JADE_RELAY_USER_ID").unwrap_or_else(|_| "jeremy".to_string()); + let session_id = std::env::var("JADE_RELAY_SESSION_ID") + .unwrap_or_else(|_| format!("rust-live-{}", chrono::Utc::now().timestamp())); + + let ch = JadeRelayChannel::new( + api_base, + token, + token_id.clone(), + user_id.clone(), + session_id.clone(), + true, + ); + + // 1) heartbeat (device register) + ch.heartbeat("jcode-test-device").await; + + // 2) baseline cursor: no prompts yet + let (events, after) = ch.poll_prompts(0, 0).await.expect("baseline poll"); + eprintln!("baseline: {} events, next_after={}", events.len(), after); + + // 3) simulate a cloud client posting a prompt by POSTing a prompt event + let prompt_text = format!( + "hello from rust live test {}", + chrono::Utc::now().timestamp() + ); + let prompt_body = serde_json::json!({ + "user_id": user_id, + "type": "prompt", + "text": prompt_text, + "origin": "rust-test-client", + }); + let resp = ch + .auth( + ch.client + .post(ch.url(&format!( + "v1/sessions/{}/events", + urlencoding_encode(&session_id) + ))) + .json(&prompt_body), + ) + .send() + .await + .expect("post prompt"); + assert!( + resp.status().is_success(), + "post prompt status {}", + resp.status() + ); + + // 4) the channel polls and sees the prompt + let (events, after2) = ch.poll_prompts(after, 5).await.expect("poll after prompt"); + assert!(!events.is_empty(), "expected at least one prompt event"); + let prompt_ev = events + .iter() + .find(|e| e.text.as_deref() == Some(prompt_text.as_str())) + .expect("our prompt event present"); + eprintln!("received prompt seq={} after2={}", prompt_ev.seq, after2); + + // 5) the channel posts a response tied to that prompt's seq + let reply = format!("rust live reply to seq {}", prompt_ev.seq); + ch.post_response(&reply, prompt_ev.seq) + .await + .expect("post response"); + + // 6) verify the response is visible (poll all event types via raw GET) + let verify_url = ch.url(&format!( + "v1/sessions/{}/events?user_id={}&after=0&types=response&wait=5", + urlencoding_encode(&session_id), + urlencoding_encode(&user_id) + )); + let verify: RelayEventsResponse = ch + .auth(ch.client.get(&verify_url)) + .send() + .await + .expect("verify get") + .json() + .await + .expect("verify json"); + assert!( + verify + .events + .iter() + .any(|e| e.text.as_deref() == Some(reply.as_str())), + "response event should be readable back from the relay" + ); + eprintln!("LIVE ROUNDTRIP OK: prompt -> poll -> response verified"); + } +} diff --git a/crates/jcode-app-core/src/external_auth.rs b/crates/jcode-app-core/src/external_auth.rs new file mode 100644 index 0000000..0eaea1e --- /dev/null +++ b/crates/jcode-app-core/src/external_auth.rs @@ -0,0 +1,727 @@ +//! External-auth-source review and auto-import flow. +//! +//! Discovers credentials left behind by other tools (Claude Code, Codex, +//! Copilot, Cursor, Gemini CLI, ...), asks the user to approve trusting them, +//! and imports approved sources. This is provider/auth domain logic that +//! depends only on core modules (`auth`, `config`, `provider`, +//! `provider_catalog`), so it lives in the core layer and can be driven by +//! both the CLI login flow and the TUI auth UI. + +use anyhow::Result; +use std::io::{self, IsTerminal, Write}; + +use crate::auth; + +pub fn can_prompt_for_external_auth() -> bool { + std::io::stdin().is_terminal() + && std::io::stderr().is_terminal() + && std::env::var("JCODE_NON_INTERACTIVE").is_err() +} + +pub fn external_auth_blocked_message( + provider_name: &str, + source_name: &str, + path: &std::path::Path, + login_hint: &str, +) -> String { + format!( + "Found existing {} credentials from {} at {} but jcode will not read them without confirmation. Re-run in an interactive terminal to approve this auth source for future jcode sessions, or run `{}`.", + provider_name, + source_name, + path.display(), + login_hint + ) +} + +pub fn prompt_to_trust_external_auth( + provider_name: &str, + source_name: &str, + path: &std::path::Path, +) -> Result { + eprintln!(); + eprintln!( + "Found existing {} credentials from {} at {}.", + provider_name, + source_name, + path.display() + ); + eprintln!("jcode will only read that source in place after you approve it."); + eprintln!("It will not move, delete, or rewrite the original auth there."); + eprint!("Trust this auth source for future jcode sessions? [y/N]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(matches!( + input.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExternalAuthReviewAction { + SharedExternal(auth::external::ExternalAuthSource), + CodexLegacy, + ClaudeCode, + /// Claude Code's native credentials (macOS Keychain item or + /// `CLAUDE_CODE_OAUTH_TOKEN` env var), which have no stable on-disk path. + ClaudeCodeNative, + GeminiCli, + Copilot(auth::copilot::ExternalCopilotAuthSource), + Cursor(auth::cursor::ExternalCursorAuthSource), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalAuthReviewCandidate { + pub(crate) provider_summary: String, + pub(crate) source_name: String, + pub(crate) path: std::path::PathBuf, + action: ExternalAuthReviewAction, +} + +// Read-only accessors. Kept available outside tests so onboarding/UI can +// summarize detected import candidates (e.g. for the first-run welcome card). +impl ExternalAuthReviewCandidate { + pub fn provider_summary(&self) -> &str { + &self.provider_summary + } + + pub fn source_name(&self) -> &str { + &self.source_name + } + + /// Build a synthetic candidate for tests / UI fixtures. The resulting + /// candidate points at the legacy Codex action so it can be summarized and + /// rendered, but is not expected to actually import successfully. + #[doc(hidden)] + pub fn fixture(provider_summary: impl Into, source_name: impl Into) -> Self { + Self { + provider_summary: provider_summary.into(), + source_name: source_name.into(), + path: std::path::PathBuf::from("/dev/null"), + action: ExternalAuthReviewAction::CodexLegacy, + } + } +} + +impl ExternalAuthReviewCandidate { + /// Coarse telemetry `(provider, method)` labels for the providers this + /// candidate activates on a successful import. Used by the onboarding flow + /// to record `auth_success` so auto-imported logins show up in the + /// activation funnel (they previously did not, because auto-import never + /// flows through the manual `pending_login` telemetry path). + /// + /// The method is reported as `"import"` so import-driven activation can be + /// distinguished from manual login in the funnel. + pub fn telemetry_auth_labels(&self) -> Vec<(&'static str, &'static str)> { + const METHOD: &str = "import"; + match &self.action { + ExternalAuthReviewAction::CodexLegacy => vec![("openai", METHOD)], + ExternalAuthReviewAction::ClaudeCode => vec![("claude", METHOD)], + ExternalAuthReviewAction::ClaudeCodeNative => vec![("claude", METHOD)], + ExternalAuthReviewAction::GeminiCli => vec![("gemini", METHOD)], + ExternalAuthReviewAction::Copilot(_) => vec![("copilot", METHOD)], + ExternalAuthReviewAction::Cursor(_) => vec![("cursor", METHOD)], + ExternalAuthReviewAction::SharedExternal(source) => { + auth::external::source_provider_labels(*source) + .into_iter() + .filter_map(|label| { + telemetry_provider_id_for_label(label).map(|id| (id, METHOD)) + }) + .collect() + } + } + } +} + +/// Map a human-facing provider label (as produced by +/// [`auth::external::source_provider_labels`]) to the canonical telemetry +/// provider id used by the activation funnel. +fn telemetry_provider_id_for_label(label: &str) -> Option<&'static str> { + match label { + "OpenAI/Codex" => Some("openai"), + "Claude" => Some("claude"), + "Gemini" => Some("gemini"), + "Antigravity" => Some("antigravity"), + "GitHub Copilot" => Some("copilot"), + "OpenRouter/API-key providers" => Some("openrouter"), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalAuthAutoImportOutcome { + pub imported: usize, + pub messages: Vec, + /// Coarse `(provider, method)` telemetry labels for each provider that was + /// successfully imported, so callers can record `auth_success` for the + /// activation funnel. May contain more entries than `imported` when a + /// single source carries multiple providers. + pub imported_auth_labels: Vec<(&'static str, &'static str)>, +} + +impl ExternalAuthAutoImportOutcome { + pub fn render_markdown(&self) -> String { + if self.messages.is_empty() { + return "No external logins were imported.".to_string(); + } + + // Messages are tagged with a leading "✓"/"✕" marker by the importer. + let imported: Vec<&String> = self + .messages + .iter() + .filter(|m| m.starts_with('✓')) + .collect(); + let skipped: Vec<&String> = self + .messages + .iter() + .filter(|m| m.starts_with('✕')) + .collect(); + + let mut out = String::from("**Logins imported**\n"); + out.push('\n'); + if imported.is_empty() { + out.push_str("No logins could be imported."); + } else { + out.push_str(&format!( + "Reusing {} existing login{}:", + imported.len(), + if imported.len() == 1 { "" } else { "s" } + )); + } + for line in &imported { + out.push_str("\n- "); + out.push_str(line.trim_start_matches('✓').trim()); + } + + if !skipped.is_empty() { + out.push_str(&format!( + "\n\nSkipped {} source{}:", + skipped.len(), + if skipped.len() == 1 { "" } else { "s" } + )); + for line in &skipped { + out.push_str("\n- "); + out.push_str(line.trim_start_matches('✕').trim()); + } + } + + out + } +} + +pub fn pending_external_auth_review_candidates() -> Result> { + let mut candidates = Vec::new(); + + for source in auth::external::unconsented_sources() { + let provider_summary = auth::external::source_provider_labels(source).join(", "); + if provider_summary.is_empty() { + continue; + } + candidates.push(ExternalAuthReviewCandidate { + provider_summary, + source_name: source.display_name().to_string(), + path: source.path()?, + action: ExternalAuthReviewAction::SharedExternal(source), + }); + } + + if auth::codex::has_unconsented_legacy_credentials() { + candidates.push(ExternalAuthReviewCandidate { + provider_summary: "OpenAI/Codex".to_string(), + source_name: "Codex auth.json".to_string(), + path: auth::codex::legacy_auth_file_path()?, + action: ExternalAuthReviewAction::CodexLegacy, + }); + } + + if let Some(source) = auth::claude::has_unconsented_external_auth() + && matches!(source, auth::claude::ExternalClaudeAuthSource::ClaudeCode) + { + candidates.push(ExternalAuthReviewCandidate { + provider_summary: "Claude".to_string(), + source_name: source.display_name().to_string(), + path: source.path()?, + action: ExternalAuthReviewAction::ClaudeCode, + }); + } + + // Claude Code's native credentials live in the macOS Keychain (or the + // CLAUDE_CODE_OAUTH_TOKEN env var), not in the JSON file. Offer them when + // the JSON file was not already detected above, so macOS users (where the + // file usually does not exist) can still import their Claude login. + if !auth::claude::native_source_allowed() + && !candidates + .iter() + .any(|candidate| matches!(candidate.action, ExternalAuthReviewAction::ClaudeCode)) + && auth::claude::native_credentials_present() + { + candidates.push(ExternalAuthReviewCandidate { + provider_summary: "Claude".to_string(), + source_name: auth::claude::native_source_display_name().to_string(), + path: auth::claude::native_source_path_hint(), + action: ExternalAuthReviewAction::ClaudeCodeNative, + }); + } + + if auth::gemini::has_unconsented_cli_auth() { + candidates.push(ExternalAuthReviewCandidate { + provider_summary: "Gemini".to_string(), + source_name: "Gemini CLI".to_string(), + path: auth::gemini::gemini_cli_oauth_path()?, + action: ExternalAuthReviewAction::GeminiCli, + }); + } + + if let Some(source) = auth::copilot::has_unconsented_external_auth() + && !matches!( + source, + auth::copilot::ExternalCopilotAuthSource::OpenCodeAuth + | auth::copilot::ExternalCopilotAuthSource::PiAuth + ) + { + candidates.push(ExternalAuthReviewCandidate { + provider_summary: "GitHub Copilot".to_string(), + source_name: source.display_name().to_string(), + path: source.path(), + action: ExternalAuthReviewAction::Copilot(source), + }); + } + + if let Some(source) = auth::cursor::has_unconsented_external_auth() { + candidates.push(ExternalAuthReviewCandidate { + provider_summary: "Cursor".to_string(), + source_name: source.display_name().to_string(), + path: source.path()?, + action: ExternalAuthReviewAction::Cursor(source), + }); + } + + Ok(candidates) +} + +pub fn parse_external_auth_review_selection(input: &str, count: usize) -> Result> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + if matches!(trimmed.to_ascii_lowercase().as_str(), "a" | "all") { + return Ok((0..count).collect()); + } + + let mut selected = Vec::new(); + for part in trimmed.split(',') { + let value = part.trim(); + if value.is_empty() { + continue; + } + let index: usize = value.parse().map_err(|_| { + anyhow::anyhow!( + "Invalid selection '{}'. Enter numbers like 1,3 or 'a' for all.", + value + ) + })?; + if index == 0 || index > count { + anyhow::bail!( + "Selection '{}' is out of range. Enter 1-{} or 'a' for all.", + index, + count + ); + } + let zero_based = index - 1; + if !selected.contains(&zero_based) { + selected.push(zero_based); + } + } + Ok(selected) +} + +fn prompt_to_review_external_auth_sources( + candidates: &[ExternalAuthReviewCandidate], +) -> Result> { + if candidates.is_empty() { + return Ok(Vec::new()); + } + + eprintln!(); + eprintln!("Found existing logins that jcode can reuse."); + eprintln!("Nothing has been imported yet."); + eprintln!( + "Approve the sources you want jcode to read in place; rejected sources stay untouched." + ); + eprintln!(); + + for (index, candidate) in candidates.iter().enumerate() { + eprintln!( + " {}. {:<22} via {}", + index + 1, + candidate.provider_summary, + candidate.source_name + ); + eprintln!(" {}", candidate.path.display()); + } + + eprintln!(); + eprint!("Approve sources [a=all, Enter=skip, example: 1,3]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + parse_external_auth_review_selection(&input, candidates.len()) +} + +fn approve_external_auth_review_candidate(candidate: &ExternalAuthReviewCandidate) -> Result<()> { + match candidate.action { + ExternalAuthReviewAction::SharedExternal(source) => { + auth::external::trust_external_auth_source(source)? + } + ExternalAuthReviewAction::CodexLegacy => auth::codex::trust_legacy_auth_for_future_use()?, + ExternalAuthReviewAction::ClaudeCode => auth::claude::trust_external_auth_source( + auth::claude::ExternalClaudeAuthSource::ClaudeCode, + )?, + ExternalAuthReviewAction::ClaudeCodeNative => { + // Trust, then snapshot the Keychain/env credentials into jcode's own + // auth.json so future loads and refreshes do not depend on the + // (possibly prompting) Keychain. A successful trust with a failed + // copy still leaves the env-token path usable. + auth::claude::trust_native_source()?; + if let Err(err) = auth::claude::import_native_credentials_into_account() { + crate::logging::warn(&format!( + "Trusted Claude Code native credentials but could not snapshot them into jcode: {err}" + )); + } + } + ExternalAuthReviewAction::GeminiCli => auth::gemini::trust_cli_auth_for_future_use()?, + ExternalAuthReviewAction::Copilot(source) => { + auth::copilot::trust_external_auth_source(source)? + } + ExternalAuthReviewAction::Cursor(source) => { + auth::cursor::trust_external_auth_source(source)? + } + } + Ok(()) +} + +fn revoke_external_auth_review_candidate(candidate: &ExternalAuthReviewCandidate) -> Result<()> { + match candidate.action { + ExternalAuthReviewAction::SharedExternal(source) => { + crate::config::Config::revoke_external_auth_source_for_path( + source.source_id(), + &candidate.path, + )? + } + ExternalAuthReviewAction::CodexLegacy => { + crate::config::Config::revoke_external_auth_source_for_path( + auth::codex::LEGACY_CODEX_AUTH_SOURCE_ID, + &candidate.path, + )? + } + ExternalAuthReviewAction::ClaudeCode => { + crate::config::Config::revoke_external_auth_source_for_path( + auth::claude::CLAUDE_CODE_AUTH_SOURCE_ID, + &candidate.path, + )? + } + ExternalAuthReviewAction::ClaudeCodeNative => { + crate::config::Config::revoke_external_auth_source( + auth::claude::CLAUDE_CODE_NATIVE_AUTH_SOURCE_ID, + )? + } + ExternalAuthReviewAction::GeminiCli => { + crate::config::Config::revoke_external_auth_source_for_path( + auth::gemini::GEMINI_CLI_AUTH_SOURCE_ID, + &candidate.path, + )? + } + ExternalAuthReviewAction::Copilot(source) => { + crate::config::Config::revoke_external_auth_source_for_path( + source.source_id(), + &candidate.path, + )? + } + ExternalAuthReviewAction::Cursor(source) => { + crate::config::Config::revoke_external_auth_source_for_path( + source.source_id(), + &candidate.path, + )? + } + } + Ok(()) +} + +/// Render a human-friendly note about how soon a token-based credential +/// expires, used to tell the user when a re-login is likely needed without +/// failing the import. +fn token_freshness_note(expires_at_ms: i64) -> String { + let now_ms = chrono::Utc::now().timestamp_millis(); + if expires_at_ms <= now_ms { + " The access token is expired; jcode will refresh it on first use, or run /login if that fails.".to_string() + } else { + String::new() + } +} + +// Import validation is intentionally *non-destructive*: it only checks that +// reusable credentials are present after trusting the source. It must NOT +// perform a live OAuth refresh, because OAuth refresh tokens are single-use -- +// refreshing here would rotate (and thus burn) the source's refresh token and +// then discard the rotated result, breaking both jcode and the original tool. +// Expired tokens are still imported: they get refreshed lazily (and persisted) +// at request time, or the user is prompted to /login. + +async fn validate_claude_import() -> Result { + let creds = auth::claude::load_credentials()?; + if creds.access_token.trim().is_empty() && creds.refresh_token.trim().is_empty() { + anyhow::bail!("Claude source did not expose a usable access or refresh token."); + } + Ok(format!( + "Loaded Claude credentials.{}", + token_freshness_note(creds.expires_at) + )) +} + +async fn validate_openai_import() -> Result { + let creds = auth::codex::load_credentials()?; + if creds.refresh_token.trim().is_empty() { + if creds.access_token.trim().is_empty() { + anyhow::bail!("OpenAI source did not expose a usable token or API key."); + } + return Ok("Loaded OpenAI API key credentials.".to_string()); + } + Ok(format!( + "Loaded OpenAI OAuth credentials.{}", + creds + .expires_at + .map(token_freshness_note) + .unwrap_or_default() + )) +} + +async fn validate_gemini_import() -> Result { + let tokens = auth::gemini::load_tokens()?; + Ok(format!( + "Loaded Gemini credentials.{}", + token_freshness_note(tokens.expires_at) + )) +} + +async fn validate_antigravity_import() -> Result { + let tokens = auth::antigravity::load_tokens()?; + Ok(format!( + "Loaded Antigravity credentials.{}", + token_freshness_note(tokens.expires_at) + )) +} + +async fn validate_copilot_import() -> Result { + // Presence check only: confirm a GitHub token is readable. The + // GitHub->Copilot exchange happens lazily at request time. + let _github_token = auth::copilot::load_github_token()?; + Ok("Loaded GitHub Copilot credentials.".to_string()) +} + +async fn validate_cursor_import() -> Result { + let has_api_key = auth::cursor::has_cursor_api_key(); + let has_vscdb = auth::cursor::has_cursor_vscdb_token(); + if has_api_key || has_vscdb { + Ok(format!( + "Cursor native source loaded (api_key={}, vscdb_token={}).", + has_api_key, has_vscdb + )) + } else { + anyhow::bail!("Cursor source did not expose a usable auth token.") + } +} + +fn validate_openrouter_like_import() -> Result { + for (env_key, env_file) in crate::provider_catalog::openrouter_like_api_key_sources() { + if crate::provider_catalog::load_api_key_from_env_or_config(&env_key, &env_file).is_some() { + return Ok(format!("Loaded API key for `{}`.", env_key)); + } + } + anyhow::bail!("No reusable API key became available after import.") +} + +async fn validate_shared_external_import( + source: auth::external::ExternalAuthSource, +) -> Result { + let mut errors = Vec::new(); + for label in auth::external::source_provider_labels(source) { + let result = match label { + "OpenAI/Codex" => validate_openai_import().await, + "Claude" => validate_claude_import().await, + "Gemini" => validate_gemini_import().await, + "Antigravity" => validate_antigravity_import().await, + "GitHub Copilot" => validate_copilot_import().await, + "OpenRouter/API-key providers" => validate_openrouter_like_import(), + _ => continue, + }; + match result { + Ok(detail) => return Ok(detail), + Err(err) => errors.push(format!("{}: {}", label, err)), + } + } + anyhow::bail!(errors.join("; ")) +} + +async fn validate_external_auth_review_candidate( + candidate: &ExternalAuthReviewCandidate, +) -> Result { + match candidate.action { + ExternalAuthReviewAction::SharedExternal(source) => { + validate_shared_external_import(source).await + } + ExternalAuthReviewAction::CodexLegacy => validate_openai_import().await, + ExternalAuthReviewAction::ClaudeCode => validate_claude_import().await, + ExternalAuthReviewAction::ClaudeCodeNative => validate_claude_import().await, + ExternalAuthReviewAction::GeminiCli => validate_gemini_import().await, + ExternalAuthReviewAction::Copilot(_) => validate_copilot_import().await, + ExternalAuthReviewAction::Cursor(_) => validate_cursor_import().await, + } +} + +pub async fn maybe_run_external_auth_auto_import_flow() -> Result> { + if !can_prompt_for_external_auth() { + return Ok(None); + } + + let candidates = pending_external_auth_review_candidates()?; + if candidates.is_empty() { + return Ok(None); + } + + let selected = prompt_to_review_external_auth_sources(&candidates)?; + let outcome = run_external_auth_auto_import_candidates(&candidates, &selected).await?; + for line in &outcome.messages { + eprintln!("{}", line); + } + auth::AuthStatus::invalidate_cache(); + Ok(Some(outcome.imported)) +} + +pub fn format_external_auth_review_candidates_markdown( + candidates: &[ExternalAuthReviewCandidate], +) -> String { + let mut message = String::from( + "**Auto Import Existing Logins**\n\nFound existing logins that jcode can reuse. Nothing has been imported yet.\n\nReply with `a` to approve all, `1,3` to approve specific sources, or `/cancel` to abort.\n", + ); + for (index, candidate) in candidates.iter().enumerate() { + message.push_str(&format!( + "\n{}. **{}** via {}\n - `{}`\n", + index + 1, + candidate.provider_summary, + candidate.source_name, + candidate.path.display() + )); + } + message +} + +pub async fn run_external_auth_auto_import_candidates( + candidates: &[ExternalAuthReviewCandidate], + selected: &[usize], +) -> Result { + let mut outcome = ExternalAuthAutoImportOutcome { + imported: 0, + messages: Vec::new(), + imported_auth_labels: Vec::new(), + }; + + for &index in selected { + let Some(candidate) = candidates.get(index) else { + continue; + }; + approve_external_auth_review_candidate(candidate)?; + match validate_external_auth_review_candidate(candidate).await { + Ok(detail) => { + outcome.imported += 1; + outcome + .imported_auth_labels + .extend(candidate.telemetry_auth_labels()); + outcome.messages.push(format!( + "✓ {} (from {}): {}", + candidate.provider_summary, candidate.source_name, detail + )); + } + Err(err) => { + let _ = revoke_external_auth_review_candidate(candidate); + outcome.messages.push(format!( + "✕ {} (from {}): {}", + candidate.provider_summary, candidate.source_name, err + )); + } + } + } + + auth::AuthStatus::invalidate_cache(); + Ok(outcome) +} + +#[cfg(test)] +mod render_markdown_tests { + use super::ExternalAuthAutoImportOutcome; + + #[test] + fn empty_outcome_reports_nothing_imported() { + let outcome = ExternalAuthAutoImportOutcome { + imported: 0, + messages: Vec::new(), + imported_auth_labels: Vec::new(), + }; + assert_eq!( + outcome.render_markdown(), + "No external logins were imported." + ); + } + + #[test] + fn groups_imported_and_skipped_with_counts() { + let outcome = ExternalAuthAutoImportOutcome { + imported: 2, + messages: vec![ + "✓ OpenAI/Codex (from Codex auth.json): Loaded OpenAI OAuth credentials." + .to_string(), + "✓ Claude (from Claude Code): Loaded Claude credentials.".to_string(), + "✕ Cursor (from Cursor native): no usable auth token.".to_string(), + ], + imported_auth_labels: vec![("openai", "import"), ("claude", "import")], + }; + let md = outcome.render_markdown(); + assert!(md.starts_with("**Logins imported**"), "got: {md}"); + assert!(md.contains("Reusing 2 existing logins:"), "got: {md}"); + assert!( + md.contains("- OpenAI/Codex (from Codex auth.json): Loaded OpenAI OAuth credentials."), + "got: {md}" + ); + assert!(md.contains("Skipped 1 source:"), "got: {md}"); + assert!( + md.contains("- Cursor (from Cursor native): no usable auth token."), + "got: {md}" + ); + // Markers themselves should be stripped from the rendered list. + assert!(!md.contains('✓'), "got: {md}"); + assert!(!md.contains('✕'), "got: {md}"); + } + + #[test] + fn singular_wording_for_one_login() { + let outcome = ExternalAuthAutoImportOutcome { + imported: 1, + messages: vec!["✓ Gemini (from Gemini CLI): Loaded Gemini credentials.".to_string()], + imported_auth_labels: vec![("gemini", "import")], + }; + let md = outcome.render_markdown(); + assert!(md.contains("Reusing 1 existing login:"), "got: {md}"); + } + + #[test] + fn fixture_candidate_reports_import_auth_labels() { + use super::ExternalAuthReviewCandidate; + // The fixture points at the legacy Codex action -> OpenAI provider. + let candidate = ExternalAuthReviewCandidate::fixture("OpenAI/Codex", "Codex auth.json"); + assert_eq!( + candidate.telemetry_auth_labels(), + vec![("openai", "import")] + ); + } +} diff --git a/crates/jcode-app-core/src/lib.rs b/crates/jcode-app-core/src/lib.rs new file mode 100644 index 0000000..dc08338 --- /dev/null +++ b/crates/jcode-app-core/src/lib.rs @@ -0,0 +1,66 @@ +#![allow( + unknown_lints, + clippy::collapsible_match, + clippy::manual_checked_ops, + clippy::unnecessary_sort_by, + clippy::useless_conversion +)] +// The `swarm` tool's `json!` parameter schema is large; the default macro +// recursion limit (128) is exceeded once more properties are added. +#![recursion_limit = "256"] + +//! Application core for jcode (upper layer). +//! +//! This crate holds the server/tool/agent layer and its presentation-adjacent +//! leaves. The foundational layer it builds on (provider, auth, config, session, +//! message, memory, telemetry, ...) lives in the `jcode-base` crate and is +//! re-exported here via `pub use jcode_base::*`, so every existing +//! `crate::` path (e.g. `crate::config`, `crate::provider`) keeps +//! resolving unchanged across this crate and the root `jcode` crate, which in +//! turn re-exports this crate via `pub use jcode_app_core::*`. + +// Foundational layer: re-export every `jcode-base` module so `crate::` +// paths resolve here exactly as they did before the split. +pub use jcode_base::*; + +// Upper layer (server / tool / agent and supporting leaves). +pub mod agent; +pub mod ambient; +pub mod ambient_runner; +pub mod ambient_scheduler; +pub mod build; +pub mod catchup; +pub mod channel; +pub mod external_auth; +pub mod mission; +pub mod network_retry; +pub mod notifications; +pub mod overnight; +pub mod perf; +pub mod replay; +pub mod restart_snapshot; +pub mod server; +pub mod server_spawn; +pub mod session_effort; +pub mod session_launch; +pub mod session_rebuild; +pub mod setup_hints; +pub mod ssh_remote; +pub mod startup_profile; +pub mod tool; +pub mod turn_cancel_registry; +pub mod update; + +use std::sync::Mutex; + +static CURRENT_SESSION_ID: Mutex> = Mutex::new(None); + +pub fn set_current_session(session_id: &str) { + if let Ok(mut guard) = CURRENT_SESSION_ID.lock() { + *guard = Some(session_id.to_string()); + } +} + +pub fn get_current_session() -> Option { + CURRENT_SESSION_ID.lock().ok()?.clone() +} diff --git a/crates/jcode-app-core/src/message_notifications.rs b/crates/jcode-app-core/src/message_notifications.rs new file mode 100644 index 0000000..cbba5f5 --- /dev/null +++ b/crates/jcode-app-core/src/message_notifications.rs @@ -0,0 +1,212 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InputShellResult { + pub command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + pub output: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + pub duration_ms: u64, + #[serde(default)] + pub truncated: bool, + #[serde(default)] + pub failed_to_start: bool, +} + +fn sanitize_fenced_block(text: &str) -> String { + text.replace("```", "``\u{200b}`") +} + +pub fn format_input_shell_result_markdown(shell: &InputShellResult) -> String { + let status = if shell.failed_to_start { + "✗ failed to start".to_string() + } else if shell.exit_code == Some(0) { + "✓ exit 0".to_string() + } else if let Some(code) = shell.exit_code { + format!("✗ exit {}", code) + } else { + "✗ terminated".to_string() + }; + + let mut meta = vec![status, Message::format_duration(shell.duration_ms)]; + if let Some(cwd) = shell.cwd.as_deref() { + meta.push(format!("cwd {}", cwd)); + } + if shell.truncated { + meta.push("truncated".to_string()); + } + + let mut message = format!( + "Shell command · {}\n\n{}", + meta.join(" · "), + indent_block(&shell.command) + ); + + if shell.output.trim().is_empty() { + message.push_str("\n\nNo output."); + } else { + message.push_str("\n\n"); + message.push_str(&indent_block(shell.output.trim_end())); + } + + message +} + +fn indent_block(text: &str) -> String { + text.lines() + .map(|line| format!(" {}", line)) + .collect::>() + .join("\n") +} + +pub fn input_shell_status_notice(shell: &InputShellResult) -> String { + if shell.failed_to_start { + "Shell command failed to start".to_string() + } else if shell.exit_code == Some(0) { + "Shell command completed".to_string() + } else if let Some(code) = shell.exit_code { + format!("Shell command failed (exit {})", code) + } else { + "Shell command terminated".to_string() + } +} + +fn format_background_task_status(status: &BackgroundTaskStatus) -> &'static str { + match status { + BackgroundTaskStatus::Completed => "✓ completed", + BackgroundTaskStatus::Superseded => "↻ superseded", + BackgroundTaskStatus::Failed => "✗ failed", + BackgroundTaskStatus::Running => "running", + } +} + +fn normalize_background_task_preview(preview: &str) -> Option { + let normalized = preview.replace("\r\n", "\n").replace('\r', "\n"); + let trimmed = normalized.trim_end(); + if trimmed.trim().is_empty() { + None + } else { + Some(sanitize_fenced_block(trimmed)) + } +} + +pub fn format_background_task_notification_markdown(task: &BackgroundTaskCompleted) -> String { + let exit_code = task + .exit_code + .map(|code| format!("exit {}", code)) + .unwrap_or_else(|| "exit n/a".to_string()); + + let mut message = format!( + "**Background task** `{}` · `{}` · {} · {:.1}s · {}", + task.task_id, + task.tool_name, + format_background_task_status(&task.status), + task.duration_secs, + exit_code, + ); + + if let Some(preview) = normalize_background_task_preview(&task.output_preview) { + message.push_str(&format!("\n\n```text\n{}\n```", preview)); + } else { + message.push_str("\n\n_No output captured._"); + } + + message.push_str(&format!( + "\n\n_Full output:_ `bg action=\"output\" task_id=\"{}\"`", + task.task_id + )); + + message +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBackgroundTaskNotification { + pub task_id: String, + pub tool_name: String, + pub status: String, + pub duration: String, + pub exit_label: String, + pub preview: Option, + pub full_output_command: String, +} + +pub fn parse_background_task_notification_markdown( + content: &str, +) -> Option { + static HEADER_RE: OnceLock> = OnceLock::new(); + static FULL_OUTPUT_RE: OnceLock> = OnceLock::new(); + + let header_re = HEADER_RE + .get_or_init(|| { + compile_static_regex( + r"^\*\*Background task\*\* `(?P[^`]+)` · `(?P[^`]+)` · (?P.+?) · (?P[0-9]+(?:\.[0-9]+)?s) · (?P.+)$", + ) + }) + .as_ref()?; + let full_output_re = FULL_OUTPUT_RE + .get_or_init(|| compile_static_regex(r#"^_Full output:_ `(?P[^`]+)`$"#)) + .as_ref()?; + + let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); + let mut sections = normalized.split("\n\n"); + let header = sections.next()?.trim(); + let captures = header_re.captures(header)?; + + let mut preview: Option = None; + let mut full_output_command: Option = None; + + for section in sections { + let trimmed = section.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(captures) = full_output_re.captures(trimmed) { + full_output_command = Some(captures["command"].to_string()); + continue; + } + + if trimmed == "_No output captured._" { + preview = None; + continue; + } + + if let Some(fenced) = trimmed + .strip_prefix("```text\n") + .and_then(|body| body.strip_suffix("\n```")) + { + preview = Some(fenced.to_string()); + } + } + + Some(ParsedBackgroundTaskNotification { + task_id: captures["task_id"].to_string(), + tool_name: captures["tool_name"].to_string(), + status: captures["status"].to_string(), + duration: captures["duration"].to_string(), + exit_label: captures["exit_label"].to_string(), + preview, + full_output_command: full_output_command?, + }) +} + +pub fn background_task_status_notice(task: &BackgroundTaskCompleted) -> String { + match task.status { + BackgroundTaskStatus::Completed => { + format!("Background task completed · {}", task.tool_name) + } + BackgroundTaskStatus::Superseded => { + format!("Background task superseded · {}", task.tool_name) + } + BackgroundTaskStatus::Failed => match task.exit_code { + Some(code) => format!( + "Background task failed · {} · exit {}", + task.tool_name, code + ), + None => format!("Background task failed · {}", task.tool_name), + }, + BackgroundTaskStatus::Running => format!("Background task running · {}", task.tool_name), + } +} diff --git a/crates/jcode-app-core/src/mission.rs b/crates/jcode-app-core/src/mission.rs new file mode 100644 index 0000000..651ab67 --- /dev/null +++ b/crates/jcode-app-core/src/mission.rs @@ -0,0 +1,197 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::prompt::MISSION_CONTINUATION_TEMPLATE; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MissionStatus { + Active, + Paused, + Blocked, + NeedsDecision, + BudgetLimited, + Complete, + Abandoned, +} + +impl MissionStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Paused => "paused", + Self::Blocked => "blocked", + Self::NeedsDecision => "needs_decision", + Self::BudgetLimited => "budget_limited", + Self::Complete => "complete", + Self::Abandoned => "abandoned", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MissionCheckpoint { + pub at: DateTime, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Mission { + pub session_id: String, + pub objective: String, + pub long_horizon_intent: String, + pub status: MissionStatus, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub semantic_expansion: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub success_criteria: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub validation_plan: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checkpoints: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub fn load(session_id: &str) -> Result> { + let path = mission_path(session_id)?; + if !path.exists() { + return Ok(None); + } + crate::storage::read_json(&path) +} + +pub fn set(session_id: &str, objective: &str) -> Result { + let objective = objective.trim(); + if objective.is_empty() { + anyhow::bail!("mission objective cannot be empty"); + } + let now = Utc::now(); + let mut mission = load(session_id)?.unwrap_or_else(|| Mission { + session_id: session_id.to_string(), + objective: String::new(), + long_horizon_intent: String::new(), + status: MissionStatus::Active, + semantic_expansion: Vec::new(), + success_criteria: Vec::new(), + validation_plan: Vec::new(), + checkpoints: Vec::new(), + created_at: now, + updated_at: now, + }); + mission.objective = objective.to_string(); + mission.long_horizon_intent = default_long_horizon_intent(objective); + mission.status = MissionStatus::Active; + mission.updated_at = now; + save(&mission)?; + Ok(mission) +} + +pub fn update_status(session_id: &str, status: MissionStatus) -> Result> { + let Some(mut mission) = load(session_id)? else { + return Ok(None); + }; + mission.status = status; + mission.updated_at = Utc::now(); + save(&mission)?; + Ok(Some(mission)) +} + +pub fn checkpoint(session_id: &str, summary: &str) -> Result> { + let summary = summary.trim(); + if summary.is_empty() { + anyhow::bail!("checkpoint summary cannot be empty"); + } + let Some(mut mission) = load(session_id)? else { + return Ok(None); + }; + mission.checkpoints.push(MissionCheckpoint { + at: Utc::now(), + summary: summary.to_string(), + }); + mission.updated_at = Utc::now(); + save(&mission)?; + Ok(Some(mission)) +} + +pub fn clear(session_id: &str) -> Result { + let path = mission_path(session_id)?; + if path.exists() { + std::fs::remove_file(path)?; + Ok(true) + } else { + Ok(false) + } +} + +pub fn render_status(mission: &Mission) -> String { + let mut out = format!( + "Mission **{}**\n\nStatus: **{}**\n\nLong-horizon intent: {}", + mission.objective, + mission.status.as_str(), + mission.long_horizon_intent + ); + if let Some(last) = mission.checkpoints.last() { + out.push_str(&format!("\n\nLast checkpoint: {}", last.summary)); + } + out.push_str("\n\nMission loop: keep updating todos, expand adjacent work, validate progress, and continue until complete, blocked, paused, or a decision is needed."); + out +} + +pub fn active_system_reminder(session_id: &str) -> Result> { + let Some(mission) = load(session_id)? else { + return Ok(None); + }; + if !matches!(mission.status, MissionStatus::Active) { + return Ok(None); + } + Ok(Some(render_mission_continuation_prompt(&mission))) +} + +pub fn render_mission_continuation_prompt(mission: &Mission) -> String { + MISSION_CONTINUATION_TEMPLATE + .replace("{{ objective }}", &escape_xml_text(&mission.objective)) + .replace( + "{{ long_horizon_intent }}", + &escape_xml_text(&mission.long_horizon_intent), + ) +} + +fn escape_xml_text(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn save(mission: &Mission) -> Result<()> { + crate::storage::write_json_fast(&mission_path(&mission.session_id)?, mission) +} + +fn mission_path(session_id: &str) -> Result { + Ok(crate::storage::jcode_dir()? + .join("missions") + .join(format!("{}.json", sanitize_session_id(session_id)))) +} + +fn sanitize_session_id(session_id: &str) -> String { + session_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +fn default_long_horizon_intent(objective: &str) -> String { + format!( + "Interpret `{}` broadly: pursue the literal objective, continuously refresh the todo frontier, include semantically adjacent work that improves the outcome, and preserve long-term quality.", + objective + ) +} diff --git a/crates/jcode-app-core/src/network_retry.rs b/crates/jcode-app-core/src/network_retry.rs new file mode 100644 index 0000000..3c19ced --- /dev/null +++ b/crates/jcode-app-core/src/network_retry.rs @@ -0,0 +1,258 @@ +use std::time::Duration; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use tokio::process::Command; +use tokio::time::sleep; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use tokio::time::timeout; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetworkWaitPlan { + pub reason: String, + pub listener_summary: String, +} + +pub fn classify_network_interruption(error: &(dyn std::error::Error + 'static)) -> Option { + let mut parts = Vec::new(); + let mut current = Some(error); + while let Some(err) = current { + let text = err.to_string().to_ascii_lowercase(); + parts.push(text); + current = err.source(); + } + classify_text(&parts.join(" | ")) +} + +pub fn classify_message(message: &str) -> Option { + classify_text(&message.to_ascii_lowercase()) +} + +fn classify_text(text: &str) -> Option { + let network_markers = [ + "connection reset", + "connection aborted", + "connection refused", + "broken pipe", + "network is unreachable", + "network unreachable", + "host is down", + "no route to host", + "not connected", + // TLS-level drops. rustls reports an abrupt close as "peer closed + // connection without sending TLS close_notify" (its docs URL spells + // it "unexpected-eof", which the plain "unexpected eof" marker below + // does not match). + "close_notify", + "peer closed connection", + "tls handshake eof", + // reqwest/hyper wrap connect-phase failures as "client error + // (Connect)" and connection-level faults as "connection error: ...". + "client error (connect)", + "connection error", + "dns error", + "failed to lookup address", + "temporary failure in name resolution", + "name or service not known", + "could not resolve host", + "couldn't resolve host", + "host is unreachable", + "operation timed out", + "timed out", + "timeout", + "error trying to connect", + "connection closed before message completed", + "unexpected eof", + "end of file before message completed", + ]; + if network_markers.iter().any(|marker| text.contains(marker)) { + return Some("the network connection appears to have dropped".to_string()); + } + None +} + +pub fn wait_plan() -> NetworkWaitPlan { + #[cfg(target_os = "linux")] + { + NetworkWaitPlan { + reason: "stream interrupted by a likely network disconnect".to_string(), + listener_summary: + "listening for Linux netlink changes via `ip monitor`; also verifying with reconnect probes" + .to_string(), + } + } + #[cfg(target_os = "macos")] + { + return NetworkWaitPlan { + reason: "stream interrupted by a likely network disconnect".to_string(), + listener_summary: + "listening for macOS route/interface changes via `route -n monitor`; also verifying with reconnect probes" + .to_string(), + }; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + NetworkWaitPlan { + reason: "stream interrupted by a likely network disconnect".to_string(), + listener_summary: "waiting with lightweight reconnect probes".to_string(), + } + } +} + +pub async fn wait_until_probably_online() { + let mut delay = Duration::from_secs(1); + loop { + if probe_connectivity().await { + return; + } + wait_for_platform_change_or_delay(delay).await; + delay = (delay * 2).min(Duration::from_secs(30)); + } +} + +pub async fn is_probably_online() -> bool { + probe_connectivity().await +} + +async fn probe_connectivity() -> bool { + let client = jcode_provider_core::shared_http_client(); + let request = client + .head("https://www.gstatic.com/generate_204") + .timeout(Duration::from_secs(5)); + matches!(request.send().await, Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204) +} + +async fn wait_for_platform_change_or_delay(delay: Duration) { + #[cfg(target_os = "linux")] + { + if command_exists("ip").await { + let fut = wait_for_command_output("ip", &["monitor", "link", "address", "route"]); + let _ = timeout(delay, fut).await; + return; + } + } + #[cfg(target_os = "macos")] + { + if command_exists("route").await { + let fut = wait_for_command_output("route", &["-n", "monitor"]); + let _ = timeout(delay, fut).await; + return; + } + } + sleep(delay).await; +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn command_exists(command: &str) -> bool { + Command::new("sh") + .arg("-c") + .arg(format!( + "command -v {} >/dev/null 2>&1", + shell_escape(command) + )) + .status() + .await + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn shell_escape(value: &str) -> String { + value.replace('\'', "'\\''") +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +async fn wait_for_command_output(command: &str, args: &[&str]) { + let mut command_builder = Command::new(command); + command_builder + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let mut child = match command_builder.spawn() { + Ok(child) => child, + Err(_) => return, + }; + if let Some(mut stdout) = child.stdout.take() { + use tokio::io::AsyncReadExt; + let mut buf = [0u8; 1]; + let _ = stdout.read(&mut buf).await; + } + let _ = child.kill().await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_common_network_errors() { + assert!(classify_message("connection reset by peer").is_some()); + assert!(classify_message("temporary failure in name resolution").is_some()); + assert!(classify_message("network is unreachable").is_some()); + assert!(classify_message("401 unauthorized").is_none()); + } + + /// Real error strings harvested from ~/.jcode/logs. Every wifi-outage + /// shape observed in the wild must classify as a network interruption so + /// the wait-for-network path engages instead of failing the turn. + #[test] + fn classifies_real_world_outage_errors() { + let real_errors = [ + // DNS failure: wifi down when a new request starts, or link up + // before DHCP delivers DNS servers. + "Failed to send request to Anthropic API: error sending request for url \ + (https://api.anthropic.com/v1/messages): client error (Connect): dns error: \ + failed to lookup address information: Name or service not known", + "error sending request for url (https://models.dev/api.json): client error \ + (Connect): dns error: failed to lookup address information: Temporary failure \ + in name resolution", + // Stale pooled HTTP/2 connection dying after an outage. + "Failed to send request to Anthropic API: error sending request for url \ + (https://api.anthropic.com/v1/messages): client error (SendRequest): \ + http2 error: keep-alive timed out: operation timed out", + // rustls abrupt-close shape (docs URL spells "unexpected-eof" so + // the plain "unexpected eof" marker never matched it). + "error sending request for url (https://generativelanguage.googleapis.com/v1beta/models): \ + client error (SendRequest): connection error: peer closed connection without \ + sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof", + // Connect-phase timeout. + "error sending request for url (https://html.duckduckgo.com/html/): \ + client error (Connect): operation timed out", + // Connection-level timeout on an established connection. + "error sending request for url (https://dblp.org/search/author/api): \ + client error (SendRequest): connection error: timed out", + // TLS handshake dying mid-outage. + "error sending request for url (https://generativelanguage.googleapis.com/v1beta/models): \ + client error (Connect): tls handshake eof", + // Body cut off mid-stream. + "error decoding response body: request or response body error: operation timed out", + ]; + for error in real_errors { + assert!( + classify_message(error).is_some(), + "should classify as network interruption: {error}" + ); + } + } + + /// Deterministic failures must NOT trigger the wait-for-network path, + /// otherwise a bad API key or exhausted quota would hang forever waiting + /// for connectivity that is already fine. + #[test] + fn does_not_classify_deterministic_failures() { + let deterministic = [ + "401 unauthorized", + "403 forbidden: permission_denied", + "invalid x-api-key", + "404 not_found_error: model: claude-fable-5", + "insufficient_quota: your credit balance is too low", + "400 bad request: context length exceeded", + "content_policy_violation", + ]; + for error in deterministic { + assert!( + classify_message(error).is_none(), + "should NOT classify as network interruption: {error}" + ); + } + } +} diff --git a/crates/jcode-app-core/src/notifications.rs b/crates/jcode-app-core/src/notifications.rs new file mode 100644 index 0000000..def622a --- /dev/null +++ b/crates/jcode-app-core/src/notifications.rs @@ -0,0 +1,653 @@ +//! Notification dispatcher for ambient mode. +//! +//! Sends notifications via: +//! - ntfy.sh (push notifications to phone) +//! - Desktop notifications (notify-send) +//! - Email (SMTP via lettre) +//! +//! All sends are fire-and-forget: errors are logged, never block. + +use crate::config::{SafetyConfig, config}; +use crate::logging; +use crate::safety::AmbientTranscript; + +use jcode_notify_email::{ + ReplyAction, SendEmailRequest, build_permission_email_html, poll_imap_once, send_email, +}; +pub use jcode_notify_email::{extract_permission_id, parse_permission_reply}; + +/// Notification priority levels (maps to ntfy priority header). +#[derive(Debug, Clone, Copy)] +pub enum Priority { + /// Routine cycle summaries + Default, + /// Permission requests, errors + High, + /// Critical safety issues + Urgent, +} + +impl Priority { + fn ntfy_value(self) -> &'static str { + match self { + Priority::Default => "3", + Priority::High => "4", + Priority::Urgent => "5", + } + } + + fn ntfy_tags(self) -> &'static str { + match self { + Priority::Default => "robot", + Priority::High => "warning", + Priority::Urgent => "rotating_light", + } + } +} + +/// Dispatcher that sends notifications through all configured channels. +#[derive(Clone)] +pub struct NotificationDispatcher { + client: reqwest::Client, + config: SafetyConfig, + channels: crate::channel::ChannelRegistry, +} + +impl Default for NotificationDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl NotificationDispatcher { + pub fn new() -> Self { + let cfg = config().safety.clone(); + Self { + client: crate::provider::shared_http_client(), + channels: crate::channel::ChannelRegistry::from_config(&cfg), + config: cfg, + } + } + + #[cfg(test)] + pub fn from_config(config: SafetyConfig) -> Self { + Self { + client: crate::provider::shared_http_client(), + channels: crate::channel::ChannelRegistry::from_config(&config), + config, + } + } + + /// Send a cycle summary notification (after ambient cycle completes). + pub fn dispatch_cycle_summary(&self, transcript: &AmbientTranscript) { + let title = format!( + "Ambient cycle: {} memories, {} compactions", + transcript.memories_modified, transcript.compactions + ); + let safe_body = format_cycle_body_safe(transcript); + let detailed_body = format_cycle_body_detailed(transcript); + + let priority = if transcript.pending_permissions > 0 { + Priority::High + } else { + Priority::Default + }; + + self.send_all( + &title, + &safe_body, + &detailed_body, + priority, + Some(&transcript.session_id), + ); + } + + /// Send a permission request notification (high priority). + pub fn dispatch_permission_request(&self, action: &str, description: &str, request_id: &str) { + let title = format!("jcode: permission needed ({})", action); + let safe_body = "An ambient action needs your approval. Open jcode to review.".to_string(); + let detailed_body = format!( + "Action: {}\n{}\n\nRequest ID: {}\nReview in jcode to approve or deny.", + action, description, request_id + ); + + // Build rich HTML email with approve/deny buttons + let reply_to = self + .config + .email_from + .as_deref() + .unwrap_or("jcode@localhost"); + let email_html = build_permission_email_html(action, description, request_id, reply_to); + + self.send_all_with_email_override( + &title, + &safe_body, + &detailed_body, + Priority::High, + Some(request_id), + Some(&email_html), + ); + } + + /// Send through all configured channels (fire-and-forget). + /// + /// `safe_body` is sanitized (no secrets) — used for ntfy (potentially public). + /// `detailed_body` includes full info — used for email and desktop (private channels). + /// `cycle_id` is embedded as Message-ID in emails for reply tracking. + fn send_all( + &self, + title: &str, + safe_body: &str, + detailed_body: &str, + priority: Priority, + cycle_id: Option<&str>, + ) { + self.send_all_with_email_override( + title, + safe_body, + detailed_body, + priority, + cycle_id, + None, + ); + } + + /// Like `send_all`, but with an optional pre-built HTML body for the email channel. + /// When `email_html_override` is Some, it's used directly as the email body instead + /// of converting `detailed_body` through `markdown_to_html_email`. + fn send_all_with_email_override( + &self, + title: &str, + safe_body: &str, + detailed_body: &str, + priority: Priority, + cycle_id: Option<&str>, + email_html_override: Option<&str>, + ) { + // Guard: only dispatch if inside a tokio runtime + if tokio::runtime::Handle::try_current().is_err() { + logging::info("Notification skipped: no tokio runtime"); + return; + } + + // ntfy.sh — uses SAFE body (may be publicly readable) + if let Some(ref topic) = self.config.ntfy_topic { + let client = self.client.clone(); + let url = format!("{}/{}", self.config.ntfy_server, topic); + let title = title.to_string(); + let body = safe_body.to_string(); + tokio::spawn(async move { + if let Err(e) = send_ntfy(&client, &url, &title, &body, priority).await { + logging::error(&format!("ntfy notification failed: {}", e)); + } + }); + } + + // Desktop notification — uses DETAILED body (local machine, private) + if self.config.desktop_notifications { + let title = title.to_string(); + let body = detailed_body.to_string(); + let urgency = match priority { + Priority::Default => "normal", + Priority::High | Priority::Urgent => "critical", + }; + tokio::spawn(async move { + send_desktop(&title, &body, urgency); + }); + } + + // Email — uses DETAILED body (sent to your own address, private) + // If email_html_override is provided, send it directly as HTML. + if self.config.email_enabled + && let (Some(to), Some(host), Some(from)) = ( + &self.config.email_to, + &self.config.email_smtp_host, + &self.config.email_from, + ) + { + let to = to.clone(); + let host = host.clone(); + let from = from.clone(); + let port = self.config.email_smtp_port; + let password = self.config.email_password.clone(); + let title = title.to_string(); + let body = detailed_body.to_string(); + let cycle_id = cycle_id.map(|s| s.to_string()); + let html_override = email_html_override.map(|s| s.to_string()); + tokio::spawn(async move { + if let Err(e) = send_email(SendEmailRequest { + smtp_host: &host, + smtp_port: port, + from: &from, + to: &to, + password: password.as_deref(), + subject: &title, + body: &body, + cycle_id: cycle_id.as_deref(), + html_override: html_override.as_deref(), + }) + .await + { + logging::error(&format!("Email notification failed: {}", e)); + } else { + logging::info(&format!("Email notification sent to {}: {}", to, title)); + } + }); + } + + // Message channels (Telegram, Discord, etc.) — uses DETAILED body + let channel_text = format!("*{}*\n\n{}", title, detailed_body); + self.channels.send_all(&channel_text); + } +} + +// --------------------------------------------------------------------------- +// ntfy.sh +// --------------------------------------------------------------------------- + +async fn send_ntfy( + client: &reqwest::Client, + url: &str, + title: &str, + body: &str, + priority: Priority, +) -> anyhow::Result<()> { + let resp = client + .post(url) + .header("Title", title) + .header("Priority", priority.ntfy_value()) + .header("Tags", priority.ntfy_tags()) + .body(body.to_string()) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("ntfy returned {}: {}", status, text); + } + + logging::info(&format!("ntfy notification sent: {}", title)); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Desktop (cross-platform, fire-and-forget) +// --------------------------------------------------------------------------- + +/// Send a local desktop notification without blocking. +/// +/// Uses Notification Center via `osascript` on macOS and `notify-send` on +/// Linux. The child process is spawned detached and never waited on; failures +/// are ignored (a missing notifier is not an error). +pub fn send_desktop_notification(title: &str, body: &str) { + send_desktop_notification_rich(title, None, body, None); +} + +/// Send a local desktop notification with optional macOS subtitle and sound. +/// +/// `subtitle` renders as a second bold line on macOS (ignored elsewhere). +/// `sound` is a Notification Center sound name such as "Glass" or "Ping" +/// (macOS only). Both are best-effort; a missing notifier is not an error. +pub fn send_desktop_notification_rich( + title: &str, + subtitle: Option<&str>, + body: &str, + sound: Option<&str>, +) { + #[cfg(target_os = "macos")] + { + fn applescript_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => {} + _ => out.push(ch), + } + } + out + } + let mut script = format!( + "display notification \"{}\" with title \"{}\"", + applescript_escape(body), + applescript_escape(title) + ); + if let Some(subtitle) = subtitle.filter(|s| !s.trim().is_empty()) { + script.push_str(&format!(" subtitle \"{}\"", applescript_escape(subtitle))); + } + if let Some(sound) = sound.filter(|s| !s.trim().is_empty()) { + script.push_str(&format!(" sound name \"{}\"", applescript_escape(sound))); + } + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + #[cfg(target_os = "linux")] + { + let _ = (subtitle, sound); + let _ = std::process::Command::new("notify-send") + .arg("--app-name=jcode") + .arg(title) + .arg(body) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (title, subtitle, body, sound); + } +} + +// --------------------------------------------------------------------------- +// Desktop (notify-send) +// --------------------------------------------------------------------------- + +fn send_desktop(title: &str, body: &str, urgency: &str) { + // On macOS notify-send does not exist; route through Notification Center. + #[cfg(target_os = "macos")] + { + let _ = urgency; + send_desktop_notification(title, body); + } + #[cfg(not(target_os = "macos"))] + { + let result = std::process::Command::new("notify-send") + .arg("--app-name=jcode") + .arg(format!("--urgency={}", urgency)) + .arg("--icon=dialog-information") + .arg(title) + .arg(body) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + + match result { + Ok(status) if status.success() => { + logging::info(&format!("Desktop notification sent: {}", title)); + } + Ok(status) => { + logging::warn(&format!("notify-send exited with {}", status)); + } + Err(e) => { + // notify-send not available - not an error, just skip + logging::info(&format!("notify-send unavailable: {}", e)); + } + } + } +} + +// --------------------------------------------------------------------------- +// IMAP reply polling +// --------------------------------------------------------------------------- + +/// Run an IMAP polling loop checking for replies to ambient emails. +/// Should be spawned as a tokio task alongside the ambient runner. +pub async fn imap_reply_loop(config: SafetyConfig) { + let host = match config.email_imap_host.as_ref() { + Some(h) => h.clone(), + None => { + logging::error("IMAP reply loop: no imap_host configured"); + return; + } + }; + let port = config.email_imap_port; + let user = match config.email_from.as_ref() { + Some(u) => u.clone(), + None => { + logging::error("IMAP reply loop: no email_from configured"); + return; + } + }; + let pass = match config.email_password.as_ref() { + Some(p) => p.clone(), + None => { + logging::error("IMAP reply loop: no email password configured"); + return; + } + }; + + logging::info(&format!( + "IMAP reply loop: starting ({}:{}, user: {})", + host, port, user + )); + + loop { + // Run synchronous IMAP in a blocking task + let h = host.clone(); + let u = user.clone(); + let p = pass.clone(); + let pt = port; + let result = tokio::task::spawn_blocking(move || poll_imap_once(&h, pt, &u, &p)).await; + + match result { + Ok(Ok(actions)) => { + for action in &actions { + match action { + ReplyAction::PermissionDecision { + request_id, + approved, + message, + } => { + if let Err(e) = crate::safety::record_permission_via_file( + request_id, + *approved, + "email_reply", + message.clone(), + ) { + logging::error(&format!( + "Failed to record permission decision for {}: {}", + request_id, e + )); + } else { + logging::info(&format!( + "Permission {} via email: {}", + if *approved { "approved" } else { "denied" }, + request_id + )); + } + } + ReplyAction::DirectiveReply { cycle_id, text } => { + if let Err(e) = + crate::ambient::add_directive(text.clone(), cycle_id.clone()) + { + logging::error(&format!("Failed to save directive: {}", e)); + } + } + } + } + + if !actions.is_empty() { + logging::info(&format!("IMAP: processed {} email replies", actions.len())); + } + } + Ok(Err(e)) => { + logging::error(&format!("IMAP poll error: {}", e)); + } + Err(e) => { + logging::error(&format!("IMAP poll task panicked: {}", e)); + } + } + + // Poll every 60 seconds + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + } +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +/// Sanitized body for potentially public channels (ntfy.sh). +/// Only includes counts and status — no model-generated text. +fn format_cycle_body_safe(transcript: &AmbientTranscript) -> String { + let mut lines = Vec::new(); + + lines.push(format!("Status: {:?}", transcript.status)); + lines.push(format!( + "Memories modified: {}", + transcript.memories_modified + )); + lines.push(format!("Compactions: {}", transcript.compactions)); + + if transcript.pending_permissions > 0 { + lines.push(format!( + "{} permission request(s) pending", + transcript.pending_permissions + )); + } + + lines.push("Check jcode for full details.".to_string()); + lines.join("\n") +} + +/// Full detailed body for private channels (email, desktop). +/// Includes the model-generated summary and provider info. +/// Output is markdown — rendered to HTML for email, plain text for desktop. +fn format_cycle_body_detailed(transcript: &AmbientTranscript) -> String { + let mut lines = Vec::new(); + + if let Some(ref summary) = transcript.summary { + lines.push("# Summary".to_string()); + lines.push(String::new()); + lines.push(summary.clone()); + lines.push(String::new()); + } + + lines.push("---".to_string()); + lines.push(String::new()); + lines.push(format!( + "**Status:** {:?} · **Provider:** {} ({}) · **Memories:** {} · **Compactions:** {}", + transcript.status, + transcript.provider, + transcript.model, + transcript.memories_modified, + transcript.compactions, + )); + + if transcript.pending_permissions > 0 { + lines.push(String::new()); + lines.push(format!( + "**⚠ {} permission request(s) pending** — review in jcode", + transcript.pending_permissions + )); + } + + // Include full conversation transcript if available + if let Some(ref conversation) = transcript.conversation { + lines.push(String::new()); + lines.push("---".to_string()); + lines.push(String::new()); + lines.push("# Full Transcript".to_string()); + lines.push(String::new()); + lines.push(conversation.clone()); + } + + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_cycle_body_safe() { + let transcript = AmbientTranscript { + session_id: "test_001".to_string(), + started_at: chrono::Utc::now(), + ended_at: Some(chrono::Utc::now()), + status: crate::safety::TranscriptStatus::Complete, + provider: "claude".to_string(), + model: "claude-sonnet-4".to_string(), + actions: Vec::new(), + pending_permissions: 0, + summary: Some("Cleaned up 3 stale memories.".to_string()), + compactions: 1, + memories_modified: 3, + conversation: None, + }; + + let body = format_cycle_body_safe(&transcript); + assert!(body.contains("Memories modified: 3")); + assert!(body.contains("Compactions: 1")); + assert!(body.contains("Check jcode for full details")); + // Safe body must NOT include model-generated summary + assert!(!body.contains("Cleaned up")); + assert!(!body.contains("permission")); + } + + #[test] + fn test_format_cycle_body_detailed() { + let transcript = AmbientTranscript { + session_id: "test_001".to_string(), + started_at: chrono::Utc::now(), + ended_at: Some(chrono::Utc::now()), + status: crate::safety::TranscriptStatus::Complete, + provider: "claude".to_string(), + model: "claude-sonnet-4".to_string(), + actions: Vec::new(), + pending_permissions: 0, + summary: Some("Cleaned up 3 stale memories.".to_string()), + compactions: 1, + memories_modified: 3, + conversation: Some("### User\n\nBegin cycle.\n\n### Assistant\n\nDone.\n".to_string()), + }; + + let body = format_cycle_body_detailed(&transcript); + // Detailed body SHOULD include the summary + assert!(body.contains("Cleaned up 3 stale memories.")); + assert!(body.contains("**Memories:** 3")); + assert!(body.contains("claude")); + // Should include conversation transcript + assert!(body.contains("# Full Transcript")); + assert!(body.contains("### User")); + assert!(body.contains("Begin cycle.")); + } + + #[test] + fn test_format_cycle_body_with_pending_permissions() { + let transcript = AmbientTranscript { + session_id: "test_002".to_string(), + started_at: chrono::Utc::now(), + ended_at: Some(chrono::Utc::now()), + status: crate::safety::TranscriptStatus::Complete, + provider: "claude".to_string(), + model: "claude-sonnet-4".to_string(), + actions: Vec::new(), + pending_permissions: 2, + summary: None, + compactions: 0, + memories_modified: 0, + conversation: None, + }; + + let safe = format_cycle_body_safe(&transcript); + assert!(safe.contains("2 permission request(s) pending")); + assert!(safe.contains("Check jcode for full details")); + + let detailed = format_cycle_body_detailed(&transcript); + assert!(detailed.contains("2 permission request(s) pending")); + } + + #[test] + fn test_priority_values() { + assert_eq!(Priority::Default.ntfy_value(), "3"); + assert_eq!(Priority::High.ntfy_value(), "4"); + assert_eq!(Priority::Urgent.ntfy_value(), "5"); + } + + #[test] + fn test_dispatcher_creation() { + // Just verify it doesn't panic + let cfg = SafetyConfig::default(); + let _dispatcher = NotificationDispatcher::from_config(cfg); + } +} diff --git a/crates/jcode-app-core/src/overnight.rs b/crates/jcode-app-core/src/overnight.rs new file mode 100644 index 0000000..15bdf80 --- /dev/null +++ b/crates/jcode-app-core/src/overnight.rs @@ -0,0 +1,1275 @@ +use crate::agent::Agent; +use crate::provider::Provider; +use crate::session::{Session, SessionStatus}; +use crate::storage; +use crate::tool::Registry; +use anyhow::{Context, Result}; +use chrono::{Duration as ChronoDuration, Utc}; +use serde_json::{Value, json}; +#[cfg(unix)] +use std::ffi::CString; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +pub use jcode_overnight_core::{ + GitSnapshot, OVERNIGHT_VERSION, OvernightCommand, OvernightDuration, OvernightEvent, + OvernightManifest, OvernightPreflight, OvernightProgressCard, OvernightRunStatus, + OvernightTaskCard, OvernightTaskCardAfter, OvernightTaskCardBefore, OvernightTaskCardSummary, + OvernightTaskCardValidation, OvernightTaskStatusCounts, ResourceSnapshot, UsageLimitSnapshot, + UsageProjection, UsageProviderSnapshot, build_continuation_prompt, build_coordinator_prompt, + build_final_wrapup_prompt, build_handoff_ready_prompt, build_morning_report_prompt, + build_post_wake_continuation_prompt, build_progress_card_from_parts, build_review_html, + build_visible_current_session_prompt, event_class, format_log_markdown_from_events, + format_minutes, format_status_markdown_from_summary, git_summary, html_escape, overnight_usage, + parse_duration, parse_overnight_command, preflight_summary, prompt_event_summary, + render_task_cards_html, resource_summary, summarize_task_cards_slice, task_card_title, + task_card_validated, task_status_bucket, +}; + +const RESOURCE_SAMPLE_INTERVAL: Duration = Duration::from_secs(5 * 60); +const LONG_TURN_NOTICE_INTERVAL: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone)] +pub struct OvernightLaunch { + pub manifest: OvernightManifest, + /// Initial coordinator prompt to enqueue in the visible launching session. + /// When present, the TUI should run this as a normal user turn so tool calls, + /// spawned agents, and streaming output are visible like any other session. + pub initial_prompt: Option, +} + +pub struct OvernightStartOptions { + pub duration: OvernightDuration, + pub mission: Option, + pub parent_session: Session, + pub provider: Arc, + pub registry: Registry, + pub working_dir: Option, + /// When true, run the overnight coordinator in the session that launched + /// `/overnight` instead of forking an invisible child transcript. + pub use_current_session: bool, +} + +pub fn start_overnight_run(options: OvernightStartOptions) -> Result { + let run_id = crate::id::new_id("overnight"); + let started_at = Utc::now(); + let duration = ChronoDuration::minutes(options.duration.minutes as i64); + let target_wake_at = started_at + duration; + let handoff_ready_at = target_wake_at - ChronoDuration::minutes(30).min(duration / 4); + let post_wake_grace_until = target_wake_at + ChronoDuration::hours(2); + let run_dir = run_dir(&run_id)?; + let events_path = run_dir.join("events.jsonl"); + let human_log_path = run_dir.join("run.log"); + let review_path = run_dir.join("review.html"); + let review_notes_path = run_dir.join("review-notes.md"); + let preflight_path = run_dir.join("preflight.json"); + let task_cards_dir = run_dir.join("task-cards"); + let issue_drafts_dir = run_dir.join("issue-drafts"); + let validation_dir = run_dir.join("validation"); + std::fs::create_dir_all(&task_cards_dir)?; + std::fs::create_dir_all(&issue_drafts_dir)?; + std::fs::create_dir_all(&validation_dir)?; + + let mut child = if options.use_current_session { + options.parent_session.clone() + } else { + create_coordinator_session(&options.parent_session, &options.mission)? + }; + if let Some(working_dir) = options.working_dir.as_ref() { + child.working_dir = Some(working_dir.to_string_lossy().to_string()); + } + child.model = Some(options.provider.model()); + let coordinator_session_id = child.id.clone(); + let coordinator_session_name = child.display_name().to_string(); + let child_is_canary = child.is_canary; + if !options.use_current_session { + child.status = SessionStatus::Closed; + } + child.save()?; + + if !options.use_current_session + && let Ok(todos) = crate::todo::load_todos(&options.parent_session.id) + { + let _ = crate::todo::save_todos(&coordinator_session_id, &todos); + } + + let manifest = OvernightManifest { + version: OVERNIGHT_VERSION, + run_id: run_id.clone(), + parent_session_id: options.parent_session.id.clone(), + coordinator_session_id: coordinator_session_id.clone(), + coordinator_session_name, + started_at, + target_wake_at, + handoff_ready_at, + post_wake_grace_until, + morning_report_posted_at: None, + completed_at: None, + cancel_requested_at: None, + status: OvernightRunStatus::Running, + mission: options.mission.clone(), + working_dir: child.working_dir.clone(), + provider_name: options.provider.name().to_string(), + model: options.provider.model(), + max_agents_guidance: 2, + process_id: std::process::id(), + run_dir, + events_path, + human_log_path, + review_path, + review_notes_path, + preflight_path, + task_cards_dir, + issue_drafts_dir, + validation_dir, + last_activity_at: started_at, + }; + + save_manifest(&manifest)?; + write_initial_review_notes(&manifest)?; + write_task_card_schema(&manifest)?; + record_event( + &manifest, + "run_started", + format!( + "Started overnight run for {} (target wake: {})", + format_minutes(options.duration.minutes), + manifest.target_wake_at.to_rfc3339() + ), + json!({ + "mission": manifest.mission, + "parent_session_id": manifest.parent_session_id, + "coordinator_session_id": manifest.coordinator_session_id, + "review_path": manifest.review_path, + }), + true, + )?; + render_review_html(&manifest)?; + + let initial_prompt = if options.use_current_session { + Some(build_visible_current_session_prompt(&manifest)) + } else { + spawn_supervisor( + manifest.clone(), + child, + options.provider, + options.registry, + child_is_canary, + ); + None + }; + + Ok(OvernightLaunch { + manifest, + initial_prompt, + }) +} + +fn create_coordinator_session(parent: &Session, mission: &Option) -> Result { + let title = Some(match mission { + Some(mission) => format!("Overnight: {}", crate::util::truncate_str(mission, 48)), + None => "Overnight coordinator".to_string(), + }); + let mut child = Session::create(Some(parent.id.clone()), title); + child.replace_messages(parent.messages.clone()); + child.compaction = parent.compaction.clone(); + child.provider_key = parent.provider_key.clone(); + child.route_api_method = parent.route_api_method.clone(); + child.reasoning_effort = parent.reasoning_effort.clone(); + child.subagent_model = parent.subagent_model.clone(); + child.improve_mode = parent.improve_mode; + child.autoreview_enabled = Some(false); + child.autojudge_enabled = Some(false); + child.is_canary = parent.is_canary; + child.testing_build = parent.testing_build.clone(); + child.working_dir = parent.working_dir.clone(); + child.provider_session_id = None; + Ok(child) +} + +fn spawn_supervisor( + manifest: OvernightManifest, + child: Session, + provider: Arc, + registry: Registry, + child_is_canary: bool, +) { + let fut = async move { + if let Err(err) = + run_supervisor(manifest.clone(), child, provider, registry, child_is_canary).await + { + let mut updated = load_manifest(&manifest.run_id).unwrap_or(manifest.clone()); + updated.status = OvernightRunStatus::Failed; + updated.completed_at = Some(Utc::now()); + let _ = save_manifest(&updated); + let _ = record_event( + &updated, + "run_failed", + format!("Overnight supervisor failed: {}", err), + json!({ "error": crate::util::format_error_chain(&err) }), + true, + ); + let _ = render_review_html(&updated); + } + }; + + if tokio::runtime::Handle::try_current().is_ok() { + tokio::spawn(fut); + } else { + std::thread::spawn(move || match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime.block_on(fut), + Err(err) => crate::logging::error(&format!( + "Failed to start overnight supervisor runtime: {}", + err + )), + }); + } +} + +async fn run_supervisor( + manifest: OvernightManifest, + child: Session, + provider: Arc, + registry: Registry, + child_is_canary: bool, +) -> Result<()> { + record_event( + &manifest, + "preflight_started", + "Collecting overnight usage/resource/git preflight".to_string(), + json!({}), + true, + )?; + let preflight = gather_preflight(&manifest).await; + storage::write_json(&manifest.preflight_path, &preflight)?; + record_event( + &manifest, + "preflight_completed", + preflight_summary(&preflight), + serde_json::to_value(&preflight).unwrap_or_else(|_| json!({})), + true, + )?; + render_review_html(&manifest)?; + + if child_is_canary { + registry.register_selfdev_tools().await; + } + + let mut agent = Agent::new_with_session(provider, registry, child, None); + let mut next_prompt = build_coordinator_prompt(&manifest, &preflight); + let mut handoff_notice_sent = false; + let mut morning_report_prompt_sent = false; + let mut final_wrapup_prompt_sent = false; + + loop { + let current = load_manifest(&manifest.run_id)?; + if matches!(current.status, OvernightRunStatus::CancelRequested) { + record_event( + ¤t, + "run_cancel_acknowledged", + "Cancellation requested; stopping before next coordinator turn".to_string(), + json!({}), + true, + )?; + mark_completed( + ¤t, + OvernightRunStatus::Completed, + "Cancelled before next turn", + )?; + break; + } + + let now = Utc::now(); + if !handoff_notice_sent && now >= current.handoff_ready_at && now < current.target_wake_at { + record_event( + ¤t, + "handoff_ready_notice", + "Entering handoff-ready mode".to_string(), + json!({ "target_wake_at": current.target_wake_at }), + true, + )?; + next_prompt = build_handoff_ready_prompt(¤t); + handoff_notice_sent = true; + } + + record_event( + ¤t, + "coordinator_turn_started", + prompt_event_summary(&next_prompt), + json!({ "prompt_preview": crate::util::truncate_str(&next_prompt, 600) }), + true, + )?; + render_review_html(¤t)?; + + let output = run_turn_monitored(&mut agent, ¤t, &next_prompt).await?; + let after_turn = load_manifest(&manifest.run_id)?; + record_event( + &after_turn, + "coordinator_turn_completed", + "Coordinator turn completed".to_string(), + json!({ "output_preview": crate::util::truncate_str(&output, 4000) }), + true, + )?; + render_review_html(&after_turn)?; + + let after_turn = load_manifest(&manifest.run_id)?; + if matches!(after_turn.status, OvernightRunStatus::CancelRequested) { + mark_completed( + &after_turn, + OvernightRunStatus::Completed, + "Cancelled after coordinator turn", + )?; + break; + } + + let now = Utc::now(); + if now >= after_turn.target_wake_at { + if !morning_report_prompt_sent && after_turn.morning_report_posted_at.is_none() { + let mut updated = after_turn.clone(); + updated.morning_report_posted_at = Some(now); + save_manifest(&updated)?; + record_event( + &updated, + "morning_report_requested", + "Target wake time reached; requesting morning report".to_string(), + json!({ "target_wake_at": updated.target_wake_at }), + true, + )?; + next_prompt = build_morning_report_prompt(&updated); + morning_report_prompt_sent = true; + continue; + } + + if now < after_turn.post_wake_grace_until { + record_event( + &after_turn, + "post_wake_continuation", + "Morning report is posted; allowing bounded post-wake continuation".to_string(), + json!({ "post_wake_grace_until": after_turn.post_wake_grace_until }), + true, + )?; + next_prompt = build_post_wake_continuation_prompt(&after_turn); + continue; + } + + if !final_wrapup_prompt_sent { + record_event( + &after_turn, + "post_wake_grace_expired", + "Post-wake grace window expired; requesting final wrap-up".to_string(), + json!({ "post_wake_grace_until": after_turn.post_wake_grace_until }), + true, + )?; + next_prompt = build_final_wrapup_prompt(&after_turn); + final_wrapup_prompt_sent = true; + continue; + } + + mark_completed( + &after_turn, + OvernightRunStatus::Completed, + "Morning report turn completed", + )?; + break; + } + + next_prompt = build_continuation_prompt(&after_turn); + } + + Ok(()) +} + +async fn run_turn_monitored( + agent: &mut Agent, + manifest: &OvernightManifest, + prompt: &str, +) -> Result { + let started = Utc::now(); + let mut sample_interval = tokio::time::interval_at( + tokio::time::Instant::now() + RESOURCE_SAMPLE_INTERVAL, + RESOURCE_SAMPLE_INTERVAL, + ); + sample_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut long_notice_interval = tokio::time::interval_at( + tokio::time::Instant::now() + LONG_TURN_NOTICE_INTERVAL, + LONG_TURN_NOTICE_INTERVAL, + ); + long_notice_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let run_future = agent.run_once_capture(prompt); + tokio::pin!(run_future); + + loop { + tokio::select! { + result = &mut run_future => return result, + _ = sample_interval.tick() => { + let snapshot = gather_resource_snapshot(manifest.working_dir.as_deref().map(Path::new)); + let _ = record_event( + manifest, + "resource_sample", + resource_summary(&snapshot), + serde_json::to_value(&snapshot).unwrap_or_else(|_| json!({})), + false, + ); + let _ = render_review_html(manifest); + } + _ = long_notice_interval.tick() => { + let elapsed = Utc::now().signed_duration_since(started).num_minutes().max(0); + let _ = record_event( + manifest, + "coordinator_turn_still_running", + format!("Coordinator turn still running after {}m", elapsed), + json!({ "elapsed_minutes": elapsed }), + true, + ); + let _ = render_review_html(manifest); + } + } + } +} + +async fn gather_preflight(manifest: &OvernightManifest) -> OvernightPreflight { + let usage_reports = crate::usage::fetch_all_provider_usage().await; + let usage = build_usage_projection(&usage_reports, manifest); + let resources = gather_resource_snapshot(manifest.working_dir.as_deref().map(Path::new)); + let git = gather_git_snapshot(manifest.working_dir.as_deref().map(Path::new)); + OvernightPreflight { + captured_at: Utc::now(), + usage, + resources, + git, + } +} + +fn build_usage_projection( + reports: &[crate::usage::ProviderUsage], + manifest: &OvernightManifest, +) -> UsageProjection { + let providers: Vec = reports + .iter() + .map(|provider| UsageProviderSnapshot { + provider_name: provider.provider_name.clone(), + hard_limit_reached: provider.hard_limit_reached, + error: provider.error.clone(), + limits: provider + .limits + .iter() + .map(|limit| UsageLimitSnapshot { + name: limit.name.clone(), + usage_percent: limit.usage_percent, + resets_at: limit.resets_at.clone(), + }) + .collect(), + extra_info: provider.extra_info.clone(), + }) + .collect(); + + let max_usage = providers + .iter() + .flat_map(|provider| provider.limits.iter().map(|limit| limit.usage_percent)) + .fold(None::, |acc, value| { + Some(acc.unwrap_or(value).max(value)) + }); + let hard_limit = providers.iter().any(|provider| provider.hard_limit_reached); + let has_errors = providers.iter().any(|provider| provider.error.is_some()); + let hours = manifest + .target_wake_at + .signed_duration_since(manifest.started_at) + .num_minutes() + .max(1) as f32 + / 60.0; + let delta_min = (hours * 3.0).min(35.0); + let delta_max = (hours * 7.0 * manifest.max_agents_guidance as f32 / 2.0).min(75.0); + let projected_end_min = max_usage.map(|current| (current + delta_min).min(100.0)); + let projected_end_max = max_usage.map(|current| (current + delta_max).min(100.0)); + + let risk = if hard_limit || projected_end_max.is_some_and(|value| value >= 95.0) { + "high" + } else if projected_end_max.is_some_and(|value| value >= 80.0) || has_errors { + "medium" + } else if max_usage.is_some() { + "low" + } else { + "unknown" + } + .to_string(); + + let confidence = if max_usage.is_some() && !has_errors { + "medium" + } else { + "low" + } + .to_string(); + + let mut notes = Vec::new(); + if providers.is_empty() { + notes.push( + "No connected-provider usage reports were available; projection is heuristic." + .to_string(), + ); + } else { + notes.push("Projection uses provider usage percentages plus a conservative overnight burn-rate heuristic.".to_string()); + } + notes.push("This is a warning only; the run starts regardless and should adapt concurrency conservatively.".to_string()); + + UsageProjection { + captured_at: Utc::now(), + risk, + confidence, + projected_delta_min_percent: max_usage.map(|_| delta_min), + projected_delta_max_percent: max_usage.map(|_| delta_max), + projected_end_min_percent: projected_end_min, + projected_end_max_percent: projected_end_max, + providers, + notes, + } +} + +pub fn gather_resource_snapshot(working_dir: Option<&Path>) -> ResourceSnapshot { + let (memory_total_mb, memory_available_mb, swap_total_mb, swap_free_mb) = detect_memory(); + let memory_used_percent = + memory_total_mb + .zip(memory_available_mb) + .and_then(|(total, available)| { + if total == 0 { + None + } else { + Some(((total.saturating_sub(available)) as f32 / total as f32) * 100.0) + } + }); + let (load_one, cpu_count) = detect_load(); + let (battery_percent, battery_status) = detect_battery(); + let disk_available_gb = working_dir.and_then(disk_available_gb); + + ResourceSnapshot { + captured_at: Utc::now(), + memory_total_mb, + memory_available_mb, + memory_used_percent, + swap_total_mb, + swap_free_mb, + load_one, + cpu_count, + battery_percent, + battery_status, + disk_available_gb, + } +} + +fn detect_memory() -> (Option, Option, Option, Option) { + #[cfg(target_os = "linux")] + { + let Ok(contents) = std::fs::read_to_string("/proc/meminfo") else { + return (None, None, None, None); + }; + let mut total_kb = None; + let mut available_kb = None; + let mut swap_total_kb = None; + let mut swap_free_kb = None; + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("MemTotal:") { + total_kb = parse_meminfo_kb(rest); + } else if let Some(rest) = line.strip_prefix("MemAvailable:") { + available_kb = parse_meminfo_kb(rest); + } else if let Some(rest) = line.strip_prefix("SwapTotal:") { + swap_total_kb = parse_meminfo_kb(rest); + } else if let Some(rest) = line.strip_prefix("SwapFree:") { + swap_free_kb = parse_meminfo_kb(rest); + } + } + ( + total_kb.map(|kb| kb / 1024), + available_kb.map(|kb| kb / 1024), + swap_total_kb.map(|kb| kb / 1024), + swap_free_kb.map(|kb| kb / 1024), + ) + } + #[cfg(not(target_os = "linux"))] + { + (None, None, None, None) + } +} + +#[cfg(target_os = "linux")] +fn parse_meminfo_kb(rest: &str) -> Option { + rest.split_whitespace().next()?.parse().ok() +} + +fn detect_load() -> (Option, Option) { + #[cfg(target_os = "linux")] + { + let load = std::fs::read_to_string("/proc/loadavg") + .ok() + .and_then(|contents| contents.split_whitespace().next()?.parse::().ok()); + let cpus = std::thread::available_parallelism() + .ok() + .map(|value| value.get()); + (load, cpus) + } + #[cfg(not(target_os = "linux"))] + { + let cpus = std::thread::available_parallelism() + .ok() + .map(|value| value.get()); + (None, cpus) + } +} + +fn detect_battery() -> (Option, Option) { + #[cfg(target_os = "linux")] + { + let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") else { + return (None, None); + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with("BAT") { + continue; + } + let percent = std::fs::read_to_string(path.join("capacity")) + .ok() + .and_then(|value| value.trim().parse::().ok()); + let status = std::fs::read_to_string(path.join("status")) + .ok() + .map(|value| value.trim().to_string()); + return (percent, status); + } + (None, None) + } + #[cfg(not(target_os = "linux"))] + { + (None, None) + } +} + +fn disk_available_gb(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) }; + if rc != 0 { + return None; + } + let bytes = stat.f_bavail as f64 * stat.f_frsize as f64; + Some(bytes / 1024.0 / 1024.0 / 1024.0) + } + #[cfg(not(unix))] + { + let _ = path; + None + } +} + +pub fn gather_git_snapshot(working_dir: Option<&Path>) -> GitSnapshot { + let captured_at = Utc::now(); + let dir = working_dir.unwrap_or_else(|| Path::new(".")); + let branch = run_git(dir, &["branch", "--show-current"]) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + match run_git(dir, &["status", "--short"]) { + Ok(status) => { + let dirty_summary: Vec = status + .lines() + .filter(|line| !line.trim().is_empty()) + .take(20) + .map(str::to_string) + .collect(); + let dirty_count = status + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + GitSnapshot { + captured_at, + branch, + dirty_count: Some(dirty_count), + dirty_summary, + error: None, + } + } + Err(error) => GitSnapshot { + captured_at, + branch, + dirty_count: None, + dirty_summary: Vec::new(), + error: Some(error), + }, + } +} + +fn run_git(dir: &Path, args: &[&str]) -> std::result::Result { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .map_err(|err| format!("failed to run git {}: {}", args.join(" "), err))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +pub fn overnight_root_dir() -> Result { + Ok(storage::jcode_dir()?.join("overnight")) +} + +pub fn runs_dir() -> Result { + Ok(overnight_root_dir()?.join("runs")) +} + +pub fn run_dir(run_id: &str) -> Result { + Ok(runs_dir()?.join(run_id)) +} + +pub fn manifest_path(run_id: &str) -> Result { + Ok(run_dir(run_id)?.join("manifest.json")) +} + +pub fn save_manifest(manifest: &OvernightManifest) -> Result<()> { + storage::write_json(&manifest_path(&manifest.run_id)?, manifest) +} + +pub fn load_manifest(run_id: &str) -> Result { + storage::read_json(&manifest_path(run_id)?) +} + +pub fn latest_manifest() -> Result> { + let dir = runs_dir()?; + if !dir.exists() { + return Ok(None); + } + let mut manifests = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let path = entry.path().join("manifest.json"); + if path.exists() + && let Ok(manifest) = storage::read_json::(&path) + { + manifests.push(manifest); + } + } + manifests.sort_by_key(|manifest| manifest.started_at); + Ok(manifests.pop()) +} + +pub fn cancel_latest_run() -> Result { + let mut manifest = latest_manifest()?.context("No overnight runs found")?; + if matches!( + manifest.status, + OvernightRunStatus::Completed | OvernightRunStatus::Failed + ) { + return Ok(manifest); + } + manifest.status = OvernightRunStatus::CancelRequested; + manifest.cancel_requested_at = Some(Utc::now()); + save_manifest(&manifest)?; + record_event( + &manifest, + "cancel_requested", + "User requested overnight cancellation".to_string(), + json!({}), + true, + )?; + render_review_html(&manifest)?; + Ok(manifest) +} + +pub fn read_events(manifest: &OvernightManifest) -> Result> { + if !manifest.events_path.exists() { + return Ok(Vec::new()); + } + let contents = std::fs::read_to_string(&manifest.events_path)?; + Ok(contents + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect()) +} + +pub fn read_task_cards(manifest: &OvernightManifest) -> Result> { + if !manifest.task_cards_dir.exists() { + return Ok(Vec::new()); + } + + let mut paths = Vec::new(); + for entry in std::fs::read_dir(&manifest.task_cards_dir)? { + let entry = entry?; + let path = entry.path(); + if !entry.file_type()?.is_file() { + continue; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if file_name.starts_with('_') + || path.extension().and_then(|ext| ext.to_str()) != Some("json") + { + continue; + } + paths.push(path); + } + paths.sort(); + + let mut cards = Vec::new(); + for path in paths { + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; + if let Ok(mut parsed) = serde_json::from_str::>(&contents) { + cards.append(&mut parsed); + } else if let Ok(card) = serde_json::from_str::(&contents) { + cards.push(card); + } + } + + cards.retain(|card| !card.title.trim().is_empty() || !card.id.trim().is_empty()); + cards.sort_by(|a, b| { + a.updated_at + .cmp(&b.updated_at) + .then_with(|| a.id.cmp(&b.id)) + .then_with(|| a.title.cmp(&b.title)) + }); + Ok(cards) +} + +pub fn summarize_task_cards(manifest: &OvernightManifest) -> OvernightTaskCardSummary { + summarize_task_cards_slice(&read_task_cards(manifest).unwrap_or_default()) +} + +pub fn format_progress_card_content(manifest: &OvernightManifest) -> Result { + Ok(serde_json::to_string(&build_progress_card(manifest))?) +} + +pub fn latest_progress_card_content() -> Result> { + latest_manifest()? + .map(|manifest| format_progress_card_content(&manifest)) + .transpose() +} + +pub fn build_progress_card(manifest: &OvernightManifest) -> OvernightProgressCard { + let events = read_events(manifest).unwrap_or_default(); + let preflight = read_preflight(manifest); + let task_cards = read_task_cards(manifest).unwrap_or_default(); + build_progress_card_from_parts( + manifest, + &events, + preflight.as_ref(), + &task_cards, + Utc::now(), + ) +} + +fn read_preflight(manifest: &OvernightManifest) -> Option { + if !manifest.preflight_path.exists() { + return None; + } + storage::read_json(&manifest.preflight_path).ok() +} + +pub fn record_event( + manifest: &OvernightManifest, + kind: &str, + summary: String, + details: Value, + meaningful: bool, +) -> Result<()> { + let event = OvernightEvent { + timestamp: Utc::now(), + run_id: manifest.run_id.clone(), + session_id: Some(manifest.coordinator_session_id.clone()), + kind: kind.to_string(), + summary: summary.clone(), + details, + meaningful, + }; + + if let Some(parent) = manifest.events_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut events = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&manifest.events_path)?; + writeln!(events, "{}", serde_json::to_string(&event)?)?; + + if let Some(parent) = manifest.human_log_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut human = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&manifest.human_log_path)?; + writeln!( + human, + "{} [{}] {}", + event.timestamp.format("%H:%M:%S"), + event.kind, + summary + )?; + + if meaningful { + let mut updated = load_manifest(&manifest.run_id).unwrap_or_else(|_| manifest.clone()); + updated.last_activity_at = event.timestamp; + let _ = save_manifest(&updated); + } + + Ok(()) +} + +fn mark_completed( + manifest: &OvernightManifest, + status: OvernightRunStatus, + summary: &str, +) -> Result<()> { + let mut updated = load_manifest(&manifest.run_id).unwrap_or_else(|_| manifest.clone()); + updated.status = status; + updated.completed_at = Some(Utc::now()); + updated.last_activity_at = Utc::now(); + save_manifest(&updated)?; + record_event( + &updated, + "run_completed", + summary.to_string(), + json!({ "status": updated.status.label() }), + true, + )?; + render_review_html(&updated)?; + Ok(()) +} + +pub fn format_status_markdown(manifest: &OvernightManifest) -> String { + let task_summary = summarize_task_cards(manifest); + format_status_markdown_from_summary(manifest, &task_summary, Utc::now()) +} + +pub fn format_log_markdown(manifest: &OvernightManifest, max_lines: usize) -> String { + let events = read_events(manifest).unwrap_or_default(); + format_log_markdown_from_events(manifest, &events, max_lines) +} + +fn write_initial_review_notes(manifest: &OvernightManifest) -> Result<()> { + if manifest.review_notes_path.exists() { + return Ok(()); + } + let content = format!( + "# Overnight review notes\n\nRun: `{}`\nCoordinator session: `{}`\nTarget wake time: `{}`\n\nThe coordinator must keep this file useful as the run progresses. Required sections for each meaningful task:\n\n## Executive summary\n\n- Status: running\n- Current task: not started\n- Verified fixes: 0\n- Issue drafts/posts: 0\n- Repo risk: unknown\n\n## Task reviews\n\nFor each task, include:\n\n### Task: \n\n- Source: user request / GH issue / static analysis / failing test / code quality\n- Why chosen:\n- Verifiability:\n- Risk:\n- Outcome:\n\n#### Before\n\n- Observed behavior or code state:\n- Reproduction/evidence:\n\n#### After\n\n- Changed behavior or code state:\n- Validation run:\n- Files changed:\n\n## Decisions and skipped work\n\nRecord tasks considered but skipped, with reasons.\n\n## Open questions and next steps\n\nRecord user decisions needed and safe continuation options.\n", + manifest.run_id, + manifest.coordinator_session_id, + manifest.target_wake_at.to_rfc3339(), + ); + write_text_file(&manifest.review_notes_path, &content) +} + +fn write_task_card_schema(manifest: &OvernightManifest) -> Result<()> { + let schema_path = manifest.task_cards_dir.join("task-card-schema.md"); + if schema_path.exists() { + return Ok(()); + } + let content = r#"# Overnight task-card schema + +Create one `*.json` file per meaningful task. Keep it current while you work. The generated review page and TUI progress card read these files continuously. + +Required spirit: make the morning review objectively useful. Each completed or important attempted task should show why it was selected, what was true before, what changed after, and exactly how it was validated. + +```json +{ + "id": "task-001", + "title": "Fix deterministic provider reload timeout", + "status": "active | completed | blocked | deferred | failed | skipped", + "priority": "high | medium | low", + "source": "GH issue | failing test | static analysis | code quality | user request", + "why_selected": "Objective, bounded, high-confidence reason for choosing this task.", + "verifiability": "How we can prove the problem and prove the fix.", + "risk": "low | medium | high", + "outcome": "Concise final outcome or current state.", + "before": { + "problem": "Observed bug/code state before work.", + "evidence": ["validation/task-001-before.txt"] + }, + "after": { + "change": "Changed behavior or code state after work.", + "files_changed": ["src/example.rs"], + "evidence": ["validation/task-001-after.txt"] + }, + "validation": { + "commands": ["cargo test provider_reload"], + "result": "passed", + "evidence": ["validation/task-001-test.txt"] + }, + "followups": ["Optional remaining safe next step"], + "updated_at": "2026-05-01T08:00:00Z" +} +``` + +Notes: +- Use `active` for the task currently being worked. +- Use `completed` only when validation evidence exists or the task is intentionally documentation/issue-draft only. +- Use `blocked` when user input, credentials, external access, or taste is required. +- Use `deferred` or `skipped` when a task was considered but not pursued. +"#; + write_text_file(&schema_path, content) +} + +pub fn render_review_html(manifest: &OvernightManifest) -> Result<()> { + let events = read_events(manifest).unwrap_or_default(); + let notes = std::fs::read_to_string(&manifest.review_notes_path).unwrap_or_else(|_| { + "# Overnight review notes + +Coordinator has not written notes yet." + .to_string() + }); + let preflight = if manifest.preflight_path.exists() { + std::fs::read_to_string(&manifest.preflight_path).unwrap_or_default() + } else { + String::new() + }; + let task_cards = read_task_cards(manifest).unwrap_or_default(); + let html = build_review_html(manifest, &events, ¬es, &preflight, &task_cards); + write_text_file(&manifest.review_path, &html) +} + +fn write_text_file(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_manifest(root: &Path, run_id: &str) -> OvernightManifest { + let run_dir = root.join("run"); + let now = Utc::now(); + OvernightManifest { + version: OVERNIGHT_VERSION, + run_id: run_id.to_string(), + parent_session_id: "parent".to_string(), + coordinator_session_id: "coord".to_string(), + coordinator_session_name: "coordinator".to_string(), + started_at: now, + target_wake_at: now + ChronoDuration::hours(7), + handoff_ready_at: now + ChronoDuration::hours(6), + post_wake_grace_until: now + ChronoDuration::hours(9), + morning_report_posted_at: None, + completed_at: None, + cancel_requested_at: None, + status: OvernightRunStatus::Running, + mission: Some("verify things".to_string()), + working_dir: Some("/tmp/project".to_string()), + provider_name: "test-provider".to_string(), + model: "test-model".to_string(), + max_agents_guidance: 2, + process_id: 123, + run_dir: run_dir.clone(), + events_path: run_dir.join("events.jsonl"), + human_log_path: run_dir.join("run.log"), + review_path: run_dir.join("review.html"), + review_notes_path: run_dir.join("review-notes.md"), + preflight_path: run_dir.join("preflight.json"), + task_cards_dir: run_dir.join("task-cards"), + issue_drafts_dir: run_dir.join("issue-drafts"), + validation_dir: run_dir.join("validation"), + last_activity_at: now, + } + } + + #[test] + fn parse_duration_accepts_hours_minutes_and_decimals() { + assert_eq!(parse_duration("7").unwrap().minutes, 420); + assert_eq!(parse_duration("7h").unwrap().minutes, 420); + assert_eq!(parse_duration("90m").unwrap().minutes, 90); + assert_eq!(parse_duration("1.5").unwrap().minutes, 90); + } + + #[test] + fn parse_overnight_command_start_with_mission() { + let parsed = parse_overnight_command("/overnight 7 fix verified bugs") + .unwrap() + .unwrap(); + match parsed { + OvernightCommand::Start { duration, mission } => { + assert_eq!(duration.minutes, 420); + assert_eq!(mission.as_deref(), Some("fix verified bugs")); + } + other => panic!("unexpected command: {:?}", other), + } + } + + #[test] + fn parse_overnight_command_subcommands() { + assert_eq!( + parse_overnight_command("/overnight status") + .unwrap() + .unwrap(), + OvernightCommand::Status + ); + assert_eq!( + parse_overnight_command("/overnight log").unwrap().unwrap(), + OvernightCommand::Log + ); + assert_eq!( + parse_overnight_command("/overnight review") + .unwrap() + .unwrap(), + OvernightCommand::Review + ); + assert_eq!( + parse_overnight_command("/overnight cancel") + .unwrap() + .unwrap(), + OvernightCommand::Cancel + ); + } + + #[test] + fn html_escape_escapes_basic_entities() { + assert_eq!(html_escape("<a&b>\"'"), "<a&b>"'"); + } + + #[test] + fn render_review_html_writes_required_sections() { + let temp = tempfile::tempdir().expect("tempdir"); + let manifest = test_manifest(temp.path(), "overnight_test"); + write_initial_review_notes(&manifest).expect("write notes"); + render_review_html(&manifest).expect("render review"); + + let html = std::fs::read_to_string(&manifest.review_path).expect("read review html"); + assert!(html.contains("Executive summary")); + assert!(html.contains("Coordinator review notes")); + assert!(html.contains("Timeline")); + assert!(html.contains("Artifacts")); + assert!(html.contains("Before")); + assert!(html.contains("After")); + } + + #[test] + fn task_card_summary_reads_structured_json_cards() { + let temp = tempfile::tempdir().expect("tempdir"); + let manifest = test_manifest(temp.path(), "overnight_cards"); + std::fs::create_dir_all(&manifest.task_cards_dir).expect("task card dir"); + std::fs::write( + manifest.task_cards_dir.join("task-001.json"), + r#"{ + "id": "task-001", + "title": "Fix deterministic bug", + "status": "completed", + "risk": "low", + "validation": { "commands": ["cargo test bug"], "result": "passed" }, + "updated_at": "2026-05-01T08:00:00Z" + }"#, + ) + .expect("write completed card"); + std::fs::write( + manifest.task_cards_dir.join("task-002.json"), + r#"{ + "id": "task-002", + "title": "Investigate static-analysis finding", + "status": "active", + "risk": "high", + "updated_at": "2026-05-01T08:10:00Z" + }"#, + ) + .expect("write active card"); + + let cards = read_task_cards(&manifest).expect("read cards"); + assert_eq!(cards.len(), 2); + let summary = summarize_task_cards_slice(&cards); + assert_eq!(summary.total, 2); + assert_eq!(summary.counts.completed, 1); + assert_eq!(summary.counts.active, 1); + assert_eq!(summary.validated, 1); + assert_eq!(summary.high_risk, 1); + assert_eq!( + summary.latest_title.as_deref(), + Some("Investigate static-analysis finding") + ); + } + + #[test] + fn progress_card_content_includes_task_summary_and_latest_event() { + let temp = tempfile::tempdir().expect("tempdir"); + let manifest = test_manifest(temp.path(), "overnight_progress"); + std::fs::create_dir_all(&manifest.task_cards_dir).expect("task card dir"); + std::fs::create_dir_all(manifest.events_path.parent().unwrap()).expect("events dir"); + std::fs::write( + manifest.task_cards_dir.join("task-001.json"), + r#"{ + "id": "task-001", + "title": "Verify reload race", + "status": "completed", + "validation": { "result": "passed" }, + "updated_at": "2026-05-01T08:00:00Z" + }"#, + ) + .expect("write card"); + let event = OvernightEvent { + timestamp: Utc::now(), + run_id: manifest.run_id.clone(), + session_id: Some(manifest.coordinator_session_id.clone()), + kind: "coordinator_turn_completed".to_string(), + summary: "Coordinator turn completed".to_string(), + details: json!({}), + meaningful: true, + }; + std::fs::write( + &manifest.events_path, + format!("{}\n", serde_json::to_string(&event).unwrap()), + ) + .expect("write event"); + + let card: OvernightProgressCard = + serde_json::from_str(&format_progress_card_content(&manifest).expect("progress card")) + .expect("parse card"); + assert_eq!(card.task_summary.counts.completed, 1); + assert_eq!(card.task_summary.validated, 1); + assert_eq!( + card.latest_event_kind.as_deref(), + Some("coordinator_turn_completed") + ); + assert_eq!( + card.active_task_title.as_deref(), + Some("Verify reload race") + ); + } + + #[test] + fn render_review_html_includes_structured_task_cards() { + let temp = tempfile::tempdir().expect("tempdir"); + let manifest = test_manifest(temp.path(), "overnight_review_cards"); + write_initial_review_notes(&manifest).expect("write notes"); + std::fs::create_dir_all(&manifest.task_cards_dir).expect("task card dir"); + std::fs::write( + manifest.task_cards_dir.join("task-001.json"), + r#"{ + "id": "task-001", + "title": "Fix deterministic bug", + "status": "completed", + "why_selected": "Reproducible failure", + "before": { "problem": "Test failed before the fix" }, + "after": { "change": "Test passes after the fix", "files_changed": ["src/example.rs"] }, + "validation": { "commands": ["cargo test deterministic_bug"], "result": "passed" }, + "updated_at": "2026-05-01T08:00:00Z" + }"#, + ) + .expect("write card"); + + render_review_html(&manifest).expect("render review"); + let html = std::fs::read_to_string(&manifest.review_path).expect("read html"); + assert!(html.contains("Structured task cards")); + assert!(html.contains("Fix deterministic bug")); + assert!(html.contains("Reproducible failure")); + assert!(html.contains("cargo test deterministic_bug")); + } +} diff --git a/crates/jcode-app-core/src/perf.rs b/crates/jcode-app-core/src/perf.rs new file mode 100644 index 0000000..1603ae5 --- /dev/null +++ b/crates/jcode-app-core/src/perf.rs @@ -0,0 +1,899 @@ +use std::sync::OnceLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PerformanceTier { + Full, + Reduced, + Minimal, +} + +impl PerformanceTier { + pub fn label(self) -> &'static str { + match self { + Self::Full => "full", + Self::Reduced => "reduced", + Self::Minimal => "minimal", + } + } + + pub fn badge(self) -> Option<&'static str> { + match self { + Self::Full => None, + Self::Reduced => Some("perf:reduced"), + Self::Minimal => Some("perf:minimal"), + } + } + + pub fn animations_enabled(self) -> bool { + !matches!(self, Self::Minimal) + } + + pub fn idle_animation_enabled(self) -> bool { + matches!(self, Self::Full) + } + + pub fn prompt_entry_animation_enabled(self) -> bool { + !matches!(self, Self::Minimal) + } +} + +impl std::fmt::Display for PerformanceTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +#[derive(Debug, Clone)] +pub struct SystemProfile { + pub load_avg_1m: Option<f64>, + pub cpu_count: Option<usize>, + pub available_memory_mb: Option<u64>, + pub total_memory_mb: Option<u64>, + pub is_ssh: bool, + pub is_wsl: bool, + pub terminal: String, + pub tier: PerformanceTier, + /// True when the host terminal is known to corrupt its GPU glyph atlas + /// under heavy per-cell color/redraw churn (the macOS 26 "garbled glyphs" + /// bug seen in the VS Code integrated terminal and Apple Terminal, where + /// letters like n/m/r/w get re-rendered as stale boxes). When set we run a + /// "glyph-safe" policy that suppresses decorative per-cell color animation + /// and caps full-frame repaints to keep the atlas stable. + pub fragile_glyph_cache: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntheticSystemProfile { + Native, + Wsl, + WslWindowsTerminal, +} + +impl SyntheticSystemProfile { + pub fn label(self) -> &'static str { + match self { + Self::Native => "native", + Self::Wsl => "wsl", + Self::WslWindowsTerminal => "wsl-windows-terminal", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TuiPerfPolicy { + pub tier: PerformanceTier, + pub redraw_fps: u32, + pub animation_fps: u32, + pub enable_decorative_animations: bool, + pub enable_focus_change: bool, + pub enable_mouse_capture: bool, + pub enable_keyboard_enhancement: bool, + pub simplified_model_picker: bool, + pub linked_side_panel_refresh_interval: std::time::Duration, +} + +impl SystemProfile { + pub fn load_ratio(&self) -> Option<f64> { + match (self.load_avg_1m, self.cpu_count) { + (Some(load), Some(cpus)) if cpus > 0 => Some(load / cpus as f64), + _ => None, + } + } + + pub fn memory_pressure(&self) -> Option<f64> { + match (self.available_memory_mb, self.total_memory_mb) { + (Some(avail), Some(total)) if total > 0 => Some(1.0 - (avail as f64 / total as f64)), + _ => None, + } + } + + pub fn is_windows_terminal(&self) -> bool { + self.terminal == "windows-terminal" + } + + pub fn is_windows_terminal_family(&self) -> bool { + matches!( + self.terminal.as_str(), + "windows-terminal" | "cmd" | "conhost" + ) + } + + pub fn is_wsl_windows_terminal(&self) -> bool { + self.is_wsl && self.is_windows_terminal() + } +} + +static PROFILE: OnceLock<SystemProfile> = OnceLock::new(); + +pub fn profile() -> &'static SystemProfile { + PROFILE.get_or_init(detect) +} + +pub fn synthetic_profile(kind: SyntheticSystemProfile) -> SystemProfile { + match kind { + SyntheticSystemProfile::Native => SystemProfile { + load_avg_1m: Some(0.2), + cpu_count: Some(8), + available_memory_mb: Some(8192), + total_memory_mb: Some(16384), + is_ssh: false, + is_wsl: false, + terminal: "kitty".to_string(), + tier: PerformanceTier::Full, + fragile_glyph_cache: false, + }, + SyntheticSystemProfile::Wsl => SystemProfile { + load_avg_1m: Some(0.4), + cpu_count: Some(8), + available_memory_mb: Some(8192), + total_memory_mb: Some(16384), + is_ssh: false, + is_wsl: true, + terminal: "wezterm".to_string(), + tier: compute_tier( + Some(0.4), + Some(8), + Some(8192), + Some(16384), + false, + true, + "wezterm", + ), + fragile_glyph_cache: false, + }, + SyntheticSystemProfile::WslWindowsTerminal => SystemProfile { + load_avg_1m: Some(0.4), + cpu_count: Some(8), + available_memory_mb: Some(8192), + total_memory_mb: Some(16384), + is_ssh: false, + is_wsl: true, + terminal: "windows-terminal".to_string(), + tier: compute_tier( + Some(0.4), + Some(8), + Some(8192), + Some(16384), + false, + true, + "windows-terminal", + ), + fragile_glyph_cache: false, + }, + } +} + +pub fn tui_policy() -> TuiPerfPolicy { + tui_policy_for(profile(), &crate::config::config().display) +} + +pub fn tui_policy_for( + profile: &SystemProfile, + display: &crate::config::DisplayConfig, +) -> TuiPerfPolicy { + let mut redraw_fps = display.redraw_fps.clamp(1, 120); + let mut animation_fps = display.animation_fps.clamp(1, 120); + let mut enable_decorative_animations = !matches!(profile.tier, PerformanceTier::Minimal); + let mut enable_focus_change = true; + let enable_mouse_capture = display.mouse_capture; + let mut enable_keyboard_enhancement = true; + let mut simplified_model_picker = false; + let mut linked_side_panel_refresh_interval = std::time::Duration::from_millis(250); + + if profile.is_wsl || profile.is_windows_terminal_family() { + enable_decorative_animations = false; + } + + // Glyph-safe mode for terminals with a fragile GPU glyph atlas (macOS 26 + // VS Code integrated terminal / Apple Terminal). The primary fix lives in + // `jcode-tui-style`: colors are quantized to the 256-palette there, which + // bounds the distinct (glyph, color) atlas keys so the animations no longer + // overflow the cache (#330). Here we only trim full-frame repaint pressure + // as cheap insurance; decorative animations stay ON so the experience is + // unchanged apart from slightly reduced color fidelity. + if profile.fragile_glyph_cache { + redraw_fps = redraw_fps.min(30); + } + + if profile.is_wsl { + redraw_fps = redraw_fps.min(30); + linked_side_panel_refresh_interval = std::time::Duration::from_millis(500); + } + + if profile.is_wsl_windows_terminal() { + redraw_fps = redraw_fps.min(20); + enable_focus_change = false; + enable_keyboard_enhancement = false; + simplified_model_picker = true; + linked_side_panel_refresh_interval = std::time::Duration::from_millis(1000); + } + + match profile.tier { + PerformanceTier::Full => {} + PerformanceTier::Reduced => { + redraw_fps = redraw_fps.min(30); + if enable_decorative_animations { + animation_fps = animation_fps.min(24); + } + linked_side_panel_refresh_interval = + linked_side_panel_refresh_interval.max(std::time::Duration::from_millis(500)); + } + PerformanceTier::Minimal => { + redraw_fps = redraw_fps.min(12); + enable_decorative_animations = false; + linked_side_panel_refresh_interval = + linked_side_panel_refresh_interval.max(std::time::Duration::from_millis(1000)); + } + } + + if !enable_decorative_animations { + animation_fps = 1; + } + + TuiPerfPolicy { + tier: profile.tier, + redraw_fps, + animation_fps, + enable_decorative_animations, + enable_focus_change, + enable_mouse_capture, + enable_keyboard_enhancement, + simplified_model_picker, + linked_side_panel_refresh_interval, + } +} + +pub fn init_background() { + std::thread::spawn(|| { + let p = PROFILE.get_or_init(detect); + crate::logging::info(&format!( + "perf: tier={} terminal={} ssh={} wsl={} glyph_safe={} load={} cpus={} mem_avail={}MB mem_total={}MB", + p.tier, + p.terminal, + p.is_ssh, + p.is_wsl, + p.fragile_glyph_cache, + p.load_avg_1m + .map(|v| format!("{:.1}", v)) + .unwrap_or_else(|| "?".into()), + p.cpu_count + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".into()), + p.available_memory_mb + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".into()), + p.total_memory_mb + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".into()), + )); + }); +} + +fn detect() -> SystemProfile { + let is_ssh = std::env::var("SSH_CONNECTION").is_ok() || std::env::var("SSH_TTY").is_ok(); + let is_wsl = detect_wsl(); + let terminal = detect_terminal(); + let (load_avg_1m, cpu_count) = detect_load(); + let (available_memory_mb, total_memory_mb) = detect_memory(); + + let auto_tier = compute_tier( + load_avg_1m, + cpu_count, + available_memory_mb, + total_memory_mb, + is_ssh, + is_wsl, + &terminal, + ); + + let tier = match crate::config::config().display.performance.as_str() { + "full" => PerformanceTier::Full, + "reduced" => PerformanceTier::Reduced, + "minimal" => PerformanceTier::Minimal, + _ => auto_tier, + }; + + SystemProfile { + load_avg_1m, + cpu_count, + available_memory_mb, + total_memory_mb, + is_ssh, + is_wsl, + fragile_glyph_cache: detect_fragile_glyph_cache(&terminal), + terminal, + tier, + } +} + +/// Detect terminals whose GPU glyph atlas corrupts under heavy per-cell +/// color/redraw churn. On macOS 26 (Tahoe) the VS Code integrated terminal +/// (xterm.js) and Apple Terminal exhibit the "garbled glyphs" bug where a +/// fixed set of similar-shaped letters (n/m/r/w/...) get re-rendered as stale +/// cached boxes once the atlas overflows. Anthropic shipped the same class of +/// bug for Claude Code (anthropics/claude-code#60831, #61562) with the +/// `gpuAcceleration: off` workaround; we instead reduce the churn that +/// surfaces it. GPU-robust terminals (Ghostty, iTerm2, kitty, WezTerm, +/// Alacritty) are unaffected and excluded. +fn detect_fragile_glyph_cache(terminal: &str) -> bool { + // Opt-out / opt-in override for users who want to force the behavior. + if let Ok(raw) = std::env::var("JCODE_GLYPH_SAFE_MODE") { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => return true, + "0" | "false" | "no" | "off" => return false, + _ => {} + } + } + + // Only macOS surfaces this; other platforms render these terminals fine. + if !cfg!(target_os = "macos") { + return false; + } + + matches!(terminal, "vscode" | "apple_terminal") +} + +fn compute_tier( + load_avg: Option<f64>, + cpu_count: Option<usize>, + avail_mb: Option<u64>, + _total_mb: Option<u64>, + is_ssh: bool, + is_wsl: bool, + terminal: &str, +) -> PerformanceTier { + if is_ssh { + return PerformanceTier::Minimal; + } + + let mut score: i32 = 0; + + if let (Some(load), Some(cpus)) = (load_avg, cpu_count) { + let ratio = load / cpus as f64; + if ratio > 2.0 { + score += 3; + } else if ratio > 1.0 { + score += 2; + } else if ratio > 0.8 { + score += 1; + } + } + + if let Some(avail) = avail_mb { + if avail < 512 { + score += 3; + } else if avail < 1024 { + score += 2; + } else if avail < 2048 { + score += 1; + } + } + + if is_wsl { + score += 1; + } + + match terminal { + "windows-terminal" | "cmd" | "conhost" => score += 1, + _ => {} + } + + match score { + 0..=1 => PerformanceTier::Full, + 2..=3 => PerformanceTier::Reduced, + _ => PerformanceTier::Minimal, + } +} + +fn detect_wsl() -> bool { + if std::env::var("WSL_DISTRO_NAME").is_ok() || std::env::var("WSLENV").is_ok() { + return true; + } + #[cfg(target_os = "linux")] + { + if let Ok(v) = std::fs::read_to_string("/proc/version") { + let lower = v.to_ascii_lowercase(); + if lower.contains("microsoft") || lower.contains("wsl") { + return true; + } + } + } + false +} + +fn detect_terminal() -> String { + if std::env::var("WT_SESSION").is_ok() { + return "windows-terminal".to_string(); + } + if std::env::var("WEZTERM_EXECUTABLE").is_ok() || std::env::var("WEZTERM_PANE").is_ok() { + return "wezterm".to_string(); + } + if std::env::var("KITTY_PID").is_ok() { + return "kitty".to_string(); + } + if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() { + return "ghostty".to_string(); + } + if std::env::var("ALACRITTY_WINDOW_ID").is_ok() { + return "alacritty".to_string(); + } + if let Ok(tp) = std::env::var("TERM_PROGRAM") { + return tp.to_lowercase(); + } + "unknown".to_string() +} + +#[cfg(target_os = "linux")] +fn detect_load() -> (Option<f64>, Option<usize>) { + let load = std::fs::read_to_string("/proc/loadavg").ok().and_then(|s| { + s.split_whitespace() + .next() + .and_then(|v| v.parse::<f64>().ok()) + }); + + let cpus = std::fs::read_to_string("/proc/cpuinfo") + .ok() + .map(|s| s.matches("processor\t:").count()) + .filter(|&c| c > 0) + .or_else(|| std::thread::available_parallelism().ok().map(|n| n.get())); + + (load, cpus) +} + +#[cfg(target_os = "macos")] +fn detect_load() -> (Option<f64>, Option<usize>) { + let load = { + let mut loadavg: [libc::c_double; 3] = [0.0; 3]; + let n = unsafe { libc::getloadavg(loadavg.as_mut_ptr(), 1) }; + if n >= 1 { Some(loadavg[0]) } else { None } + }; + let cpus = std::thread::available_parallelism().ok().map(|n| n.get()); + (load, cpus) +} + +#[cfg(windows)] +fn detect_load() -> (Option<f64>, Option<usize>) { + let cpus = std::thread::available_parallelism().ok().map(|n| n.get()); + (None, cpus) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn detect_load() -> (Option<f64>, Option<usize>) { + let cpus = std::thread::available_parallelism().ok().map(|n| n.get()); + (None, cpus) +} + +#[cfg(target_os = "linux")] +fn detect_memory() -> (Option<u64>, Option<u64>) { + let contents = match std::fs::read_to_string("/proc/meminfo") { + Ok(c) => c, + Err(_) => return (None, None), + }; + + let mut total_kb: Option<u64> = None; + let mut available_kb: Option<u64> = None; + + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("MemTotal:") { + total_kb = parse_meminfo_kb(rest); + } else if let Some(rest) = line.strip_prefix("MemAvailable:") { + available_kb = parse_meminfo_kb(rest); + } + if total_kb.is_some() && available_kb.is_some() { + break; + } + } + + (available_kb.map(|k| k / 1024), total_kb.map(|k| k / 1024)) +} + +#[cfg(target_os = "linux")] +fn parse_meminfo_kb(s: &str) -> Option<u64> { + s.split_whitespace().next()?.parse().ok() +} + +#[cfg(windows)] +fn detect_memory() -> (Option<u64>, Option<u64>) { + use std::mem; + + #[repr(C)] + struct MemoryStatusEx { + dw_length: u32, + dw_memory_load: u32, + ull_total_phys: u64, + ull_avail_phys: u64, + ull_total_page_file: u64, + ull_avail_page_file: u64, + ull_total_virtual: u64, + ull_avail_virtual: u64, + ull_avail_extended_virtual: u64, + } + + unsafe extern "system" { + fn GlobalMemoryStatusEx(lpBuffer: *mut MemoryStatusEx) -> i32; + } + + let mut status: MemoryStatusEx = unsafe { mem::zeroed() }; + status.dw_length = mem::size_of::<MemoryStatusEx>() as u32; + + let ret = unsafe { GlobalMemoryStatusEx(&mut status) }; + if ret != 0 { + let total_mb = status.ull_total_phys / (1024 * 1024); + let avail_mb = status.ull_avail_phys / (1024 * 1024); + (Some(avail_mb), Some(total_mb)) + } else { + (None, None) + } +} + +#[cfg(target_os = "macos")] +fn detect_memory() -> (Option<u64>, Option<u64>) { + let total = { + let mut size: u64 = 0; + let mut len = std::mem::size_of::<u64>(); + let name = c"hw.memsize"; + let ret = unsafe { + libc::sysctlbyname( + name.as_ptr(), + &mut size as *mut u64 as *mut libc::c_void, + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if ret == 0 { + Some(size / (1024 * 1024)) + } else { + None + } + }; + + // macOS doesn't have a simple "available" metric like Linux's MemAvailable. + // vm_stat gives pages free + inactive but parsing it adds complexity. + // For tier detection, total memory is sufficient on macOS. + (None, total) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn detect_memory() -> (Option<u64>, Option<u64>) { + (None, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssh_is_minimal() { + let tier = compute_tier( + Some(0.1), + Some(8), + Some(8000), + Some(16000), + true, + false, + "kitty", + ); + assert_eq!(tier, PerformanceTier::Minimal); + } + + #[test] + fn test_healthy_system_is_full() { + let tier = compute_tier( + Some(0.5), + Some(8), + Some(8000), + Some(16000), + false, + false, + "kitty", + ); + assert_eq!(tier, PerformanceTier::Full); + } + + #[test] + fn test_high_load_reduces() { + let tier = compute_tier( + Some(12.0), + Some(4), + Some(8000), + Some(16000), + false, + false, + "kitty", + ); + assert!(matches!( + tier, + PerformanceTier::Reduced | PerformanceTier::Minimal + )); + } + + #[test] + fn test_low_memory_reduces() { + let tier = compute_tier( + Some(0.5), + Some(8), + Some(400), + Some(16000), + false, + false, + "kitty", + ); + assert!(matches!( + tier, + PerformanceTier::Reduced | PerformanceTier::Minimal + )); + } + + #[test] + fn test_wsl_penalty() { + let tier_no_wsl = compute_tier( + Some(0.5), + Some(4), + Some(3000), + Some(8000), + false, + false, + "wezterm", + ); + let tier_wsl = compute_tier( + Some(0.5), + Some(4), + Some(3000), + Some(8000), + false, + true, + "wezterm", + ); + assert!(tier_wsl as i32 >= tier_no_wsl as i32); + } + + #[test] + fn test_windows_terminal_penalty() { + let tier_kitty = compute_tier( + Some(0.7), + Some(4), + Some(2500), + Some(8000), + false, + false, + "kitty", + ); + let tier_wt = compute_tier( + Some(0.7), + Some(4), + Some(2500), + Some(8000), + false, + false, + "windows-terminal", + ); + assert!(tier_wt as i32 >= tier_kitty as i32); + } + + #[test] + fn test_profile_accessors() { + let p = SystemProfile { + load_avg_1m: Some(4.0), + cpu_count: Some(8), + available_memory_mb: Some(4000), + total_memory_mb: Some(16000), + is_ssh: false, + is_wsl: false, + terminal: "kitty".to_string(), + tier: PerformanceTier::Full, + fragile_glyph_cache: false, + }; + assert!((p.load_ratio().unwrap() - 0.5).abs() < 0.01); + assert!((p.memory_pressure().unwrap() - 0.75).abs() < 0.01); + } + + #[test] + fn test_tier_display() { + assert_eq!(PerformanceTier::Full.label(), "full"); + assert_eq!(PerformanceTier::Reduced.label(), "reduced"); + assert_eq!(PerformanceTier::Minimal.label(), "minimal"); + } + + #[test] + fn test_badge() { + assert!(PerformanceTier::Full.badge().is_none()); + assert!(PerformanceTier::Reduced.badge().is_some()); + assert!(PerformanceTier::Minimal.badge().is_some()); + } + + #[test] + fn test_animation_gates() { + assert!(PerformanceTier::Full.animations_enabled()); + assert!(PerformanceTier::Full.idle_animation_enabled()); + assert!(PerformanceTier::Full.prompt_entry_animation_enabled()); + + assert!(PerformanceTier::Reduced.animations_enabled()); + assert!(!PerformanceTier::Reduced.idle_animation_enabled()); + assert!(PerformanceTier::Reduced.prompt_entry_animation_enabled()); + + assert!(!PerformanceTier::Minimal.animations_enabled()); + assert!(!PerformanceTier::Minimal.idle_animation_enabled()); + assert!(!PerformanceTier::Minimal.prompt_entry_animation_enabled()); + } + + #[test] + fn test_tui_policy_caps_wsl_windows_terminal() { + let profile = synthetic_profile(SyntheticSystemProfile::WslWindowsTerminal); + let mut display = crate::config::DisplayConfig::default(); + display.mouse_capture = true; + display.redraw_fps = 60; + display.animation_fps = 60; + let policy = tui_policy_for(&profile, &display); + assert_eq!(policy.tier, PerformanceTier::Reduced); + assert_eq!(policy.redraw_fps, 20); + assert_eq!(policy.animation_fps, 1); + assert!(!policy.enable_decorative_animations); + assert!(!policy.enable_focus_change); + assert!(!policy.enable_keyboard_enhancement); + assert!(policy.simplified_model_picker); + assert!(policy.enable_mouse_capture); + assert_eq!( + policy.linked_side_panel_refresh_interval, + std::time::Duration::from_millis(1000) + ); + } + + #[test] + fn test_tui_policy_keeps_native_defaults() { + let profile = synthetic_profile(SyntheticSystemProfile::Native); + let mut display = crate::config::DisplayConfig::default(); + display.mouse_capture = true; + display.redraw_fps = 48; + display.animation_fps = 50; + let policy = tui_policy_for(&profile, &display); + assert_eq!(policy.tier, PerformanceTier::Full); + assert_eq!(policy.redraw_fps, 48); + assert_eq!(policy.animation_fps, 50); + assert!(policy.enable_decorative_animations); + assert!(policy.enable_focus_change); + assert!(policy.enable_keyboard_enhancement); + assert!(!policy.simplified_model_picker); + assert!(policy.enable_mouse_capture); + assert_eq!( + policy.linked_side_panel_refresh_interval, + std::time::Duration::from_millis(250) + ); + } + + #[test] + fn test_tui_policy_caps_generic_wsl_without_disabling_terminal_features() { + let profile = synthetic_profile(SyntheticSystemProfile::Wsl); + let mut display = crate::config::DisplayConfig::default(); + display.mouse_capture = false; + display.redraw_fps = 60; + display.animation_fps = 60; + let policy = tui_policy_for(&profile, &display); + assert_eq!(policy.redraw_fps, 30); + assert_eq!(policy.animation_fps, 1); + assert!(!policy.enable_decorative_animations); + assert!(policy.enable_focus_change); + assert!(policy.enable_keyboard_enhancement); + assert!(!policy.simplified_model_picker); + assert!(!policy.enable_mouse_capture); + assert_eq!( + policy.linked_side_panel_refresh_interval, + std::time::Duration::from_millis(500) + ); + } + + #[test] + fn test_tui_policy_disables_decorative_animation_on_windows_terminal_family() { + let profile = SystemProfile { + load_avg_1m: Some(0.2), + cpu_count: Some(8), + available_memory_mb: Some(8192), + total_memory_mb: Some(16384), + is_ssh: false, + is_wsl: false, + terminal: "windows-terminal".to_string(), + tier: PerformanceTier::Full, + fragile_glyph_cache: false, + }; + let mut display = crate::config::DisplayConfig::default(); + display.redraw_fps = 60; + display.animation_fps = 60; + let policy = tui_policy_for(&profile, &display); + assert_eq!(policy.redraw_fps, 60); + assert_eq!(policy.animation_fps, 1); + assert!(!policy.enable_decorative_animations); + } + + #[test] + fn test_detect_runs() { + let p = detect(); + assert!(!p.terminal.is_empty()); + } + + fn glyph_safe_profile(terminal: &str) -> SystemProfile { + SystemProfile { + load_avg_1m: Some(0.2), + cpu_count: Some(8), + available_memory_mb: Some(8192), + total_memory_mb: Some(16384), + is_ssh: false, + is_wsl: false, + terminal: terminal.to_string(), + tier: PerformanceTier::Full, + fragile_glyph_cache: true, + } + } + + #[test] + fn test_glyph_safe_mode_keeps_animations_and_caps_redraw() { + // VS Code integrated terminal / Apple Terminal on macOS 26 corrupt the + // GPU glyph atlas under truecolor color churn (#330). The root-cause fix + // is color quantization in jcode-tui-style, so the perf policy keeps + // decorative animations ON and only trims full-frame repaint pressure. + let profile = glyph_safe_profile("vscode"); + let mut display = crate::config::DisplayConfig::default(); + display.redraw_fps = 60; + display.animation_fps = 60; + let policy = tui_policy_for(&profile, &display); + assert_eq!(policy.tier, PerformanceTier::Full); + assert!(policy.enable_decorative_animations); + assert_eq!(policy.redraw_fps, 30); + // Interactive features stay on; this is purely a rendering mitigation. + assert!(policy.enable_focus_change); + assert!(policy.enable_keyboard_enhancement); + } + + #[test] + fn test_non_fragile_terminal_keeps_decorative_animations() { + let profile = SystemProfile { + fragile_glyph_cache: false, + ..glyph_safe_profile("ghostty") + }; + let mut display = crate::config::DisplayConfig::default(); + display.redraw_fps = 60; + display.animation_fps = 60; + let policy = tui_policy_for(&profile, &display); + assert!(policy.enable_decorative_animations); + assert_eq!(policy.redraw_fps, 60); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_detect_fragile_glyph_cache_targets_macos_terminals() { + // Env override must not leak between cases. + let prev = std::env::var("JCODE_GLYPH_SAFE_MODE").ok(); + unsafe { + std::env::remove_var("JCODE_GLYPH_SAFE_MODE"); + } + assert!(detect_fragile_glyph_cache("vscode")); + assert!(detect_fragile_glyph_cache("apple_terminal")); + assert!(!detect_fragile_glyph_cache("ghostty")); + assert!(!detect_fragile_glyph_cache("iterm.app")); + assert!(!detect_fragile_glyph_cache("kitty")); + if let Some(prev) = prev { + unsafe { + std::env::set_var("JCODE_GLYPH_SAFE_MODE", prev); + } + } + } +} diff --git a/crates/jcode-app-core/src/protocol_memory.rs b/crates/jcode-app-core/src/protocol_memory.rs new file mode 100644 index 0000000..22382fa --- /dev/null +++ b/crates/jcode-app-core/src/protocol_memory.rs @@ -0,0 +1,56 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryStateSnapshot { + Idle, + Embedding, + SidecarChecking { count: usize }, + FoundRelevant { count: usize }, + Extracting { reason: String }, + Maintaining { phase: String }, + ToolAction { action: String, detail: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryStepStatusSnapshot { + Pending, + Running, + Done, + Error, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryStepResultSnapshot { + pub summary: String, + pub latency_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryPipelineSnapshot { + pub search: MemoryStepStatusSnapshot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_result: Option<MemoryStepResultSnapshot>, + pub verify: MemoryStepStatusSnapshot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verify_result: Option<MemoryStepResultSnapshot>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verify_progress: Option<(usize, usize)>, + pub inject: MemoryStepStatusSnapshot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inject_result: Option<MemoryStepResultSnapshot>, + pub maintain: MemoryStepStatusSnapshot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maintain_result: Option<MemoryStepResultSnapshot>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryActivitySnapshot { + pub state: MemoryStateSnapshot, + #[serde(default)] + pub state_age_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pipeline: Option<MemoryPipelineSnapshot>, +} diff --git a/crates/jcode-app-core/src/protocol_tests.rs b/crates/jcode-app-core/src/protocol_tests.rs new file mode 100644 index 0000000..2a6c752 --- /dev/null +++ b/crates/jcode-app-core/src/protocol_tests.rs @@ -0,0 +1,16 @@ +use super::*; +use anyhow::{Result, anyhow}; + +fn parse_request_json(json: &str) -> Result<Request> { + serde_json::from_str(json).map_err(Into::into) +} + +fn parse_event_json(json: &str) -> Result<ServerEvent> { + serde_json::from_str(json).map_err(Into::into) +} + +include!("protocol_tests/core_events.rs"); +include!("protocol_tests/comm_requests.rs"); +include!("protocol_tests/comm_responses.rs"); +include!("protocol_tests/misc_events.rs"); +include!("protocol_tests/randomized.rs"); diff --git a/crates/jcode-app-core/src/protocol_tests/comm_requests.rs b/crates/jcode-app-core/src/protocol_tests/comm_requests.rs new file mode 100644 index 0000000..a1176ac --- /dev/null +++ b/crates/jcode-app-core/src/protocol_tests/comm_requests.rs @@ -0,0 +1,559 @@ +#[test] +fn test_comm_propose_plan_roundtrip() -> Result<()> { + let req = Request::CommProposePlan { + id: 42, + session_id: "sess_a".to_string(), + items: vec![PlanItem { + content: "Refactor parser".to_string(), + status: "pending".to_string(), + priority: "high".to_string(), + id: "p1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec!["p0".to_string()], + assigned_to: Some("sess_b".to_string()), + }], + }; + let json = serde_json::to_string(&req)?; + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 42); + let Request::CommProposePlan { items, .. } = decoded else { + return Err(anyhow!("wrong request type")); + }; + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "p1"); + Ok(()) +} + +#[test] +fn test_stdin_response_roundtrip() -> Result<()> { + let req = Request::StdinResponse { + id: 99, + request_id: "stdin-call_abc-1".to_string(), + input: "my_password".to_string(), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"stdin_response\"")); + assert!(json.contains("\"request_id\":\"stdin-call_abc-1\"")); + assert!(json.contains("\"input\":\"my_password\"")); + + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 99); + let Request::StdinResponse { + request_id, input, .. + } = decoded + else { + return Err(anyhow!("expected StdinResponse")); + }; + assert_eq!(request_id, "stdin-call_abc-1"); + assert_eq!(input, "my_password"); + Ok(()) +} + +#[test] +fn test_stdin_response_deserialize_from_json() -> Result<()> { + let json = r#"{"type":"stdin_response","id":5,"request_id":"req-42","input":"hello world"}"#; + let decoded = parse_request_json(json)?; + assert_eq!(decoded.id(), 5); + let Request::StdinResponse { + request_id, input, .. + } = decoded + else { + return Err(anyhow!("expected StdinResponse")); + }; + assert_eq!(request_id, "req-42"); + assert_eq!(input, "hello world"); + Ok(()) +} + +#[test] +fn test_stdin_request_event_roundtrip() -> Result<()> { + let event = ServerEvent::StdinRequest { + request_id: "stdin-xyz-1".to_string(), + prompt: "Password: ".to_string(), + is_password: true, + tool_call_id: "call_abc".to_string(), + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"stdin_request\"")); + assert!(json.contains("\"is_password\":true")); + + let decoded = parse_event_json(json.trim())?; + let ServerEvent::StdinRequest { + request_id, + prompt, + is_password, + tool_call_id, + } = decoded + else { + return Err(anyhow!("expected StdinRequest")); + }; + assert_eq!(request_id, "stdin-xyz-1"); + assert_eq!(prompt, "Password: "); + assert!(is_password); + assert_eq!(tool_call_id, "call_abc"); + Ok(()) +} + +#[test] +fn test_stdin_request_event_defaults() -> Result<()> { + // is_password defaults to false when not present + let json = r#"{"type":"stdin_request","request_id":"r1","prompt":"","tool_call_id":"tc1"}"#; + let decoded = parse_event_json(json)?; + let ServerEvent::StdinRequest { is_password, .. } = decoded else { + return Err(anyhow!("expected StdinRequest")); + }; + assert!(!is_password, "is_password should default to false"); + Ok(()) +} + +#[test] +fn test_comm_await_members_roundtrip() -> Result<()> { + let req = Request::CommAwaitMembers { + id: 55, + session_id: "sess_waiter".to_string(), + target_status: vec!["completed".to_string(), "stopped".to_string()], + session_ids: vec!["sess_a".to_string(), "sess_b".to_string()], + mode: Some("any".to_string()), + timeout_secs: Some(120), + background: false, + notify: false, + wake: false, + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_await_members\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 55); + let Request::CommAwaitMembers { + session_id, + target_status, + session_ids, + mode, + timeout_secs, + background, + notify, + wake, + .. + } = decoded + else { + return Err(anyhow!("expected CommAwaitMembers")); + }; + assert_eq!(session_id, "sess_waiter"); + assert_eq!(target_status, vec!["completed", "stopped"]); + assert_eq!(session_ids, vec!["sess_a", "sess_b"]); + assert_eq!(mode.as_deref(), Some("any")); + assert_eq!(timeout_secs, Some(120)); + assert!(!background); + assert!(!notify); + assert!(!wake); + Ok(()) +} + +#[test] +fn test_comm_await_members_defaults() -> Result<()> { + let json = + r#"{"type":"comm_await_members","id":1,"session_id":"s1","target_status":["completed"]}"#; + let decoded = parse_request_json(json)?; + let Request::CommAwaitMembers { + session_ids, + mode, + timeout_secs, + background, + notify, + wake, + .. + } = decoded + else { + return Err(anyhow!("expected CommAwaitMembers")); + }; + assert!( + session_ids.is_empty(), + "session_ids should default to empty" + ); + assert_eq!(mode, None, "mode should default to None"); + assert_eq!(timeout_secs, None, "timeout_secs should default to None"); + assert!(background, "background should default to true"); + assert!(notify, "notify should default to true"); + assert!(wake, "wake should default to true"); + Ok(()) +} + +#[test] +fn test_comm_report_roundtrip() -> Result<()> { + let req = Request::CommReport { + id: 57, + session_id: "sess_worker".to_string(), + status: Some("ready".to_string()), + message: "Implemented report action.".to_string(), + validation: Some("Focused tests passed.".to_string()), + follow_up: Some("None.".to_string()), + tldr: None, + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_report\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 57); + let Request::CommReport { + session_id, + status, + message, + validation, + follow_up, + .. + } = decoded + else { + return Err(anyhow!("expected CommReport")); + }; + assert_eq!(session_id, "sess_worker"); + assert_eq!(status.as_deref(), Some("ready")); + assert_eq!(message, "Implemented report action."); + assert_eq!(validation.as_deref(), Some("Focused tests passed.")); + assert_eq!(follow_up.as_deref(), Some("None.")); + Ok(()) +} + +#[test] +fn test_comm_report_response_roundtrip() -> Result<()> { + let event = ServerEvent::CommReportResponse { + id: 57, + status: "ready".to_string(), + message: "Report recorded.".to_string(), + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"comm_report_response\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CommReportResponse { + id, + status, + message, + } = decoded + else { + return Err(anyhow!("expected CommReportResponse")); + }; + assert_eq!(id, 57); + assert_eq!(status, "ready"); + assert_eq!(message, "Report recorded."); + Ok(()) +} + +#[test] +fn test_comm_await_members_response_roundtrip() -> Result<()> { + let event = ServerEvent::CommAwaitMembersResponse { + id: 55, + completed: true, + members: vec![ + AwaitedMemberStatus { + session_id: "sess_a".to_string(), + friendly_name: Some("fox".to_string()), + status: "completed".to_string(), + done: true, + completion_report: None, + }, + AwaitedMemberStatus { + session_id: "sess_b".to_string(), + friendly_name: Some("wolf".to_string()), + status: "stopped".to_string(), + done: true, + completion_report: None, + }, + ], + summary: "All 2 members are done: fox, wolf".to_string(), + background_started: false, + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"comm_await_members_response\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CommAwaitMembersResponse { + id, + completed, + members, + summary, + .. + } = decoded + else { + return Err(anyhow!("expected CommAwaitMembersResponse")); + }; + assert_eq!(id, 55); + assert!(completed); + assert_eq!(members.len(), 2); + assert_eq!(members[0].friendly_name.as_deref(), Some("fox")); + assert!(members[0].done); + assert_eq!(members[1].status, "stopped"); + assert!(summary.contains("fox")); + Ok(()) +} + +#[test] +fn test_comm_task_control_roundtrip() -> Result<()> { + let req = Request::CommTaskControl { + id: 58, + session_id: "sess_coord".to_string(), + action: "salvage".to_string(), + task_id: "task_42".to_string(), + target_session: Some("sess_replacement".to_string()), + message: Some("Recover partial progress first.".to_string()), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_task_control\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 58); + let Request::CommTaskControl { + session_id, + action, + task_id, + target_session, + message, + .. + } = decoded + else { + return Err(anyhow!("expected CommTaskControl")); + }; + assert_eq!(session_id, "sess_coord"); + assert_eq!(action, "salvage"); + assert_eq!(task_id, "task_42"); + assert_eq!(target_session.as_deref(), Some("sess_replacement")); + assert_eq!(message.as_deref(), Some("Recover partial progress first.")); + Ok(()) +} + +#[test] +fn test_comm_assign_task_roundtrip_without_explicit_task_id() -> Result<()> { + let req = Request::CommAssignTask { + id: 57, + session_id: "sess_coord".to_string(), + target_session: None, + task_id: None, + message: Some("Take the next highest-priority runnable task.".to_string()), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_assign_task\"")); + assert!(!json.contains("\"task_id\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 57); + let Request::CommAssignTask { + session_id, + target_session, + task_id, + message, + .. + } = decoded + else { + return Err(anyhow!("expected CommAssignTask")); + }; + assert_eq!(session_id, "sess_coord"); + assert_eq!(target_session, None); + assert_eq!(task_id, None); + assert_eq!( + message.as_deref(), + Some("Take the next highest-priority runnable task.") + ); + Ok(()) +} + +#[test] +fn test_comm_assign_task_response_roundtrip() -> Result<()> { + let event = ServerEvent::CommAssignTaskResponse { + id: 60, + task_id: "task-7".to_string(), + target_session: "sess_worker".to_string(), + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"comm_assign_task_response\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } = decoded + else { + return Err(anyhow!("expected CommAssignTaskResponse")); + }; + assert_eq!(id, 60); + assert_eq!(task_id, "task-7"); + assert_eq!(target_session, "sess_worker"); + Ok(()) +} + +#[test] +fn test_comm_assign_next_roundtrip() -> Result<()> { + let req = Request::CommAssignNext { + id: 60, + session_id: "sess_coord".to_string(), + target_session: Some("sess_worker".to_string()), + working_dir: Some("/tmp/project".to_string()), + 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()), + effort: Some("low".to_string()), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_assign_next\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 60); + let Request::CommAssignNext { + session_id, + target_session, + working_dir, + prefer_spawn, + spawn_if_needed, + message, + model, + effort, + .. + } = decoded + else { + return Err(anyhow!("expected CommAssignNext")); + }; + assert_eq!(session_id, "sess_coord"); + assert_eq!(target_session.as_deref(), Some("sess_worker")); + assert_eq!(working_dir.as_deref(), Some("/tmp/project")); + 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!(effort.as_deref(), Some("low")); + Ok(()) +} + +#[test] +fn test_comm_stop_roundtrip_with_force() -> Result<()> { + let req = Request::CommStop { + id: 61, + session_id: "sess_coord".to_string(), + target_session: "sess_worker".to_string(), + force: Some(true), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_stop\"")); + assert!(json.contains("\"force\":true")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 61); + let Request::CommStop { + session_id, + target_session, + force, + .. + } = decoded + else { + return Err(anyhow!("expected CommStop")); + }; + assert_eq!(session_id, "sess_coord"); + assert_eq!(target_session, "sess_worker"); + assert_eq!(force, Some(true)); + Ok(()) +} + +#[test] +fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> { + let req = Request::CommSpawn { + id: 59, + session_id: "sess_coord".to_string(), + working_dir: Some("/tmp/project".to_string()), + initial_message: Some("Start here".to_string()), + request_nonce: Some("planner-fresh-123".to_string()), + spawn_mode: Some("headless".to_string()), + model: Some("openai-api:gpt-5.5".to_string()), + effort: Some("low".to_string()), + label: Some("review auth flow".to_string()), + }; + let json = serde_json::to_string(&req)?; + 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\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 59); + let Request::CommSpawn { + session_id, + working_dir, + initial_message, + request_nonce, + spawn_mode, + model, + effort, + label, + .. + } = decoded + else { + return Err(anyhow!("expected CommSpawn")); + }; + assert_eq!(session_id, "sess_coord"); + assert_eq!(working_dir.as_deref(), Some("/tmp/project")); + assert_eq!(initial_message.as_deref(), Some("Start here")); + assert_eq!(request_nonce.as_deref(), Some("planner-fresh-123")); + assert_eq!(spawn_mode.as_deref(), Some("headless")); + assert_eq!(model.as_deref(), Some("openai-api:gpt-5.5")); + assert_eq!(effort.as_deref(), Some("low")); + assert_eq!(label.as_deref(), Some("review auth flow")); + Ok(()) +} + +#[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); + Ok(()) +} + +#[test] +fn test_comm_list_models_roundtrip() -> Result<()> { + let req = Request::CommListModels { + id: 61, + session_id: "sess_coord".to_string(), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_list_models\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 61); + assert!(decoded.is_lightweight_control_request()); + let Request::CommListModels { session_id, .. } = decoded else { + return Err(anyhow!("expected CommListModels")); + }; + assert_eq!(session_id, "sess_coord"); + Ok(()) +} + +#[test] +fn test_reload_force_defaults_true_for_legacy_clients() -> Result<()> { + // Old clients (and the desktop Swift enum, which has no reload case) send a + // reload request with no `force` field. It must default to true so their + // behavior stays unconditional, matching the pre-#291 protocol. + let json = r#"{"type":"reload","id":7}"#; + let decoded = parse_request_json(json)?; + let Request::Reload { id, force } = decoded else { + return Err(anyhow!("expected Reload")); + }; + assert_eq!(id, 7); + assert!(force, "missing force must default to true"); + Ok(()) +} + +#[test] +fn test_reload_force_roundtrip() -> Result<()> { + for force in [false, true] { + let req = Request::Reload { id: 9, force }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"reload\"")); + let decoded = parse_request_json(&json)?; + let Request::Reload { + id, + force: decoded_force, + } = decoded + else { + return Err(anyhow!("expected Reload")); + }; + assert_eq!(id, 9); + assert_eq!(decoded_force, force); + } + Ok(()) +} diff --git a/crates/jcode-app-core/src/protocol_tests/comm_responses.rs b/crates/jcode-app-core/src/protocol_tests/comm_responses.rs new file mode 100644 index 0000000..5ff2636 --- /dev/null +++ b/crates/jcode-app-core/src/protocol_tests/comm_responses.rs @@ -0,0 +1,251 @@ +#[test] +fn test_swarm_plan_event_roundtrip_with_summary() -> Result<()> { + let event = ServerEvent::SwarmPlan { + swarm_id: "swarm_123".to_string(), + version: 7, + items: vec![PlanItem { + content: "Investigate planner state".to_string(), + status: "queued".to_string(), + priority: "high".to_string(), + id: "task-1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec![], + assigned_to: None, + }], + participants: vec!["session_fox".to_string()], + reason: Some("task_completed".to_string()), + summary: Some(crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm_123".to_string()), + version: 7, + item_count: 1, + ready_ids: vec!["task-1".to_string()], + blocked_ids: Vec::new(), + active_ids: Vec::new(), + completed_ids: Vec::new(), + failed_ids: Vec::new(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: vec!["task-1".to_string()], + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "light".to_string(), + }), + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"swarm_plan\"")); + assert!(json.contains("\"summary\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::SwarmPlan { + swarm_id, + version, + items, + participants, + reason, + summary, + } = decoded + else { + return Err(anyhow!("expected SwarmPlan event")); + }; + assert_eq!(swarm_id, "swarm_123"); + assert_eq!(version, 7); + assert_eq!(participants, vec!["session_fox"]); + assert_eq!(reason.as_deref(), Some("task_completed")); + assert_eq!(items.len(), 1); + let summary = summary.ok_or_else(|| anyhow!("expected plan summary"))?; + assert_eq!(summary.ready_ids, vec!["task-1"]); + assert_eq!(summary.next_ready_ids, vec!["task-1"]); + Ok(()) +} + +#[test] +fn test_comm_task_control_response_roundtrip() -> Result<()> { + let event = ServerEvent::CommTaskControlResponse { + id: 61, + action: "start".to_string(), + task_id: "task-1".to_string(), + target_session: Some("sess_worker".to_string()), + status: "running".to_string(), + summary: crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm_123".to_string()), + version: 3, + item_count: 2, + ready_ids: vec!["task-2".to_string()], + blocked_ids: Vec::new(), + active_ids: vec!["task-1".to_string()], + completed_ids: vec!["setup".to_string()], + failed_ids: Vec::new(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: vec!["task-2".to_string()], + newly_ready_ids: vec!["task-2".to_string()], + low_confidence_ids: Vec::new(), + mode: "deep".to_string(), + }, + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"comm_task_control_response\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CommTaskControlResponse { + id, + action, + task_id, + target_session, + status, + summary, + } = decoded + else { + return Err(anyhow!("expected CommTaskControlResponse")); + }; + assert_eq!(id, 61); + assert_eq!(action, "start"); + assert_eq!(task_id, "task-1"); + assert_eq!(target_session.as_deref(), Some("sess_worker")); + assert_eq!(status, "running"); + assert_eq!(summary.next_ready_ids, vec!["task-2"]); + assert_eq!(summary.newly_ready_ids, vec!["task-2"]); + Ok(()) +} + +#[test] +fn test_comm_status_roundtrip() -> Result<()> { + let req = Request::CommStatus { + id: 56, + session_id: "sess_watcher".to_string(), + target_session: "sess_peer".to_string(), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_status\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 56); + let Request::CommStatus { + session_id, + target_session, + .. + } = decoded + else { + return Err(anyhow!("expected CommStatus")); + }; + assert_eq!(session_id, "sess_watcher"); + assert_eq!(target_session, "sess_peer"); + Ok(()) +} + +#[test] +fn test_comm_plan_status_roundtrip() -> Result<()> { + let req = Request::CommPlanStatus { + id: 59, + session_id: "sess_coord".to_string(), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"comm_plan_status\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 59); + let Request::CommPlanStatus { session_id, .. } = decoded else { + return Err(anyhow!("expected CommPlanStatus")); + }; + assert_eq!(session_id, "sess_coord"); + Ok(()) +} + +#[test] +fn test_comm_members_roundtrip_includes_status() -> Result<()> { + let event = ServerEvent::CommMembers { + id: 9, + members: vec![AgentInfo { + session_id: "sess-peer".to_string(), + friendly_name: Some("bear".to_string()), + files_touched: vec!["src/main.rs".to_string()], + status: Some("running".to_string()), + detail: Some("working on tests".to_string()), + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("sess-coord".to_string()), + latest_completion_report: None, + live_attachments: Some(0), + status_age_secs: Some(12), + ..Default::default() + }], + }; + + let json = encode_event(&event); + assert!(json.contains("\"type\":\"comm_members\"")); + assert!(json.contains("\"status\":\"running\"")); + + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CommMembers { id, members } = decoded else { + return Err(anyhow!("expected CommMembers")); + }; + assert_eq!(id, 9); + assert_eq!(members.len(), 1); + assert_eq!(members[0].friendly_name.as_deref(), Some("bear")); + assert_eq!(members[0].status.as_deref(), Some("running")); + assert_eq!(members[0].detail.as_deref(), Some("working on tests")); + assert_eq!(members[0].is_headless, Some(true)); + assert_eq!( + members[0].report_back_to_session_id.as_deref(), + Some("sess-coord") + ); + assert_eq!(members[0].live_attachments, Some(0)); + assert_eq!(members[0].status_age_secs, Some(12)); + Ok(()) +} + +#[test] +fn test_session_close_requested_roundtrip() -> Result<()> { + let event = ServerEvent::SessionCloseRequested { + reason: "Stopped by coordinator coord".to_string(), + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"session_close_requested\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::SessionCloseRequested { reason } = decoded else { + return Err(anyhow!("expected SessionCloseRequested")); + }; + assert_eq!(reason, "Stopped by coordinator coord"); + Ok(()) +} + +#[test] +fn test_comm_status_response_roundtrip() -> Result<()> { + let event = ServerEvent::CommStatusResponse { + id: 57, + snapshot: AgentStatusSnapshot { + session_id: "sess-peer".to_string(), + friendly_name: Some("bear".to_string()), + swarm_id: Some("swarm-test".to_string()), + status: Some("running".to_string()), + detail: Some("working on tests".to_string()), + role: Some("agent".to_string()), + is_headless: Some(true), + live_attachments: Some(0), + status_age_secs: Some(5), + last_activity_age_secs: Some(2), + joined_age_secs: Some(30), + files_touched: vec!["src/main.rs".to_string()], + activity: Some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: Some("bash".to_string()), + }), + provider_name: None, + provider_model: None, + }, + }; + + let json = encode_event(&event); + assert!(json.contains("\"type\":\"comm_status_response\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CommStatusResponse { id, snapshot } = decoded else { + return Err(anyhow!("expected CommStatusResponse")); + }; + assert_eq!(id, 57); + assert_eq!(snapshot.session_id, "sess-peer"); + assert_eq!(snapshot.friendly_name.as_deref(), Some("bear")); + assert_eq!( + snapshot + .activity + .and_then(|activity| activity.current_tool_name), + Some("bash".to_string()) + ); + 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 new file mode 100644 index 0000000..d0574e7 --- /dev/null +++ b/crates/jcode-app-core/src/protocol_tests/core_events.rs @@ -0,0 +1,388 @@ +#[test] +fn test_request_roundtrip() -> Result<()> { + let req = Request::Message { + id: 1, + content: "hello".to_string(), + images: vec![], + system_reminder: None, + }; + let json = serde_json::to_string(&req)?; + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 1); + Ok(()) +} + +#[test] +fn test_compacted_history_request_roundtrip() -> Result<()> { + let req = Request::GetCompactedHistory { + id: 7, + visible_messages: 64, + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"get_compacted_history\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 7); + let Request::GetCompactedHistory { + visible_messages, .. + } = decoded + else { + return Err(anyhow!("wrong request type")); + }; + assert_eq!(visible_messages, 64); + Ok(()) +} + +#[test] +fn test_notify_auth_changed_provider_hint_is_optional() -> Result<()> { + let legacy = r#"{"type":"notify_auth_changed","id":9}"#; + let decoded = parse_request_json(legacy)?; + let Request::NotifyAuthChanged { id, provider } = decoded else { + return Err(anyhow!("wrong request type")); + }; + assert_eq!(id, 9); + assert_eq!(provider, None); + + let req = Request::NotifyAuthChanged { + id: 10, + provider: Some("azure-openai".to_string()), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"provider\":\"azure-openai\"")); + let decoded = parse_request_json(&json)?; + let Request::NotifyAuthChanged { id, provider } = decoded else { + return Err(anyhow!("wrong request type")); + }; + assert_eq!(id, 10); + assert_eq!(provider.as_deref(), Some("azure-openai")); + Ok(()) +} + +#[test] +fn test_event_roundtrip() -> Result<()> { + let event = ServerEvent::TextDelta { + text: "hello".to_string(), + }; + let json = encode_event(&event); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::TextDelta { text } = decoded else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(text, "hello"); + Ok(()) +} + +#[test] +fn test_interrupted_event_decodes_from_json() -> Result<()> { + let json = r#"{"type":"interrupted"}"#; + let decoded = parse_event_json(json)?; + let ServerEvent::Interrupted = decoded else { + return Err(anyhow!("wrong event type")); + }; + Ok(()) +} + +#[test] +fn test_connection_type_event_roundtrip() -> Result<()> { + let event = ServerEvent::ConnectionType { + connection: "websocket".to_string(), + }; + let json = encode_event(&event); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::ConnectionType { connection } = decoded else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(connection, "websocket"); + Ok(()) +} + +#[test] +fn test_status_detail_event_roundtrip() -> Result<()> { + let event = ServerEvent::StatusDetail { + detail: "reusing websocket".to_string(), + }; + let json = encode_event(&event); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::StatusDetail { detail } = decoded else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(detail, "reusing websocket"); + Ok(()) +} + +#[test] +fn test_generated_image_event_roundtrip() -> Result<()> { + let event = ServerEvent::GeneratedImage { + id: "ig_123".to_string(), + path: "/tmp/generated.png".to_string(), + metadata_path: Some("/tmp/generated.json".to_string()), + output_format: "png".to_string(), + revised_prompt: Some("A polished image prompt".to_string()), + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"generated_image\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::GeneratedImage { + id, + path, + metadata_path, + output_format, + revised_prompt, + } = decoded else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(id, "ig_123"); + assert_eq!(path, "/tmp/generated.png"); + assert_eq!(metadata_path.as_deref(), Some("/tmp/generated.json")); + assert_eq!(output_format, "png"); + assert_eq!(revised_prompt.as_deref(), Some("A polished image prompt")); + Ok(()) +} + +#[test] +fn test_interrupted_event_roundtrip() -> Result<()> { + let event = ServerEvent::Interrupted; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"interrupted\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::Interrupted = decoded else { + return Err(anyhow!("wrong event type")); + }; + Ok(()) +} + +#[test] +fn test_history_event_decodes_without_compaction_mode_for_older_servers() -> Result<()> { + let json = r#"{ + "type":"history", + "id":1, + "session_id":"ses_test_123", + "messages":[], + "provider_name":"openai", + "provider_model":"gpt-5.4", + "available_models":["gpt-5.4"], + "connection_type":"websocket" + }"#; + let decoded = parse_event_json(json)?; + let ServerEvent::History { + provider_name, + provider_model, + available_models, + connection_type, + compaction_mode, + side_panel, + .. + } = decoded + else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(provider_name.as_deref(), Some("openai")); + assert_eq!(provider_model.as_deref(), Some("gpt-5.4")); + assert_eq!(available_models, vec!["gpt-5.4"]); + assert_eq!(connection_type.as_deref(), Some("websocket")); + assert_eq!(compaction_mode, crate::config::CompactionMode::Reactive); + assert!(!side_panel.has_pages()); + Ok(()) +} + +#[test] +fn test_history_event_roundtrip_preserves_side_panel_snapshot() -> Result<()> { + let event = ServerEvent::History { + id: 101, + session_id: "ses_test_456".to_string(), + messages: vec![HistoryMessage { + role: "assistant".to_string(), + content: "hello".to_string(), + tool_calls: None, + tool_data: None, + }], + images: Vec::new(), + provider_name: Some("openai".to_string()), + provider_model: Some("gpt-5.4".to_string()), + available_models: vec!["gpt-5.4".to_string()], + available_model_routes: Vec::new(), + mcp_servers: Vec::new(), + skills: Vec::new(), + total_tokens: Some((123, 45)), + token_usage_totals: Some(TokenUsageTotals { + messages_with_token_usage: 2, + input_tokens: 123, + output_tokens: 45, + cache_reported_input_tokens: 100, + cache_read_input_tokens: 80, + cache_creation_input_tokens: 10, + }), + all_sessions: Vec::new(), + client_count: None, + is_canary: None, + reload_recovery: None, + server_version: None, + server_name: None, + server_icon: None, + server_has_update: None, + was_interrupted: None, + connection_type: Some("websocket".to_string()), + status_detail: None, + upstream_provider: None, + resolved_credential: None, + reasoning_effort: None, + service_tier: None, + subagent_model: None, + autoreview_enabled: None, + autojudge_enabled: None, + compaction_mode: crate::config::CompactionMode::Reactive, + activity: None, + side_panel: crate::side_panel::SidePanelSnapshot { + focused_page_id: Some("page-1".to_string()), + pages: vec![crate::side_panel::SidePanelPage { + id: "page-1".to_string(), + title: "Notes".to_string(), + file_path: "/tmp/notes.md".to_string(), + format: crate::side_panel::SidePanelPageFormat::Markdown, + source: crate::side_panel::SidePanelPageSource::Managed, + content: "# Notes".to_string(), + updated_at_ms: 42, + }], + }, + }; + let json = encode_event(&event); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::History { + id, + side_panel, + messages, + provider_name, + provider_model, + total_tokens, + token_usage_totals, + .. + } = decoded + else { + return Err(anyhow!("expected History event")); + }; + assert_eq!(id, 101); + assert_eq!(provider_name.as_deref(), Some("openai")); + assert_eq!(provider_model.as_deref(), Some("gpt-5.4")); + assert_eq!(total_tokens, Some((123, 45))); + assert_eq!( + token_usage_totals.map(|totals| totals.cache_read_input_tokens), + Some(80) + ); + assert_eq!(messages.len(), 1); + assert_eq!(side_panel.focused_page_id.as_deref(), Some("page-1")); + assert_eq!(side_panel.pages.len(), 1); + assert_eq!(side_panel.pages[0].title, "Notes"); + assert_eq!(side_panel.pages[0].content, "# Notes"); + Ok(()) +} + +#[test] +fn test_compacted_history_event_roundtrip() -> Result<()> { + let event = ServerEvent::CompactedHistory { + id: 77, + session_id: "ses_compact_123".to_string(), + messages: vec![HistoryMessage { + role: "assistant".to_string(), + content: "older response".to_string(), + tool_calls: None, + tool_data: None, + }], + images: Vec::new(), + compacted_total: 128, + compacted_visible: 64, + compacted_remaining: 64, + compacted_hidden_prompts: 3, + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"compacted_history\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::CompactedHistory { + id, + session_id, + messages, + compacted_total, + compacted_visible, + compacted_remaining, + .. + } = decoded + else { + return Err(anyhow!("expected CompactedHistory event")); + }; + assert_eq!(id, 77); + assert_eq!(session_id, "ses_compact_123"); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "older response"); + assert_eq!(compacted_total, 128); + assert_eq!(compacted_visible, 64); + assert_eq!(compacted_remaining, 64); + Ok(()) +} + +#[test] +fn test_side_panel_state_event_roundtrip() -> Result<()> { + let event = ServerEvent::SidePanelState { + snapshot: crate::side_panel::SidePanelSnapshot { + focused_page_id: Some("page-1".to_string()), + pages: vec![crate::side_panel::SidePanelPage { + id: "page-1".to_string(), + title: "Notes".to_string(), + file_path: "/tmp/notes.md".to_string(), + format: crate::side_panel::SidePanelPageFormat::Markdown, + source: crate::side_panel::SidePanelPageSource::Managed, + content: "updated".to_string(), + updated_at_ms: 99, + }], + }, + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"side_panel_state\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::SidePanelState { snapshot } = decoded else { + return Err(anyhow!("expected SidePanelState event")); + }; + assert_eq!(snapshot.focused_page_id.as_deref(), Some("page-1")); + assert_eq!(snapshot.pages.len(), 1); + assert_eq!(snapshot.pages[0].title, "Notes"); + assert_eq!(snapshot.pages[0].content, "updated"); + Ok(()) +} + +#[test] +fn test_error_event_retry_after_roundtrip() -> Result<()> { + let event = ServerEvent::Error { + id: 42, + message: "rate limited".to_string(), + retry_after_secs: Some(17), + }; + let json = encode_event(&event); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::Error { + id, + message, + retry_after_secs, + } = decoded + else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(id, 42); + assert_eq!(message, "rate limited"); + assert_eq!(retry_after_secs, Some(17)); + Ok(()) +} + +#[test] +fn test_error_event_retry_after_back_compat_default() -> Result<()> { + let json = r#"{"type":"error","id":7,"message":"oops"}"#; + let decoded = parse_event_json(json)?; + let ServerEvent::Error { + id, + message, + retry_after_secs, + } = decoded + else { + return Err(anyhow!("wrong event type")); + }; + assert_eq!(id, 7); + assert_eq!(message, "oops"); + assert_eq!(retry_after_secs, None); + Ok(()) +} diff --git a/crates/jcode-app-core/src/protocol_tests/misc_events.rs b/crates/jcode-app-core/src/protocol_tests/misc_events.rs new file mode 100644 index 0000000..2818507 --- /dev/null +++ b/crates/jcode-app-core/src/protocol_tests/misc_events.rs @@ -0,0 +1,319 @@ +#[test] +fn test_transcript_request_roundtrip() -> Result<()> { + let req = Request::Transcript { + id: 77, + text: "hello from whisper".to_string(), + mode: TranscriptMode::Send, + session_id: Some("sess_abc".to_string()), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"transcript\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 77); + let Request::Transcript { + text, + mode, + session_id, + .. + } = decoded + else { + return Err(anyhow!("expected Transcript request")); + }; + assert_eq!(text, "hello from whisper"); + assert_eq!(mode, TranscriptMode::Send); + assert_eq!(session_id.as_deref(), Some("sess_abc")); + Ok(()) +} + +#[test] +fn test_transcript_event_roundtrip() -> Result<()> { + let event = ServerEvent::Transcript { + text: "dictated text".to_string(), + mode: TranscriptMode::Replace, + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"transcript\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::Transcript { text, mode } = decoded else { + return Err(anyhow!("expected Transcript event")); + }; + assert_eq!(text, "dictated text"); + assert_eq!(mode, TranscriptMode::Replace); + Ok(()) +} + +#[test] +fn test_memory_activity_event_roundtrip() -> Result<()> { + let event = ServerEvent::MemoryActivity { + activity: MemoryActivitySnapshot { + state: MemoryStateSnapshot::SidecarChecking { count: 3 }, + state_age_ms: 275, + pipeline: Some(MemoryPipelineSnapshot { + search: MemoryStepStatusSnapshot::Done, + search_result: Some(MemoryStepResultSnapshot { + summary: "5 hits".to_string(), + latency_ms: 14, + }), + verify: MemoryStepStatusSnapshot::Running, + verify_result: None, + verify_progress: Some((1, 3)), + inject: MemoryStepStatusSnapshot::Pending, + inject_result: None, + maintain: MemoryStepStatusSnapshot::Pending, + maintain_result: None, + }), + }, + }; + + let json = encode_event(&event); + assert!(json.contains("\"type\":\"memory_activity\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::MemoryActivity { activity } = decoded else { + return Err(anyhow!("expected MemoryActivity event")); + }; + assert_eq!( + activity.state, + MemoryStateSnapshot::SidecarChecking { count: 3 } + ); + assert_eq!(activity.state_age_ms, 275); + let pipeline = activity + .pipeline + .ok_or_else(|| anyhow!("pipeline snapshot"))?; + assert_eq!(pipeline.search, MemoryStepStatusSnapshot::Done); + assert_eq!(pipeline.verify, MemoryStepStatusSnapshot::Running); + assert_eq!(pipeline.verify_progress, Some((1, 3))); + Ok(()) +} + +#[test] +fn test_input_shell_request_roundtrip() -> Result<()> { + let req = Request::InputShell { + id: 88, + command: "ls -la".to_string(), + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"input_shell\"")); + let decoded = parse_request_json(&json)?; + assert_eq!(decoded.id(), 88); + let Request::InputShell { id, command } = decoded else { + return Err(anyhow!("expected InputShell request")); + }; + assert_eq!(id, 88); + assert_eq!(command, "ls -la"); + Ok(()) +} + +#[test] +fn test_input_shell_result_event_roundtrip() -> Result<()> { + let event = ServerEvent::InputShellResult { + result: crate::message::InputShellResult { + command: "pwd".to_string(), + cwd: Some("/tmp/project".to_string()), + output: "/tmp/project\n".to_string(), + exit_code: Some(0), + duration_ms: 7, + truncated: false, + failed_to_start: false, + }, + }; + let json = encode_event(&event); + assert!(json.contains("\"type\":\"input_shell_result\"")); + let decoded = parse_event_json(json.trim())?; + let ServerEvent::InputShellResult { result } = decoded else { + return Err(anyhow!("expected InputShellResult event")); + }; + assert_eq!(result.command, "pwd"); + assert_eq!(result.cwd.as_deref(), Some("/tmp/project")); + assert_eq!(result.exit_code, Some(0)); + Ok(()) +} + +#[test] +fn test_protocol_enum_roundtrips_cover_wire_names() -> Result<()> { + let transcript_modes = [ + (TranscriptMode::Insert, "insert"), + (TranscriptMode::Append, "append"), + (TranscriptMode::Replace, "replace"), + (TranscriptMode::Send, "send"), + ]; + for (mode, wire) in transcript_modes { + let json = serde_json::to_string(&mode)?; + assert_eq!(json, format!("\"{}\"", wire)); + let decoded: TranscriptMode = serde_json::from_str(&json)?; + assert_eq!(decoded, mode); + } + + let delivery_modes = [ + (CommDeliveryMode::Notify, "notify"), + (CommDeliveryMode::Interrupt, "interrupt"), + (CommDeliveryMode::Wake, "wake"), + ]; + for (mode, wire) in delivery_modes { + let json = serde_json::to_string(&mode)?; + assert_eq!(json, format!("\"{}\"", wire)); + let decoded: CommDeliveryMode = serde_json::from_str(&json)?; + assert_eq!(decoded, mode); + } + + let feature_toggles = [ + (FeatureToggle::Memory, "memory"), + (FeatureToggle::Swarm, "swarm"), + (FeatureToggle::Autoreview, "autoreview"), + (FeatureToggle::Autojudge, "autojudge"), + ]; + for (feature, wire) in feature_toggles { + let json = serde_json::to_string(&feature)?; + assert_eq!(json, format!("\"{}\"", wire)); + let decoded: FeatureToggle = serde_json::from_str(&json)?; + assert_eq!(decoded, feature); + } + + Ok(()) +} + +#[test] +fn test_set_feature_roundtrip() -> Result<()> { + let req = Request::SetFeature { + id: 77, + feature: FeatureToggle::Swarm, + enabled: true, + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"set_feature\"")); + let decoded = parse_request_json(&json)?; + let Request::SetFeature { + id, + feature, + enabled, + } = decoded + else { + return Err(anyhow!("expected SetFeature")); + }; + assert_eq!(id, 77); + assert_eq!(feature, FeatureToggle::Swarm); + assert!(enabled); + Ok(()) +} + +#[test] +fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result<()> { + let req = Request::Subscribe { + id: 89, + working_dir: Some("/tmp/project".to_string()), + selfdev: Some(true), + target_session_id: Some("sess_target".to_string()), + client_instance_id: Some("client-123".to_string()), + client_has_local_history: true, + allow_session_takeover: true, + terminal_env: vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())], + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"subscribe\"")); + let decoded = parse_request_json(&json)?; + let Request::Subscribe { + id, + working_dir, + selfdev, + target_session_id, + client_instance_id, + client_has_local_history, + allow_session_takeover, + terminal_env, + } = decoded + else { + return Err(anyhow!("expected Subscribe")); + }; + assert_eq!(id, 89); + assert_eq!(working_dir.as_deref(), Some("/tmp/project")); + assert_eq!(selfdev, Some(true)); + assert_eq!(target_session_id.as_deref(), Some("sess_target")); + assert_eq!(client_instance_id.as_deref(), Some("client-123")); + assert!(client_has_local_history); + assert!(allow_session_takeover); + assert_eq!( + terminal_env, + vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())] + ); + Ok(()) +} + +#[test] +fn test_subscribe_request_defaults_optional_flags() -> Result<()> { + let json = r#"{"type":"subscribe","id":91}"#; + let decoded = parse_request_json(json)?; + let Request::Subscribe { + id, + working_dir, + selfdev, + target_session_id, + client_instance_id, + client_has_local_history, + allow_session_takeover, + terminal_env, + } = decoded + else { + return Err(anyhow!("expected Subscribe")); + }; + assert_eq!(id, 91); + assert_eq!(working_dir, None); + assert_eq!(selfdev, None); + assert_eq!(target_session_id, None); + assert_eq!(client_instance_id, None); + assert!(!client_has_local_history); + assert!(!allow_session_takeover); + assert!(terminal_env.is_empty()); + Ok(()) +} + +#[test] +fn test_resume_session_defaults_sync_flags() -> Result<()> { + let json = r#"{"type":"resume_session","id":92,"session_id":"sess_resume"}"#; + let decoded = parse_request_json(json)?; + let Request::ResumeSession { + id, + session_id, + client_instance_id, + client_has_local_history, + allow_session_takeover, + } = decoded + else { + return Err(anyhow!("expected ResumeSession")); + }; + assert_eq!(id, 92); + assert_eq!(session_id, "sess_resume"); + assert_eq!(client_instance_id, None); + assert!(!client_has_local_history); + assert!(!allow_session_takeover); + Ok(()) +} + +#[test] +fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Result<()> { + let req = Request::Message { + id: 88, + content: "inspect this".to_string(), + images: vec![ + ("image/png".to_string(), "AAA".to_string()), + ("image/jpeg".to_string(), "BBB".to_string()), + ], + system_reminder: Some("be concise".to_string()), + }; + let json = serde_json::to_string(&req)?; + let decoded = parse_request_json(&json)?; + let Request::Message { + id, + content, + images, + system_reminder, + } = decoded + else { + return Err(anyhow!("expected Message")); + }; + assert_eq!(id, 88); + assert_eq!(content, "inspect this"); + assert_eq!(images.len(), 2); + assert_eq!(images[0].0, "image/png"); + assert_eq!(images[1].0, "image/jpeg"); + assert_eq!(system_reminder.as_deref(), Some("be concise")); + Ok(()) +} diff --git a/crates/jcode-app-core/src/protocol_tests/randomized.rs b/crates/jcode-app-core/src/protocol_tests/randomized.rs new file mode 100644 index 0000000..e86531b --- /dev/null +++ b/crates/jcode-app-core/src/protocol_tests/randomized.rs @@ -0,0 +1,121 @@ +#[test] +fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> { + use rand::{Rng, SeedableRng}; + + fn sample_ascii(rng: &mut rand::rngs::StdRng, max_len: usize) -> String { + let len = rng.random_range(0..=max_len); + (0..len) + .map(|_| char::from(rng.random_range(b'a'..=b'z'))) + .collect() + } + + let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0DEC0DE); + + for id in 0..32u64 { + let content = sample_ascii(&mut rng, 24); + let images = if rng.random_bool(0.5) { + vec![("image/png".to_string(), sample_ascii(&mut rng, 12))] + } else { + Vec::new() + }; + let system_reminder = if rng.random_bool(0.5) { + Some(sample_ascii(&mut rng, 20)) + } else { + None + }; + let req = Request::Message { + id, + content: content.clone(), + images: images.clone(), + system_reminder: system_reminder.clone(), + }; + let decoded = parse_request_json(&serde_json::to_string(&req)?)?; + let Request::Message { + id: decoded_id, + content: decoded_content, + images: decoded_images, + system_reminder: decoded_system_reminder, + } = decoded + else { + return Err(anyhow!("expected randomized Message")); + }; + assert_eq!(decoded_id, id); + assert_eq!(decoded_content, content); + assert_eq!(decoded_images, images); + assert_eq!(decoded_system_reminder, system_reminder); + } + + for id in 100..132u64 { + let working_dir = rng + .random_bool(0.5) + .then(|| format!("/tmp/{}", sample_ascii(&mut rng, 12))); + let selfdev = rng.random_bool(0.5).then(|| rng.random_bool(0.5)); + let target_session_id = rng.random_bool(0.5).then(|| format!("sess_{}", id)); + 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 req = Request::Subscribe { + id, + working_dir: working_dir.clone(), + selfdev, + target_session_id: target_session_id.clone(), + client_instance_id: client_instance_id.clone(), + client_has_local_history, + allow_session_takeover, + terminal_env: Vec::new(), + }; + let decoded = parse_request_json(&serde_json::to_string(&req)?)?; + let Request::Subscribe { + id: decoded_id, + working_dir: decoded_working_dir, + selfdev: decoded_selfdev, + target_session_id: decoded_target_session_id, + client_instance_id: decoded_client_instance_id, + client_has_local_history: decoded_client_has_local_history, + allow_session_takeover: decoded_allow_session_takeover, + terminal_env: _, + } = decoded + else { + return Err(anyhow!("expected randomized Subscribe")); + }; + assert_eq!(decoded_id, id); + assert_eq!(decoded_working_dir, working_dir); + assert_eq!(decoded_selfdev, selfdev); + assert_eq!(decoded_target_session_id, target_session_id); + 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); + } + + Ok(()) +} + +#[test] +fn test_resume_session_roundtrip_preserves_client_sync_flags() -> Result<()> { + let req = Request::ResumeSession { + id: 90, + session_id: "sess_resume".to_string(), + client_instance_id: Some("client-456".to_string()), + client_has_local_history: true, + allow_session_takeover: true, + }; + let json = serde_json::to_string(&req)?; + assert!(json.contains("\"type\":\"resume_session\"")); + let decoded = parse_request_json(&json)?; + let Request::ResumeSession { + id, + session_id, + client_instance_id, + client_has_local_history, + allow_session_takeover, + } = decoded + else { + return Err(anyhow!("expected ResumeSession")); + }; + assert_eq!(id, 90); + assert_eq!(session_id, "sess_resume"); + assert_eq!(client_instance_id.as_deref(), Some("client-456")); + assert!(client_has_local_history); + assert!(allow_session_takeover); + Ok(()) +} diff --git a/crates/jcode-app-core/src/replay.rs b/crates/jcode-app-core/src/replay.rs new file mode 100644 index 0000000..a429d69 --- /dev/null +++ b/crates/jcode-app-core/src/replay.rs @@ -0,0 +1,972 @@ +use crate::message::{ContentBlock, Role}; +use crate::protocol::ServerEvent; +use crate::session::{Session, StoredReplayEventKind}; +use anyhow::Result; +use chrono::Duration; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// A single event in a replay timeline. +/// +/// The `t` field is milliseconds from the start of the replay. +/// Edit this value to change pacing in post-production. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineEvent { + /// Milliseconds from replay start + pub t: u64, + /// The event payload + #[serde(flatten)] + pub kind: TimelineEventKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum TimelineEventKind { + /// User message appears instantly + #[serde(rename = "user_message")] + UserMessage { text: String }, + + /// Assistant starts streaming (sets processing state) + #[serde(rename = "thinking")] + Thinking { + /// How long to show the thinking spinner (ms) + #[serde(default = "default_thinking_duration")] + duration: u64, + }, + + /// Stream a chunk of assistant text + #[serde(rename = "stream_text")] + StreamText { + text: String, + /// Tokens per second for streaming speed (default 80) + #[serde(default = "default_stream_speed")] + speed: u64, + }, + + /// Tool call starts + #[serde(rename = "tool_start")] + ToolStart { + name: String, + #[serde(default)] + input: serde_json::Value, + }, + + /// Tool execution completes + #[serde(rename = "tool_done")] + ToolDone { + name: String, + output: String, + #[serde(default)] + is_error: bool, + }, + + /// Token usage update (drives context bar) + #[serde(rename = "token_usage")] + TokenUsage { + input: u64, + output: u64, + #[serde(default)] + cache_read: Option<u64>, + #[serde(default)] + cache_creation: Option<u64>, + }, + + /// Turn complete (commits streaming text, resets to idle) + #[serde(rename = "done")] + Done, + + /// Memory injection from auto-recall + #[serde(rename = "memory_injection")] + MemoryInjection { + summary: String, + content: String, + count: u32, + }, + /// A persisted non-provider display message. + #[serde(rename = "display_message")] + DisplayMessage { + role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option<String>, + content: String, + }, + /// Historical swarm status snapshot. + #[serde(rename = "swarm_status")] + SwarmStatus { + members: Vec<crate::protocol::SwarmMemberStatus>, + }, + /// Historical swarm plan snapshot. + #[serde(rename = "swarm_plan")] + SwarmPlan { + swarm_id: String, + version: u64, + items: Vec<crate::plan::PlanItem>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + participants: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option<String>, + }, +} + +fn default_thinking_duration() -> u64 { + 1200 +} +fn default_stream_speed() -> u64 { + 80 +} + +const MAX_INITIAL_REPLAY_IDLE_MS: u64 = 0; + +fn cap_initial_replay_idle(events: &mut [TimelineEvent]) { + let Some(first_t) = events.first().map(|event| event.t) else { + return; + }; + let shift = first_t.saturating_sub(MAX_INITIAL_REPLAY_IDLE_MS); + if shift == 0 { + return; + } + for event in events { + event.t = event.t.saturating_sub(shift); + } +} + +/// Export a session to a replay timeline. +/// +/// Uses stored timestamps for real pacing, falls back to estimates. +/// Memory injections from `session.memory_injections` are inserted at the +/// correct positions based on their `before_message` index. +pub fn export_timeline(session: &Session) -> Vec<TimelineEvent> { + let mut events = Vec::new(); + let mut t: u64 = 0; + let session_start = session.created_at; + + // Track tool IDs for pairing ToolUse → ToolResult + let mut pending_tools: Vec<(String, String, serde_json::Value)> = Vec::new(); // (id, name, input) + + // Track memory injections by message index + let mut memory_by_msg: std::collections::HashMap<usize, Vec<_>> = + std::collections::HashMap::new(); + for inj in &session.memory_injections { + if let Some(idx) = inj.before_message { + memory_by_msg.entry(idx).or_default().push(inj); + } + } + + for (msg_idx, msg) in session.messages.iter().enumerate() { + // Insert memory injections before this message + if let Some(injs) = memory_by_msg.get(&msg_idx) { + for inj in injs { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::MemoryInjection { + summary: inj.summary.clone(), + content: inj.content.clone(), + count: inj.count, + }, + }); + t += 500; // Brief pause after memory injection + } + } + + // Advance time based on stored timestamp + if let Some(ts) = msg.timestamp { + let offset = ts + .signed_duration_since(session_start) + .num_milliseconds() + .max(0) as u64; + if offset > t { + t = offset; + } + } + + match msg.role { + Role::User => { + // Check if this is a tool result + let mut has_tool_result = false; + for block in &msg.content { + if let ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } = block + { + has_tool_result = true; + // Find matching tool start + let tool_name = pending_tools + .iter() + .find(|(id, _, _)| id == tool_use_id) + .map(|(_, name, _)| name.clone()) + .unwrap_or_else(|| "tool".to_string()); + + // Use stored duration or estimate + let duration_ms = msg.tool_duration_ms.unwrap_or(500); + + events.push(TimelineEvent { + t, + kind: TimelineEventKind::ToolDone { + name: tool_name, + output: truncate_for_timeline(content), + is_error: is_error.unwrap_or(false), + }, + }); + t += duration_ms.min(100); // Small gap after tool result + pending_tools.retain(|(id, _, _)| id != tool_use_id); + } + } + + if !has_tool_result { + // Regular user message + let text = extract_text(&msg.content); + if !text.is_empty() { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::UserMessage { text }, + }); + t += 300; // Brief pause after user message + } + } + } + Role::Assistant => { + let text = extract_text(&msg.content); + let tool_uses: Vec<_> = msg + .content + .iter() + .filter_map(|b| { + if let ContentBlock::ToolUse { + id, name, input, .. + } = b + { + Some((id.clone(), name.clone(), input.clone())) + } else { + None + } + }) + .collect(); + + // Thinking phase + if !text.is_empty() || !tool_uses.is_empty() { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::Thinking { duration: 800 }, + }); + t += 800; + } + + // Stream text + if !text.is_empty() { + let speed = 80; + let stream_duration_ms = (text.len() as u64 * 1000) / (speed * 4); // ~4 chars/token + events.push(TimelineEvent { + t, + kind: TimelineEventKind::StreamText { + text: text.clone(), + speed, + }, + }); + t += stream_duration_ms; + } + + // Token usage + if let Some(ref usage) = msg.token_usage { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::TokenUsage { + input: usage.input_tokens, + output: usage.output_tokens, + cache_read: usage.cache_read_input_tokens, + cache_creation: usage.cache_creation_input_tokens, + }, + }); + } + + // Tool calls + for (id, name, input) in &tool_uses { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::ToolStart { + name: name.clone(), + input: input.clone(), + }, + }); + pending_tools.push((id.clone(), name.clone(), input.clone())); + t += 200; // Small gap between tool starts + } + + // Done if no pending tools + if tool_uses.is_empty() { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::Done, + }); + t += 200; + } + } + } + } + + // Final done if we haven't emitted one + if !events.is_empty() { + let last_is_done = events + .last() + .is_some_and(|e| matches!(e.kind, TimelineEventKind::Done)); + if !last_is_done { + events.push(TimelineEvent { + t, + kind: TimelineEventKind::Done, + }); + } + } + + for replay_event in &session.replay_events { + let offset = replay_event + .timestamp + .signed_duration_since(session_start) + .num_milliseconds() + .max(0) as u64; + let kind = match &replay_event.kind { + StoredReplayEventKind::DisplayMessage { + role, + title, + content, + } => TimelineEventKind::DisplayMessage { + role: role.clone(), + title: title.clone(), + content: content.clone(), + }, + StoredReplayEventKind::SwarmStatus { members } => TimelineEventKind::SwarmStatus { + members: members.clone(), + }, + StoredReplayEventKind::SwarmPlan { + swarm_id, + version, + items, + participants, + reason, + } => TimelineEventKind::SwarmPlan { + swarm_id: swarm_id.clone(), + version: *version, + items: items.clone(), + participants: participants.clone(), + reason: reason.clone(), + }, + }; + events.push(TimelineEvent { t: offset, kind }); + } + + events.sort_by_key(|event| event.t); + cap_initial_replay_idle(&mut events); + + events +} + +/// Replay-specific server events that don't exist in the normal protocol. +/// These are handled specially in `run_replay`. +#[derive(Debug, Clone)] +#[expect( + clippy::large_enum_variant, + reason = "replay events mirror protocol events directly for simpler playback serialization and handling" +)] +pub enum ReplayEvent { + /// A normal server event + Server(ServerEvent), + /// User message (displayed directly, not via server event) + UserMessage { text: String }, + /// Start processing state (shows thinking spinner) + StartProcessing, + /// Memory injection from auto-recall + MemoryInjection { + summary: String, + content: String, + count: u32, + }, + /// Persisted non-provider display message. + DisplayMessage { + role: String, + title: Option<String>, + content: String, + }, + /// Historical swarm status snapshot. + SwarmStatus { + members: Vec<crate::protocol::SwarmMemberStatus>, + }, + /// Historical swarm plan snapshot. + SwarmPlan { + swarm_id: String, + version: u64, + items: Vec<crate::plan::PlanItem>, + }, +} + +/// Convert a timeline into a sequence of (delay_ms, ReplayEvent) pairs for playback. +pub fn timeline_to_replay_events(timeline: &[TimelineEvent]) -> Vec<(u64, ReplayEvent)> { + let mut out = Vec::new(); + let mut prev_t: u64 = 0; + let mut turn_id: u64 = 1; + let mut tool_id_counter: u64 = 0; + let mut pending_tool_ids: Vec<String> = Vec::new(); + + for event in timeline { + let delay = event.t.saturating_sub(prev_t); + let delay = if out.is_empty() { + MAX_INITIAL_REPLAY_IDLE_MS + } else { + delay + }; + prev_t = event.t; + + match &event.kind { + TimelineEventKind::UserMessage { text } => { + out.push((delay, ReplayEvent::UserMessage { text: text.clone() })); + } + TimelineEventKind::Thinking { .. } => { + out.push((delay, ReplayEvent::StartProcessing)); + } + TimelineEventKind::StreamText { text, speed } => { + let chars_per_chunk = 4; // ~1 token + let ms_per_chunk = if *speed > 0 { 1000 / speed } else { 12 }; + let chunks: Vec<String> = text + .chars() + .collect::<Vec<_>>() + .chunks(chars_per_chunk) + .map(|c| c.iter().collect::<String>()) + .collect(); + + for (i, chunk) in chunks.iter().enumerate() { + let chunk_delay = if i == 0 { delay } else { ms_per_chunk }; + out.push(( + chunk_delay, + ReplayEvent::Server(ServerEvent::TextDelta { + text: chunk.clone(), + }), + )); + } + } + TimelineEventKind::ToolStart { name, input } => { + tool_id_counter += 1; + let id = format!("replay_tool_{}", tool_id_counter); + pending_tool_ids.push(id.clone()); + + out.push(( + delay, + ReplayEvent::Server(ServerEvent::ToolStart { + id: id.clone(), + name: name.clone(), + }), + )); + + let input_str = serde_json::to_string(input).unwrap_or_default(); + if !input_str.is_empty() && input_str != "null" { + out.push(( + 0, + ReplayEvent::Server(ServerEvent::ToolInput { delta: input_str }), + )); + } + + out.push(( + 50, + ReplayEvent::Server(ServerEvent::ToolExec { + id: id.clone(), + name: name.clone(), + }), + )); + } + TimelineEventKind::ToolDone { + name, + output, + is_error, + } => { + let id = pending_tool_ids.pop().unwrap_or_else(|| { + tool_id_counter += 1; + format!("replay_tool_{}", tool_id_counter) + }); + out.push(( + delay, + ReplayEvent::Server(ServerEvent::ToolDone { + id, + name: name.clone(), + output: output.clone(), + error: if *is_error { + Some(output.clone()) + } else { + None + }, + }), + )); + } + TimelineEventKind::TokenUsage { + input, + output, + cache_read, + cache_creation, + } => { + out.push(( + delay, + ReplayEvent::Server(ServerEvent::TokenUsage { + input: *input, + output: *output, + cache_read_input: *cache_read, + cache_creation_input: *cache_creation, + }), + )); + } + TimelineEventKind::Done => { + out.push(( + delay, + ReplayEvent::Server(ServerEvent::Done { id: turn_id }), + )); + turn_id += 1; + } + TimelineEventKind::MemoryInjection { + summary, + content, + count, + } => { + out.push(( + delay, + ReplayEvent::MemoryInjection { + summary: summary.clone(), + content: content.clone(), + count: *count, + }, + )); + } + TimelineEventKind::DisplayMessage { + role, + title, + content, + } => { + out.push(( + delay, + ReplayEvent::DisplayMessage { + role: role.clone(), + title: title.clone(), + content: content.clone(), + }, + )); + } + TimelineEventKind::SwarmStatus { members } => { + out.push(( + delay, + ReplayEvent::SwarmStatus { + members: members.clone(), + }, + )); + } + TimelineEventKind::SwarmPlan { + swarm_id, + version, + items, + .. + } => { + out.push(( + delay, + ReplayEvent::SwarmPlan { + swarm_id: swarm_id.clone(), + version: *version, + items: items.clone(), + }, + )); + } + } + } + + out +} + +/// Load a session by ID or path +pub fn load_session(id_or_path: &str) -> Result<Session> { + use std::path::Path; + + // Try as file path first + let path = Path::new(id_or_path); + if path.exists() { + return Session::load_from_path(path); + } + + // Try as session ID in the sessions directory + let sessions_dir = crate::storage::jcode_dir()?.join("sessions"); + // Try exact match + let exact = sessions_dir.join(format!("{}.json", id_or_path)); + if exact.exists() { + return Session::load_from_path(&exact); + } + + // Try prefix match (session_<id>.json or session_<name>_<ts>.json) + for entry in std::fs::read_dir(&sessions_dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.contains(id_or_path) && name.ends_with(".json") { + return Session::load_from_path(&entry.path()); + } + } + + anyhow::bail!( + "Session not found: '{}'. Provide a session ID, name, or file path.", + id_or_path + ); +} + +#[derive(Debug, Clone)] +pub struct SwarmReplaySession { + pub session: Session, + pub timeline: Vec<TimelineEvent>, +} + +pub fn load_swarm_sessions( + seed_id_or_path: &str, + auto_edit: bool, +) -> Result<Vec<SwarmReplaySession>> { + let seed = load_session(seed_id_or_path)?; + let seed_working_dir = seed.working_dir.clone(); + let lower_bound = seed.created_at - Duration::hours(6); + let upper_bound = seed.updated_at + Duration::hours(6); + + let sessions_dir = crate::storage::jcode_dir()?.join("sessions"); + if !sessions_dir.exists() { + return Ok(vec![SwarmReplaySession { + timeline: maybe_auto_edit(&seed, auto_edit), + session: seed, + }]); + } + + let mut all_sessions: Vec<Session> = Vec::new(); + for entry in std::fs::read_dir(&sessions_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.extension().map(|e| e == "json").unwrap_or(false) { + continue; + } + let Ok(session) = Session::load_from_path(&path) else { + continue; + }; + all_sessions.push(session); + } + + let mut selected_ids: BTreeSet<String> = BTreeSet::new(); + selected_ids.insert(seed.id.clone()); + + for session in &all_sessions { + if session.id == seed.id { + continue; + } + let same_working_dir = + seed_working_dir.is_some() && session.working_dir == seed_working_dir; + let linked_parent = session.parent_id.as_deref() == Some(seed.id.as_str()) + || seed.parent_id.as_deref() == Some(session.id.as_str()) + || (seed.parent_id.is_some() && session.parent_id == seed.parent_id); + let overlapping_time = + session.updated_at >= lower_bound && session.created_at <= upper_bound; + let has_swarm_events = session.replay_events.iter().any(|evt| { + matches!( + evt.kind, + StoredReplayEventKind::SwarmStatus { .. } | StoredReplayEventKind::SwarmPlan { .. } + ) + }); + + if overlapping_time && (same_working_dir || linked_parent || has_swarm_events) { + selected_ids.insert(session.id.clone()); + } + } + + let mut selected: Vec<Session> = all_sessions + .into_iter() + .filter(|session| selected_ids.contains(&session.id)) + .collect(); + if !selected.iter().any(|session| session.id == seed.id) { + selected.push(seed.clone()); + } + + selected.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(selected + .into_iter() + .map(|session| { + let timeline = maybe_auto_edit(&session, auto_edit); + SwarmReplaySession { session, timeline } + }) + .collect()) +} + +fn maybe_auto_edit(session: &Session, auto_edit: bool) -> Vec<TimelineEvent> { + let timeline = export_timeline(session); + if auto_edit { + auto_edit_timeline(&timeline, &AutoEditOpts::default()) + } else { + timeline + } +} + +#[derive(Debug, Clone)] +pub struct PaneReplayInput { + pub session: Session, + pub timeline: Vec<TimelineEvent>, +} + +#[derive(Debug, Clone)] +pub struct SwarmPaneFrames { + pub session_id: String, + pub title: String, + pub frames: Vec<(f64, ratatui::buffer::Buffer)>, +} + +pub fn compose_swarm_buffers( + pane_frames: &[SwarmPaneFrames], + width: u16, + height: u16, + fps: u32, + cols: u16, +) -> Vec<(f64, ratatui::buffer::Buffer)> { + use ratatui::{buffer::Buffer, layout::Rect}; + + if pane_frames.is_empty() { + return Vec::new(); + } + + let fps = fps.max(1); + let frame_step = 1.0 / fps as f64; + let end_time = pane_frames + .iter() + .filter_map(|pane| pane.frames.last().map(|(t, _)| *t)) + .fold(0.0, f64::max); + + let pane_count = pane_frames.len() as u16; + let cols = cols.clamp(1, pane_count.max(1)); + let rows = pane_count.div_ceil(cols).max(1); + let pane_width = (width / cols).max(1); + let pane_height = (height / rows).max(1); + + let mut output = Vec::new(); + let mut t = 0.0; + while t <= end_time + frame_step { + let mut canvas = Buffer::empty(Rect::new(0, 0, width, height)); + for (idx, pane) in pane_frames.iter().enumerate() { + let idx = idx as u16; + let col = idx % cols; + let row = idx / cols; + let x = col * pane_width; + let y = row * pane_height; + let area = Rect::new( + x, + y, + if col == cols - 1 { + width - x + } else { + pane_width + }, + if row == rows - 1 { + height - y + } else { + pane_height + }, + ); + if let Some(buf) = buffer_at_time(&pane.frames, t) { + blit_buffer(&mut canvas, area, buf); + } + } + output.push((t, canvas)); + t += frame_step; + } + + output +} + +fn buffer_at_time( + frames: &[(f64, ratatui::buffer::Buffer)], + t: f64, +) -> Option<&ratatui::buffer::Buffer> { + let mut current = None; + for (frame_t, buf) in frames { + if *frame_t <= t { + current = Some(buf); + } else { + break; + } + } + current.or_else(|| frames.first().map(|(_, buf)| buf)) +} + +fn blit_buffer( + dst: &mut ratatui::buffer::Buffer, + area: ratatui::layout::Rect, + src: &ratatui::buffer::Buffer, +) { + for sy in 0..area.height.min(src.area.height) { + for sx in 0..area.width.min(src.area.width) { + let dx = area.x + sx; + let dy = area.y + sy; + if let (Some(src_cell), Some(dst_cell)) = (src.cell((sx, sy)), dst.cell_mut((dx, dy))) { + *dst_cell = src_cell.clone(); + } + } + } +} + +fn extract_text(blocks: &[ContentBlock]) -> String { + let mut text = String::new(); + for block in blocks { + if let ContentBlock::Text { text: t, .. } = block { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(t); + } + } + text +} + +/// Auto-edit a timeline for demo-quality pacing. +/// +/// Compresses dead time so the replay feels snappy: +/// - Tool call execution (tool_start → tool_done): capped to `tool_max_ms` +/// - Gaps between turns (done → next user_message): capped to `gap_max_ms` +/// - Thinking duration: capped to `think_max_ms` +/// - Streaming text and everything else: preserved as-is +pub fn auto_edit_timeline(timeline: &[TimelineEvent], opts: &AutoEditOpts) -> Vec<TimelineEvent> { + if timeline.is_empty() { + return vec![]; + } + + let mut out: Vec<TimelineEvent> = Vec::with_capacity(timeline.len()); + let mut time_shift: i64 = 0; // accumulated shift (negative = earlier) + + // Track tool nesting for compressing tool_start→tool_done spans + let mut tool_depth: u32 = 0; + let mut tool_span_start_t: Option<u64> = None; + // Track the end of the most recent top-level tool span so we can + // compress any long idle wait before the assistant resumes. + let mut last_tool_done_t: Option<u64> = None; + + // Track done→user_message gaps + let mut last_done_t: Option<u64> = None; + // Track user_message→thinking gaps + let mut last_user_msg_t: Option<u64> = None; + + for event in timeline { + let orig_t = event.t; + let mut new_t = (orig_t as i64 + time_shift).max(0) as u64; + + // If the assistant sat idle for a long time after a tool completed + // (for example during a selfdev reload), compress that post-tool gap + // before the next later event. + if let Some(tool_done_t) = last_tool_done_t + && orig_t > tool_done_t + { + let gap = orig_t.saturating_sub(tool_done_t); + if gap > opts.response_delay_max_ms { + time_shift -= (gap - opts.response_delay_max_ms) as i64; + new_t = (orig_t as i64 + time_shift).max(0) as u64; + } + last_tool_done_t = None; + } + + match &event.kind { + TimelineEventKind::Thinking { duration } => { + // Clamp gap from done→thinking + if let Some(done_t) = last_done_t.take() { + let gap = orig_t.saturating_sub(done_t); + if gap > opts.gap_max_ms { + time_shift -= (gap - opts.gap_max_ms) as i64; + new_t = (orig_t as i64 + time_shift).max(0) as u64; + } + } + // Clamp gap from user_message→thinking (model response delay) + if let Some(user_t) = last_user_msg_t.take() { + let gap = orig_t.saturating_sub(user_t); + if gap > opts.response_delay_max_ms { + time_shift -= (gap - opts.response_delay_max_ms) as i64; + new_t = (orig_t as i64 + time_shift).max(0) as u64; + } + } + + let clamped = (*duration).min(opts.think_max_ms); + out.push(TimelineEvent { + t: new_t, + kind: TimelineEventKind::Thinking { duration: clamped }, + }); + continue; + } + TimelineEventKind::UserMessage { .. } => { + // Compress gap after last done + if let Some(done_t) = last_done_t.take() { + let gap = orig_t.saturating_sub(done_t); + if gap > opts.gap_max_ms { + time_shift -= (gap - opts.gap_max_ms) as i64; + new_t = (orig_t as i64 + time_shift).max(0) as u64; + } + } + last_user_msg_t = Some(orig_t); + } + TimelineEventKind::ToolStart { .. } => { + if tool_depth == 0 { + tool_span_start_t = Some(orig_t); + } + tool_depth += 1; + } + TimelineEventKind::ToolDone { .. } => { + tool_depth = tool_depth.saturating_sub(1); + if tool_depth == 0 { + if let Some(start_t) = tool_span_start_t.take() { + let span = orig_t.saturating_sub(start_t); + if span > opts.tool_max_ms { + time_shift -= (span - opts.tool_max_ms) as i64; + new_t = (orig_t as i64 + time_shift).max(0) as u64; + } + } + last_tool_done_t = Some(orig_t); + } + } + TimelineEventKind::Done => { + last_done_t = Some(orig_t); + } + _ => {} + } + + out.push(TimelineEvent { + t: new_t, + kind: event.kind.clone(), + }); + } + + out +} + +/// Options for [`auto_edit_timeline`]. +pub struct AutoEditOpts { + /// Max ms for a tool_start→tool_done span (default: 800) + pub tool_max_ms: u64, + /// Max ms gap between done→next user_message (default: 2000) + pub gap_max_ms: u64, + /// Max ms for thinking duration (default: 1200) + pub think_max_ms: u64, + /// Max ms between user_message→thinking (model response delay, default: 1000) + pub response_delay_max_ms: u64, +} + +impl Default for AutoEditOpts { + fn default() -> Self { + Self { + tool_max_ms: 800, + gap_max_ms: 2000, + think_max_ms: 1200, + response_delay_max_ms: 1000, + } + } +} + +fn truncate_for_timeline(s: &str) -> String { + if s.len() > 500 { + let mut end = 497; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &s[..end]) + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/jcode-app-core/src/replay/tests.rs b/crates/jcode-app-core/src/replay/tests.rs new file mode 100644 index 0000000..1e6e96b --- /dev/null +++ b/crates/jcode-app-core/src/replay/tests.rs @@ -0,0 +1,834 @@ +use super::*; +use crate::plan::PlanItem; +use crate::protocol::SwarmMemberStatus; +use crate::session::{StoredReplayEvent, StoredReplayEventKind}; +use chrono::{Duration, Utc}; +use std::ffi::OsString; + +fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::storage::lock_test_env() +} + +struct EnvVarGuard { + key: &'static str, + prev: Option<OsString>, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self { + let prev = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, prev } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(prev) = &self.prev { + crate::env::set_var(self.key, prev); + } else { + crate::env::remove_var(self.key); + } + } +} + +#[test] +fn test_timeline_roundtrip() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { + text: "hello".to_string(), + }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Thinking { duration: 1000 }, + }, + TimelineEvent { + t: 1500, + kind: TimelineEventKind::StreamText { + text: "Hi there!".to_string(), + speed: 80, + }, + }, + TimelineEvent { + t: 2000, + kind: TimelineEventKind::Done, + }, + ]; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&events).unwrap(); + assert!(json.contains("user_message")); + assert!(json.contains("stream_text")); + + // Deserialize back + let parsed: Vec<TimelineEvent> = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.len(), 4); + assert_eq!(parsed[0].t, 0); + assert_eq!(parsed[2].t, 1500); +} + +#[test] +fn test_timeline_to_replay_events() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::StreamText { + text: "Hello world".to_string(), + speed: 80, + }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Done, + }, + ]; + + let replay_events = timeline_to_replay_events(&events); + assert!(!replay_events.is_empty()); + + // First event should be a Server(TextDelta) + match &replay_events[0].1 { + ReplayEvent::Server(ServerEvent::TextDelta { text }) => assert!(!text.is_empty()), + _ => panic!("Expected Server(TextDelta)"), + } + + // Last event should be Server(Done) + match &replay_events.last().unwrap().1 { + ReplayEvent::Server(ServerEvent::Done { .. }) => {} + _ => panic!("Expected Server(Done)"), + } +} + +#[test] +fn test_timeline_to_replay_events_caps_initial_idle() { + let events = vec![ + TimelineEvent { + t: 8_000, + kind: TimelineEventKind::UserMessage { + text: "hello".to_string(), + }, + }, + TimelineEvent { + t: 8_500, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + ]; + + let replay_events = timeline_to_replay_events(&events); + assert_eq!(replay_events[0].0, 0); + assert!(matches!( + replay_events[0].1, + ReplayEvent::UserMessage { .. } + )); +} + +#[test] +fn test_cap_initial_replay_idle_shifts_timeline_start() { + let mut events = vec![ + TimelineEvent { + t: 8_000, + kind: TimelineEventKind::UserMessage { + text: "hello".to_string(), + }, + }, + TimelineEvent { + t: 8_750, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + ]; + + cap_initial_replay_idle(&mut events); + assert_eq!(events[0].t, 0); + assert_eq!(events[1].t, 750); +} + +#[test] +fn test_tool_events() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::ToolStart { + name: "file_read".to_string(), + input: serde_json::json!({"file_path": "/tmp/test.rs"}), + }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::ToolDone { + name: "file_read".to_string(), + output: "fn main() {}".to_string(), + is_error: false, + }, + }, + ]; + + let replay_events = timeline_to_replay_events(&events); + let types: Vec<&str> = replay_events + .iter() + .filter_map(|(_, e)| match e { + ReplayEvent::Server(se) => Some(match se { + ServerEvent::ToolStart { .. } => "start", + ServerEvent::ToolInput { .. } => "input", + ServerEvent::ToolExec { .. } => "exec", + ServerEvent::ToolDone { .. } => "done", + _ => "other", + }), + _ => None, + }) + .collect(); + assert!(types.contains(&"start")); + assert!(types.contains(&"exec")); + assert!(types.contains(&"done")); +} + +#[test] +fn test_user_message_and_thinking() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { + text: "hello".to_string(), + }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + TimelineEvent { + t: 1300, + kind: TimelineEventKind::StreamText { + text: "Hi!".to_string(), + speed: 80, + }, + }, + ]; + + let replay_events = timeline_to_replay_events(&events); + + // First should be UserMessage + assert!(matches!( + &replay_events[0].1, + ReplayEvent::UserMessage { .. } + )); + + // Second should be StartProcessing + assert!(matches!(&replay_events[1].1, ReplayEvent::StartProcessing)); + + // Third should be Server(TextDelta) + assert!(matches!( + &replay_events[2].1, + ReplayEvent::Server(ServerEvent::TextDelta { .. }) + )); +} + +#[test] +fn test_export_timeline_includes_persisted_swarm_replay_events() { + let base = Utc::now(); + let mut session = Session::create_with_id("session_replay_swarm_test".to_string(), None, None); + session.created_at = base; + session.updated_at = base; + session.replay_events = vec![ + StoredReplayEvent { + timestamp: base + Duration::milliseconds(100), + kind: StoredReplayEventKind::DisplayMessage { + role: "swarm".to_string(), + title: Some("DM from fox".to_string()), + content: "Take parser tests".to_string(), + }, + }, + StoredReplayEvent { + timestamp: base + Duration::milliseconds(200), + kind: StoredReplayEventKind::SwarmStatus { + members: vec![SwarmMemberStatus { + session_id: "session_fox".to_string(), + friendly_name: Some("fox".to_string()), + status: "running".to_string(), + detail: Some("parser tests".to_string()), + task_label: None, + role: Some("agent".to_string()), + is_headless: None, + live_attachments: None, + status_age_secs: None, + output_tail: None, + report_back_to_session_id: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }], + }, + }, + StoredReplayEvent { + timestamp: base + Duration::milliseconds(300), + kind: StoredReplayEventKind::SwarmPlan { + swarm_id: "swarm_123".to_string(), + version: 2, + items: vec![PlanItem { + content: "Run parser tests".to_string(), + status: "running".to_string(), + priority: "high".to_string(), + id: "task-1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec![], + assigned_to: Some("session_fox".to_string()), + }], + participants: vec!["session_fox".to_string()], + reason: Some("proposal approved".to_string()), + }, + }, + ]; + + let timeline = export_timeline(&session); + assert!(timeline.iter().any(|event| matches!( + &event.kind, + TimelineEventKind::DisplayMessage { role, title, content } + if role == "swarm" + && title.as_deref() == Some("DM from fox") + && content == "Take parser tests" + ))); + assert!(timeline.iter().any(|event| matches!( + &event.kind, + TimelineEventKind::SwarmStatus { members } + if members.len() == 1 && members[0].status == "running" + ))); + assert!(timeline.iter().any(|event| matches!( + &event.kind, + TimelineEventKind::SwarmPlan { swarm_id, version, items, .. } + if swarm_id == "swarm_123" && *version == 2 && items.len() == 1 + ))); +} + +#[test] +fn test_timeline_to_replay_events_converts_swarm_replay_events() { + let timeline = vec![ + TimelineEvent { + t: 100, + kind: TimelineEventKind::DisplayMessage { + role: "swarm".to_string(), + title: Some("Broadcast · oak".to_string()), + content: "Plan updated".to_string(), + }, + }, + TimelineEvent { + t: 200, + kind: TimelineEventKind::SwarmStatus { + members: vec![SwarmMemberStatus { + session_id: "session_oak".to_string(), + friendly_name: Some("oak".to_string()), + status: "completed".to_string(), + detail: None, + task_label: None, + role: Some("agent".to_string()), + is_headless: None, + live_attachments: None, + status_age_secs: None, + output_tail: None, + report_back_to_session_id: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }], + }, + }, + TimelineEvent { + t: 300, + kind: TimelineEventKind::SwarmPlan { + swarm_id: "swarm_abc".to_string(), + version: 7, + items: vec![PlanItem { + content: "Integrate results".to_string(), + status: "pending".to_string(), + priority: "high".to_string(), + id: "task-7".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec![], + assigned_to: None, + }], + participants: vec![], + reason: None, + }, + }, + ]; + + let replay_events = timeline_to_replay_events(&timeline); + assert!(replay_events.iter().any(|(_, event)| matches!( + event, + ReplayEvent::DisplayMessage { role, title, content } + if role == "swarm" + && title.as_deref() == Some("Broadcast · oak") + && content == "Plan updated" + ))); + assert!(replay_events.iter().any(|(_, event)| matches!( + event, + ReplayEvent::SwarmStatus { members } + if members.len() == 1 && members[0].status == "completed" + ))); + assert!(replay_events.iter().any(|(_, event)| matches!( + event, + ReplayEvent::SwarmPlan { swarm_id, version, items } + if swarm_id == "swarm_abc" && *version == 7 && items.len() == 1 + ))); +} + +#[test] +fn test_load_swarm_sessions_discovers_related_sessions() { + let _env_lock = lock_env(); + let temp_home = tempfile::Builder::new() + .prefix("jcode-replay-swarm-test-") + .tempdir() + .expect("create temp JCODE_HOME"); + let _home = EnvVarGuard::set("JCODE_HOME", temp_home.path().as_os_str()); + + let mut seed = Session::create_with_id("session_seed".to_string(), None, None); + seed.working_dir = Some("/tmp/repo".to_string()); + seed.record_swarm_status_event(vec![SwarmMemberStatus { + session_id: "session_seed".to_string(), + friendly_name: Some("seed".to_string()), + status: "running".to_string(), + detail: None, + task_label: None, + role: Some("coordinator".to_string()), + is_headless: None, + live_attachments: None, + status_age_secs: None, + output_tail: None, + report_back_to_session_id: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }]); + seed.save().unwrap(); + + let mut child = Session::create_with_id( + "session_child".to_string(), + Some(seed.id.clone()), + Some("child".to_string()), + ); + child.working_dir = Some("/tmp/repo".to_string()); + child.record_swarm_plan_event( + "swarm_x".to_string(), + 1, + vec![PlanItem { + content: "Task".to_string(), + status: "running".to_string(), + priority: "high".to_string(), + id: "task-1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec![], + assigned_to: Some(seed.id.clone()), + }], + vec![seed.id.clone(), child.id.clone()], + None, + ); + child.save().unwrap(); + + let mut unrelated = Session::create_with_id("session_other".to_string(), None, None); + unrelated.working_dir = Some("/tmp/other".to_string()); + unrelated.save().unwrap(); + + let loaded = load_swarm_sessions("session_seed", false).unwrap(); + let ids: Vec<_> = loaded.iter().map(|s| s.session.id.as_str()).collect(); + assert!(ids.contains(&"session_seed")); + assert!(ids.contains(&"session_child")); + assert!(!ids.contains(&"session_other")); +} + +#[test] +fn test_compose_swarm_buffers_combines_panes() { + use ratatui::{buffer::Buffer, layout::Rect, style::Style}; + + let mut left = Buffer::empty(Rect::new(0, 0, 4, 2)); + left[(0, 0)].set_symbol("L").set_style(Style::default()); + let mut right = Buffer::empty(Rect::new(0, 0, 4, 2)); + right[(0, 0)].set_symbol("R").set_style(Style::default()); + + let panes = vec![ + SwarmPaneFrames { + session_id: "left".to_string(), + title: "left".to_string(), + frames: vec![(0.0, left)], + }, + SwarmPaneFrames { + session_id: "right".to_string(), + title: "right".to_string(), + frames: vec![(0.0, right)], + }, + ]; + + let frames = compose_swarm_buffers(&panes, 8, 2, 1, 2); + assert!(!frames.is_empty()); + let buf = &frames[0].1; + assert_eq!(buf[(0, 0)].symbol(), "L"); + assert_eq!(buf[(4, 0)].symbol(), "R"); +} + +#[test] +fn test_tool_ids_match_between_start_and_done() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::ToolStart { + name: "file_read".to_string(), + input: serde_json::json!({"file_path": "/tmp/test.rs"}), + }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::ToolDone { + name: "file_read".to_string(), + output: "fn main() {}".to_string(), + is_error: false, + }, + }, + ]; + + let replay_events = timeline_to_replay_events(&events); + + let start_id = replay_events.iter().find_map(|(_, e)| match e { + ReplayEvent::Server(ServerEvent::ToolStart { id, .. }) => Some(id.clone()), + _ => None, + }); + let exec_id = replay_events.iter().find_map(|(_, e)| match e { + ReplayEvent::Server(ServerEvent::ToolExec { id, .. }) => Some(id.clone()), + _ => None, + }); + let done_id = replay_events.iter().find_map(|(_, e)| match e { + ReplayEvent::Server(ServerEvent::ToolDone { id, .. }) => Some(id.clone()), + _ => None, + }); + + assert!(start_id.is_some(), "Should have ToolStart"); + assert_eq!(start_id, exec_id, "ToolStart and ToolExec IDs must match"); + assert_eq!(start_id, done_id, "ToolStart and ToolDone IDs must match"); +} + +#[test] +fn test_batch_tool_input_preserved() { + let batch_input = serde_json::json!({ + "tool_calls": [ + {"tool": "file_read", "parameters": {"file_path": "/tmp/a.rs"}}, + {"tool": "file_read", "parameters": {"file_path": "/tmp/b.rs"}}, + {"tool": "file_grep", "parameters": {"pattern": "foo"}}, + ] + }); + + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::ToolStart { + name: "batch".to_string(), + input: batch_input.clone(), + }, + }, + TimelineEvent { + t: 1000, + kind: TimelineEventKind::ToolDone { + name: "batch".to_string(), + output: "--- [1] file_read ---\nok\n--- [2] file_read ---\nok\n--- [3] file_grep ---\nok".to_string(), + is_error: false, + }, + }, + ]; + + let replay_events = timeline_to_replay_events(&events); + + // Verify the ToolInput delta contains the batch input + let input_delta = replay_events.iter().find_map(|(_, e)| match e { + ReplayEvent::Server(ServerEvent::ToolInput { delta }) => Some(delta.clone()), + _ => None, + }); + assert!( + input_delta.is_some(), + "Should have ToolInput with batch params" + ); + let parsed: serde_json::Value = serde_json::from_str(&input_delta.unwrap()).unwrap(); + let tool_calls = parsed.get("tool_calls").and_then(|v| v.as_array()); + assert_eq!( + tool_calls.map(|a| a.len()), + Some(3), + "Batch should have 3 tool calls" + ); + + // Verify IDs match + let start_id = replay_events.iter().find_map(|(_, e)| match e { + ReplayEvent::Server(ServerEvent::ToolStart { id, .. }) => Some(id.clone()), + _ => None, + }); + let done_id = replay_events.iter().find_map(|(_, e)| match e { + ReplayEvent::Server(ServerEvent::ToolDone { id, .. }) => Some(id.clone()), + _ => None, + }); + assert_eq!( + start_id, done_id, + "Batch ToolStart and ToolDone IDs must match" + ); +} + +#[test] +fn test_auto_edit_compresses_tool_spans() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { text: "hi".into() }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + TimelineEvent { + t: 1300, + kind: TimelineEventKind::StreamText { + text: "Let me check.".into(), + speed: 80, + }, + }, + TimelineEvent { + t: 2000, + kind: TimelineEventKind::ToolStart { + name: "file_read".into(), + input: serde_json::json!({}), + }, + }, + TimelineEvent { + t: 12000, + kind: TimelineEventKind::ToolDone { + name: "file_read".into(), + output: "ok".into(), + is_error: false, + }, + }, + TimelineEvent { + t: 13000, + kind: TimelineEventKind::StreamText { + text: "Done!".into(), + speed: 80, + }, + }, + TimelineEvent { + t: 14000, + kind: TimelineEventKind::Done, + }, + ]; + + let opts = AutoEditOpts { + tool_max_ms: 800, + gap_max_ms: 2000, + think_max_ms: 1200, + response_delay_max_ms: 1000, + }; + let edited = auto_edit_timeline(&events, &opts); + + assert_eq!(edited.len(), events.len()); + + let tool_start_t = edited[3].t; + let tool_done_t = edited[4].t; + let tool_span = tool_done_t - tool_start_t; + assert!( + tool_span <= 800, + "Tool span should be compressed to ≤800ms, got {tool_span}ms" + ); + + assert!( + edited[5].t > tool_done_t, + "Events after tool_done should still be ordered" + ); +} + +#[test] +fn test_auto_edit_compresses_post_tool_idle_gap() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { text: "hi".into() }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + TimelineEvent { + t: 1500, + kind: TimelineEventKind::ToolStart { + name: "selfdev".into(), + input: serde_json::json!({"action": "reload"}), + }, + }, + TimelineEvent { + t: 2500, + kind: TimelineEventKind::ToolDone { + name: "selfdev".into(), + output: "Reload initiated. Process restarting...".into(), + is_error: false, + }, + }, + TimelineEvent { + t: 48000, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + TimelineEvent { + t: 49000, + kind: TimelineEventKind::StreamText { + text: "Reloaded.".into(), + speed: 80, + }, + }, + ]; + + let opts = AutoEditOpts::default(); + let edited = auto_edit_timeline(&events, &opts); + + let tool_done_t = edited[3].t; + let resumed_t = edited[4].t; + let gap = resumed_t - tool_done_t; + assert!( + gap <= opts.response_delay_max_ms, + "Gap after tool completion should be compressed to ≤{}ms, got {gap}ms", + opts.response_delay_max_ms + ); +} + +#[test] +fn test_auto_edit_compresses_inter_prompt_gaps() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { + text: "first".into(), + }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + TimelineEvent { + t: 1500, + kind: TimelineEventKind::StreamText { + text: "response".into(), + speed: 80, + }, + }, + TimelineEvent { + t: 2000, + kind: TimelineEventKind::Done, + }, + TimelineEvent { + t: 30000, + kind: TimelineEventKind::UserMessage { + text: "second".into(), + }, + }, + TimelineEvent { + t: 30500, + kind: TimelineEventKind::Thinking { duration: 800 }, + }, + TimelineEvent { + t: 31500, + kind: TimelineEventKind::StreamText { + text: "response2".into(), + speed: 80, + }, + }, + TimelineEvent { + t: 32000, + kind: TimelineEventKind::Done, + }, + ]; + + let opts = AutoEditOpts::default(); + let edited = auto_edit_timeline(&events, &opts); + + let done_t = edited[3].t; + let next_user_t = edited[4].t; + let gap = next_user_t - done_t; + assert!( + gap <= 2000, + "Gap between turns should be compressed to ≤2000ms, got {gap}ms" + ); + + let total_original = events.last().unwrap().t; + let total_edited = edited.last().unwrap().t; + assert!( + total_edited < total_original, + "Total time should be shorter: {total_edited} < {total_original}" + ); +} + +#[test] +fn test_auto_edit_clamps_thinking() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { text: "hi".into() }, + }, + TimelineEvent { + t: 500, + kind: TimelineEventKind::Thinking { duration: 5000 }, + }, + TimelineEvent { + t: 5500, + kind: TimelineEventKind::StreamText { + text: "ok".into(), + speed: 80, + }, + }, + ]; + + let opts = AutoEditOpts { + think_max_ms: 1200, + ..Default::default() + }; + let edited = auto_edit_timeline(&events, &opts); + + match &edited[1].kind { + TimelineEventKind::Thinking { duration } => { + assert_eq!(*duration, 1200, "Thinking should be clamped to 1200ms"); + } + _ => panic!("Expected Thinking event"), + } +} + +#[test] +fn test_auto_edit_preserves_already_fast_timeline() { + let events = vec![ + TimelineEvent { + t: 0, + kind: TimelineEventKind::UserMessage { text: "hi".into() }, + }, + TimelineEvent { + t: 200, + kind: TimelineEventKind::Thinking { duration: 500 }, + }, + TimelineEvent { + t: 700, + kind: TimelineEventKind::StreamText { + text: "hello!".into(), + speed: 80, + }, + }, + TimelineEvent { + t: 900, + kind: TimelineEventKind::Done, + }, + TimelineEvent { + t: 1500, + kind: TimelineEventKind::UserMessage { text: "bye".into() }, + }, + ]; + + let opts = AutoEditOpts::default(); + let edited = auto_edit_timeline(&events, &opts); + + for (orig, ed) in events.iter().zip(edited.iter()) { + assert_eq!(orig.t, ed.t, "Fast timeline should not be modified"); + } +} + +#[test] +fn test_auto_edit_empty_timeline() { + let edited = auto_edit_timeline(&[], &AutoEditOpts::default()); + assert!(edited.is_empty()); +} diff --git a/crates/jcode-app-core/src/restart_snapshot.rs b/crates/jcode-app-core/src/restart_snapshot.rs new file mode 100644 index 0000000..093baba --- /dev/null +++ b/crates/jcode-app-core/src/restart_snapshot.rs @@ -0,0 +1,241 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +const AUTO_RESTORE_CRASH_MAX_AGE_HOURS: i64 = 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestartSnapshot { + pub version: u32, + pub created_at: DateTime<Utc>, + #[serde(default)] + pub auto_restore_on_next_start: bool, + pub sessions: Vec<RestartSnapshotSession>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestartSnapshotSession { + pub session_id: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option<String>, + #[serde(default)] + pub is_selfdev: bool, +} + +#[derive(Debug, Clone)] +pub struct RestoreLaunchOutcome { + pub session: RestartSnapshotSession, + pub launched: bool, + pub command: String, +} + +#[derive(Debug, Clone)] +pub struct RestoreSnapshotResult { + pub snapshot: RestartSnapshot, + pub outcomes: Vec<RestoreLaunchOutcome>, +} + +pub fn snapshot_path() -> Result<PathBuf> { + Ok(crate::storage::jcode_dir()?.join("restart-snapshot.json")) +} + +pub fn save_current_snapshot() -> Result<RestartSnapshot> { + let snapshot = capture_current_snapshot()?; + write_snapshot(&snapshot)?; + Ok(snapshot) +} + +pub fn write_snapshot(snapshot: &RestartSnapshot) -> Result<()> { + crate::storage::write_json(&snapshot_path()?, snapshot) +} + +pub fn load_snapshot() -> Result<RestartSnapshot> { + crate::storage::read_json(&snapshot_path()?) +} + +pub fn clear_snapshot() -> Result<bool> { + let path = snapshot_path()?; + if !path.exists() { + return Ok(false); + } + std::fs::remove_file(path)?; + Ok(true) +} + +pub fn set_auto_restore_on_next_start(enabled: bool) -> Result<bool> { + let mut snapshot = match load_snapshot() { + Ok(snapshot) => snapshot, + Err(_) => return Ok(false), + }; + snapshot.auto_restore_on_next_start = enabled; + write_snapshot(&snapshot)?; + Ok(true) +} + +pub fn arm_auto_restore_from_recent_crashes() -> Result<Option<RestartSnapshot>> { + let cutoff = Utc::now() - chrono::Duration::hours(AUTO_RESTORE_CRASH_MAX_AGE_HOURS); + let mut unique_ids = HashSet::new(); + let mut captured: Vec<(DateTime<Utc>, RestartSnapshotSession)> = Vec::new(); + + for (session_id, _) in crate::session::find_recent_crashed_sessions() { + if !unique_ids.insert(session_id.clone()) { + continue; + } + + let Ok(session) = crate::session::Session::load(&session_id) else { + continue; + }; + + if !matches!( + session.status, + crate::session::SessionStatus::Crashed { .. } + ) { + continue; + } + + let sort_key = session.last_active_at.unwrap_or(session.updated_at); + if sort_key < cutoff { + continue; + } + + captured.push(( + sort_key, + RestartSnapshotSession { + session_id: session.id.clone(), + display_name: session.display_name().to_string(), + working_dir: session.working_dir.clone(), + is_selfdev: session.is_canary, + }, + )); + } + + if captured.is_empty() { + return Ok(None); + } + + captured.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.display_name.cmp(&b.1.display_name)) + .then_with(|| a.1.session_id.cmp(&b.1.session_id)) + }); + + let snapshot = RestartSnapshot { + version: 1, + created_at: Utc::now(), + auto_restore_on_next_start: true, + sessions: captured.into_iter().map(|(_, session)| session).collect(), + }; + + write_snapshot(&snapshot)?; + Ok(Some(snapshot)) +} + +pub fn capture_current_snapshot() -> Result<RestartSnapshot> { + let mut unique_ids = HashSet::new(); + let mut captured: Vec<(DateTime<Utc>, RestartSnapshotSession)> = Vec::new(); + + for session_id in crate::storage::active_session_ids() { + if !unique_ids.insert(session_id.clone()) { + continue; + } + + let Ok(mut session) = crate::session::Session::load(&session_id) else { + continue; + }; + + if session.detect_crash() { + let _ = session.save(); + continue; + } + + if !matches!(session.status, crate::session::SessionStatus::Active) { + continue; + } + + let sort_key = session.last_active_at.unwrap_or(session.updated_at); + captured.push(( + sort_key, + RestartSnapshotSession { + session_id: session.id.clone(), + display_name: session.display_name().to_string(), + working_dir: session.working_dir.clone(), + is_selfdev: session.is_canary, + }, + )); + } + + captured.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.display_name.cmp(&b.1.display_name)) + .then_with(|| a.1.session_id.cmp(&b.1.session_id)) + }); + + Ok(RestartSnapshot { + version: 1, + created_at: Utc::now(), + auto_restore_on_next_start: false, + sessions: captured.into_iter().map(|(_, session)| session).collect(), + }) +} + +pub fn restore_snapshot(exe: &Path) -> Result<RestoreSnapshotResult> { + let snapshot = load_snapshot()?; + let mut outcomes = Vec::new(); + + for session in &snapshot.sessions { + let cwd = resolve_session_cwd(session.working_dir.as_deref()); + let context = crate::session_launch::SessionSpawnContext::kind("restart"); + let launched = if session.is_selfdev { + crate::session_launch::spawn_selfdev_in_new_terminal_with_context( + exe, + &session.session_id, + &cwd, + None, + &context, + )? + } else { + crate::session_launch::spawn_resume_in_new_terminal_with_context( + exe, + &session.session_id, + &cwd, + None, + &context, + )? + }; + outcomes.push(RestoreLaunchOutcome { + session: session.clone(), + launched, + command: restore_command_display(exe, session), + }); + } + + Ok(RestoreSnapshotResult { snapshot, outcomes }) +} + +fn resolve_session_cwd(configured: Option<&str>) -> PathBuf { + configured + .filter(|path| Path::new(path).is_dir()) + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn shell_escape(text: &str) -> String { + format!("'{}'", text.replace('\'', "'\"'\"'")) +} + +pub fn restore_command_display(exe: &Path, session: &RestartSnapshotSession) -> String { + let exe = shell_escape(exe.to_string_lossy().as_ref()); + if session.is_selfdev { + format!("{} --resume {} self-dev", exe, session.session_id) + } else { + format!("{} --resume {}", exe, session.session_id) + } +} + +#[cfg(test)] +#[path = "restart_snapshot_tests.rs"] +mod restart_snapshot_tests; diff --git a/crates/jcode-app-core/src/restart_snapshot_tests.rs b/crates/jcode-app-core/src/restart_snapshot_tests.rs new file mode 100644 index 0000000..2260667 --- /dev/null +++ b/crates/jcode-app-core/src/restart_snapshot_tests.rs @@ -0,0 +1,179 @@ +use super::{ + AUTO_RESTORE_CRASH_MAX_AGE_HOURS, arm_auto_restore_from_recent_crashes, + capture_current_snapshot, clear_snapshot, load_snapshot, save_current_snapshot, +}; +use crate::session::Session; +use chrono::Utc; +use std::ffi::OsString; + +struct TestEnvGuard { + prev_home: Option<OsString>, + _temp_home: tempfile::TempDir, + _lock: std::sync::MutexGuard<'static, ()>, +} + +impl TestEnvGuard { + fn new() -> anyhow::Result<Self> { + let lock = crate::storage::lock_test_env(); + let temp_home = tempfile::Builder::new() + .prefix("jcode-restart-snapshot-test-home-") + .tempdir()?; + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + Ok(Self { + prev_home, + _temp_home: temp_home, + _lock: lock, + }) + } +} + +impl Drop for TestEnvGuard { + fn drop(&mut self) { + if let Some(prev_home) = &self.prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} + +#[test] +fn capture_current_snapshot_includes_active_sessions_only() { + let _guard = TestEnvGuard::new().expect("setup test env"); + + let mut active = Session::create(None, Some("Active".to_string())); + active.working_dir = Some("/tmp".to_string()); + active.mark_active_with_pid(std::process::id()); + active.save().expect("save active session"); + + let mut closed = Session::create(None, Some("Closed".to_string())); + closed.mark_closed(); + closed.save().expect("save closed session"); + + let snapshot = capture_current_snapshot().expect("capture snapshot"); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].session_id, active.id); + assert_eq!(snapshot.sessions[0].working_dir.as_deref(), Some("/tmp")); +} + +#[test] +fn save_and_load_snapshot_round_trip() { + let _guard = TestEnvGuard::new().expect("setup test env"); + + let mut active = Session::create(None, Some("Restore Me".to_string())); + active.mark_active_with_pid(std::process::id()); + active.save().expect("save active session"); + + let saved = save_current_snapshot().expect("save snapshot"); + let loaded = load_snapshot().expect("load snapshot"); + assert_eq!(saved.sessions.len(), 1); + assert_eq!(loaded.sessions.len(), 1); + assert!(!loaded.auto_restore_on_next_start); + assert_eq!(loaded.sessions[0].session_id, active.id); +} + +#[test] +fn set_auto_restore_updates_saved_snapshot() { + let _guard = TestEnvGuard::new().expect("setup test env"); + + let mut active = Session::create(None, Some("Auto Restore".to_string())); + active.mark_active_with_pid(std::process::id()); + active.save().expect("save active session"); + save_current_snapshot().expect("save snapshot"); + + assert!(super::set_auto_restore_on_next_start(true).expect("set auto restore")); + let loaded = load_snapshot().expect("load snapshot"); + assert!(loaded.auto_restore_on_next_start); +} + +#[test] +fn clear_snapshot_removes_saved_file() { + let _guard = TestEnvGuard::new().expect("setup test env"); + + let mut active = Session::create(None, Some("Clear Me".to_string())); + active.mark_active_with_pid(std::process::id()); + active.save().expect("save active session"); + save_current_snapshot().expect("save snapshot"); + + assert!(clear_snapshot().expect("clear snapshot")); + assert!(load_snapshot().is_err()); +} + +#[test] +fn arm_auto_restore_from_recent_crashes_captures_dead_active_sessions() { + let _guard = TestEnvGuard::new().expect("setup test env"); + + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg("exit 0") + .spawn() + .expect("spawn child"); + let dead_pid = child.id(); + let _ = child.wait().expect("wait for child"); + + let mut crashed = Session::create_with_id( + "session_auto_restore_crash".to_string(), + None, + Some("Crash Me".to_string()), + ); + crashed.working_dir = Some("/tmp".to_string()); + crashed.mark_active_with_pid(dead_pid); + crashed.save().expect("save crashed session"); + + let snapshot = arm_auto_restore_from_recent_crashes() + .expect("arm crash snapshot") + .expect("expected crash snapshot"); + assert!(snapshot.auto_restore_on_next_start); + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].session_id, crashed.id); + assert_eq!(snapshot.sessions[0].working_dir.as_deref(), Some("/tmp")); + + let persisted = load_snapshot().expect("load persisted snapshot"); + assert!(persisted.auto_restore_on_next_start); + assert_eq!(persisted.sessions.len(), 1); + + let refreshed = Session::load(&crashed.id).expect("reload crashed session"); + assert!(matches!( + refreshed.status, + crate::session::SessionStatus::Crashed { .. } + )); +} + +#[test] +fn arm_auto_restore_from_recent_crashes_ignores_old_crashes() { + let _guard = TestEnvGuard::new().expect("setup test env"); + + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg("exit 0") + .spawn() + .expect("spawn child"); + let dead_pid = child.id(); + let _ = child.wait().expect("wait for child"); + + let mut crashed = Session::create_with_id( + "session_old_auto_restore_crash".to_string(), + None, + Some("Old Crash".to_string()), + ); + let old_ts = Utc::now() - chrono::Duration::hours(AUTO_RESTORE_CRASH_MAX_AGE_HOURS + 2); + crashed.updated_at = old_ts; + crashed.last_active_at = Some(old_ts); + crashed.status = crate::session::SessionStatus::Active; + crashed.last_pid = Some(dead_pid); + crashed.save().expect("save stale active session"); + let active_dir = crate::storage::jcode_dir() + .expect("jcode dir") + .join("active_pids"); + std::fs::create_dir_all(&active_dir).expect("create active pid dir"); + std::fs::write(active_dir.join(&crashed.id), dead_pid.to_string()) + .expect("write active pid file"); + + assert!( + arm_auto_restore_from_recent_crashes() + .expect("arm stale crash snapshot") + .is_none() + ); + assert!(load_snapshot().is_err()); +} diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs new file mode 100644 index 0000000..6bce4b5 --- /dev/null +++ b/crates/jcode-app-core/src/server.rs @@ -0,0 +1,2246 @@ +mod await_members_state; +mod background_tasks; +mod client_actions; +mod client_api; +mod client_comm; +mod client_comm_channels; +mod client_comm_context; +mod client_comm_message; +mod client_disconnect_cleanup; +mod client_lifecycle; +mod client_lifecycle_logging; +mod client_lightweight_control; +mod client_session; +mod client_state; +mod client_writer; +mod comm_await; +mod comm_control; +mod comm_graph; +mod comm_plan; +mod comm_session; +mod comm_sync; +mod debug; +mod debug_ambient; +mod debug_command_exec; +mod debug_events; +mod debug_help; +mod debug_jobs; +mod debug_server_state; +mod debug_session_admin; +mod debug_swarm_read; +mod debug_swarm_write; +mod debug_testers; +mod durable_state; +mod headless; +mod jade_relay; +mod lifecycle; +mod live_turn; +mod provider_control; +mod reload; +mod reload_recovery; +mod reload_state; +mod reload_trace; +mod runtime; +mod socket; +mod swarm; +mod swarm_channels; +mod swarm_mutation_state; +mod swarm_persistence; +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, +}; +use self::debug::{ClientConnectionInfo, ClientDebugState}; +use self::debug_jobs::DebugJob; +use self::headless::create_headless_session; +use self::reload::await_reload_signal; +use self::runtime::ServerRuntime; +use self::swarm::{ + MAX_SWARM_MEMBERS, broadcast_swarm_plan, broadcast_swarm_plan_with_previous, + broadcast_swarm_status, expired_terminal_member_ids, member_consumes_swarm_capacity, + record_swarm_event, record_swarm_event_for_session, refresh_swarm_task_staleness, + remove_plan_participant, remove_session_from_swarm, rename_plan_participant, run_swarm_message, + send_swarm_plan_to_session, set_member_task_label, swarm_is_self_or_ancestor, + update_member_status, update_member_status_with_report, update_member_status_with_report_tldr, +}; +use self::swarm_channels::{ + remove_session_channel_subscriptions, subscribe_session_to_channel, + unsubscribe_session_from_channel, +}; +pub(super) use self::swarm_mutation_state::SwarmMutationRuntime; +use self::swarm_persistence::{ + LoadedSwarmRuntimeState, capture_swarm_state_version, + load_runtime_state as load_persisted_swarm_runtime_state, + persist_swarm_state as persist_swarm_state_snapshot, remove_swarm_state_if_version, + swarm_operation_lock, +}; +use self::util::get_shared_mcp_pool; +use crate::agent::Agent; +use crate::ambient_runner::AmbientRunnerHandle; +use crate::bus::{Bus, BusEvent}; +use crate::protocol::{NotificationType, ServerEvent}; +use crate::provider::Provider; +use crate::runtime_memory_log::{ + RuntimeMemoryLogController, RuntimeMemoryLogSampling, RuntimeMemoryLogTrigger, + ServerRuntimeMemoryBackground, ServerRuntimeMemoryClients, ServerRuntimeMemoryEmbeddings, + ServerRuntimeMemorySample, ServerRuntimeMemoryServer, ServerRuntimeMemorySessions, + ServerRuntimeMemoryTopSession, +}; +use crate::tool::selfdev::ReloadContext; +use crate::transport::Listener; +use anyhow::Result; +use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource}; +use jcode_swarm_core::{ + append_swarm_completion_report_instructions, format_structured_completion_report, + summarize_plan_items, truncate_detail, +}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, OnceCell, RwLock, broadcast, mpsc}; + +pub(super) type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +pub(super) type ChannelSubscriptions = + Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +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; +const SWARM_TERMINAL_MEMBER_GC_BATCH_SIZE: usize = 64; + +async fn prune_expired_terminal_swarm_members( + sessions: &SessionAgents, + swarm_state: &SwarmState, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, +) -> usize { + let retention = swarm::swarm_terminal_member_retention(); + let mut candidates = { + let members = swarm_state.members.read().await; + expired_terminal_member_ids(&members, retention) + }; + candidates.truncate(SWARM_TERMINAL_MEMBER_GC_BATCH_SIZE); + if candidates.is_empty() { + return 0; + } + + let live_sessions: HashSet<String> = sessions.read().await.keys().cloned().collect(); + let mut pruned = 0usize; + for session_id in candidates { + // A session can be resumed between candidate collection and removal. + // Never collect a member that currently has a live agent runtime. + if live_sessions.contains(&session_id) || sessions.read().await.contains_key(&session_id) { + continue; + } + let removed_swarm_id = { + let mut members = swarm_state.members.write().await; + let still_expired = members.get(&session_id).is_some_and(|member| { + swarm::member_status_is_terminal(&member.status) + && member.last_status_change.elapsed() >= retention + }); + if still_expired { + members + .remove(&session_id) + .and_then(|member| member.swarm_id) + } else { + None + } + }; + let Some(swarm_id) = removed_swarm_id else { + continue; + }; + + 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; + pruned += 1; + } + + if pruned > 0 { + crate::logging::info(&format!( + "Garbage-collected {pruned} expired terminal swarm member(s)" + )); + } + pruned +} + +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. + let operation_lock = swarm_operation_lock(swarm_id); + let _operation_guard = operation_lock.lock().await; + let runtime = swarm_state.load_runtime(swarm_id).await; + persist_swarm_state_snapshot( + swarm_id, + runtime.plan.as_ref(), + runtime.coordinator_session_id.as_deref(), + &runtime.members, + ); +} + +pub(super) async fn remove_persisted_swarm_state_for(swarm_id: &str, swarm_state: &SwarmState) { + // Persist and remove share one per-swarm ordering domain. The file version + // is an extra CAS guard against direct/recovery writers outside this path. + let operation_lock = swarm_operation_lock(swarm_id); + let _operation_guard = operation_lock.lock().await; + let file_version = capture_swarm_state_version(swarm_id); + let runtime = swarm_state.load_runtime(swarm_id).await; + if runtime.has_any_state() { + return; + } + let _ = remove_swarm_state_if_version(swarm_id, &file_version); +} + +fn headless_member_should_restore(status: &str, is_headless: bool) -> bool { + is_headless + && !matches!( + status, + "ready" | "completed" | "done" | "failed" | "stopped" + ) +} + +fn headless_reload_continuation_message(reload_ctx: Option<ReloadContext>) -> Option<String> { + ReloadContext::recovery_directive(reload_ctx.as_ref(), true, "", None) + .map(|directive| directive.continuation_message) +} + +fn configured_server_name(cli_name: Option<String>) -> Option<String> { + cli_name + .as_deref() + .and_then(normalize_configured_server_name) + .or_else(configured_server_name_from_env) +} + +fn configured_server_name_from_env() -> Option<String> { + [SERVER_NAME_ENV, SERVER_DISPLAY_NAME_ENV] + .into_iter() + .find_map(|key| { + std::env::var(key) + .ok() + .and_then(|value| normalize_configured_server_name(&value)) + }) +} + +fn normalize_configured_server_name(raw: &str) -> Option<String> { + let mut normalized = String::new(); + let mut previous_dash = false; + + for ch in raw.trim().chars() { + let mapped = if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else if ch == '.' || ch == '-' { + ch + } else { + '-' + }; + + if mapped == '-' { + if previous_dash { + continue; + } + previous_dash = true; + } else { + previous_dash = false; + } + normalized.push(mapped); + if normalized.len() >= MAX_CONFIGURED_SERVER_NAME_LEN { + break; + } + } + + let trimmed = normalized.trim_matches(|ch| matches!(ch, '-' | '.')); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[derive(Default)] +struct HeadlessRecoveryStats { + candidates: usize, + resumed: usize, + skipped: usize, + failed_to_load: usize, +} + +async fn capture_runtime_memory_common_sample( + identity: &ServerIdentity, + client_count: &Arc<RwLock<usize>>, + server_start_time: Instant, + kind: &str, + source: &str, + trigger: RuntimeMemoryLogTrigger, + sampling: RuntimeMemoryLogSampling, +) -> ServerRuntimeMemorySample { + let now = chrono::Utc::now(); + let process = + crate::process_memory::snapshot_with_source(format!("server:runtime-log:{source}")); + let connected_count = *client_count.read().await; + let background_task_count = crate::background::global().list().await.len(); + let embedder_stats = crate::embedding::stats(); + let embedding_model_available = crate::embedding::is_model_available(); + + ServerRuntimeMemorySample { + schema_version: 2, + kind: kind.to_string(), + timestamp: now.to_rfc3339(), + timestamp_ms: now.timestamp_millis(), + source: source.to_string(), + trigger, + sampling, + server: ServerRuntimeMemoryServer { + id: identity.id.clone(), + name: identity.name.clone(), + icon: identity.icon.clone(), + version: identity.version.clone(), + git_hash: identity.git_hash.clone(), + uptime_secs: server_start_time.elapsed().as_secs(), + }, + process_diagnostics: crate::runtime_memory_log::build_process_diagnostics(&process), + process, + clients: ServerRuntimeMemoryClients { connected_count }, + sessions: None, + background: ServerRuntimeMemoryBackground { + task_count: background_task_count, + }, + embeddings: ServerRuntimeMemoryEmbeddings { + model_available: embedding_model_available, + stats: embedder_stats, + }, + } +} + +async fn capture_runtime_memory_process_sample( + identity: &ServerIdentity, + client_count: &Arc<RwLock<usize>>, + server_start_time: Instant, + source: &str, + trigger: RuntimeMemoryLogTrigger, + sampling: RuntimeMemoryLogSampling, +) -> ServerRuntimeMemorySample { + capture_runtime_memory_common_sample( + identity, + client_count, + server_start_time, + "process", + source, + trigger, + sampling, + ) + .await +} + +async fn capture_runtime_memory_attribution_sample( + identity: &ServerIdentity, + sessions: &SessionAgents, + client_count: &Arc<RwLock<usize>>, + server_start_time: Instant, + source: &str, + trigger: RuntimeMemoryLogTrigger, + sampling: RuntimeMemoryLogSampling, +) -> ServerRuntimeMemorySample { + let mut sample = capture_runtime_memory_common_sample( + identity, + client_count, + server_start_time, + "attribution", + source, + trigger, + sampling, + ) + .await; + + let sessions_guard = sessions.read().await; + let live_count = sessions_guard.len(); + let mut sampled_count = 0usize; + let mut contended_count = 0usize; + let mut memory_enabled_session_count = 0usize; + let mut total_message_count = 0u64; + let mut total_provider_cache_message_count = 0u64; + let mut total_json_bytes = 0u64; + let mut total_payload_text_bytes = 0u64; + let mut total_provider_cache_json_bytes = 0u64; + let mut total_tool_result_bytes = 0u64; + let mut total_provider_cache_tool_result_bytes = 0u64; + let mut total_large_blob_bytes = 0u64; + let mut total_provider_cache_large_blob_bytes = 0u64; + let mut top_sessions: Vec<ServerRuntimeMemoryTopSession> = Vec::new(); + + for (session_id, agent_arc) in sessions_guard.iter() { + let Ok(mut agent) = agent_arc.try_lock() else { + contended_count += 1; + continue; + }; + + sampled_count += 1; + let profile = agent.session_memory_profile_snapshot(); + let memory_enabled = agent.memory_enabled(); + if memory_enabled { + memory_enabled_session_count += 1; + } + + let message_count = profile.message_count as u64; + let provider_cache_message_count = profile.provider_cache_message_count as u64; + let json_bytes = profile.total_json_bytes as u64; + let payload_text_bytes = profile.payload_text_bytes as u64; + let provider_cache_json_bytes = profile.provider_cache_json_bytes as u64; + let tool_result_bytes = profile.canonical_tool_result_bytes as u64; + let provider_cache_tool_result_bytes = profile.provider_cache_tool_result_bytes as u64; + let large_blob_bytes = profile.canonical_large_blob_bytes as u64; + let provider_cache_large_blob_bytes = profile.provider_cache_large_blob_bytes as u64; + + total_message_count += message_count; + total_provider_cache_message_count += provider_cache_message_count; + total_json_bytes += json_bytes; + total_payload_text_bytes += payload_text_bytes; + total_provider_cache_json_bytes += provider_cache_json_bytes; + total_tool_result_bytes += tool_result_bytes; + total_provider_cache_tool_result_bytes += provider_cache_tool_result_bytes; + total_large_blob_bytes += large_blob_bytes; + total_provider_cache_large_blob_bytes += provider_cache_large_blob_bytes; + + top_sessions.push(ServerRuntimeMemoryTopSession { + session_id: session_id.clone(), + provider: agent.provider_name(), + model: agent.provider_model(), + memory_enabled, + message_count, + provider_cache_message_count, + json_bytes, + payload_text_bytes, + provider_cache_json_bytes, + tool_result_bytes, + provider_cache_tool_result_bytes, + large_blob_bytes, + provider_cache_large_blob_bytes, + }); + } + drop(sessions_guard); + + top_sessions.sort_by(|left, right| right.json_bytes.cmp(&left.json_bytes)); + top_sessions.truncate(5); + + sample.sessions = Some(ServerRuntimeMemorySessions { + live_count, + sampled_count, + contended_count, + memory_enabled_session_count, + total_message_count, + total_provider_cache_message_count, + total_json_bytes, + total_payload_text_bytes, + total_provider_cache_json_bytes, + total_tool_result_bytes, + total_provider_cache_tool_result_bytes, + total_large_blob_bytes, + total_provider_cache_large_blob_bytes, + top_by_json_bytes: top_sessions, + }); + sample +} + +mod state; + +use self::state::latest_peer_touches; +pub use self::state::{ + FileAccess, SessionControlHandle, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, + SwarmState, +}; +use self::state::{ + SessionInterruptQueues, fanout_live_client_event, fanout_session_event, + queue_soft_interrupt_for_session, register_background_tool_signal, + register_session_event_sender, register_session_interrupt_queue, remove_background_tool_signal, + remove_session_interrupt_queue, rename_background_tool_signal, rename_session_interrupt_queue, + session_event_fanout_sender, unregister_session_event_sender, +}; +pub use crate::plan::{SwarmTaskProgress, VersionedPlan}; + +pub use self::await_members_state::pending_await_members_for_session; +use self::reload_state::clear_reload_marker_if_stale_for_pid; +#[cfg(test)] +pub(crate) use self::reload_state::subscribe_reload_signal_for_tests; +pub use self::reload_state::{ + ReloadAck, ReloadPhase, ReloadSignal, ReloadState, ReloadWaitStatus, acknowledge_reload_signal, + await_reload_handoff, clear_reload_marker, inspect_reload_wait_status, + publish_reload_socket_ready, recent_reload_state, reload_marker_active, reload_marker_exists, + reload_marker_path, reload_process_alive, reload_state_summary, send_reload_signal, + wait_for_reload_ack, wait_for_reload_handoff_event, write_reload_marker, write_reload_state, +}; + +pub use self::lifecycle::configure_temporary_server; +#[cfg(unix)] +pub use self::socket::spawn_server_notify; +#[cfg(unix)] +use self::socket::{acquire_daemon_lock, mark_close_on_exec}; +pub use self::socket::{ + cleanup_socket_pair, connect_socket, debug_socket_path, has_live_listener, is_server_ready, + reap_stale_socket_if_dead, set_socket_path, socket_path, wait_for_server_ready, +}; +use self::socket::{signal_ready_fd, socket_has_live_listener}; + +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, +}; + +mod file_activity; +use self::file_activity::file_activity_scope_label; + +mod file_touch_service; +pub(crate) use self::file_touch_service::FileTouchService; + +#[cfg(test)] +mod socket_tests; + +#[cfg(test)] +mod startup_tests; + +#[cfg(test)] +mod queue_tests; + +#[cfg(test)] +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 the retained-heap watchdog samples allocator retention. +const HEAP_RETENTION_CHECK_SECS: u64 = 120; + +/// Exit code when server shuts down due to idle timeout +pub const EXIT_IDLE_TIMEOUT: i32 = 44; + +/// Server state +pub struct Server { + provider: Arc<dyn Provider>, + socket_path: PathBuf, + debug_socket_path: PathBuf, + gateway_config_override: Option<crate::gateway::GatewayConfig>, + /// Server identity for multi-server support + identity: ServerIdentity, + /// Broadcast channel for streaming events to all subscribers + event_tx: broadcast::Sender<ServerEvent>, + /// Active sessions (session_id -> Agent) + sessions: Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>, + /// Current processing state + is_processing: Arc<RwLock<bool>>, + /// Session ID for the default session + session_id: Arc<RwLock<String>>, + /// Number of connected clients + client_count: Arc<RwLock<usize>>, + /// Connected client mapping (client_id -> session_id) + client_connections: Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + /// File-touch tracking service (forward path index + reverse session index) + file_touch: FileTouchService, + /// Shared ownership of core swarm coordination state. + swarm_state: SwarmState, + /// Shared context by swarm (swarm_id -> key -> SharedContext) + shared_context: Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + /// Active and available TUI debug channels (request_id, command) + client_debug_state: Arc<RwLock<ClientDebugState>>, + /// Channel to receive client debug responses from TUI (request_id, response) + client_debug_response_tx: broadcast::Sender<(u64, String)>, + /// Background debug jobs (async debug commands) + debug_jobs: Arc<RwLock<HashMap<String, DebugJob>>>, + /// Channel subscriptions (swarm_id -> channel -> session_ids) + channel_subscriptions: ChannelSubscriptions, + /// Reverse index for channel subscriptions: session_id -> swarm_id -> channels + channel_subscriptions_by_session: ChannelSubscriptions, + /// Event history for real-time event subscription (ring buffer) + event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + /// Counter for event IDs + event_counter: Arc<std::sync::atomic::AtomicU64>, + /// Broadcast channel for swarm event subscriptions (debug socket subscribers) + swarm_event_tx: broadcast::Sender<SwarmEvent>, + /// Ambient mode runner handle (None if ambient is disabled) + ambient_runner: Option<AmbientRunnerHandle>, + /// Shared MCP server pool (processes shared across sessions), initialized lazily. + mcp_pool: Arc<OnceCell<Arc<crate::mcp::SharedMcpPool>>>, + /// Graceful shutdown signals by session_id (stored outside agent mutex so they + /// can be signaled without locking the agent during active tool execution) + shutdown_signals: Arc<RwLock<HashMap<String, InterruptSignal>>>, + /// Soft interrupt queues by session_id (stored outside agent mutex so swarm/debug + /// notifications can be enqueued while an agent is actively processing) + soft_interrupt_queues: SessionInterruptQueues, + /// Persisted communicate await_members wait registry. + await_members_runtime: AwaitMembersRuntime, + /// Persisted dedupe registry for mutating swarm coordinator operations. + swarm_mutation_runtime: SwarmMutationRuntime, +} + +impl Server { + pub fn new(provider: Arc<dyn Provider>) -> Self { + Self::new_with_name(provider, None) + } + + pub fn new_with_name(provider: Arc<dyn Provider>, server_name: Option<String>) -> Self { + use crate::id::{new_id, new_memorable_server_id, server_icon}; + + // Register the live provider so background helpers (the memory sidecar) + // can make cheap model calls on whatever provider the user is running. + // Without this, the sidecar only works on OpenAI/Claude OAuth and + // silently degrades (rerank -> hybrid order, no relevance/extraction) on + // Copilot, Antigravity, Gemini, Cursor, Bedrock, and OpenRouter. + crate::provider::set_active_provider(Arc::clone(&provider)); + + let (event_tx, _) = broadcast::channel(1024); + let (client_debug_response_tx, _) = broadcast::channel(64); + + // Generate a memorable server name unless the operator configured a + // stable one for long-lived remote runtimes. + let (id, name) = match configured_server_name(server_name) { + Some(name) => (new_id(&format!("server_{name}")), name), + None => new_memorable_server_id(), + }; + let icon = server_icon(&name).to_string(); + let identity = ServerIdentity { + id, + name, + icon, + git_hash: jcode_build_meta::GIT_HASH.to_string(), + version: jcode_build_meta::VERSION.to_string(), + }; + crate::process_title::set_server_title(&identity.name); + + // Initialize the background runner even when ambient mode is disabled so + // session-targeted scheduled tasks still have a live delivery loop. + let ambient_runner = { + let safety = Arc::new(crate::safety::SafetySystem::new()); + let handle = AmbientRunnerHandle::new(safety); + crate::tool::ambient::init_schedule_runner(handle.clone()); + Some(handle) + }; + + let LoadedSwarmRuntimeState { + plans: restored_swarm_plans, + coordinators: restored_swarm_coordinators, + members: restored_swarm_members, + swarms_by_id: restored_swarms_by_id, + } = load_persisted_swarm_runtime_state(); + + Self { + provider, + socket_path: socket_path(), + debug_socket_path: debug_socket_path(), + gateway_config_override: None, + identity, + event_tx, + sessions: Arc::new(RwLock::new(HashMap::new())), + is_processing: Arc::new(RwLock::new(false)), + session_id: Arc::new(RwLock::new(String::new())), + client_count: Arc::new(RwLock::new(0)), + client_connections: Arc::new(RwLock::new(HashMap::new())), + file_touch: FileTouchService::new(), + swarm_state: SwarmState::new( + restored_swarm_members, + restored_swarms_by_id, + restored_swarm_plans, + restored_swarm_coordinators, + ), + shared_context: Arc::new(RwLock::new(HashMap::new())), + client_debug_state: Arc::new(RwLock::new(ClientDebugState::default())), + client_debug_response_tx, + debug_jobs: Arc::new(RwLock::new(HashMap::new())), + channel_subscriptions: Arc::new(RwLock::new(HashMap::new())), + channel_subscriptions_by_session: Arc::new(RwLock::new(HashMap::new())), + event_history: Arc::new(RwLock::new(std::collections::VecDeque::new())), + event_counter: Arc::new(std::sync::atomic::AtomicU64::new(1)), + swarm_event_tx: broadcast::channel(256).0, + ambient_runner, + mcp_pool: Arc::new(OnceCell::new()), + shutdown_signals: Arc::new(RwLock::new(HashMap::new())), + soft_interrupt_queues: Arc::new(RwLock::new(HashMap::new())), + await_members_runtime: AwaitMembersRuntime::default(), + swarm_mutation_runtime: SwarmMutationRuntime::default(), + } + } + + pub fn new_with_paths( + provider: Arc<dyn Provider>, + socket_path: PathBuf, + debug_socket_path: PathBuf, + ) -> Self { + let mut server = Self::new(provider); + server.socket_path = socket_path; + server.debug_socket_path = debug_socket_path; + server + } + + pub fn with_gateway_config(mut self, gateway_config: crate::gateway::GatewayConfig) -> Self { + self.gateway_config_override = Some(gateway_config); + self + } + + /// Get the server identity + pub fn identity(&self) -> &ServerIdentity { + &self.identity + } + + fn runtime(&self) -> ServerRuntime { + ServerRuntime::from_server(self) + } + + fn build_registry_info(&self) -> crate::registry::ServerInfo { + crate::registry::ServerInfo { + id: self.identity.id.clone(), + name: self.identity.name.clone(), + icon: self.identity.icon.clone(), + socket: self.socket_path.clone(), + debug_socket: self.debug_socket_path.clone(), + git_hash: self.identity.git_hash.clone(), + version: self.identity.version.clone(), + pid: std::process::id(), + started_at: chrono::Utc::now().to_rfc3339(), + sessions: Vec::new(), + } + } + + fn spawn_registry_prewarm(&self) { + let registry_warm_provider = Arc::clone(&self.provider); + tokio::spawn(async move { + let start = Instant::now(); + let provider = registry_warm_provider.fork(); + let _ = crate::tool::Registry::new(provider).await; + crate::logging::info(&format!( + "Registry prewarm completed in {}ms", + start.elapsed().as_millis() + )); + }); + } + + async fn recover_headless_sessions_on_startup(&self) { + let sessions_to_restore = { + let members = self.swarm_state.members.read().await; + members + .values() + .filter(|member| headless_member_should_restore(&member.status, member.is_headless)) + .map(|member| member.session_id.clone()) + .collect::<Vec<_>>() + }; + + if sessions_to_restore.is_empty() { + return; + } + + crate::logging::info(&format!( + "Recovering {} headless session(s) after startup: {:?}", + sessions_to_restore.len(), + sessions_to_restore + )); + + if let Some(delay) = startup_headless_recovery_test_delay() { + crate::logging::info(&format!( + "Applying test-only headless startup recovery delay of {}ms", + delay.as_millis() + )); + tokio::time::sleep(delay).await; + } + + let mcp_pool = get_shared_mcp_pool(&self.mcp_pool).await; + let recovery_started = Instant::now(); + let mut stats = HeadlessRecoveryStats::default(); + let mut swarms_to_persist = HashSet::new(); + + for session_id in sessions_to_restore { + stats.candidates += 1; + let session = match crate::session::Session::load(&session_id) { + Ok(session) => session, + Err(error) => { + stats.failed_to_load += 1; + crate::logging::warn(&format!( + "Failed to load headless session {} during startup recovery: {}", + session_id, error + )); + update_member_status( + &session_id, + "failed", + Some(truncate_detail(&error.to_string(), 120)), + &self.swarm_state.members, + &self.swarm_state.swarms_by_id, + Some(&self.event_history), + Some(&self.event_counter), + Some(&self.swarm_event_tx), + ) + .await; + if let Some(swarm_id) = { + let members = self.swarm_state.members.read().await; + members + .get(&session_id) + .and_then(|member| member.swarm_id.clone()) + } { + persist_swarm_state_for(&swarm_id, &self.swarm_state).await; + } + continue; + } + }; + + let previous_status = session.status.clone(); + let provider = self.provider.fork(); + let registry = crate::tool::Registry::new(provider.clone()).await; + if session.is_canary { + registry.register_selfdev_tools().await; + } + registry + .register_mcp_tools_for_dir( + None, + Some(Arc::clone(&mcp_pool)), + Some("headless".to_string()), + session.working_dir.as_ref().map(std::path::PathBuf::from), + ) + .await; + + let agent = Arc::new(Mutex::new(Agent::new_with_session( + provider, registry, session, None, + ))); + + { + let mut sessions = self.sessions.write().await; + if sessions.contains_key(&session_id) { + continue; + } + sessions.insert(session_id.clone(), Arc::clone(&agent)); + } + + { + let agent_guard = agent.lock().await; + register_session_interrupt_queue( + &self.soft_interrupt_queues, + &session_id, + agent_guard.soft_interrupt_queue(), + ) + .await; + let mut shutdown_signals = self.shutdown_signals.write().await; + shutdown_signals.insert(session_id.clone(), agent_guard.graceful_shutdown_signal()); + register_background_tool_signal(&session_id, agent_guard.background_tool_signal()); + } + + let stored_recovery_record = reload_recovery::peek_for_session(&session_id) + .ok() + .flatten(); + let has_stored_recovery_intent = stored_recovery_record + .as_ref() + .map(|record| record.status == reload_recovery::ReloadRecoveryStatus::Pending) + .unwrap_or(false); + let should_resume = has_stored_recovery_intent || { + let agent_guard = agent.lock().await; + self::client_session::restored_session_was_interrupted( + &session_id, + &previous_status, + &agent_guard, + ) + }; + if let Some(record) = stored_recovery_record.as_ref() { + reload_trace::record_value( + &record.reload_id, + "startup_recovery_decision", + serde_json::json!({ + "session_id": session_id, + "has_stored_recovery_intent": has_stored_recovery_intent, + "should_resume": should_resume, + "previous_status": previous_status, + "is_headless": true, + }), + ); + } + + if !should_resume { + ReloadContext::log_recovery_outcome( + "server_startup_headless", + &session_id, + "skipped", + "restored session was not interrupted by reload", + ); + stats.skipped += 1; + update_member_status( + &session_id, + "ready", + None, + &self.swarm_state.members, + &self.swarm_state.swarms_by_id, + Some(&self.event_history), + Some(&self.event_counter), + Some(&self.swarm_event_tx), + ) + .await; + if let Some(swarm_id) = { + let members = self.swarm_state.members.read().await; + members + .get(&session_id) + .and_then(|member| member.swarm_id.clone()) + } { + swarms_to_persist.insert(swarm_id); + } + continue; + } + + let stored_directive = reload_recovery::pending_directive_for_session(&session_id) + .ok() + .flatten(); + let reload_ctx = if stored_directive.is_none() { + ReloadContext::load_for_session(&session_id).ok().flatten() + } else { + None + }; + let reminder = stored_directive + .map(|directive| directive.continuation_message) + .or_else(|| headless_reload_continuation_message(reload_ctx)); + let Some(reminder) = reminder else { + ReloadContext::log_recovery_outcome( + "server_startup_headless", + &session_id, + "failed", + "recovery directive missing for interrupted headless session", + ); + continue; + }; + stats.resumed += 1; + ReloadContext::log_recovery_outcome( + "server_startup_headless", + &session_id, + "resuming", + "restored interrupted headless session after reload", + ); + let recover_swarm_members = Arc::clone(&self.swarm_state.members); + let recover_swarms_by_id = Arc::clone(&self.swarm_state.swarms_by_id); + let recover_event_history = Arc::clone(&self.event_history); + let recover_event_counter = Arc::clone(&self.event_counter); + let recover_swarm_event_tx = self.swarm_event_tx.clone(); + let recover_swarm_state = self.swarm_state.clone(); + let recovery_reload_id = stored_recovery_record.map(|record| record.reload_id); + + tokio::spawn(async move { + if let Some(reload_id) = recovery_reload_id.as_deref() { + reload_trace::record_value( + reload_id, + "continuation_started", + serde_json::json!({ + "session_id": session_id, + "source": "server_startup_headless", + }), + ); + } + update_member_status( + &session_id, + "running", + Some("resuming after reload".to_string()), + &recover_swarm_members, + &recover_swarms_by_id, + Some(&recover_event_history), + Some(&recover_event_counter), + Some(&recover_swarm_event_tx), + ) + .await; + if let Some(swarm_id) = { + let members = recover_swarm_members.read().await; + members + .get(&session_id) + .and_then(|member| member.swarm_id.clone()) + } { + persist_swarm_state_for(&swarm_id, &recover_swarm_state).await; + } + + match reload_recovery::mark_delivered_if_matching_continuation( + &session_id, + &reminder, + "server_startup_headless", + ) { + Ok(true) => {} + Ok(false) => {} + Err(error) => crate::logging::warn(&format!( + "Failed to mark headless reload recovery intent delivered for {}: {}", + session_id, error + )), + } + + let event_tx = self::state::session_event_fanout_sender( + session_id.clone(), + Arc::clone(&recover_swarm_members), + ); + let result = self::client_lifecycle::process_message_streaming_mpsc( + Arc::clone(&agent), + "", + vec![], + Some(reminder), + event_tx, + ) + .await; + + let (status, detail) = match result { + Ok(()) => { + if let Some(reload_id) = recovery_reload_id.as_deref() { + reload_trace::record_value( + reload_id, + "continuation_finished", + serde_json::json!({ + "session_id": session_id, + "source": "server_startup_headless", + "status": "ready", + }), + ); + } + ReloadContext::log_recovery_outcome( + "server_startup_headless", + &session_id, + "resumed", + "continuation dispatched successfully", + ); + ("ready", None) + } + Err(error) => { + if let Some(reload_id) = recovery_reload_id.as_deref() { + reload_trace::record_value( + reload_id, + "continuation_failed", + serde_json::json!({ + "session_id": session_id, + "source": "server_startup_headless", + "error": error.to_string(), + }), + ); + } + ReloadContext::log_recovery_outcome( + "server_startup_headless", + &session_id, + "failed", + &error.to_string(), + ); + ("failed", Some(truncate_detail(&error.to_string(), 120))) + } + }; + update_member_status( + &session_id, + status, + detail, + &recover_swarm_members, + &recover_swarms_by_id, + Some(&recover_event_history), + Some(&recover_event_counter), + Some(&recover_swarm_event_tx), + ) + .await; + if let Some(swarm_id) = { + let members = recover_swarm_members.read().await; + members + .get(&session_id) + .and_then(|member| member.swarm_id.clone()) + } { + persist_swarm_state_for(&swarm_id, &recover_swarm_state).await; + } + }); + } + + for swarm_id in swarms_to_persist { + persist_swarm_state_for(&swarm_id, &self.swarm_state).await; + } + + crate::logging::info(&format!( + "[TIMING] headless reload startup recovery: candidates={}, resumed={}, skipped={}, failed_to_load={}, total={}ms", + stats.candidates, + stats.resumed, + stats.skipped, + stats.failed_to_load, + recovery_started.elapsed().as_millis() + )); + } + + async fn finish_startup_after_bind( + &self, + main_listener: Listener, + debug_listener: Listener, + server_start_time: Instant, + ) -> ( + ServerRuntime, + tokio::task::JoinHandle<()>, + tokio::task::JoinHandle<()>, + ) { + self.spawn_registry_prewarm(); + let registry_info = self.build_registry_info(); + + let runtime = self.runtime(); + let main_handle = runtime.spawn_main_accept_loop(main_listener); + let debug_handle = runtime.spawn_debug_accept_loop(debug_listener, server_start_time); + + crate::logging::info("Accept loop tasks spawned"); + + // Signal readiness to the spawning client only after the accept loops + // are live, so a "ready" server can immediately handle requests. + publish_reload_socket_ready(); + signal_ready_fd(); + + // Persist auxiliary discovery metadata after the server is already live. + self.spawn_registry_metadata_publisher(registry_info); + + // Spawn WebSocket gateway for iOS/web clients (if enabled) + self.spawn_gateway(runtime.clone()).await; + + // Startup recovery can be expensive in multi-session reloads. Run it + // only after the replacement daemon is already accepting reconnects. + self.recover_headless_sessions_on_startup().await; + + (runtime, main_handle, debug_handle) + } + + fn spawn_background_tasks( + &self, + server_start_time: Instant, + temporary_server_policy: Option<lifecycle::TemporaryServerPolicy>, + ) { + // Preload the embedding model in background so warm startups get fast + // memory recall. On a cold install, skip eager preload because the + // first-time model download can make the first spawned client look hung + // while the daemon finishes bootstrapping. + if crate::embedding::is_model_available() { + tokio::task::spawn_blocking(|| { + let start = std::time::Instant::now(); + match crate::embedding::get_embedder() { + Ok(_) => { + crate::logging::info(&format!( + "Embedding model preloaded in {}ms", + start.elapsed().as_millis() + )); + } + Err(e) => { + crate::logging::info(&format!( + "Embedding model preload failed (non-fatal): {}", + e + )); + } + } + }); + } else { + crate::logging::info( + "Embedding model not installed yet; skipping eager preload during server startup", + ); + } + + // Warm the lightweight session-search index after daemon startup. This + // keeps the first agent `session_search` call from paying the cold + // indexing cost while leaving exhaustive searches available on demand. + crate::tool::spawn_recent_index_warmup(); + + // Reconcile background-task status files orphaned by a previous + // process image (crash or exec-based reload). Non-detached tasks die + // with their owning process but their status files still say Running, + // which leaves phantom entries in `bg list` and blocks `bg wait` + // until timeout. Detached tasks are untouched (they survive reloads + // and reconcile via their real pid). + tokio::spawn(async move { + let reconciled = crate::background::global().reconcile_orphaned_tasks().await; + if reconciled > 0 { + crate::logging::info(&format!( + "Marked {} orphaned background task(s) from a previous server process as failed", + reconciled + )); + } + }); + + // Spawn reload monitor (event-driven via in-process channel). + // In the unified server design, self-dev sessions share the main server, + // so the shared server must always listen for reload signals. + let signal_sessions = Arc::clone(&self.sessions); + let signal_swarm_members = Arc::clone(&self.swarm_state.members); + let signal_shutdown_signals = Arc::clone(&self.shutdown_signals); + let signal_swarm_event_tx = self.swarm_event_tx.clone(); + tokio::spawn(async move { + await_reload_signal( + signal_sessions, + signal_swarm_members, + signal_shutdown_signals, + signal_swarm_event_tx, + ) + .await; + }); + + // Log when we receive SIGTERM for debugging + #[cfg(unix)] + { + let sigterm_server_name = self.identity.name.clone(); + tokio::spawn(async move { + use tokio::signal::unix::{SignalKind, signal}; + if let Ok(mut sigterm) = signal(SignalKind::terminate()) { + sigterm.recv().await; + crate::logging::info("Server received SIGTERM, shutting down gracefully"); + let _ = crate::registry::unregister_server(&sigterm_server_name).await; + std::process::exit(0); + } + }); + } + + // Spawn the bus monitor for swarm coordination + let monitor_file_touch = self.file_touch.clone(); + let monitor_swarm_members = Arc::clone(&self.swarm_state.members); + let monitor_swarms_by_id = Arc::clone(&self.swarm_state.swarms_by_id); + let monitor_swarm_plans = Arc::clone(&self.swarm_state.plans); + let monitor_swarm_coordinators = Arc::clone(&self.swarm_state.coordinators); + let monitor_shared_context = Arc::clone(&self.shared_context); + let monitor_sessions = Arc::clone(&self.sessions); + let monitor_soft_interrupt_queues = Arc::clone(&self.soft_interrupt_queues); + let monitor_event_history = Arc::clone(&self.event_history); + let monitor_event_counter = Arc::clone(&self.event_counter); + let monitor_swarm_event_tx = self.swarm_event_tx.clone(); + tokio::spawn(async move { + Self::monitor_bus( + monitor_file_touch, + monitor_swarm_members, + monitor_swarms_by_id, + monitor_swarm_plans, + monitor_swarm_coordinators, + monitor_shared_context, + monitor_sessions, + monitor_soft_interrupt_queues, + monitor_event_history, + monitor_event_counter, + monitor_swarm_event_tx, + ) + .await; + }); + + // Resume any background `swarm await_members` watchers that were active + // before this (re)start. Their results are delivered via notify/wake, so + // they can pick up transparently without the agent rerunning the wait. + { + let resume_swarm_members = Arc::clone(&self.swarm_state.members); + let resume_swarms_by_id = Arc::clone(&self.swarm_state.swarms_by_id); + let resume_swarm_event_tx = self.swarm_event_tx.clone(); + let resume_await_runtime = self.await_members_runtime.clone(); + tokio::spawn(async move { + comm_await::resume_background_awaits( + &resume_swarm_members, + &resume_swarms_by_id, + &resume_swarm_event_tx, + &resume_await_runtime, + ) + .await; + }); + } + + let stale_swarm_members = Arc::clone(&self.swarm_state.members); + let stale_swarms_by_id = Arc::clone(&self.swarm_state.swarms_by_id); + let stale_swarm_plans = Arc::clone(&self.swarm_state.plans); + let stale_swarm_coordinators = Arc::clone(&self.swarm_state.coordinators); + tokio::spawn(async move { + let mut interval = + tokio::time::interval(crate::server::swarm::swarm_task_sweep_interval()); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + refresh_swarm_task_staleness( + &stale_swarm_members, + &stale_swarms_by_id, + &stale_swarm_plans, + &stale_swarm_coordinators, + ) + .await; + } + }); + + let gc_sessions = Arc::clone(&self.sessions); + let gc_swarm_state = self.swarm_state.clone(); + let gc_channel_subscriptions = Arc::clone(&self.channel_subscriptions); + let gc_channel_subscriptions_by_session = + Arc::clone(&self.channel_subscriptions_by_session); + 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); + loop { + interval.tick().await; + prune_expired_terminal_swarm_members( + &gc_sessions, + &gc_swarm_state, + &gc_channel_subscriptions, + &gc_channel_subscriptions_by_session, + ) + .await; + } + }); + + // Keep the machine awake while any session is actively streaming/processing. + // This watches the same "running" member signal Waybar surfaces as + // "N streaming" and toggles a best-effort OS power inhibitor accordingly. + Self::spawn_power_inhibitor(Arc::clone(&self.swarm_state.members)); + + // Initialize the memory agent early so it's ready for all sessions + if crate::config::config().features.memory { + tokio::spawn(async { + let _ = crate::memory_agent::init().await; + }); + } + + // Spawn the background ambient/schedule loop. + if let Some(ref runner) = self.ambient_runner { + let ambient_handle = runner.clone(); + let ambient_provider = Arc::clone(&self.provider); + crate::logging::info("Starting ambient/schedule background loop"); + tokio::spawn(async move { + ambient_handle.run_loop(ambient_provider).await; + }); + } + + // Spawn the Jade cloud relay listener independently of ambient mode. The + // worker is strictly opt-in and requires an explicit API base, token, + // session id, and reply-enabled flag before it makes any outbound calls. + jade_relay::spawn_if_configured( + &crate::config::config().safety, + Arc::clone(&self.sessions), + Arc::clone(&self.soft_interrupt_queues), + Arc::clone(&self.shutdown_signals), + Arc::clone(&self.swarm_state.members), + ); + + // Spawn embedding idle monitor so the model can be unloaded when this + // server has been quiet for a while. + let embedding_idle_secs = embedding_idle_unload_secs(); + tokio::spawn(async move { + let idle_for = std::time::Duration::from_secs(embedding_idle_secs); + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(EMBEDDING_IDLE_CHECK_SECS)); + loop { + interval.tick().await; + let unloaded = crate::embedding::maybe_unload_if_idle(idle_for); + if unloaded { + let stats = crate::embedding::stats(); + crate::logging::info(&format!( + "Embedding idle monitor: model unloaded (loads={}, unloads={}, calls={}, avg_ms={})", + stats.load_count, + stats.unload_count, + stats.embed_calls, + stats + .avg_embed_ms + .map(|v| format!("{:.1}", v)) + .unwrap_or_else(|| "n/a".to_string()) + )); + } + } + }); + + // Spawn the retained-heap watchdog: glibc/jemalloc keep freed pages + // inside arenas, and the event-driven trim hooks (turn completion, + // history load) rarely fire on a server hosting mostly-idle sessions. + // Periodically check the allocator's freed-but-retained byte count and + // trim when it crosses the threshold, returning the pages to the OS. + let retention_threshold = crate::process_memory::retention_trim_threshold_bytes(); + if retention_threshold != u64::MAX { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs( + HEAP_RETENTION_CHECK_SECS, + )); + loop { + interval.tick().await; + crate::process_memory::release_retained_heap_if_excessive( + "server_retention_watchdog", + retention_threshold, + std::time::Duration::from_secs(60), + ); + } + }); + } + + if crate::runtime_memory_log::server_logging_enabled() { + let log_identity = self.identity.clone(); + let log_sessions = Arc::clone(&self.sessions); + let log_client_count = Arc::clone(&self.client_count); + let (memory_event_tx, mut memory_event_rx) = mpsc::unbounded_channel(); + crate::runtime_memory_log::install_event_sink(memory_event_tx); + tokio::spawn(async move { + match crate::runtime_memory_log::prune_old_server_logs() { + Ok(removed) if removed > 0 => { + crate::logging::info(&format!( + "Runtime memory logging pruned {} old log files", + removed + )); + } + Ok(_) => {} + Err(err) => { + crate::logging::info(&format!( + "Runtime memory logging could not prune old logs: {}", + err + )); + } + } + + let log_config = crate::runtime_memory_log::server_logging_config(); + match crate::runtime_memory_log::current_server_log_path() { + Ok(path) => crate::logging::info(&format!( + "Runtime memory logging enabled: process={}s attribution={}s -> {}", + log_config.process_interval.as_secs(), + log_config.attribution_interval.as_secs(), + path.display() + )), + Err(err) => crate::logging::info(&format!( + "Runtime memory logging enabled: process={}s attribution={}s (path unavailable: {})", + log_config.process_interval.as_secs(), + log_config.attribution_interval.as_secs(), + err + )), + } + + let mut controller = RuntimeMemoryLogController::new(log_config); + let startup_now = Instant::now(); + let mut startup_sample = capture_runtime_memory_attribution_sample( + &log_identity, + &log_sessions, + &log_client_count, + server_start_time, + "attribution:startup", + RuntimeMemoryLogTrigger { + category: "startup".to_string(), + reason: "server_start".to_string(), + session_id: None, + detail: None, + }, + RuntimeMemoryLogSampling { + forced: true, + threshold_reasons: vec!["initial_attribution".to_string()], + pending_event_count: 0, + pending_categories: Vec::new(), + }, + ) + .await; + controller.record_process_sample(startup_now); + controller.finalize_attribution_sample(startup_now, &mut startup_sample); + if let Err(err) = crate::runtime_memory_log::append_server_sample(&startup_sample) { + crate::logging::info(&format!( + "Runtime memory logging startup sample failed: {}", + err + )); + } + + let mut process_interval = + tokio::time::interval(controller.config().process_interval); + let mut attribution_interval = + tokio::time::interval(controller.config().attribution_interval); + process_interval.tick().await; + attribution_interval.tick().await; + loop { + tokio::select! { + _ = process_interval.tick() => { + let now = Instant::now(); + let process_sample = capture_runtime_memory_process_sample( + &log_identity, + &log_client_count, + server_start_time, + "process:heartbeat", + RuntimeMemoryLogTrigger { + category: "process_heartbeat".to_string(), + reason: "periodic".to_string(), + session_id: None, + detail: None, + }, + controller.build_sampling_for_process(None), + ) + .await; + controller.record_process_sample(now); + if let Err(err) = crate::runtime_memory_log::append_server_sample(&process_sample) { + crate::logging::info(&format!( + "Runtime memory logging process heartbeat sample failed: {}", + err + )); + } + + if let Some(sampling) = controller.build_sampling_for_attribution( + now, + &process_sample.process, + None, + None, + ) { + let mut attribution_sample = capture_runtime_memory_attribution_sample( + &log_identity, + &log_sessions, + &log_client_count, + server_start_time, + "attribution:process-heartbeat", + RuntimeMemoryLogTrigger { + category: "process_heartbeat".to_string(), + reason: "threshold_flush".to_string(), + session_id: None, + detail: None, + }, + sampling, + ) + .await; + controller.finalize_attribution_sample(now, &mut attribution_sample); + if let Err(err) = crate::runtime_memory_log::append_server_sample(&attribution_sample) { + crate::logging::info(&format!( + "Runtime memory logging attribution flush failed: {}", + err + )); + } + } + } + _ = attribution_interval.tick() => { + let now = Instant::now(); + let preflight = capture_runtime_memory_process_sample( + &log_identity, + &log_client_count, + server_start_time, + "process:attribution-preflight", + RuntimeMemoryLogTrigger { + category: "attribution_heartbeat".to_string(), + reason: "preflight".to_string(), + session_id: None, + detail: None, + }, + RuntimeMemoryLogSampling::default(), + ) + .await; + if let Some(sampling) = controller.build_sampling_for_attribution( + now, + &preflight.process, + None, + Some("attribution_heartbeat"), + ) { + let mut attribution_sample = capture_runtime_memory_attribution_sample( + &log_identity, + &log_sessions, + &log_client_count, + server_start_time, + "attribution:heartbeat", + RuntimeMemoryLogTrigger { + category: "attribution_heartbeat".to_string(), + reason: "periodic".to_string(), + session_id: None, + detail: None, + }, + sampling, + ) + .await; + controller.finalize_attribution_sample(now, &mut attribution_sample); + if let Err(err) = crate::runtime_memory_log::append_server_sample(&attribution_sample) { + crate::logging::info(&format!( + "Runtime memory logging attribution heartbeat failed: {}", + err + )); + } + } else { + controller.mark_attribution_heartbeat_pending(); + } + } + maybe_event = memory_event_rx.recv() => { + let Some(event) = maybe_event else { + break; + }; + let now = Instant::now(); + let should_write_process = controller.should_write_process_for_event(now, &event); + let process_sample = if should_write_process { + Some( + capture_runtime_memory_process_sample( + &log_identity, + &log_client_count, + server_start_time, + &format!("process:event:{}", event.category), + RuntimeMemoryLogTrigger { + category: event.category.clone(), + reason: event.reason.clone(), + session_id: event.session_id.clone(), + detail: event.detail.clone(), + }, + controller.build_sampling_for_process(Some(&event)), + ) + .await, + ) + } else { + None + }; + + if let Some(process_sample) = process_sample.as_ref() { + controller.record_process_sample(now); + if let Err(err) = crate::runtime_memory_log::append_server_sample(process_sample) { + crate::logging::info(&format!( + "Runtime memory logging event process sample failed: {}", + err + )); + } + } + + let mut wrote_attribution = false; + let preflight_sample = if process_sample.is_none() && controller.can_write_attribution(now) { + Some( + capture_runtime_memory_process_sample( + &log_identity, + &log_client_count, + server_start_time, + &format!("process:event-preflight:{}", event.category), + RuntimeMemoryLogTrigger { + category: event.category.clone(), + reason: "preflight".to_string(), + session_id: event.session_id.clone(), + detail: event.detail.clone(), + }, + RuntimeMemoryLogSampling::default(), + ) + .await, + ) + } else { + None + }; + let preflight = process_sample.as_ref().or(preflight_sample.as_ref()); + if let Some(preflight) = preflight + && let Some(sampling) = controller.build_sampling_for_attribution( + now, + &preflight.process, + Some(&event), + None, + ) + { + let mut attribution_sample = capture_runtime_memory_attribution_sample( + &log_identity, + &log_sessions, + &log_client_count, + server_start_time, + &format!("attribution:event:{}", event.category), + RuntimeMemoryLogTrigger { + category: event.category.clone(), + reason: event.reason.clone(), + session_id: event.session_id.clone(), + detail: event.detail.clone(), + }, + sampling, + ) + .await; + controller.finalize_attribution_sample(now, &mut attribution_sample); + wrote_attribution = true; + if let Err(err) = crate::runtime_memory_log::append_server_sample(&attribution_sample) { + crate::logging::info(&format!( + "Runtime memory logging event attribution sample failed: {}", + err + )); + } + } + + if !wrote_attribution { + controller.defer_event(event); + } + } + } + } + }); + } + + if let Some(policy) = temporary_server_policy { + lifecycle::spawn_temporary_lifecycle_monitor( + Arc::clone(&self.client_count), + self.socket_path.clone(), + self.debug_socket_path.clone(), + self.identity.name.clone(), + policy, + ); + } else if debug_control_allowed() { + crate::logging::info("Debug control enabled; idle timeout monitor disabled."); + } else { + let idle_client_count = Arc::clone(&self.client_count); + let idle_server_name = self.identity.name.clone(); + tokio::spawn(async move { + let mut idle_since: Option<std::time::Instant> = None; + let mut check_interval = tokio::time::interval(std::time::Duration::from_secs(10)); + + loop { + check_interval.tick().await; + + let count = *idle_client_count.read().await; + + if count == 0 { + // No clients connected + if idle_since.is_none() { + idle_since = Some(std::time::Instant::now()); + crate::logging::info(&format!( + "No clients connected. Server will exit after {} minutes of idle.", + IDLE_TIMEOUT_SECS / 60 + )); + } + + if let Some(since) = idle_since { + let idle_duration = since.elapsed().as_secs(); + if idle_duration >= IDLE_TIMEOUT_SECS { + crate::logging::info(&format!( + "Server idle for {} minutes with no clients. Shutting down.", + idle_duration / 60 + )); + let _ = crate::registry::unregister_server(&idle_server_name).await; + std::process::exit(EXIT_IDLE_TIMEOUT); + } + } + } else { + // Clients connected - reset idle timer + if idle_since.is_some() { + crate::logging::info("Client connected. Idle timer cancelled."); + } + idle_since = None; + } + } + }); + } + } + + fn spawn_registry_metadata_publisher(&self, registry_info: crate::registry::ServerInfo) { + let registry_identity = self.identity.display_name(); + tokio::spawn(async move { + let hash_path = format!("{}.hash", registry_info.socket.display()); + let _ = std::fs::write(&hash_path, jcode_build_meta::GIT_HASH); + + let mut registry = crate::registry::ServerRegistry::load() + .await + .unwrap_or_default(); + registry.register(registry_info); + let _ = registry.save().await; + crate::logging::info(&format!( + "Registered as {} in server registry", + registry_identity, + )); + + if let Ok(mut registry) = crate::registry::ServerRegistry::load().await { + let _ = registry.cleanup_stale().await; + let _ = registry.save().await; + } + }); + } + + /// Spawn the background loop that keeps the machine awake while any session + /// is actively streaming/processing. + /// + /// The shared daemon owns every session, so a single inhibitor here covers + /// all of them. We poll the swarm-member map (the authoritative "running" + /// signal that also drives Waybar's "N streaming" indicator) on a short + /// interval and reconcile a best-effort OS power inhibitor against it. The + /// inhibitor only blocks system suspend / lid sleep; the display can still + /// turn off. When no session is running the helper is killed so normal power + /// management resumes immediately. + fn spawn_power_inhibitor(swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>) { + // Reconcile interval. Short enough that the inhibitor engages promptly + // when a turn starts and releases promptly when work finishes, but cheap + // (a read lock + a scan) so it adds no meaningful load. + const RECONCILE_INTERVAL: Duration = Duration::from_secs(5); + + let mut inhibitor = crate::power_inhibit::PowerInhibitor::new(); + if !inhibitor.is_available() { + // Disabled via the legacy env escape hatch, or unsupported platform. + crate::logging::info( + "power_inhibit: unavailable (unsupported platform or JCODE_DISABLE_POWER_INHIBIT set); not monitoring", + ); + return; + } + + crate::logging::info( + "power_inhibit: monitoring active sessions to prevent sleep while streaming", + ); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(RECONCILE_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut last_active: Option<bool> = None; + loop { + interval.tick().await; + + // Re-evaluate the config each tick so toggling it at runtime + // takes effect without restarting the daemon. + let enabled = crate::config::config().power.prevent_sleep_while_streaming; + + let active = enabled && Self::any_session_streaming(&swarm_members).await; + if last_active != Some(active) { + crate::logging::info(&format!( + "power_inhibit: {} (streaming sessions {})", + if active { "engaging" } else { "releasing" }, + if active { "present" } else { "absent" }, + )); + last_active = Some(active); + } + inhibitor.set_active(active); + } + }); + } + + /// Whether at least one session is currently in the "running" state, i.e. + /// actively streaming/processing a turn. This is the same signal that drives + /// the Waybar "N streaming" indicator. + async fn any_session_streaming( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + ) -> bool { + let members = swarm_members.read().await; + members.values().any(|member| member.status == "running") + } + + /// Monitor the global Bus for FileTouch events and detect conflicts + #[expect( + clippy::too_many_arguments, + reason = "bus monitor needs file state, swarm state, sessions, queues, and event history sinks" + )] + async fn monitor_bus( + file_touch: FileTouchService, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + _swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + _swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + _shared_context: Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + sessions: Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>, + soft_interrupt_queues: SessionInterruptQueues, + event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + ) { + let mut receiver = Bus::global().subscribe(); + let mut last_cleanup = Instant::now(); + const TOUCH_EXPIRY: Duration = Duration::from_secs(30 * 60); // 30 min + const CLEANUP_INTERVAL: Duration = Duration::from_secs(5 * 60); // 5 min + + loop { + // Periodic cleanup of expired file touches + if last_cleanup.elapsed() > CLEANUP_INTERVAL { + file_touch.expire_older_than(TOUCH_EXPIRY).await; + last_cleanup = Instant::now(); + } + + match receiver.recv().await { + Ok(BusEvent::FileTouch(touch)) => { + let path = touch.path.clone(); + let session_id = touch.session_id.clone(); + + // Record this touch + file_touch + .record_touch( + path.clone(), + FileAccess { + session_id: session_id.clone(), + op: touch.op.clone(), + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + intent: touch.intent.clone(), + summary: touch.summary.clone(), + detail: touch.detail.clone(), + }, + ) + .await; + + // Record event for subscription + { + let members = swarm_members.read().await; + let member = members.get(&session_id); + let session_name = member.and_then(|m| m.friendly_name.clone()); + let swarm_id = member.and_then(|m| m.swarm_id.clone()); + + drop(members); + record_swarm_event( + &event_history, + &event_counter, + &swarm_event_tx, + session_id.clone(), + session_name, + swarm_id, + SwarmEventType::FileTouch { + path: path.to_string_lossy().to_string(), + op: touch.op.as_str().to_string(), + intent: touch.intent.clone(), + summary: touch.summary.clone(), + detail: touch.detail.clone(), + }, + ) + .await; + } + + // Find the swarm this session belongs to + let swarm_session_ids: Vec<String> = { + let members = swarm_members.read().await; + if let Some(member) = members.get(&session_id) { + if let Some(ref swarm_id) = member.swarm_id { + let swarms = swarms_by_id.read().await; + if let Some(swarm) = swarms.get(swarm_id) { + swarm.iter().cloned().collect() + } else { + vec![] + } + } else { + vec![] + } + } else { + vec![] + } + }; + + // Only notify on modifications, and only about prior peer modifications. + // Plain reads are still tracked for later context/listing but should not + // proactively alert the swarm. + let is_modification = touch.op.is_modification(); + if is_modification { + crate::logging::info(&format!( + "[file-activity] modification by {} on {}, swarm_peers: {:?}", + &session_id[..8.min(session_id.len())], + path.display(), + swarm_session_ids + .iter() + .map(|s| &s[..8.min(s.len())]) + .collect::<Vec<_>>() + )); + } + let previous_touches: Vec<FileAccess> = if is_modification { + if let Some(accesses) = file_touch.accesses_for_path(&path).await { + let swarm_session_ids_set: HashSet<String> = + swarm_session_ids.iter().cloned().collect(); + let result = + latest_peer_touches(&accesses, &session_id, &swarm_session_ids_set); + crate::logging::info(&format!( + "[file-activity] {} prior peer touches ({} total accesses)", + result.len(), + accesses.len() + )); + result + } else { + crate::logging::info("[file-activity] no touches for this path yet"); + vec![] + } + } else { + vec![] + }; + + // If swarm peers previously touched this file, notify both sides so they + // can coordinate before the work diverges further. + if !previous_touches.is_empty() { + crate::logging::info(&format!( + "[file-activity] {} touched by peers before modification — sending alerts", + path.display() + )); + let members = swarm_members.read().await; + let current_member = members.get(&session_id); + let current_name = current_member.and_then(|m| m.friendly_name.clone()); + + // Alert the current agent about previous peer touches (one per agent). + if let Some(member) = current_member { + for prev in &previous_touches { + let prev_member = members.get(&prev.session_id); + let prev_name = prev_member.and_then(|m| m.friendly_name.clone()); + let scope = file_activity_scope_label(prev, &touch); + let intent_suffix = prev + .intent + .as_ref() + .map(|intent| format!(" — intent: {}", intent)) + .unwrap_or_default(); + let alert_msg = format!( + "⚠ File activity: {} — {} — {} previously {} this file{}{}", + path.display(), + scope, + prev_name.as_deref().unwrap_or(&prev.session_id[..8]), + prev.op.as_str(), + prev.summary + .as_ref() + .map(|s| format!(": {}", s)) + .unwrap_or_default(), + intent_suffix + ); + let notification = ServerEvent::Notification { + from_session: prev.session_id.clone(), + from_name: prev_name, + notification_type: NotificationType::FileConflict { + path: path.display().to_string(), + operation: prev.op.as_str().to_string(), + intent: prev.intent.clone(), + summary: prev.summary.clone(), + detail: prev.detail.clone(), + }, + message: alert_msg.clone(), + }; + let _ = member.event_tx.send(notification); + + if !queue_soft_interrupt_for_session( + &session_id, + alert_msg.clone(), + false, + SoftInterruptSource::System, + &soft_interrupt_queues, + &sessions, + ) + .await + { + crate::logging::warn(&format!( + "Failed to queue file-activity soft interrupt for session {}", + session_id + )); + } + } + } + + // Alert previous agents about the current modification. + for prev in &previous_touches { + if let Some(prev_member) = members.get(&prev.session_id) { + let scope = file_activity_scope_label(prev, &touch); + let intent_suffix = touch + .intent + .as_ref() + .map(|intent| format!(" — intent: {}", intent)) + .unwrap_or_default(); + let alert_msg = format!( + "⚠ File activity: {} — {} — {} just {} this file you previously worked with{}{}", + path.display(), + scope, + current_name + .as_deref() + .unwrap_or(&session_id[..8.min(session_id.len())]), + touch.op.as_str(), + touch + .summary + .as_ref() + .map(|s| format!(": {}", s)) + .unwrap_or_default(), + intent_suffix + ); + let notification = ServerEvent::Notification { + from_session: session_id.clone(), + from_name: current_name.clone(), + notification_type: NotificationType::FileConflict { + path: path.display().to_string(), + operation: touch.op.as_str().to_string(), + intent: touch.intent.clone(), + summary: touch.summary.clone(), + detail: touch.detail.clone(), + }, + message: alert_msg.clone(), + }; + let _ = prev_member.event_tx.send(notification); + + if !queue_soft_interrupt_for_session( + &prev.session_id, + alert_msg.clone(), + false, + SoftInterruptSource::System, + &soft_interrupt_queues, + &sessions, + ) + .await + { + crate::logging::warn(&format!( + "Failed to queue file-activity soft interrupt for session {}", + prev.session_id + )); + } + } + } + } + } + Ok(BusEvent::BackgroundTaskCompleted(task)) => { + dispatch_background_task_completion( + &task, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + Ok(BusEvent::BackgroundTaskProgress(task)) => { + dispatch_background_task_progress(&task, &swarm_members).await; + } + Ok(BusEvent::SwarmAwaitCompleted(event)) => { + dispatch_swarm_await_completion( + &event, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + Ok(BusEvent::UiActivity(activity)) => { + dispatch_ui_activity(&activity, &swarm_members).await; + } + Ok(BusEvent::ToolUpdated(event)) => { + dispatch_swarm_tool_activity(&event, &swarm_members, &swarms_by_id).await; + } + Ok(BusEvent::SubagentStatus(event)) => { + dispatch_swarm_runtime_status(&event, &swarm_members, &swarms_by_id).await; + } + Ok(BusEvent::BatchProgress(progress)) => { + dispatch_swarm_batch_progress(&progress, &swarm_members, &swarms_by_id).await; + } + // Session todos are private to the session's transcript, but the + // Compact todo names and progress are surfaced on the inline + // swarm strip so a coordinator can see each managed agent's work. + Ok(BusEvent::TodoUpdated(event)) => { + dispatch_swarm_todo_progress(&event, &swarm_members, &swarms_by_id).await; + } + Ok(BusEvent::SwarmOutputTail(tail)) => { + dispatch_swarm_output_tail(&tail, &swarm_members, &swarms_by_id).await; + } + Ok(_) => { + // Ignore other events + } + Err(broadcast::error::RecvError::Lagged(n)) => { + crate::logging::info(&format!("Bus monitor lagged by {} events", n)); + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + } + + /// Start the server (both main and debug sockets) + pub async fn run(&self) -> Result<()> { + // Ensure socket directory exists (for named sockets like /run/user/1000/jcode/) + if let Some(parent) = self.socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + #[cfg(unix)] + let _daemon_lock = acquire_daemon_lock()?; + + if socket_has_live_listener(&self.socket_path).await { + anyhow::bail!( + "Refusing to replace active server socket at {}", + self.socket_path.display() + ); + } + + // Remove existing sockets (uses transport abstraction for cross-platform cleanup) + crate::transport::remove_socket(&self.socket_path); + crate::transport::remove_socket(&self.debug_socket_path); + + let main_listener = Listener::bind(&self.socket_path)?; + let debug_listener = Listener::bind(&self.debug_socket_path)?; + + #[cfg(unix)] + { + // Server reload uses exec. Force the published listener fds to close + // across exec so the replacement daemon can safely rebind them. + mark_close_on_exec(&main_listener); + mark_close_on_exec(&debug_listener); + } + + // Preserve an in-flight reload marker for exec-based reloads owned by this + // process, but clear stale markers from unrelated/stale processes. + clear_reload_marker_if_stale_for_pid(std::process::id()); + + match reload_recovery::collect_garbage() { + Ok(stats) if stats.removed > 0 || stats.errors > 0 => { + crate::logging::info(&format!( + "Reload recovery GC: removed={}, retained={}, errors={}", + stats.removed, stats.retained, stats.errors + )); + } + Ok(_) => {} + Err(error) => crate::logging::warn(&format!( + "Reload recovery GC failed during startup: {error}" + )), + } + + // Restrict socket files to owner-only so other local users cannot connect. + let _ = crate::platform::set_permissions_owner_only(&self.socket_path); + let _ = crate::platform::set_permissions_owner_only(&self.debug_socket_path); + + // Set logging context for this server + crate::logging::set_server(&self.identity.name); + + // Log server identity + crate::logging::info(&format!( + "Server {} starting ({})", + self.identity.display_name(), + self.identity.version + )); + crate::logging::info(&format!("Server listening on {:?}", self.socket_path)); + crate::logging::info(&format!("Debug socket on {:?}", self.debug_socket_path)); + + let temporary_server_policy = lifecycle::temporary_server_policy_from_env(); + if let Some(policy) = temporary_server_policy.as_ref() { + crate::logging::info(&format!( + "Temporary server lifecycle enabled: owner_pid={:?}, idle_timeout_secs={}", + policy.owner_pid, policy.idle_timeout_secs + )); + let _ = lifecycle::write_temporary_metadata( + &self.socket_path, + &self.debug_socket_path, + policy, + ); + } + + let server_start_time = Instant::now(); + + self.spawn_background_tasks(server_start_time, temporary_server_policy); + let (runtime, main_handle, debug_handle) = self + .finish_startup_after_bind(main_listener, debug_listener, server_start_time) + .await; + + // If either listener exits unexpectedly, stop accepting work and wait + // for every owned connection task before returning. The normal daemon + // path runs until process shutdown or exec-based reload. + let mut main_handle = main_handle; + let mut debug_handle = debug_handle; + tokio::select! { + result = &mut main_handle => { + if let Err(error) = result { + crate::logging::error(&format!("Main accept loop failed: {error}")); + } + runtime.shutdown().await; + let _ = debug_handle.await; + } + result = &mut debug_handle => { + if let Err(error) = result { + crate::logging::error(&format!("Debug accept loop failed: {error}")); + } + runtime.shutdown().await; + let _ = main_handle.await; + } + } + Ok(()) + } + + /// Spawn the WebSocket gateway if enabled in config. + /// The runtime task scope owns both the listener and client accept loop so + /// server shutdown can cancel and join them with the other connection work. + async fn spawn_gateway(&self, runtime: ServerRuntime) { + let config = if let Some(override_config) = &self.gateway_config_override { + override_config.clone() + } else { + let gw_config = &crate::config::config().gateway; + crate::gateway::GatewayConfig { + port: gw_config.port, + bind_addr: gw_config.bind_addr.clone(), + enabled: gw_config.enabled, + } + }; + + if !config.enabled { + return; + } + + let (client_tx, client_rx) = + tokio::sync::mpsc::unbounded_channel::<crate::gateway::GatewayClient>(); + + let listener_runtime = runtime.clone(); + let listener_spawned = runtime + .spawn_background_task(async move { + if let Err(e) = crate::gateway::run_gateway(config, client_tx).await { + crate::logging::error(&format!("Gateway error: {}", e)); + } + }) + .await; + if listener_spawned { + let _ = listener_runtime.spawn_gateway_accept_loop(client_rx).await; + } + } +} + +pub use self::client_api::Client; + +#[cfg(test)] +mod tests; diff --git a/crates/jcode-app-core/src/server/await_members_state.rs b/crates/jcode-app-core/src/server/await_members_state.rs new file mode 100644 index 0000000..8807f74 --- /dev/null +++ b/crates/jcode-app-core/src/server/await_members_state.rs @@ -0,0 +1,278 @@ +use crate::protocol::{AwaitedMemberStatus, ServerEvent}; +use crate::server::durable_state::{ + hashed_request_key, load_json_state, now_unix_ms, save_json_state, state_dir, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{RwLock, mpsc}; + +const AWAIT_MEMBERS_DIR: &str = "jcode-await-members"; +const FINAL_STATE_TTL: Duration = Duration::from_secs(6 * 60 * 60); +const PENDING_STATE_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedAwaitMembersResult { + pub completed: bool, + pub members: Vec<AwaitedMemberStatus>, + pub summary: String, + pub resolved_at_unix_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedAwaitMembersState { + pub key: String, + pub session_id: String, + pub swarm_id: String, + pub target_status: Vec<String>, + pub requested_ids: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option<String>, + pub created_at_unix_ms: u64, + pub deadline_unix_ms: u64, + /// When true, the wait runs as a detached background watcher that delivers + /// its result via notify/wake instead of blocking the requesting turn. + /// Background watchers are auto-resumed at server startup after a reload. + #[serde(default)] + pub background: bool, + /// Surface a completion notification card to attached clients. + #[serde(default = "default_true")] + pub notify: bool, + /// Wake an idle requesting agent on completion (or soft-interrupt if busy). + #[serde(default = "default_true")] + pub wake: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_response: Option<PersistedAwaitMembersResult>, +} + +fn default_true() -> bool { + true +} + +impl PersistedAwaitMembersState { + pub fn is_pending(&self) -> bool { + self.final_response.is_none() + } + + pub fn remaining_timeout(&self) -> Duration { + let now = now_unix_ms(); + Duration::from_millis(self.deadline_unix_ms.saturating_sub(now)) + } +} + +#[derive(Clone)] +struct AwaitMembersWaiter { + request_id: u64, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, +} + +#[derive(Clone, Default)] +pub(crate) struct AwaitMembersRuntime { + active_keys: Arc<RwLock<HashSet<String>>>, + waiters: Arc<RwLock<HashMap<String, Vec<AwaitMembersWaiter>>>>, +} + +impl AwaitMembersRuntime { + pub(super) async fn add_waiter( + &self, + key: &str, + request_id: u64, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + ) { + let mut waiters = self.waiters.write().await; + waiters + .entry(key.to_string()) + .or_default() + .push(AwaitMembersWaiter { + request_id, + client_event_tx: client_event_tx.clone(), + }); + } + + pub(super) async fn mark_active_if_new(&self, key: &str) -> bool { + let mut active = self.active_keys.write().await; + active.insert(key.to_string()) + } + + pub(super) async fn clear_active(&self, key: &str) { + self.active_keys.write().await.remove(key); + } + + pub(super) async fn retain_open_waiters(&self, key: &str) -> usize { + let mut waiters = self.waiters.write().await; + let Some(entries) = waiters.get_mut(key) else { + return 0; + }; + entries.retain(|waiter| !waiter.client_event_tx.is_closed()); + let remaining = entries.len(); + if remaining == 0 { + waiters.remove(key); + } + remaining + } + + pub(super) async fn take_waiters( + &self, + key: &str, + ) -> Vec<(u64, mpsc::UnboundedSender<ServerEvent>)> { + self.waiters + .write() + .await + .remove(key) + .unwrap_or_default() + .into_iter() + .map(|waiter| (waiter.request_id, waiter.client_event_tx)) + .collect() + } +} + +fn is_stale(state: &PersistedAwaitMembersState) -> bool { + let now = now_unix_ms(); + if let Some(final_response) = &state.final_response { + now.saturating_sub(final_response.resolved_at_unix_ms) > FINAL_STATE_TTL.as_millis() as u64 + } else { + now.saturating_sub(state.deadline_unix_ms) > PENDING_STATE_TTL.as_millis() as u64 + } +} + +pub(super) fn request_key( + session_id: &str, + swarm_id: &str, + requested_ids: &[String], + target_status: &[String], + mode: Option<&str>, +) -> String { + let mut requested = requested_ids.to_vec(); + requested.sort(); + + let mut target = target_status.to_vec(); + target.sort(); + + hashed_request_key( + session_id, + "await_members", + &[ + swarm_id.to_string(), + requested.join("\u{1f}"), + target.join("\u{1f}"), + mode.unwrap_or("all").to_string(), + ], + ) +} + +pub(super) fn load_state(key: &str) -> Option<PersistedAwaitMembersState> { + load_json_state(AWAIT_MEMBERS_DIR, key, is_stale) +} + +pub(super) fn save_state(state: &PersistedAwaitMembersState) { + save_json_state(AWAIT_MEMBERS_DIR, &state.key, state, "await_members state") +} + +#[expect( + clippy::too_many_arguments, + reason = "pending await state mirrors persisted fields and existing call sites" +)] +pub(super) fn ensure_pending_state( + key: &str, + session_id: &str, + swarm_id: &str, + requested_ids: &[String], + target_status: &[String], + mode: Option<&str>, + deadline_unix_ms: u64, + background: bool, + notify: bool, + wake: bool, +) -> PersistedAwaitMembersState { + if let Some(existing) = load_state(key).filter(PersistedAwaitMembersState::is_pending) { + return existing; + } + + let state = PersistedAwaitMembersState { + key: key.to_string(), + session_id: session_id.to_string(), + swarm_id: swarm_id.to_string(), + target_status: target_status.to_vec(), + requested_ids: requested_ids.to_vec(), + mode: mode.map(str::to_string), + created_at_unix_ms: now_unix_ms(), + deadline_unix_ms, + background, + notify, + wake, + final_response: None, + }; + save_state(&state); + state +} + +pub(super) fn persist_final_response( + state: &PersistedAwaitMembersState, + completed: bool, + members: Vec<AwaitedMemberStatus>, + summary: String, +) -> PersistedAwaitMembersState { + let mut next = state.clone(); + next.final_response = Some(PersistedAwaitMembersResult { + completed, + members, + summary, + resolved_at_unix_ms: now_unix_ms(), + }); + save_state(&next); + next +} + +pub fn pending_await_members_for_session(session_id: &str) -> Vec<PersistedAwaitMembersState> { + let mut pending: Vec<PersistedAwaitMembersState> = all_pending_await_members() + .into_iter() + .filter(|state| state.session_id == session_id) + .collect(); + pending.sort_by_key(|state| state.deadline_unix_ms); + pending +} + +/// Load every still-pending await state across all sessions, pruning stale +/// files as a side effect. Used both for per-session lookups and for resuming +/// backgrounded watchers after a server reload. +pub(super) fn all_pending_await_members() -> Vec<PersistedAwaitMembersState> { + let now = now_unix_ms(); + all_pending_await_members_including_expired() + .into_iter() + .filter(|state| state.deadline_unix_ms > now) + .collect() +} + +/// Like [`all_pending_await_members`], but also returns pending states whose +/// deadline has already passed (still within the pending TTL). Startup resume +/// uses this so background awaits that expired while the server was down can +/// be finalized with a timeout instead of silently dropping the promised +/// notify/wake. +pub(super) fn all_pending_await_members_including_expired() -> Vec<PersistedAwaitMembersState> { + let dir = state_dir(AWAIT_MEMBERS_DIR); + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut pending = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Ok(state) = crate::storage::read_json::<PersistedAwaitMembersState>(&path) else { + continue; + }; + if is_stale(&state) { + let _ = std::fs::remove_file(path); + continue; + } + if state.is_pending() { + pending.push(state); + } + } + + pending +} diff --git a/crates/jcode-app-core/src/server/background_tasks.rs b/crates/jcode-app-core/src/server/background_tasks.rs new file mode 100644 index 0000000..9cfd423 --- /dev/null +++ b/crates/jcode-app-core/src/server/background_tasks.rs @@ -0,0 +1,580 @@ +use super::live_turn::{LiveTurnSwarmContext, run_live_turn_if_idle}; +use super::state::SwarmEvent; +use super::{ + SessionAgents, SessionInterruptQueues, SwarmMember, fanout_session_event, + queue_soft_interrupt_for_session, +}; +use crate::message::{ + format_background_task_notification_markdown, format_background_task_progress_markdown, +}; +use crate::protocol::{NotificationType, ServerEvent}; +use jcode_agent_runtime::SoftInterruptSource; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use tokio::sync::{RwLock, broadcast}; + +#[expect( + clippy::too_many_arguments, + reason = "background task completion needs session, interrupt, and swarm status state" +)] +pub(super) async fn dispatch_background_task_completion( + task: &crate::bus::BackgroundTaskCompleted, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>, + event_counter: &Arc<AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let notification = format_background_task_notification_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 completion on session {}", + task.session_id + )); + } + + if task.wake + && !run_live_turn_if_idle( + &task.session_id, + ¬ification, + Some( + "A background task for this session just finished. Review the completion message and continue if useful." + .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 completion 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 +/// for a busy one. +#[expect( + clippy::too_many_arguments, + reason = "swarm await completion needs session, interrupt, and swarm status state" +)] +pub(super) async fn dispatch_swarm_await_completion( + event: &crate::bus::SwarmAwaitCompleted, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>, + event_counter: &Arc<AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + if event.notify + && fanout_session_event( + swarm_members, + &event.session_id, + ServerEvent::Notification { + from_session: "swarm".to_string(), + from_name: Some("swarm await".to_string()), + notification_type: NotificationType::Message { + scope: Some("swarm_await".to_string()), + channel: None, + tldr: None, + }, + message: event.notification.clone(), + }, + ) + .await + == 0 + { + crate::logging::warn(&format!( + "Failed to notify attached clients for swarm await completion on session {}", + event.session_id + )); + } + + if !event.wake { + return; + } + + if !run_live_turn_if_idle( + &event.session_id, + &event.notification, + Some( + "A swarm await you started just resolved. Review the result and continue if useful." + .to_string(), + ), + sessions, + LiveTurnSwarmContext::new( + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ), + ) + .await + && !queue_soft_interrupt_for_session( + &event.session_id, + event.notification.clone(), + false, + SoftInterruptSource::BackgroundTask, + soft_interrupt_queues, + sessions, + ) + .await + { + crate::logging::warn(&format!( + "Failed to deliver swarm await completion to session {}", + event.session_id + )); + } +} + +pub(super) async fn dispatch_background_task_progress( + task: &crate::bus::BackgroundTaskProgressEvent, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) { + let notification = format_background_task_progress_markdown(task); + if 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, + }, + ) + .await + == 0 + { + crate::logging::warn(&format!( + "Failed to notify attached clients for background task progress on session {}", + task.session_id + )); + } +} + +/// Update a swarm worker's cached output tail and rebroadcast swarm status so +/// the coordinator's inline gallery can render the live viewport. The tail is +/// already capped by the producer; we only store and fan it out. +pub(super) async fn dispatch_swarm_output_tail( + tail: &crate::bus::SwarmOutputTail, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + let swarm_id = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(&tail.session_id) else { + return; + }; + member.output_tail = Some(tail.tail.clone()); + member.swarm_id.clone() + }; + if let Some(swarm_id) = swarm_id { + super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + } +} + +/// Update a swarm member's aggregate todo progress (completed/total) and a +/// compact snapshot of the items themselves from a `TodoUpdated` bus event, +/// then rebroadcast swarm status so coordinators see the counter move and the +/// focused inline panel can list what the agent is working through. Only the +/// counts and capped display essentials cross the swarm boundary. +pub(super) async fn dispatch_swarm_todo_progress( + event: &crate::bus::TodoEvent, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + let total = event.todos.len() as u32; + let completed = event + .todos + .iter() + .filter(|t| t.status == "completed") + .count() as u32; + let progress = if total == 0 { + None + } else { + Some((completed, total)) + }; + let mut items = compact_todo_items(&event.todos); + + let swarm_id = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(&event.session_id) else { + return; + }; + // Keep tool activity attached while the same todo remains active. A + // transition to a different active item starts a fresh intent history. + let old_active = member + .todo_items + .iter() + .find(|item| item.status == "in_progress"); + let new_active = items.iter_mut().find(|item| item.status == "in_progress"); + if let (Some(old), Some(new)) = (old_active, new_active) + && old.content == new.content + { + new.tool_intents = old.tool_intents.clone(); + } + if member.todo_progress == progress && member.todo_items == items { + return; // no change, skip the broadcast + } + member.todo_progress = progress; + member.todo_items = items; + member.swarm_id.clone() + }; + if let Some(swarm_id) = swarm_id { + super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + } +} + +/// Mirror a worker's three most recent agent-provided tool intents beneath its +/// active todo. Running/completed/error events update the same correlated row. +pub(super) async fn dispatch_swarm_tool_activity( + event: &crate::bus::ToolEvent, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + let swarm_id = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(&event.session_id) else { + return; + }; + if !update_active_todo_tool(&mut member.todo_items, event) { + return; + } + member.swarm_id.clone() + }; + + if let Some(swarm_id) = swarm_id { + super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + } +} + +pub(super) async fn dispatch_swarm_runtime_status( + event: &crate::bus::SubagentStatus, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + let Some(model) = event + .model + .as_ref() + .filter(|model| !model.trim().is_empty()) + else { + return; + }; + let swarm_id = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(&event.session_id) else { + return; + }; + if member.runtime.model.as_ref() == Some(model) { + return; + } + member.runtime.model = Some(model.clone()); + member.swarm_id.clone() + }; + if let Some(swarm_id) = swarm_id { + super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + } +} + +pub(super) async fn dispatch_swarm_batch_progress( + progress: &crate::bus::BatchProgress, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + if progress.total == 0 { + return; + } + let swarm_id = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(&progress.session_id) else { + return; + }; + if !update_active_todo_batch_progress(&mut member.todo_items, progress) { + return; + } + member.swarm_id.clone() + }; + if let Some(swarm_id) = swarm_id { + super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + } +} + +fn update_active_todo_batch_progress( + todo_items: &mut [crate::protocol::SwarmTodoItem], + progress: &crate::bus::BatchProgress, +) -> bool { + let Some(tool) = todo_items + .iter_mut() + .find(|todo| todo.status == "in_progress") + .and_then(|todo| { + todo.tool_intents + .iter_mut() + .find(|tool| tool.tool_call_id == progress.tool_call_id) + }) + else { + return false; + }; + let next = crate::protocol::SwarmToolProgress { + current: progress.completed as u64, + total: progress.total as u64, + unit: Some("tools".to_string()), + }; + if tool.progress.as_ref() == Some(&next) { + return false; + } + tool.progress = Some(next); + true +} + +fn update_active_todo_tool( + todo_items: &mut [crate::protocol::SwarmTodoItem], + event: &crate::bus::ToolEvent, +) -> bool { + let Some(intent) = event + .intent + .as_deref() + .map(str::trim) + .filter(|intent| !intent.is_empty()) + else { + return false; + }; + let Some(active) = todo_items + .iter_mut() + .find(|item| item.status == "in_progress") + else { + return false; + }; + + let status = event.status.as_str().to_string(); + if let Some(existing) = active + .tool_intents + .iter_mut() + .find(|tool| tool.tool_call_id == event.tool_call_id) + { + existing.tool_name = event.tool_name.clone(); + existing.intent = cap_chars(intent, SWARM_TOOL_INTENT_CAP); + existing.status = status; + } else { + active.tool_intents.push(crate::protocol::SwarmToolIntent { + tool_call_id: event.tool_call_id.clone(), + tool_name: event.tool_name.clone(), + intent: cap_chars(intent, SWARM_TOOL_INTENT_CAP), + status, + progress: None, + }); + if active.tool_intents.len() > SWARM_TOOL_INTENTS_CAP { + active + .tool_intents + .drain(..active.tool_intents.len() - SWARM_TOOL_INTENTS_CAP); + } + } + true +} + +/// Max todo entries mirrored across the swarm status boundary per member. +const SWARM_TODO_ITEMS_CAP: usize = 12; +/// Max characters per mirrored todo entry. +const SWARM_TODO_CONTENT_CAP: usize = 120; +const SWARM_TOOL_INTENTS_CAP: usize = 3; +const SWARM_TOOL_INTENT_CAP: usize = 120; + +/// Build the capped, display-only todo snapshot that crosses the swarm +/// boundary. Prefers showing the active window: everything from the first +/// non-completed item onward, then backfills with the most recent completed +/// items if there is room left in the cap. +fn compact_todo_items(todos: &[crate::todo::TodoItem]) -> Vec<crate::protocol::SwarmTodoItem> { + let first_open = todos + .iter() + .position(|t| t.status != "completed") + .unwrap_or_else(|| todos.len().saturating_sub(SWARM_TODO_ITEMS_CAP)); + // Show a little completed context above the active window when possible. + let start = first_open.saturating_sub(2); + todos + .iter() + .skip(start) + .take(SWARM_TODO_ITEMS_CAP) + .map(|t| crate::protocol::SwarmTodoItem { + content: cap_chars(&t.content, SWARM_TODO_CONTENT_CAP), + status: t.status.clone(), + tool_intents: Vec::new(), + }) + .collect() +} + +fn cap_chars(s: &str, cap: usize) -> String { + if s.chars().count() <= cap { + return s.to_string(); + } + let mut out: String = s.chars().take(cap.saturating_sub(1)).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bus::{BatchProgress, ToolEvent, ToolStatus}; + + fn tool(id: &str, intent: &str, status: ToolStatus) -> ToolEvent { + ToolEvent { + session_id: "worker".into(), + message_id: "message".into(), + tool_call_id: id.into(), + tool_name: "bash".into(), + status, + intent: Some(intent.into()), + title: None, + } + } + + #[test] + fn active_todo_keeps_last_three_tool_intents_and_updates_status_in_place() { + let mut items = vec![crate::protocol::SwarmTodoItem { + content: "test token refresh flow".into(), + status: "in_progress".into(), + tool_intents: Vec::new(), + }]; + + for id in ["one", "two", "three", "four"] { + assert!(update_active_todo_tool( + &mut items, + &tool(id, &format!("intent {id}"), ToolStatus::Running), + )); + } + let intents = &items[0].tool_intents; + assert_eq!(intents.len(), 3); + assert_eq!(intents[0].tool_call_id, "two"); + assert_eq!(intents[2].tool_call_id, "four"); + + assert!(update_active_todo_tool( + &mut items, + &tool("four", "intent four", ToolStatus::Completed), + )); + assert_eq!(items[0].tool_intents.len(), 3); + assert_eq!(items[0].tool_intents[2].status, "completed"); + } + + #[test] + fn tool_intent_is_ignored_without_an_active_todo() { + let mut items = vec![crate::protocol::SwarmTodoItem { + content: "done".into(), + status: "completed".into(), + tool_intents: Vec::new(), + }]; + assert!(!update_active_todo_tool( + &mut items, + &tool("one", "irrelevant", ToolStatus::Running), + )); + assert!(items[0].tool_intents.is_empty()); + } + + #[test] + fn batch_progress_updates_the_correlated_active_tool() { + let mut items = vec![crate::protocol::SwarmTodoItem { + content: "run targeted tests".into(), + status: "in_progress".into(), + tool_intents: vec![crate::protocol::SwarmToolIntent { + tool_call_id: "batch-1".into(), + tool_name: "batch".into(), + intent: "Run targeted authentication tests".into(), + status: "running".into(), + progress: None, + }], + }]; + let progress = BatchProgress { + session_id: "worker".into(), + tool_call_id: "batch-1".into(), + completed: 27, + total: 43, + last_completed: Some("read".into()), + running: Vec::new(), + subcalls: Vec::new(), + }; + + assert!(update_active_todo_batch_progress(&mut items, &progress)); + let captured = items[0].tool_intents[0] + .progress + .as_ref() + .expect("progress captured"); + assert_eq!((captured.current, captured.total), (27, 43)); + assert!(!update_active_todo_batch_progress(&mut items, &progress)); + } +} + +pub(super) async fn dispatch_ui_activity( + activity: &crate::bus::UiActivity, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) { + let Some(session_id) = activity.session_id.as_deref() else { + return; + }; + + if fanout_session_event( + swarm_members, + session_id, + ServerEvent::Notification { + from_session: "jcode".to_string(), + from_name: Some("Jcode".to_string()), + notification_type: NotificationType::Message { + scope: Some(activity.kind.scope().to_string()), + channel: None, + tldr: None, + }, + message: activity.message.clone(), + }, + ) + .await + == 0 + { + crate::logging::warn(&format!( + "Failed to notify attached clients for UI activity on session {}", + session_id + )); + } +} diff --git a/crates/jcode-app-core/src/server/client_actions.rs b/crates/jcode-app-core/src/server/client_actions.rs new file mode 100644 index 0000000..67dba99 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_actions.rs @@ -0,0 +1,1156 @@ +#![cfg_attr(test, allow(clippy::items_after_test_module))] + +use super::client_lifecycle::process_message_streaming_mpsc; +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, +}; +use crate::agent::Agent; +use crate::protocol::{FeatureToggle, NotificationType, ServerEvent}; +use crate::session::Session; +use crate::util::truncate_str; +use jcode_agent_runtime::{SoftInterruptSource, StreamError}; +use std::collections::{HashMap, HashSet}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Instant; +use tokio::process::Command; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +const INPUT_SHELL_MAX_OUTPUT_LEN: usize = 30_000; + +fn derive_subagent_description(prompt: &str) -> String { + let words: Vec<&str> = prompt.split_whitespace().take(4).collect(); + if words.is_empty() { + "Manual subagent".to_string() + } else { + words.join(" ") + } +} + +fn build_input_shell_command(command: &str) -> Command { + #[cfg(windows)] + { + let mut cmd = Command::new("cmd.exe"); + cmd.arg("/C").arg(command); + cmd + } + + #[cfg(not(windows))] + { + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(command); + cmd + } +} + +fn combine_input_shell_output(stdout: &[u8], stderr: &[u8]) -> (String, bool) { + let stdout = String::from_utf8_lossy(stdout); + let stderr = String::from_utf8_lossy(stderr); + let mut output = String::new(); + + if !stdout.is_empty() { + output.push_str(&stdout); + } + if !stderr.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("[stderr]\n"); + output.push_str(&stderr); + } + + let truncated = if output.len() > INPUT_SHELL_MAX_OUTPUT_LEN { + output = truncate_str(&output, INPUT_SHELL_MAX_OUTPUT_LEN).to_string(); + if !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("… output truncated"); + true + } else { + false + }; + + (output, truncated) +} + +pub(super) struct NotifySessionContext<'a> { + pub sessions: &'a SessionAgents, + pub soft_interrupt_queues: &'a SessionInterruptQueues, + pub client_connections: &'a Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + pub swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + pub swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub event_history: &'a Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + pub event_counter: &'a Arc<std::sync::atomic::AtomicU64>, + pub swarm_event_tx: &'a broadcast::Sender<SwarmEvent>, + pub client_event_tx: &'a mpsc::UnboundedSender<ServerEvent>, +} + +pub(super) async fn handle_notify_session( + id: u64, + session_id: String, + message: String, + ctx: NotifySessionContext<'_>, +) { + let target_has_client = { + let connections = ctx.client_connections.read().await; + connections + .values() + .any(|connection| connection.session_id == session_id) + }; + + let ran_immediately = if target_has_client { + super::live_turn::run_live_turn_if_idle( + &session_id, + &message, + None, + ctx.sessions, + super::live_turn::LiveTurnSwarmContext::new( + ctx.swarm_members, + ctx.swarms_by_id, + ctx.event_history, + ctx.event_counter, + ctx.swarm_event_tx, + ), + ) + .await + } else { + false + }; + + let notified = if ran_immediately { + false + } else { + let members = ctx.swarm_members.read().await; + if members.contains_key(&session_id) { + drop(members); + fanout_session_event( + ctx.swarm_members, + &session_id, + ServerEvent::Notification { + from_session: "schedule".to_string(), + from_name: Some("scheduled task".to_string()), + notification_type: NotificationType::Message { + scope: Some("scheduled".to_string()), + channel: None, + tldr: None, + }, + message: message.clone(), + }, + ) + .await + > 0 + } else { + false + } + }; + + let queued_interrupt = if ran_immediately { + false + } else { + queue_soft_interrupt_for_session( + &session_id, + message.clone(), + false, + SoftInterruptSource::System, + ctx.soft_interrupt_queues, + ctx.sessions, + ) + .await + }; + + if ran_immediately || notified || queued_interrupt { + let _ = ctx.client_event_tx.send(ServerEvent::Done { id }); + } else { + let _ = ctx.client_event_tx.send(ServerEvent::Error { + id, + message: format!("Session '{}' is not currently live", session_id), + retry_after_secs: None, + }); + } +} + +pub(super) fn handle_input_shell( + id: u64, + command: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let agent = Arc::clone(agent); + let tx = client_event_tx.clone(); + + tokio::spawn(async move { + let cwd = { + let agent_guard = agent.lock().await; + agent_guard.working_dir().map(|dir| dir.to_string()) + }; + + let started = Instant::now(); + let mut cmd = build_input_shell_command(&command); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(dir) = cwd.as_ref() { + cmd.current_dir(dir); + } + + let result = match cmd.output().await { + Ok(output) => { + let (combined_output, truncated) = + combine_input_shell_output(&output.stdout, &output.stderr); + crate::message::InputShellResult { + command, + cwd, + output: combined_output, + exit_code: output.status.code(), + duration_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + truncated, + failed_to_start: false, + } + } + Err(error) => crate::message::InputShellResult { + command, + cwd, + output: format!("Failed to run command: {}", error), + exit_code: None, + duration_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + truncated: false, + failed_to_start: true, + }, + }; + + let _ = tx.send(ServerEvent::InputShellResult { result }); + let _ = tx.send(ServerEvent::Done { id }); + }); +} + +pub(super) async fn handle_set_subagent_model( + id: u64, + model: Option<String>, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let mut agent_guard = agent.lock().await; + match agent_guard.set_subagent_model(model) { + Ok(()) => { + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + Err(error) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + } + } +} + +pub(super) fn handle_run_subagent( + id: u64, + prompt: String, + subagent_type: String, + model: Option<String>, + session_id: Option<String>, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let agent = Arc::clone(agent); + let tx = client_event_tx.clone(); + + tokio::spawn(async move { + let description = derive_subagent_description(&prompt); + let tool_call_id = crate::id::new_id("call"); + let tool_name = "subagent".to_string(); + let tool_input = serde_json::json!({ + "description": description, + "prompt": prompt, + "subagent_type": subagent_type, + "model": model, + "session_id": session_id, + "command": "/subagent", + }); + + let message_id = { + let mut agent_guard = agent.lock().await; + match agent_guard.add_manual_tool_use( + tool_call_id.clone(), + tool_name.clone(), + tool_input.clone(), + ) { + Ok(message_id) => message_id, + Err(error) => { + let _ = tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + return; + } + } + }; + + let _ = tx.send(ServerEvent::ToolStart { + id: tool_call_id.clone(), + name: tool_name.clone(), + }); + let _ = tx.send(ServerEvent::ToolInput { + delta: tool_input.to_string(), + }); + let _ = tx.send(ServerEvent::ToolExec { + id: tool_call_id.clone(), + name: tool_name.clone(), + }); + + let (registry, session_id, working_dir) = { + let agent_guard = agent.lock().await; + ( + agent_guard.registry(), + agent_guard.session_id().to_string(), + agent_guard.working_dir().map(std::path::PathBuf::from), + ) + }; + + let ctx = crate::tool::ToolContext { + session_id, + message_id, + tool_call_id: tool_call_id.clone(), + working_dir, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + let started = Instant::now(); + let tool_name_for_exec = tool_name.clone(); + let result = match tokio::spawn(async move { + registry.execute(&tool_name_for_exec, tool_input, ctx).await + }) + .await + { + Ok(result) => result, + Err(error) => Err(anyhow::anyhow!("Tool task panicked: {}", error)), + }; + let duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; + + match result { + Ok(output) => { + let output_text = output.output.clone(); + let _ = tx.send(ServerEvent::ToolDone { + id: tool_call_id.clone(), + name: tool_name, + output: output_text, + error: None, + }); + let persist = { + let mut agent_guard = agent.lock().await; + agent_guard.add_manual_tool_result(tool_call_id, output, duration_ms) + }; + if let Err(error) = persist { + let _ = tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + return; + } + let _ = tx.send(ServerEvent::Done { id }); + } + Err(error) => { + let error_msg = format!("Error: {}", error); + let _ = tx.send(ServerEvent::ToolDone { + id: tool_call_id.clone(), + name: tool_name, + output: error_msg.clone(), + error: Some(error_msg.clone()), + }); + let persist = { + let mut agent_guard = agent.lock().await; + agent_guard.add_manual_tool_error(tool_call_id, error_msg, duration_ms) + }; + if let Err(persist_error) = persist { + let _ = tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&persist_error), + retry_after_secs: None, + }); + return; + } + let _ = tx.send(ServerEvent::Done { id }); + } + } + }); +} + +#[expect( + clippy::too_many_arguments, + reason = "set feature mutates agent state, persistence, swarm/session metadata, and client notifications together" +)] +pub(super) async fn handle_set_feature( + id: u64, + feature: FeatureToggle, + enabled: bool, + agent: &Arc<Mutex<Agent>>, + client_session_id: &str, + _friendly_name: &Option<String>, + swarm_enabled: &mut bool, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + match feature { + FeatureToggle::Memory => { + let mut agent_guard = agent.lock().await; + agent_guard.set_memory_enabled(enabled); + drop(agent_guard); + if !enabled { + crate::memory::clear_pending_memory(client_session_id); + } + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "memory_feature_toggled", + if enabled { + "memory_feature_enabled" + } else { + "memory_feature_disabled" + }, + ) + .with_session_id(client_session_id.to_string()) + .force_attribution(), + ); + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + FeatureToggle::Autoreview => { + let mut agent_guard = agent.lock().await; + match agent_guard.set_autoreview_enabled(enabled) { + Ok(()) => { + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + Err(error) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + } + } + } + FeatureToggle::Autojudge => { + let mut agent_guard = agent.lock().await; + match agent_guard.set_autojudge_enabled(enabled) { + Ok(()) => { + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + Err(error) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + } + } + } + FeatureToggle::Swarm => { + if *swarm_enabled == enabled { + let _ = client_event_tx.send(ServerEvent::Done { id }); + return; + } + *swarm_enabled = enabled; + + let (old_swarm_id, working_dir) = { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(client_session_id) { + let old = member.swarm_id.clone(); + let wd = member.working_dir.clone(); + member.swarm_enabled = enabled; + if !enabled { + member.swarm_id = None; + member.role = "agent".to_string(); + } + (old, wd) + } else { + (None, None) + } + }; + + if let Some(ref old_id) = old_swarm_id { + remove_session_from_swarm( + client_session_id, + old_id, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + ) + .await; + remove_session_channel_subscriptions( + client_session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + } + + if enabled { + let new_swarm_id = swarm_id_for_dir(working_dir); + if let Some(ref id) = new_swarm_id { + { + let mut swarms = swarms_by_id.write().await; + swarms + .entry(id.clone()) + .or_insert_with(HashSet::new) + .insert(client_session_id.to_string()); + } + + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(client_session_id) { + member.swarm_id = Some(id.clone()); + member.role = "agent".to_string(); + } + } + + broadcast_swarm_status(id, swarm_members, swarms_by_id).await; + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(id, &swarm_state).await; + } else { + let _ = client_event_tx.send(ServerEvent::SwarmStatus { + members: Vec::new(), + }); + } + } else { + let _ = client_event_tx.send(ServerEvent::SwarmStatus { + members: Vec::new(), + }); + } + + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + } +} + +pub(super) async fn handle_rename_session( + id: u64, + title: Option<String>, + agent: &Arc<Mutex<Agent>>, + client_session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let started = Instant::now(); + let normalized_title = title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "rename_start".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ( + "title_chars", + normalized_title + .as_ref() + .map(|title| title.chars().count().to_string()) + .unwrap_or_else(|| "0".to_string()), + ), + ], + ); + + let (renamed_session_id, display_title) = { + let mut agent_guard = agent.lock().await; + match agent_guard.rename_session_title(normalized_title.clone()) { + Ok(display_title) => (agent_guard.session_id().to_string(), display_title), + Err(error) => { + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "rename_error".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("error", crate::util::format_error_chain(&error)), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + return; + } + } + }; + + crate::session_list_cache::invalidate(); + let event = ServerEvent::SessionRenamed { + session_id: renamed_session_id.clone(), + title: normalized_title, + display_title, + }; + let mut delivered = + fanout_session_event(swarm_members, &renamed_session_id, event.clone()).await; + if renamed_session_id != client_session_id { + delivered += fanout_session_event(swarm_members, client_session_id, event.clone()).await; + } + if delivered == 0 { + let _ = client_event_tx.send(event); + } + let _ = client_event_tx.send(ServerEvent::Done { id }); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "rename_done".to_string()), + ("request_id", id.to_string()), + ("session_id", renamed_session_id), + ("client_session_id", client_session_id.to_string()), + ("delivered", delivered.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); +} + +pub(super) async fn handle_trigger_memory_extraction( + id: u64, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let extraction = { + let agent_guard = agent.lock().await; + if !agent_guard.memory_enabled() { + None + } else { + let transcript = agent_guard.build_transcript_for_extraction(); + if transcript.len() < 200 { + None + } else { + Some(( + transcript, + agent_guard.session_id().to_string(), + agent_guard.working_dir().map(|dir| dir.to_string()), + )) + } + } + }; + + if let Some((transcript, session_id, working_dir)) = extraction { + crate::memory_agent::trigger_final_extraction_with_dir(transcript, session_id, working_dir); + } + + 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)?; + + let mut child = Session::create(Some(parent_session_id.to_string()), None); + child.replace_messages(parent.messages.clone()); + child.compaction = parent.compaction.clone(); + child.working_dir = parent.working_dir.clone(); + child.model = parent.model.clone(); + child.status = crate::session::SessionStatus::Closed; + // The parent agent keeps ownership of any in-flight request; tell the + // forked agent so it treats the next prompt as fresh work instead of + // continuing (and duplicating) the parent's current turn. + child.append_fork_notice(parent_session_id, parent.display_name()); + child.save()?; + + let name = child.display_name().to_string(); + Ok((child.id.clone(), name)) +} + +fn transfer_active_messages(session: &Session) -> Vec<crate::message::Message> { + let start = session + .compaction + .as_ref() + .map(|state| state.compacted_count.min(session.messages.len())) + .unwrap_or(0); + session.messages[start..] + .iter() + .map(crate::session::StoredMessage::to_message) + .collect() +} + +fn create_transfer_child_session( + parent_session_id: &str, + parent: &Session, + compaction: Option<crate::session::StoredCompactionState>, +) -> anyhow::Result<(String, String)> { + let todos = crate::todo::load_todos(parent_session_id).unwrap_or_default(); + let mut child = Session::create(Some(parent_session_id.to_string()), None); + child.messages.clear(); + child.compaction = compaction; + child.working_dir = parent.working_dir.clone(); + child.model = parent.model.clone(); + child.provider_key = parent.provider_key.clone(); + child.route_api_method = parent.route_api_method.clone(); + child.subagent_model = parent.subagent_model.clone(); + child.improve_mode = parent.improve_mode; + child.autoreview_enabled = parent.autoreview_enabled; + child.autojudge_enabled = parent.autojudge_enabled; + child.is_canary = parent.is_canary; + child.testing_build = parent.testing_build.clone(); + child.provider_session_id = None; + child.status = crate::session::SessionStatus::Closed; + child.save()?; + crate::todo::save_todos(&child.id, &todos)?; + Ok((child.id.clone(), child.display_name().to_string())) +} + +pub(super) async fn handle_split( + id: u64, + client_session_id: &str, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let started = Instant::now(); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "split_start".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ], + ); + let (new_session_id, new_session_name) = match clone_split_session(client_session_id) { + Ok(result) => result, + Err(e) => { + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "split_error".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("error", crate::util::format_error_chain(&e)), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to save split session: {e}"), + retry_after_secs: None, + }); + return; + } + }; + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "split_done".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("new_session_id", new_session_id.clone()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + + let _ = client_event_tx.send(ServerEvent::SplitResponse { + id, + new_session_id, + new_session_name, + }); +} + +pub(super) async fn handle_transfer( + id: u64, + client_session_id: &str, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let started = Instant::now(); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "transfer_start".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ], + ); + let parent = match Session::load(client_session_id) { + Ok(session) => session, + Err(error) => { + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "transfer_load_error".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("error", crate::util::format_error_chain(&error)), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to load session for transfer: {error}"), + retry_after_secs: None, + }); + return; + } + }; + + let provider = { + let agent_guard = agent.lock().await; + agent_guard.provider_fork() + }; + + let transfer_compaction = match crate::compaction::build_transfer_compaction_state( + provider, + transfer_active_messages(&parent), + parent.compaction.clone(), + ) + .await + { + Ok(compaction) => compaction, + Err(error) => { + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "transfer_compaction_error".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("error", crate::util::format_error_chain(&error)), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to compact session for transfer: {error}"), + retry_after_secs: None, + }); + return; + } + }; + + let (new_session_id, new_session_name) = + match create_transfer_child_session(client_session_id, &parent, transfer_compaction) { + Ok(result) => result, + Err(error) => { + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "transfer_create_error".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("error", crate::util::format_error_chain(&error)), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to create transfer session: {error}"), + retry_after_secs: None, + }); + return; + } + }; + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "transfer_done".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("new_session_id", new_session_id.clone()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + + let _ = client_event_tx.send(ServerEvent::SplitResponse { + id, + new_session_id, + new_session_name, + }); +} + +#[cfg(test)] +#[path = "client_actions_tests.rs"] +mod tests; + +/// Decide whether an idle live session still owes the model a continuation. +/// +/// This is the live-session analog of `restored_session_was_interrupted`: a +/// session "would continue if resumed" when it has a pending reload-recovery +/// directive, when it carries reload-interruption markers, or when its last +/// model-visible message is a user/tool turn the assistant never answered +/// (e.g. the turn errored or the process was interrupted mid-generation). +fn live_session_owes_continuation(agent: &Agent) -> bool { + // Never continue an empty/fresh session. + if agent.visible_conversation_message_count() == 0 { + return false; + } + + if super::reload_recovery::peek_for_session(agent.session_id()) + .ok() + .flatten() + .map(|record| record.status == super::reload_recovery::ReloadRecoveryStatus::Pending) + .unwrap_or(false) + { + return true; + } + + if super::client_session::session_was_interrupted_by_reload(agent) { + return true; + } + + matches!( + agent.last_visible_conversation_role(), + Some(crate::message::Role::User) + ) +} + +/// Continue every live, idle session that would auto-resume on a reload. +/// +/// This is the on-demand equivalent of the post-reload recovery sweep: it walks +/// the currently-live sessions, and for each idle one that still owes the model +/// a continuation, injects the standard "continue where you left off" reminder +/// so the session picks back up without the user having to open each one. +#[expect( + clippy::too_many_arguments, + reason = "resuming live sessions needs session, swarm membership, and status event state" +)] +pub(super) async fn handle_resume_all_sessions( + id: u64, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + // Snapshot live sessions (those with at least one live client attachment). + let live_session_ids: Vec<String> = { + let members = swarm_members.read().await; + members + .iter() + .filter(|(_, member)| !member.event_txs.is_empty() || !member.event_tx.is_closed()) + .map(|(session_id, _)| session_id.clone()) + .collect() + }; + + let mut resumed_sessions: Vec<String> = Vec::new(); + let mut skipped = 0usize; + + for session_id in live_session_ids { + let agent = { + let guard = sessions.read().await; + guard.get(&session_id).cloned() + }; + let Some(agent) = agent else { + continue; + }; + + // Only act on idle sessions; a busy session is already making progress. + let Ok(agent_guard) = agent.try_lock() else { + skipped += 1; + continue; + }; + + if !live_session_owes_continuation(&agent_guard) { + drop(agent_guard); + skipped += 1; + continue; + } + + let reminder = match super::reload_recovery::pending_directive_for_session(&session_id) { + Ok(Some(directive)) => directive.continuation_message, + _ => crate::tool::selfdev::ReloadContext::interrupted_session_continuation_message(), + }; + let display_name = agent_guard + .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( + &session_id, + &reminder, + "resume_all_sessions", + ) { + crate::logging::warn(&format!( + "resume_all_sessions: failed to mark recovery intent delivered for {}: {}", + session_id, error + )); + } + + super::live_turn::spawn_tracked_live_turn( + &session_id, + Arc::clone(&agent), + String::new(), + Some(reminder), + Some("resuming interrupted session".to_string()), + super::live_turn::LiveTurnSwarmContext::new( + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ), + ) + .await; + + resumed_sessions.push(display_name); + } + + let resumed = resumed_sessions.len(); + let message = if resumed == 0 { + "No interrupted sessions to resume. All live sessions are idle or already complete." + .to_string() + } else if resumed == 1 { + format!("Resuming 1 interrupted session: {}.", resumed_sessions[0]) + } else { + format!( + "Resuming {} interrupted sessions: {}.", + resumed, + resumed_sessions.join(", ") + ) + }; + + crate::logging::info(&format!( + "resume_all_sessions: resumed={} skipped={} sessions={:?}", + resumed, skipped, resumed_sessions + )); + + let _ = client_event_tx.send(ServerEvent::ResumeAllResult { + id, + resumed, + skipped, + resumed_sessions, + message, + }); +} + +pub(super) fn handle_compact( + id: u64, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let agent = Arc::clone(agent); + let tx = client_event_tx.clone(); + tokio::spawn(async move { + let mut agent_guard = agent.lock().await; + let session_id = agent_guard.session_id().to_string(); + let (message, success) = agent_guard.request_manual_compaction(); + drop(agent_guard); + + if success { + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "manual_compaction_requested", + "manual_compaction_started", + ) + .with_session_id(session_id) + .force_attribution(), + ); + } + + let result = ServerEvent::CompactResult { + id, + message, + success, + }; + let _ = tx.send(result); + }); +} + +pub(super) async fn handle_stdin_response( + id: u64, + request_id: String, + input: String, + stdin_responses: &Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<String>>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if let Some(tx) = stdin_responses.lock().await.remove(&request_id) { + let _ = tx.send(input); + } + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +pub(super) struct AgentTaskContext<'a> { + pub(super) client_event_tx: &'a mpsc::UnboundedSender<ServerEvent>, + pub(super) swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + pub(super) swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub(super) event_history: &'a Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + pub(super) event_counter: &'a Arc<std::sync::atomic::AtomicU64>, + pub(super) swarm_event_tx: &'a broadcast::Sender<SwarmEvent>, +} + +pub(super) async fn handle_agent_task( + id: u64, + task: String, + client_session_id: &str, + agent: &Arc<Mutex<Agent>>, + ctx: &AgentTaskContext<'_>, +) { + update_member_status( + client_session_id, + "running", + Some(truncate_detail(&task, 120)), + ctx.swarm_members, + ctx.swarms_by_id, + Some(ctx.event_history), + Some(ctx.event_counter), + Some(ctx.swarm_event_tx), + ) + .await; + + let result = process_message_streaming_mpsc( + Arc::clone(agent), + &task, + vec![], + None, + ctx.client_event_tx.clone(), + ) + .await; + match result { + Ok(()) => { + update_member_status( + client_session_id, + "completed", + None, + ctx.swarm_members, + ctx.swarms_by_id, + Some(ctx.event_history), + Some(ctx.event_counter), + Some(ctx.swarm_event_tx), + ) + .await; + let _ = ctx.client_event_tx.send(ServerEvent::Done { id }); + } + Err(e) => { + update_member_status( + client_session_id, + "failed", + Some(truncate_detail(&e.to_string(), 120)), + ctx.swarm_members, + ctx.swarms_by_id, + Some(ctx.event_history), + Some(ctx.event_counter), + Some(ctx.swarm_event_tx), + ) + .await; + let retry_after_secs = e + .downcast_ref::<StreamError>() + .and_then(|stream_error| stream_error.retry_after_secs); + let _ = ctx.client_event_tx.send(ServerEvent::Error { + id, + message: crate::util::format_error_chain(&e), + retry_after_secs, + }); + } + } +} diff --git a/crates/jcode-app-core/src/server/client_actions_tests.rs b/crates/jcode-app-core/src/server/client_actions_tests.rs new file mode 100644 index 0000000..6100880 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_actions_tests.rs @@ -0,0 +1,803 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::{ + NotifySessionContext, clone_split_session, handle_notify_session, handle_rename_session, + handle_resume_all_sessions, handle_set_feature, +}; +use crate::agent::Agent; +use crate::message::{ContentBlock, Message, Role, StreamEvent, ToolDefinition}; +use crate::protocol::{FeatureToggle, ServerEvent}; +use crate::provider::{EventStream, Provider}; +use crate::server::{ClientConnectionInfo, SwarmMember}; +use crate::tool::Registry; +use anyhow::Result; +use async_stream::stream; +use async_trait::async_trait; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock, mpsc}; +use tokio::time::{Duration, timeout}; + +#[allow(clippy::type_complexity)] +fn empty_swarm_status_state() -> ( + Arc<RwLock<HashMap<String, std::collections::HashSet<String>>>>, + Arc<RwLock<std::collections::VecDeque<crate::server::SwarmEvent>>>, + Arc<std::sync::atomic::AtomicU64>, + tokio::sync::broadcast::Sender<crate::server::SwarmEvent>, +) { + let (swarm_event_tx, _) = tokio::sync::broadcast::channel(16); + ( + Arc::new(RwLock::new(HashMap::new())), + Arc::new(RwLock::new(std::collections::VecDeque::new())), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + swarm_event_tx, + ) +} + +struct MockProvider; + +#[derive(Clone, Default)] +struct StreamingMockProvider { + responses: Arc<StdMutex<VecDeque<Vec<StreamEvent>>>>, +} + +impl StreamingMockProvider { + fn queue_response(&self, events: Vec<StreamEvent>) { + self.responses.lock().unwrap().push_back(events); + } +} + +#[async_trait] +impl Provider for MockProvider { + async fn complete( + &self, + _messages: &[crate::message::Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "mock provider complete should not be called in client_actions tests" + )) + } + + fn name(&self) -> &str { + "mock" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(MockProvider) + } +} + +#[async_trait] +impl Provider for StreamingMockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + let events = self + .responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_default(); + let stream = stream! { + for event in events { + yield Ok(event); + } + }; + Ok(Box::pin(stream)) + } + + fn name(&self) -> &str { + "mock" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(self.clone()) + } +} + +#[test] +fn clone_split_session_uses_persisted_session_state() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let mut parent = crate::session::Session::create_with_id( + "session_parent_split_test".to_string(), + None, + None, + ); + parent.working_dir = Some("/tmp/jcode-split-test".to_string()); + parent.model = Some("gpt-test".to_string()); + parent.add_message( + Role::User, + vec![ContentBlock::Text { + text: "hello from parent".to_string(), + cache_control: None, + }], + ); + parent.compaction = Some(crate::session::StoredCompactionState { + summary_text: "summary".to_string(), + openai_encrypted_content: None, + covers_up_to_turn: 1, + original_turn_count: 1, + compacted_count: 1, + }); + parent.save().expect("save parent"); + + let (child_id, _child_name) = clone_split_session(&parent.id).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())); + assert_eq!( + child.messages.len(), + parent.messages.len() + 1, + "fork should inherit the transcript plus one fork notice" + ); + assert_eq!( + child.messages[0].content_preview(), + parent.messages[0].content_preview() + ); + let fork_notice = child.messages.last().expect("fork notice message"); + assert_eq!( + fork_notice.display_role, + Some(crate::session::StoredDisplayRole::System), + "fork notice must be hidden from the visible transcript" + ); + let fork_notice_text = fork_notice.content_preview(); + assert!( + fork_notice_text.contains("forked") && fork_notice_text.contains(parent.id.as_str()), + "fork notice should mention the parent session: {fork_notice_text}" + ); + assert_eq!(child.compaction, parent.compaction); + assert_eq!(child.working_dir, parent.working_dir); + assert_eq!(child.model, parent.model); + assert_eq!(child.status, crate::session::SessionStatus::Closed); + assert_ne!(child.id, parent.id); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +async fn enabling_swarm_does_not_auto_elect_coordinator() { + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + let (member_event_tx, _member_event_rx) = mpsc::unbounded_channel(); + let now = Instant::now(); + let session_id = "session_test_swarm_toggle"; + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + session_id.to_string(), + crate::server::SwarmMember { + session_id: session_id.to_string(), + event_tx: member_event_tx, + event_txs: HashMap::new(), + working_dir: Some(PathBuf::from("/tmp/jcode-passive-swarm")), + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("duck".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + )]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::new())); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + let mut swarm_enabled = false; + + handle_set_feature( + 42, + FeatureToggle::Swarm, + true, + &agent, + session_id, + &Some("duck".to_string()), + &mut swarm_enabled, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &client_event_tx, + ) + .await; + + assert!(swarm_enabled); + assert!(swarm_coordinators.read().await.is_empty()); + assert_eq!( + swarm_members + .read() + .await + .get(session_id) + .and_then(|member| member.swarm_id.clone()) + .as_deref(), + Some("/tmp/jcode-passive-swarm") + ); + assert_eq!( + swarm_members + .read() + .await + .get(session_id) + .map(|member| member.role.as_str()), + Some("agent") + ); + + let events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect(); + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id: 42 })) + ); + assert!(events.iter().all(|event| { + !matches!( + event, + ServerEvent::Notification { message, .. } + if message == "You are the coordinator for this swarm." + ) + })); +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn rename_session_event_uses_agent_session_id_even_when_client_id_is_stale() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + let agent_session_id = agent.lock().await.session_id().to_string(); + let stale_client_session_id = "session_stale_client_id"; + let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel(); + let now = Instant::now(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + stale_client_session_id.to_string(), + SwarmMember { + session_id: stale_client_session_id.to_string(), + event_tx: member_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("stale".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + handle_rename_session( + 99, + Some("Release planning".to_string()), + &agent, + stale_client_session_id, + &swarm_members, + &client_event_tx, + ) + .await; + + let rename_event = timeout(Duration::from_secs(2), member_event_rx.recv()) + .await + .expect("rename event should arrive") + .expect("member event channel should stay open"); + match rename_event { + ServerEvent::SessionRenamed { + session_id, + title, + display_title, + } => { + assert_eq!(session_id, agent_session_id); + assert_eq!(title.as_deref(), Some("Release planning")); + assert_eq!(display_title, "Release planning"); + } + other => panic!("expected SessionRenamed, got {other:?}"), + } + + let client_events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect(); + assert!( + client_events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 99)) + ); + let loaded = crate::session::Session::load(&agent_session_id).expect("renamed session saved"); + assert_eq!(loaded.custom_title.as_deref(), Some("Release planning")); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +async fn notify_session_runs_scheduled_task_immediately_for_idle_live_session() { + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("Working on scheduled task.".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider_dyn: Arc<dyn Provider> = provider.clone(); + let registry = Registry::new(provider_dyn.clone()).await; + let agent = Arc::new(Mutex::new(Agent::new(provider_dyn, registry))); + let session_id = agent.lock().await.session_id().to_string(); + let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([( + session_id.clone(), + agent.clone(), + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "client-1".to_string(), + ClientConnectionInfo { + client_id: "client-1".to_string(), + session_id: session_id.clone(), + client_instance_id: None, + debug_client_id: Some("debug-1".to_string()), + connected_at: Instant::now(), + last_seen: Instant::now(), + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + SwarmMember { + session_id: session_id.clone(), + event_tx: member_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("otter".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(), + }, + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state(); + handle_notify_session( + 77, + session_id.clone(), + "[Scheduled task]\nTask: Follow up".to_string(), + NotifySessionContext { + sessions: &sessions, + soft_interrupt_queues: &soft_interrupt_queues, + client_connections: &client_connections, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + swarm_event_tx: &swarm_event_tx, + client_event_tx: &client_event_tx, + }, + ) + .await; + + let streamed_event = timeout(Duration::from_secs(2), async { + loop { + match member_event_rx.recv().await { + Some(ServerEvent::TextDelta { text }) + if text.contains("Working on scheduled task.") => + { + return text; + } + Some(_) => continue, + None => panic!("live member stream closed before scheduled task ran"), + } + } + }) + .await + .expect("scheduled task should start streaming promptly"); + assert!(streamed_event.contains("Working on scheduled task.")); + + let client_events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect(); + assert!( + client_events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 77)) + ); + + let guard = agent.lock().await; + assert!(guard.messages().iter().any(|message| { + message.role == Role::User + && message + .content_preview() + .contains("[Scheduled task] Task: Follow up") + })); + assert!(guard.messages().iter().any(|message| { + message.role == Role::Assistant + && message + .content_preview() + .contains("Working on scheduled task.") + })); +} + +#[tokio::test] +async fn notify_session_queues_soft_interrupt_when_live_session_is_busy() { + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + let session_id = agent.lock().await.session_id().to_string(); + let queue = agent.lock().await.soft_interrupt_queue(); + + let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([( + session_id.clone(), + agent.clone(), + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + queue.clone(), + )]))); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "client-1".to_string(), + ClientConnectionInfo { + client_id: "client-1".to_string(), + session_id: session_id.clone(), + client_instance_id: None, + debug_client_id: Some("debug-1".to_string()), + connected_at: Instant::now(), + last_seen: Instant::now(), + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + SwarmMember { + session_id: session_id.clone(), + event_tx: member_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "running".to_string(), + detail: None, + task_label: None, + friendly_name: Some("otter".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(), + }, + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let _busy_guard = agent.lock().await; + + let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state(); + handle_notify_session( + 88, + session_id.clone(), + "[Scheduled task]\nTask: Follow up while busy".to_string(), + NotifySessionContext { + sessions: &sessions, + soft_interrupt_queues: &soft_interrupt_queues, + client_connections: &client_connections, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + swarm_event_tx: &swarm_event_tx, + client_event_tx: &client_event_tx, + }, + ) + .await; + + let member_event = timeout(Duration::from_secs(2), member_event_rx.recv()) + .await + .expect("notification should arrive promptly") + .expect("live member should receive notification"); + match member_event { + ServerEvent::Notification { + from_session, + from_name, + message, + .. + } => { + assert_eq!(from_session, "schedule"); + assert_eq!(from_name.as_deref(), Some("scheduled task")); + assert!(message.contains("Task: Follow up while busy")); + } + other => panic!("expected notification event, got {other:?}"), + } + + let queued = queue.lock().unwrap(); + assert_eq!( + queued.len(), + 1, + "scheduled task should queue as soft interrupt" + ); + assert!(queued[0].content.contains("Task: Follow up while busy")); + drop(queued); + + let client_events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect(); + assert!( + client_events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 88)) + ); +} + +/// Build a live SwarmMember with a real client attachment so the resume-all +/// sweep treats it as live. Returns the member and the receiver for events +/// fanned out to that attachment. +fn live_member(session_id: &str) -> (SwarmMember, mpsc::UnboundedReceiver<ServerEvent>) { + let (attach_tx, attach_rx) = mpsc::unbounded_channel(); + let member = SwarmMember { + session_id: session_id.to_string(), + event_tx: mpsc::unbounded_channel().0, + event_txs: HashMap::from([("client-1".to_string(), attach_tx)]), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("otter".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(), + }; + (member, attach_rx) +} + +#[tokio::test] +async fn resume_all_continues_interrupted_idle_live_session() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("Continuing where I left off.".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider_dyn: Arc<dyn Provider> = provider.clone(); + let registry = Registry::new(provider_dyn.clone()).await; + let agent = Arc::new(Mutex::new(Agent::new(provider_dyn, registry))); + let session_id = { + let mut guard = agent.lock().await; + // Leave the session with a pending user turn the assistant never answered + // (simulating a turn that errored / was interrupted mid-generation). + guard.add_message( + Role::User, + vec![ContentBlock::Text { + text: "please keep going on the refactor".to_string(), + cache_control: None, + }], + ); + guard.session_id().to_string() + }; + + let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([( + session_id.clone(), + agent.clone(), + )]))); + let (member, mut attach_rx) = live_member(&session_id); + let swarm_members = Arc::new(RwLock::new(HashMap::from([(session_id.clone(), member)]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state(); + handle_resume_all_sessions( + 91, + &sessions, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + &client_event_tx, + ) + .await; + + // The session should resume and stream the continuation. + let streamed = timeout(Duration::from_secs(2), async { + loop { + match attach_rx.recv().await { + Some(ServerEvent::TextDelta { text }) + if text.contains("Continuing where I left off.") => + { + return text; + } + Some(_) => continue, + None => panic!("live attachment closed before continuation streamed"), + } + } + }) + .await + .expect("interrupted session should resume promptly"); + assert!(streamed.contains("Continuing where I left off.")); + + // The requesting client receives a summary describing one resumed session. + let result = timeout(Duration::from_secs(2), async { + loop { + match client_event_rx.recv().await { + Some(event @ ServerEvent::ResumeAllResult { .. }) => return event, + Some(_) => continue, + None => panic!("client channel closed before resume-all result"), + } + } + }) + .await + .expect("resume-all result should be emitted"); + match result { + ServerEvent::ResumeAllResult { + id, + resumed, + skipped, + .. + } => { + assert_eq!(id, 91); + assert_eq!(resumed, 1); + assert_eq!(skipped, 0); + } + other => panic!("expected ResumeAllResult, got {other:?}"), + } + + if let Some(home) = prev_home { + crate::env::set_var("JCODE_HOME", home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +async fn resume_all_skips_session_with_completed_turn() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(Mutex::new(Agent::new(provider, registry))); + let session_id = { + let mut guard = agent.lock().await; + // A completed turn: last visible message is from the assistant. + guard.add_message( + Role::User, + vec![ContentBlock::Text { + text: "do the thing".to_string(), + cache_control: None, + }], + ); + guard.add_message( + Role::Assistant, + vec![ContentBlock::Text { + text: "done".to_string(), + cache_control: None, + }], + ); + guard.session_id().to_string() + }; + + let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([( + session_id.clone(), + agent.clone(), + )]))); + let (member, _attach_rx) = live_member(&session_id); + let swarm_members = Arc::new(RwLock::new(HashMap::from([(session_id.clone(), member)]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state(); + handle_resume_all_sessions( + 92, + &sessions, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + &client_event_tx, + ) + .await; + + let result = timeout(Duration::from_secs(2), async { + loop { + match client_event_rx.recv().await { + Some(event @ ServerEvent::ResumeAllResult { .. }) => return event, + Some(_) => continue, + None => panic!("client channel closed before resume-all result"), + } + } + }) + .await + .expect("resume-all result should be emitted"); + match result { + ServerEvent::ResumeAllResult { + id, + resumed, + skipped, + .. + } => { + assert_eq!(id, 92); + assert_eq!(resumed, 0); + assert_eq!(skipped, 1); + } + other => panic!("expected ResumeAllResult, got {other:?}"), + } + + if let Some(home) = prev_home { + crate::env::set_var("JCODE_HOME", home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} diff --git a/crates/jcode-app-core/src/server/client_api.rs b/crates/jcode-app-core/src/server/client_api.rs new file mode 100644 index 0000000..fc247f6 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_api.rs @@ -0,0 +1,340 @@ +use super::{connect_socket, debug_socket_path, socket_path}; +use crate::protocol::{HistoryMessage, Request, ServerEvent, TranscriptMode}; +use crate::transport::{ReadHalf, WriteHalf}; +use anyhow::Result; +use std::path::PathBuf; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Client for connecting to a running server +pub struct Client { + reader: BufReader<ReadHalf>, + writer: WriteHalf, + next_id: u64, +} + +impl Client { + pub async fn connect() -> Result<Self> { + Self::connect_with_path(socket_path()).await + } + + pub async fn connect_with_path(path: PathBuf) -> Result<Self> { + let stream = connect_socket(&path).await?; + let (reader, writer) = stream.into_split(); + Ok(Self { + reader: BufReader::new(reader), + writer, + next_id: 1, + }) + } + + pub async fn connect_debug() -> Result<Self> { + Self::connect_debug_with_path(debug_socket_path()).await + } + + pub async fn connect_debug_with_path(path: PathBuf) -> Result<Self> { + let stream = connect_socket(&path).await?; + let (reader, writer) = stream.into_split(); + Ok(Self { + reader: BufReader::new(reader), + writer, + next_id: 1, + }) + } + + /// Send a message and return immediately (events come via read_event) + pub async fn send_message(&mut self, content: &str) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::Message { + id, + content: content.to_string(), + images: vec![], + system_reminder: None, + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + /// Subscribe to events + pub async fn subscribe(&mut self) -> Result<u64> { + self.subscribe_with_info(None, None, None, false, false) + .await + } + + pub async fn subscribe_with_info( + &mut self, + working_dir: Option<String>, + selfdev: Option<bool>, + target_session_id: Option<String>, + client_has_local_history: bool, + allow_session_takeover: bool, + ) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + let working_dir = match working_dir { + Some(working_dir) => working_dir, + None => std::env::current_dir()?.to_string_lossy().into_owned(), + }; + + let request = Request::Subscribe { + id, + working_dir: Some(working_dir), + selfdev, + target_session_id, + client_instance_id: None, + client_has_local_history, + allow_session_takeover, + terminal_env: crate::terminal_launch::snapshot_client_terminal_env(), + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + /// Read the next event from the server + pub async fn read_event(&mut self) -> Result<ServerEvent> { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + if n == 0 { + anyhow::bail!("Server disconnected"); + } + let event: ServerEvent = serde_json::from_str(&line)?; + Ok(event) + } + + pub async fn ping(&mut self) -> Result<bool> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::Ping { id }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + + loop { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + if n == 0 { + anyhow::bail!("Server disconnected"); + } + let event: ServerEvent = serde_json::from_str(&line)?; + + match event { + 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), + } + } + } + + pub async fn get_state(&mut self) -> Result<ServerEvent> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::GetState { id }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + if n == 0 { + anyhow::bail!("Server disconnected"); + } + let event: ServerEvent = serde_json::from_str(&line)?; + Ok(event) + } + + pub async fn clear(&mut self) -> Result<()> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::Clear { id }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(()) + } + + pub async fn get_history(&mut self) -> Result<Vec<HistoryMessage>> { + let event = self.get_history_event().await?; + match event { + ServerEvent::History { messages, .. } => Ok(messages), + _ => Ok(Vec::new()), + } + } + + pub async fn get_history_event(&mut self) -> Result<ServerEvent> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::GetHistory { id }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + for _ in 0..10 { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + if n == 0 { + anyhow::bail!("Server disconnected"); + } + let event: ServerEvent = serde_json::from_str(&line)?; + match event { + ServerEvent::Ack { .. } => continue, + _ => return Ok(event), + } + } + + Ok(ServerEvent::Error { + id, + message: "History response not received".to_string(), + retry_after_secs: None, + }) + } + + pub async fn resume_session(&mut self, session_id: &str) -> Result<u64> { + self.resume_session_with_options(session_id, false, false) + .await + } + + pub async fn resume_session_with_options( + &mut self, + session_id: &str, + client_has_local_history: bool, + allow_session_takeover: bool, + ) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::ResumeSession { + id, + session_id: session_id.to_string(), + client_instance_id: None, + client_has_local_history, + allow_session_takeover, + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn notify_session(&mut self, session_id: &str, message: &str) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::NotifySession { + id, + session_id: session_id.to_string(), + message: message.to_string(), + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + /// Ask the server to continue every live session that was interrupted and + /// would auto-resume on a reload. Returns the request id so callers can + /// correlate the `ResumeAllResult` event. + pub async fn resume_all_sessions(&mut self) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::ResumeAllSessions { id }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn send_transcript( + &mut self, + text: &str, + mode: TranscriptMode, + session_id: Option<String>, + ) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::Transcript { + id, + text: text.to_string(), + mode, + session_id, + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn reload(&mut self) -> Result<()> { + self.reload_with_force(true).await?; + Ok(()) + } + + /// Request a graceful, conditional reload: when `force` is false the server + /// only reloads if it has a strictly-newer reload candidate binary. Used by + /// `jcode server reload` so an upgrade is picked up without risking a + /// downgrade. Returns the request id so callers can correlate events. + pub async fn reload_with_force(&mut self, force: bool) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::Reload { id, force }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn cycle_model(&mut self, direction: i8) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::CycleModel { id, direction }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn refresh_models(&mut self) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::RefreshModels { id }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn notify_auth_changed(&mut self) -> Result<u64> { + self.notify_auth_changed_for_provider(None).await + } + + pub async fn notify_auth_changed_for_provider( + &mut self, + provider: Option<&str>, + ) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::NotifyAuthChanged { + id, + provider: provider.map(str::to_string), + auth: None, + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + pub async fn debug_command(&mut self, command: &str, session_id: Option<&str>) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + + let request = Request::DebugCommand { + id, + command: command.to_string(), + session_id: session_id.map(|s| s.to_string()), + }; + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } +} diff --git a/crates/jcode-app-core/src/server/client_comm.rs b/crates/jcode-app-core/src/server/client_comm.rs new file mode 100644 index 0000000..82a337f --- /dev/null +++ b/crates/jcode-app-core/src/server/client_comm.rs @@ -0,0 +1,12 @@ +pub(super) use super::client_comm_channels::{ + handle_comm_channel_members, handle_comm_list_channels, handle_comm_subscribe_channel, + handle_comm_unsubscribe_channel, +}; +pub(super) use super::client_comm_context::{ + handle_comm_list, handle_comm_read, handle_comm_share, +}; +pub(super) use super::client_comm_message::handle_comm_message; + +#[cfg(test)] +#[path = "client_comm_tests.rs"] +mod client_comm_tests; diff --git a/crates/jcode-app-core/src/server/client_comm_channels.rs b/crates/jcode-app-core/src/server/client_comm_channels.rs new file mode 100644 index 0000000..a29c3da --- /dev/null +++ b/crates/jcode-app-core/src/server/client_comm_channels.rs @@ -0,0 +1,283 @@ +use super::{ + SwarmEvent, SwarmEventType, SwarmMember, record_swarm_event, subscribe_session_to_channel, + unsubscribe_session_from_channel, +}; +use crate::protocol::{AgentInfo, ServerEvent, SwarmChannelInfo}; +use jcode_swarm_core::ChannelIndex; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{RwLock, broadcast, mpsc}; + +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +async fn swarm_id_for_session( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + let members = swarm_members.read().await; + members.get(session_id).and_then(|m| m.swarm_id.clone()) +} + +pub(super) async fn handle_comm_list_channels( + id: u64, + req_session_id: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + channel_subscriptions: &ChannelSubscriptions, +) { + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + let channels = { + let subs = channel_subscriptions.read().await; + let index = ChannelIndex { + by_swarm_channel: subs.clone(), + by_session: HashMap::new(), + }; + let mut channels: Vec<SwarmChannelInfo> = Vec::new(); + if let Some(swarm_channels) = index.by_swarm_channel.get(&swarm_id) { + for (channel, members) in swarm_channels { + channels.push(SwarmChannelInfo { + channel: channel.clone(), + member_count: members.len(), + }); + } + } + channels.sort_by(|left, right| left.channel.cmp(&right.channel)); + channels + }; + + let _ = client_event_tx.send(ServerEvent::CommChannels { id, channels }); + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(), + retry_after_secs: None, + }); + } +} + +pub(super) async fn handle_comm_channel_members( + id: u64, + req_session_id: String, + channel: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + channel_subscriptions: &ChannelSubscriptions, +) { + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + let member_ids: Vec<String> = { + let subs = channel_subscriptions.read().await; + let index = ChannelIndex { + by_swarm_channel: subs.clone(), + by_session: HashMap::new(), + }; + index.members(&swarm_id, &channel) + }; + + let members = swarm_members.read().await; + let entries: Vec<AgentInfo> = member_ids + .iter() + .filter_map(|sid: &String| { + members.get(sid).map(|member| AgentInfo { + session_id: sid.clone(), + friendly_name: member.friendly_name.clone(), + files_touched: Vec::new(), + status: Some(member.status.clone()), + detail: member.detail.clone(), + role: Some(member.role.clone()), + is_headless: Some(member.is_headless), + report_back_to_session_id: member.report_back_to_session_id.clone(), + latest_completion_report: member.latest_completion_report.clone(), + live_attachments: Some(member.event_txs.len()), + status_age_secs: Some(member.last_status_change.elapsed().as_secs()), + last_activity_age_secs: crate::session_metrics::last_activity_age_secs(sid), + ..Default::default() + }) + }) + .collect(); + + let _ = client_event_tx.send(ServerEvent::CommMembers { + id, + members: entries, + }); + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(), + retry_after_secs: None, + }); + } +} + +#[expect( + clippy::too_many_arguments, + reason = "channel subscribe updates membership, delivery, and swarm event history together" +)] +pub(super) async fn handle_comm_subscribe_channel( + id: u64, + req_session_id: String, + channel: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let started = std::time::Instant::now(); + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + crate::logging::event_info( + "COMM_LIFECYCLE", + vec![ + ("phase", "channel_subscribe_start".to_string()), + ("request_id", id.to_string()), + ("session_id", req_session_id.clone()), + ("swarm_id", swarm_id.clone()), + ("channel", channel.clone()), + ], + ); + subscribe_session_to_channel( + &req_session_id, + &swarm_id, + &channel, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + None, + Some(swarm_id.clone()), + SwarmEventType::Notification { + notification_type: "channel_subscribe".to_string(), + message: channel.clone(), + }, + ) + .await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); + crate::logging::event_info( + "COMM_LIFECYCLE", + vec![ + ("phase", "channel_subscribe_done".to_string()), + ("request_id", id.to_string()), + ("session_id", req_session_id), + ("swarm_id", swarm_id), + ("channel", channel), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + } else { + crate::logging::event_warn( + "COMM_LIFECYCLE", + vec![ + ("phase", "channel_subscribe_error".to_string()), + ("request_id", id.to_string()), + ("session_id", req_session_id), + ("channel", channel), + ("error", "not_in_swarm".to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + } +} + +#[expect( + clippy::too_many_arguments, + reason = "channel unsubscribe updates membership, delivery, and swarm event history together" +)] +pub(super) async fn handle_comm_unsubscribe_channel( + id: u64, + req_session_id: String, + channel: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let started = std::time::Instant::now(); + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + crate::logging::event_info( + "COMM_LIFECYCLE", + vec![ + ("phase", "channel_unsubscribe_start".to_string()), + ("request_id", id.to_string()), + ("session_id", req_session_id.clone()), + ("swarm_id", swarm_id.clone()), + ("channel", channel.clone()), + ], + ); + unsubscribe_session_from_channel( + &req_session_id, + &swarm_id, + &channel, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + None, + Some(swarm_id.clone()), + SwarmEventType::Notification { + notification_type: "channel_unsubscribe".to_string(), + message: channel.clone(), + }, + ) + .await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); + crate::logging::event_info( + "COMM_LIFECYCLE", + vec![ + ("phase", "channel_unsubscribe_done".to_string()), + ("request_id", id.to_string()), + ("session_id", req_session_id), + ("swarm_id", swarm_id), + ("channel", channel), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + } else { + crate::logging::event_warn( + "COMM_LIFECYCLE", + vec![ + ("phase", "channel_unsubscribe_error".to_string()), + ("request_id", id.to_string()), + ("session_id", req_session_id), + ("channel", channel), + ("error", "not_in_swarm".to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + } +} diff --git a/crates/jcode-app-core/src/server/client_comm_context.rs b/crates/jcode-app-core/src/server/client_comm_context.rs new file mode 100644 index 0000000..28719c8 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_comm_context.rs @@ -0,0 +1,336 @@ +use super::debug::ClientConnectionInfo; +use super::{ + FileTouchService, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, fanout_session_event, + record_swarm_event, +}; +use crate::protocol::{AgentInfo, ContextEntry, NotificationType, ServerEvent}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{RwLock, broadcast, mpsc}; + +async fn swarm_id_for_session( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + let members = swarm_members.read().await; + members.get(session_id).and_then(|m| m.swarm_id.clone()) +} + +async fn friendly_name_for_session( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + let members = swarm_members.read().await; + members + .get(session_id) + .and_then(|member| member.friendly_name.clone()) +} + +#[expect( + clippy::too_many_arguments, + reason = "comm share coordinates delivery state, sessions, swarm membership, and event fanout" +)] +pub(super) async fn handle_comm_share( + id: u64, + req_session_id: String, + key: String, + value: String, + append: bool, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + let friendly_name = friendly_name_for_session(&req_session_id, swarm_members).await; + + { + let mut ctx = shared_context.write().await; + let swarm_ctx = ctx.entry(swarm_id.clone()).or_insert_with(HashMap::new); + let now = Instant::now(); + let created_at = swarm_ctx.get(&key).map(|c| c.created_at).unwrap_or(now); + let stored_value = if append { + swarm_ctx + .get(&key) + .map(|existing| { + if existing.value.is_empty() { + value.clone() + } else { + format!("{}\n{}", existing.value, value) + } + }) + .unwrap_or_else(|| value.clone()) + } else { + value.clone() + }; + swarm_ctx.insert( + key.clone(), + SharedContext { + key: key.clone(), + value: stored_value.clone(), + from_session: req_session_id.clone(), + from_name: friendly_name.clone(), + created_at, + updated_at: now, + }, + ); + } + + let swarm_session_ids: Vec<String> = { + let swarms = swarms_by_id.read().await; + swarms + .get(&swarm_id) + .map(|sessions| sessions.iter().cloned().collect()) + .unwrap_or_default() + }; + + // Shared-context updates are subtree-scoped like broadcasts: notify only + // the sessions the writer (transitively) spawned, so a share cannot + // become a member-cap-sized notification storm. The coordinator keeps + // whole-swarm reach. Everyone can still `read` the key on demand. + // + // Compute the target set up front and drop the read guard before the + // fanout loop: `fanout_session_event` takes a write lock on members. + let notify_targets: Vec<String> = { + let members = swarm_members.read().await; + let sender_is_coordinator = members + .get(&req_session_id) + .is_some_and(|member| member.role == "coordinator"); + swarm_session_ids + .iter() + .filter(|sid| *sid != &req_session_id) + .filter(|sid| { + sender_is_coordinator + || super::swarm_is_self_or_ancestor(&members, &req_session_id, sid) + }) + .cloned() + .collect() + }; + for sid in ¬ify_targets { + { + let _ = fanout_session_event( + swarm_members, + sid, + ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: friendly_name.clone(), + notification_type: NotificationType::SharedContext { + key: key.clone(), + value: if append { + format!("(appended) {}", value) + } else { + value.clone() + }, + }, + message: if append { + format!("Appended shared context: {} += {}", key, value) + } else { + format!("Shared context: {} = {}", key, value) + }, + }, + ) + .await; + } + } + + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + friendly_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::ContextUpdate { + swarm_id: swarm_id.clone(), + key: key.clone(), + }, + ) + .await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(), + retry_after_secs: None, + }); + } +} + +pub(super) async fn handle_comm_read( + id: u64, + req_session_id: String, + key: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, +) { + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + let entries = if let Some(swarm_id) = swarm_id { + let ctx = shared_context.read().await; + if let Some(swarm_ctx) = ctx.get(&swarm_id) { + if let Some(k) = key { + swarm_ctx + .get(&k) + .map(|c| { + vec![ContextEntry { + key: c.key.clone(), + value: c.value.clone(), + from_session: c.from_session.clone(), + from_name: c.from_name.clone(), + }] + }) + .unwrap_or_default() + } else { + swarm_ctx + .values() + .map(|c| ContextEntry { + key: c.key.clone(), + value: c.value.clone(), + from_session: c.from_session.clone(), + from_name: c.from_name.clone(), + }) + .collect() + } + } else { + Vec::new() + } + } else { + Vec::new() + }; + + let _ = client_event_tx.send(ServerEvent::CommContext { id, entries }); +} + +#[expect( + clippy::too_many_arguments, + reason = "comm list joins swarm membership, file touches, live sessions, and connection activity" +)] +pub(super) async fn handle_comm_list( + id: u64, + req_session_id: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + file_touch: &FileTouchService, + sessions: &super::SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, +) { + let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + let swarm_session_ids: Vec<String> = { + let swarms = swarms_by_id.read().await; + swarms + .get(&swarm_id) + .map(|sessions| sessions.iter().cloned().collect()) + .unwrap_or_default() + }; + + // Snapshot the static member fields first, releasing the members lock + // before gathering per-session runtime extras (which briefly lock + // individual agents and read the connection map). + struct MemberStatic { + session_id: String, + friendly_name: Option<String>, + files: Vec<String>, + status: String, + detail: Option<String>, + task_label: Option<String>, + role: String, + is_headless: bool, + report_back_to_session_id: Option<String>, + latest_completion_report: Option<String>, + live_attachments: usize, + status_age_secs: u64, + } + + let statics: Vec<MemberStatic> = { + let members = swarm_members.read().await; + let touches = file_touch.reverse_snapshot().await; + swarm_session_ids + .iter() + .filter_map(|sid| { + members.get(sid).map(|member| { + let mut files: Vec<String> = touches + .get(sid) + .into_iter() + .flat_map(|paths| paths.iter()) + .map(|path| path.display().to_string()) + .collect(); + files.sort(); + MemberStatic { + session_id: sid.clone(), + friendly_name: member.friendly_name.clone(), + files, + status: member.status.clone(), + detail: member.detail.clone(), + task_label: member.task_label.clone(), + role: member.role.clone(), + is_headless: member.is_headless, + report_back_to_session_id: member.report_back_to_session_id.clone(), + latest_completion_report: member.latest_completion_report.clone(), + live_attachments: member.event_txs.len(), + status_age_secs: member.last_status_change.elapsed().as_secs(), + } + }) + }) + .collect() + }; + + let mut member_list: Vec<AgentInfo> = Vec::with_capacity(statics.len()); + for m in statics { + let extras = super::comm_sync::member_runtime_extras( + &m.session_id, + m.status == "running", + sessions, + client_connections, + ) + .await; + + member_list.push(AgentInfo { + session_id: m.session_id, + friendly_name: m.friendly_name, + files_touched: m.files, + status: Some(m.status), + detail: m.detail, + task_label: m.task_label, + role: Some(m.role), + is_headless: Some(m.is_headless), + report_back_to_session_id: m.report_back_to_session_id, + latest_completion_report: m.latest_completion_report, + live_attachments: Some(m.live_attachments), + status_age_secs: Some(m.status_age_secs), + last_activity_age_secs: extras.last_activity_age_secs, + activity: extras.activity, + provider_name: extras.provider_name, + provider_model: extras.provider_model, + turn_count: extras.turn_count, + recent_total_tokens: extras.recent_total_tokens, + recent_output_tokens: extras.recent_output_tokens, + recent_window_secs: extras.recent_window_secs, + cumulative_total_tokens: extras.cumulative_total_tokens, + todos_completed: extras.todos_completed, + todos_total: extras.todos_total, + }); + } + + let _ = client_event_tx.send(ServerEvent::CommMembers { + id, + members: member_list, + }); + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(), + retry_after_secs: None, + }); + } +} diff --git a/crates/jcode-app-core/src/server/client_comm_message.rs b/crates/jcode-app-core/src/server/client_comm_message.rs new file mode 100644 index 0000000..2ea0f35 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_comm_message.rs @@ -0,0 +1,422 @@ +use super::live_turn::{LiveTurnSwarmContext, run_live_turn_if_idle}; +use super::{ + ClientConnectionInfo, SessionInterruptQueues, SwarmEvent, SwarmEventType, SwarmMember, + fanout_session_event, queue_soft_interrupt_for_session, record_swarm_event, truncate_detail, +}; +use crate::agent::Agent; +use crate::protocol::{CommDeliveryMode, NotificationType, ServerEvent}; +use jcode_agent_runtime::SoftInterruptSource; +use jcode_swarm_core::ChannelIndex; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +async fn swarm_id_for_session( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + let members = swarm_members.read().await; + members.get(session_id).and_then(|m| m.swarm_id.clone()) +} + +async fn friendly_name_for_session( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + let members = swarm_members.read().await; + members + .get(session_id) + .and_then(|member| member.friendly_name.clone()) +} + +async fn resolve_dm_target_session( + target: &str, + swarm_session_ids: &[String], + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> anyhow::Result<String> { + if swarm_session_ids + .iter() + .any(|session_id| session_id == target) + { + return Ok(target.to_string()); + } + + let members = swarm_members.read().await; + let mut matches: Vec<(String, String)> = swarm_session_ids + .iter() + .filter_map(|session_id| { + let member = members.get(session_id)?; + member + .friendly_name + .as_deref() + .filter(|friendly_name| *friendly_name == target) + .map(|friendly_name| (session_id.clone(), friendly_name.to_string())) + }) + .collect(); + matches.sort_by(|(left_session, _), (right_session, _)| left_session.cmp(right_session)); + matches.dedup_by(|(left_session, _), (right_session, _)| left_session == right_session); + match matches.len() { + 1 => Ok(matches.remove(0).0), + 0 => Err(anyhow::anyhow!( + "Unknown target '{}' - use an exact session_id or a unique friendly name within the swarm.", + target + )), + _ => { + let match_list = matches + .iter() + .map(|(session_id, friendly_name)| format!("{} [{}]", friendly_name, session_id)) + .collect::<Vec<_>>() + .join(", "); + Err(anyhow::anyhow!( + "Friendly name '{}' is ambiguous in swarm. Use an exact session id instead. Matches: {}", + target, + match_list + )) + } + } +} + +fn resolve_comm_delivery_mode( + scope: &str, + delivery: Option<CommDeliveryMode>, + wake: Option<bool>, +) -> CommDeliveryMode { + if let Some(delivery) = delivery { + return delivery; + } + if wake.unwrap_or(false) { + return CommDeliveryMode::Wake; + } + match scope { + "dm" => CommDeliveryMode::Wake, + _ => CommDeliveryMode::Notify, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "comm message routes DM, channel, and broadcast delivery with session fanout state" +)] +pub(super) async fn handle_comm_message( + id: u64, + from_session: String, + message: String, + to_session: Option<String>, + channel: Option<String>, + delivery: Option<CommDeliveryMode>, + wake: Option<bool>, + tldr: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + channel_subscriptions: &ChannelSubscriptions, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + _client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, +) { + let started = std::time::Instant::now(); + crate::logging::event_info( + "COMM_LIFECYCLE", + vec![ + ("phase", "message_start".to_string()), + ("request_id", id.to_string()), + ("from_session", from_session.clone()), + ( + "to_session", + to_session.clone().unwrap_or_else(|| "none".to_string()), + ), + ( + "channel", + channel.clone().unwrap_or_else(|| "none".to_string()), + ), + ( + "delivery", + delivery + .map(|mode| format!("{:?}", mode)) + .unwrap_or_else(|| "default".to_string()), + ), + ("wake", wake.unwrap_or(false).to_string()), + ("message_chars", message.chars().count().to_string()), + ( + "tldr_chars", + tldr.as_deref() + .map(|t| t.chars().count()) + .unwrap_or(0) + .to_string(), + ), + ], + ); + let swarm_id = swarm_id_for_session(&from_session, swarm_members).await; + + if let Some(swarm_id) = swarm_id { + let friendly_name = friendly_name_for_session(&from_session, swarm_members).await; + + let swarm_session_ids: Vec<String> = { + let swarms = swarms_by_id.read().await; + swarms + .get(&swarm_id) + .map(|sessions| sessions.iter().cloned().collect()) + .unwrap_or_default() + }; + + let resolved_to_session = if let Some(ref target) = to_session { + match resolve_dm_target_session(target, &swarm_session_ids, swarm_members).await { + Ok(session_id) => Some(session_id), + Err(message) => { + crate::logging::event_warn( + "COMM_LIFECYCLE", + vec![ + ("phase", "message_resolve_error".to_string()), + ("request_id", id.to_string()), + ("from_session", from_session.clone()), + ("target", target.clone()), + ("error", message.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: message.to_string(), + retry_after_secs: None, + }); + return; + } + } + } else { + None + }; + + if let Some(ref target) = resolved_to_session + && !swarm_session_ids.contains(target) + { + crate::logging::event_warn( + "COMM_LIFECYCLE", + vec![ + ("phase", "message_target_not_in_swarm".to_string()), + ("request_id", id.to_string()), + ("from_session", from_session.clone()), + ("target_session", target.clone()), + ("swarm_id", swarm_id.clone()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("DM failed: session '{}' not in swarm", target), + retry_after_secs: None, + }); + return; + } + + let scope = if resolved_to_session.is_some() { + "dm" + } else if channel.is_some() { + "channel" + } else { + "broadcast" + }; + + let known_member_ids: std::collections::HashSet<String> = { + let members = swarm_members.read().await; + members.keys().cloned().collect() + }; + + // Broadcast-style sends are subtree-scoped: a sender reaches only the + // agents it (transitively) spawned, via the report-back ancestry chain. + // The swarm coordinator keeps whole-swarm reach as an escape hatch. + // This prevents one agent from producing a member-cap-sized + // notification storm (see docs/SWARM_TASK_GRAPH.md section 8a). + let subtree_broadcast_targets: Vec<String> = { + let members = swarm_members.read().await; + let sender_is_coordinator = members + .get(&from_session) + .is_some_and(|member| member.role == "coordinator"); + swarm_session_ids + .iter() + .filter(|session_id| *session_id != &from_session) + .filter(|session_id| { + sender_is_coordinator + || super::swarm_is_self_or_ancestor(&members, &from_session, session_id) + }) + .cloned() + .collect() + }; + + let target_sessions: Vec<String> = if let Some(target) = resolved_to_session { + vec![target] + } else if let Some(ref channel_name) = channel { + let subs = channel_subscriptions.read().await; + let index = ChannelIndex { + by_swarm_channel: subs.clone(), + by_session: HashMap::new(), + }; + let channel_members = index.members(&swarm_id, channel_name); + if channel_members.is_empty() { + // No subscribers: fall back to the subtree scope rather than + // blasting the whole swarm. + subtree_broadcast_targets.clone() + } else { + channel_members + .into_iter() + .filter(|session_id| session_id != &from_session) + .collect() + } + } else { + subtree_broadcast_targets + }; + + let mut delivered_targets = 0usize; + for session_id in &target_sessions { + if !swarm_session_ids.contains(session_id) { + continue; + } + if known_member_ids.contains(session_id) { + let from_label = friendly_name + .clone() + .unwrap_or_else(|| from_session[..8.min(from_session.len())].to_string()); + let scope_label = match (scope, channel.as_deref()) { + ("channel", Some(channel_name)) => format!("#{}", channel_name), + ("dm", _) => "DM".to_string(), + _ => "broadcast".to_string(), + }; + let delivery_mode = resolve_comm_delivery_mode(scope, delivery, wake); + let notification_msg = format!("{} from {}: {}", scope_label, from_label, message); + let _ = fanout_session_event( + swarm_members, + session_id, + ServerEvent::Notification { + from_session: from_session.clone(), + from_name: friendly_name.clone(), + notification_type: NotificationType::Message { + scope: Some(scope.to_string()), + channel: channel.clone(), + tldr: tldr.clone(), + }, + message: notification_msg.clone(), + }, + ) + .await; + + let sender_name = friendly_name + .clone() + .unwrap_or_else(|| from_session.clone()); + let reminder = match scope { + "dm" => Some(format!( + "You just received a direct swarm message from {}. Review it and respond or act if useful.", + sender_name + )), + "channel" => Some(format!( + "You just received a swarm channel message in #{} from {}. Review it and respond or act if useful.", + channel.clone().unwrap_or_else(|| "channel".to_string()), + sender_name + )), + _ => Some(format!( + "You just received a swarm broadcast from {}. Review it and respond or act if useful.", + sender_name + )), + }; + + match delivery_mode { + CommDeliveryMode::Notify => {} + CommDeliveryMode::Interrupt => { + let _ = queue_soft_interrupt_for_session( + session_id, + notification_msg.clone(), + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + } + CommDeliveryMode::Wake => { + let woke_immediately = run_live_turn_if_idle( + session_id, + ¬ification_msg, + reminder, + sessions, + LiveTurnSwarmContext::new( + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ), + ) + .await; + + if !woke_immediately { + let _ = queue_soft_interrupt_for_session( + session_id, + notification_msg.clone(), + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + } + } + } + delivered_targets += 1; + } + } + + let scope_value = if scope == "channel" { + format!("#{}", channel.clone().unwrap_or_default()) + } else { + scope.to_string() + }; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + from_session.clone(), + friendly_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::Notification { + notification_type: scope_value, + message: truncate_detail(&message, 220), + }, + ) + .await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); + crate::logging::event_info( + "COMM_LIFECYCLE", + vec![ + ("phase", "message_done".to_string()), + ("request_id", id.to_string()), + ("from_session", from_session), + ("swarm_id", swarm_id), + ("scope", scope.to_string()), + ("channel", channel.unwrap_or_else(|| "none".to_string())), + ("target_count", target_sessions.len().to_string()), + ("delivered_targets", delivered_targets.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + } else { + crate::logging::event_warn( + "COMM_LIFECYCLE", + vec![ + ("phase", "message_error".to_string()), + ("request_id", id.to_string()), + ("from_session", from_session), + ("error", "not_in_swarm".to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(), + retry_after_secs: None, + }); + } +} diff --git a/crates/jcode-app-core/src/server/client_comm_tests.rs b/crates/jcode-app-core/src/server/client_comm_tests.rs new file mode 100644 index 0000000..78f524e --- /dev/null +++ b/crates/jcode-app-core/src/server/client_comm_tests.rs @@ -0,0 +1,903 @@ +use super::{handle_comm_list, handle_comm_message}; +use crate::agent::Agent; +use crate::message::{Message, ToolDefinition}; +use crate::protocol::{CommDeliveryMode, NotificationType, ServerEvent}; +use crate::provider::{EventStream, Provider}; +use crate::server::{ClientConnectionInfo, SessionInterruptQueues, SwarmEvent, SwarmMember}; +use crate::tool::Registry; +use anyhow::Result; +use async_trait::async_trait; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, atomic::AtomicU64}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +struct TestProvider; + +#[async_trait] +impl Provider for TestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "test provider complete should not be called in client_comm tests" + )) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(TestProvider) + } +} + +async fn test_agent() -> Arc<Mutex<Agent>> { + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + Arc::new(Mutex::new(Agent::new(provider, registry))) +} + +#[tokio::test] +async fn comm_message_default_does_not_queue_soft_interrupt_for_connected_session() { + let sender = test_agent().await; + let target = test_agent().await; + + let sender_id = sender.lock().await.session_id().to_string(); + let target_id = target.lock().await.session_id().to_string(); + let target_queue = target.lock().await.soft_interrupt_queue(); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (sender_id.clone(), sender.clone()), + (target_id.clone(), target.clone()), + ]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + + let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel(); + let (target_event_tx, mut target_event_rx) = mpsc::unbounded_channel(); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_id = "swarm-test".to_string(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + sender_id.clone(), + SwarmMember { + session_id: sender_id.clone(), + event_tx: sender_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("falcon".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "coordinator".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, + }, + ), + ( + target_id.clone(), + SwarmMember { + session_id: target_id.clone(), + event_tx: target_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("bear".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, + }, + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([sender_id.clone(), target_id.clone()]), + )]))); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashMap::from([( + "religion-debate".to_string(), + HashSet::from([target_id.clone()]), + )]), + )]))); + let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> = + Arc::new(RwLock::new(std::collections::VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(0)); + let (swarm_event_tx, _) = broadcast::channel(16); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "client-1".to_string(), + ClientConnectionInfo { + client_id: "client-1".to_string(), + session_id: target_id.clone(), + 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::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + + handle_comm_message( + 1, + sender_id.clone(), + "hello".to_string(), + None, + Some("religion-debate".to_string()), + None, + None, + None, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ) + .await; + + match target_event_rx.recv().await.expect("target notification") { + ServerEvent::Notification { + from_session, + from_name, + notification_type, + message, + } => { + assert_eq!(from_session, sender_id); + assert_eq!(from_name.as_deref(), Some("falcon")); + match notification_type { + NotificationType::Message { scope, channel, .. } => { + assert_eq!(scope.as_deref(), Some("channel")); + assert_eq!(channel.as_deref(), Some("religion-debate")); + } + other => panic!("unexpected notification type: {:?}", other), + } + assert_eq!(message, "#religion-debate from falcon: hello"); + } + other => panic!("unexpected event: {:?}", other), + } + + match client_event_rx.recv().await.expect("done event") { + ServerEvent::Done { id } => assert_eq!(id, 1), + other => panic!("unexpected client event: {:?}", other), + } + + let pending = target_queue.lock().expect("target queue lock"); + assert!( + pending.is_empty(), + "connected interactive session should not get synthetic user-message interrupt" + ); +} + +#[tokio::test] +async fn comm_message_with_wake_queues_soft_interrupt_for_busy_connected_session() { + let sender = test_agent().await; + let target = test_agent().await; + + let sender_id = sender.lock().await.session_id().to_string(); + let target_id = target.lock().await.session_id().to_string(); + let target_queue = target.lock().await.soft_interrupt_queue(); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (sender_id.clone(), sender.clone()), + (target_id.clone(), target.clone()), + ]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + crate::server::register_session_interrupt_queue( + &soft_interrupt_queues, + &target_id, + target_queue.clone(), + ) + .await; + + let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel(); + let (target_event_tx, mut target_event_rx) = mpsc::unbounded_channel(); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_id = "swarm-test".to_string(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + sender_id.clone(), + SwarmMember { + session_id: sender_id.clone(), + event_tx: sender_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("falcon".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "coordinator".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, + }, + ), + ( + target_id.clone(), + SwarmMember { + session_id: target_id.clone(), + event_tx: target_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("bear".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, + }, + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([sender_id.clone(), target_id.clone()]), + )]))); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); + let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> = + Arc::new(RwLock::new(std::collections::VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(0)); + let (swarm_event_tx, _) = broadcast::channel(16); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "client-1".to_string(), + ClientConnectionInfo { + client_id: "client-1".to_string(), + session_id: target_id.clone(), + 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::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + + let _busy_guard = target.lock().await; + + tokio::time::timeout( + Duration::from_secs(2), + handle_comm_message( + 1, + sender_id.clone(), + "hello now".to_string(), + Some(target_id.clone()), + None, + Some(CommDeliveryMode::Wake), + None, + None, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ), + ) + .await + .expect("comm message should not deadlock"); + + match target_event_rx.recv().await.expect("target notification") { + ServerEvent::Notification { + from_session, + from_name, + notification_type, + message, + } => { + assert_eq!(from_session, sender_id); + assert_eq!(from_name.as_deref(), Some("falcon")); + match notification_type { + NotificationType::Message { scope, channel, .. } => { + assert_eq!(scope.as_deref(), Some("dm")); + assert_eq!(channel, None); + } + other => panic!("unexpected notification type: {:?}", other), + } + assert_eq!(message, "DM from falcon: hello now"); + } + other => panic!("unexpected event: {:?}", other), + } + + match client_event_rx.recv().await.expect("done event") { + ServerEvent::Done { id } => assert_eq!(id, 1), + other => panic!("unexpected client event: {:?}", other), + } + + let pending = target_queue.lock().expect("target queue lock"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].content, "DM from falcon: hello now"); + assert_eq!( + pending[0].source, + jcode_agent_runtime::SoftInterruptSource::System + ); +} + +#[tokio::test] +async fn comm_list_includes_member_status_and_detail() { + let requester = test_agent().await; + let peer = test_agent().await; + + let requester_id = requester.lock().await.session_id().to_string(); + let peer_id = peer.lock().await.session_id().to_string(); + let swarm_id = "swarm-test".to_string(); + + let (requester_event_tx, _requester_event_rx) = mpsc::unbounded_channel(); + let (peer_event_tx, _peer_event_rx) = mpsc::unbounded_channel(); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + requester_id.clone(), + SwarmMember { + session_id: requester_id.clone(), + event_tx: requester_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("falcon".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "coordinator".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, + }, + ), + ( + peer_id.clone(), + SwarmMember { + session_id: peer_id.clone(), + event_tx: peer_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "running".to_string(), + detail: Some("working on tests".to_string()), + friendly_name: Some("bear".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, + }, + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id, + HashSet::from([requester_id.clone(), peer_id.clone()]), + )]))); + let file_touch = crate::server::FileTouchService::new(); + let sessions = Arc::new(RwLock::new(HashMap::from([ + (requester_id.clone(), requester.clone()), + (peer_id.clone(), peer.clone()), + ]))); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + + handle_comm_list( + 1, + requester_id, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &file_touch, + &sessions, + &client_connections, + ) + .await; + + match client_event_rx.recv().await.expect("comm list response") { + ServerEvent::CommMembers { id, members } => { + assert_eq!(id, 1); + let peer = members + .into_iter() + .find(|member| member.friendly_name.as_deref() == Some("bear")) + .expect("peer entry present"); + assert_eq!(peer.status.as_deref(), Some("running")); + assert_eq!(peer.detail.as_deref(), Some("working on tests")); + } + other => panic!("unexpected response: {other:?}"), + } +} + +#[tokio::test] +async fn comm_message_accepts_friendly_name_dm_target() { + let sender = test_agent().await; + let target = test_agent().await; + + let sender_id = sender.lock().await.session_id().to_string(); + let target_id = target.lock().await.session_id().to_string(); + let swarm_id = "swarm-test".to_string(); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (sender_id.clone(), sender.clone()), + (target_id.clone(), target.clone()), + ]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + + let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel(); + let (target_event_tx, mut target_event_rx) = mpsc::unbounded_channel(); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + sender_id.clone(), + SwarmMember { + session_id: sender_id.clone(), + event_tx: sender_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("falcon".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "coordinator".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, + }, + ), + ( + target_id.clone(), + SwarmMember { + session_id: target_id.clone(), + event_tx: target_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("bear".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, + }, + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([sender_id.clone(), target_id.clone()]), + )]))); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); + let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> = + Arc::new(RwLock::new(std::collections::VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(0)); + let (swarm_event_tx, _) = broadcast::channel(16); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + + handle_comm_message( + 1, + sender_id.clone(), + "hello bear".to_string(), + Some("bear".to_string()), + None, + Some(CommDeliveryMode::Notify), + None, + None, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ) + .await; + + match target_event_rx.recv().await.expect("target notification") { + ServerEvent::Notification { + from_session, + from_name, + notification_type, + message, + } => { + assert_eq!(from_session, sender_id); + assert_eq!(from_name.as_deref(), Some("falcon")); + match notification_type { + NotificationType::Message { scope, channel, .. } => { + assert_eq!(scope.as_deref(), Some("dm")); + assert_eq!(channel, None); + } + other => panic!("unexpected notification type: {:?}", other), + } + assert_eq!(message, "DM from falcon: hello bear"); + } + other => panic!("unexpected event: {:?}", other), + } + + match client_event_rx.recv().await.expect("done event") { + ServerEvent::Done { id } => assert_eq!(id, 1), + other => panic!("unexpected client event: {:?}", other), + } +} + +#[tokio::test] +async fn comm_message_rejects_ambiguous_friendly_name_dm_target() { + let sender = test_agent().await; + let target_one = test_agent().await; + let target_two = test_agent().await; + + let sender_id = sender.lock().await.session_id().to_string(); + let target_one_id = target_one.lock().await.session_id().to_string(); + let target_two_id = target_two.lock().await.session_id().to_string(); + let swarm_id = "swarm-test".to_string(); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (sender_id.clone(), sender.clone()), + (target_one_id.clone(), target_one.clone()), + (target_two_id.clone(), target_two.clone()), + ]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + + let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel(); + let (target_one_event_tx, _target_one_event_rx) = mpsc::unbounded_channel(); + let (target_two_event_tx, _target_two_event_rx) = mpsc::unbounded_channel(); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + sender_id.clone(), + SwarmMember { + session_id: sender_id.clone(), + event_tx: sender_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("falcon".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "coordinator".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, + }, + ), + ( + target_one_id.clone(), + SwarmMember { + session_id: target_one_id.clone(), + event_tx: target_one_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("bear".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, + }, + ), + ( + target_two_id.clone(), + SwarmMember { + session_id: target_two_id.clone(), + event_tx: target_two_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.clone()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("bear".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, + }, + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([ + sender_id.clone(), + target_one_id.clone(), + target_two_id.clone(), + ]), + )]))); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); + let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> = + Arc::new(RwLock::new(std::collections::VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(0)); + let (swarm_event_tx, _) = broadcast::channel(16); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + + handle_comm_message( + 1, + sender_id, + "hello bears".to_string(), + Some("bear".to_string()), + None, + None, + None, + None, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ) + .await; + + match client_event_rx.recv().await.expect("error event") { + ServerEvent::Error { id, message, .. } => { + assert_eq!(id, 1); + assert!(message.contains("ambiguous in swarm"), "{message}"); + assert!(message.contains("Use an exact session id"), "{message}"); + assert!(message.contains(&target_one_id), "{message}"); + assert!(message.contains(&target_two_id), "{message}"); + assert!(message.contains("bear ["), "{message}"); + } + other => panic!("unexpected client event: {:?}", other), + } +} + +/// Broadcasts are subtree-scoped: a non-coordinator sender reaches only the +/// agents it (transitively) spawned, never unrelated peers, while a +/// coordinator retains whole-swarm reach. +#[tokio::test] +async fn comm_broadcast_reaches_only_senders_spawned_subtree() { + fn member( + session_id: &str, + role: &str, + report_back_to: Option<&str>, + swarm_id: &str, + ) -> (SwarmMember, mpsc::UnboundedReceiver<ServerEvent>) { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + ( + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some(session_id.to_string()), + report_back_to_session_id: report_back_to.map(str::to_string), + latest_completion_report: None, + role: role.to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }, + event_rx, + ) + } + + let swarm_id = "swarm-subtree"; + // Tree: coord (coordinator, root) + // sender (root peer) -> child -> grandchild + // outsider (root peer, unrelated) + let (coord, mut coord_rx) = member("coord", "coordinator", None, swarm_id); + let (sender, _sender_rx) = member("sender", "agent", None, swarm_id); + let (child, mut child_rx) = member("child", "agent", Some("sender"), swarm_id); + let (grandchild, mut grandchild_rx) = member("grandchild", "agent", Some("child"), swarm_id); + let (outsider, mut outsider_rx) = member("outsider", "agent", None, swarm_id); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("coord".to_string(), coord), + ("sender".to_string(), sender), + ("child".to_string(), child), + ("grandchild".to_string(), grandchild), + ("outsider".to_string(), outsider), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + "coord".to_string(), + "sender".to_string(), + "child".to_string(), + "grandchild".to_string(), + "outsider".to_string(), + ]), + )]))); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); + let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> = + Arc::new(RwLock::new(std::collections::VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(0)); + let (swarm_event_tx, _) = broadcast::channel(16); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + handle_comm_message( + 1, + "sender".to_string(), + "subtree update".to_string(), + None, + None, + None, + None, + None, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ) + .await; + + match client_event_rx.recv().await.expect("done event") { + ServerEvent::Done { id } => assert_eq!(id, 1), + other => panic!("unexpected client event: {:?}", other), + } + + // Direct child and transitive grandchild both receive the broadcast. + assert!(matches!( + child_rx.try_recv(), + Ok(ServerEvent::Notification { .. }) + )); + assert!(matches!( + grandchild_rx.try_recv(), + Ok(ServerEvent::Notification { .. }) + )); + // Unrelated root peers and the coordinator do not. + assert!(outsider_rx.try_recv().is_err()); + assert!(coord_rx.try_recv().is_err()); + + // Coordinator broadcast still reaches the whole swarm. + handle_comm_message( + 2, + "coord".to_string(), + "swarm-wide notice".to_string(), + None, + None, + None, + None, + None, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ) + .await; + match client_event_rx.recv().await.expect("done event") { + ServerEvent::Done { id } => assert_eq!(id, 2), + other => panic!("unexpected client event: {:?}", other), + } + assert!(matches!( + outsider_rx.try_recv(), + Ok(ServerEvent::Notification { .. }) + )); + assert!(matches!( + child_rx.try_recv(), + Ok(ServerEvent::Notification { .. }) + )); +} diff --git a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs new file mode 100644 index 0000000..ea0b0ee --- /dev/null +++ b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs @@ -0,0 +1,319 @@ +use super::{ + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, + SwarmEventType, SwarmMember, VersionedPlan, record_swarm_event, remove_background_tool_signal, + remove_session_channel_subscriptions, remove_session_from_swarm, + remove_session_interrupt_queue, unregister_session_event_sender, update_member_status, +}; +use crate::agent::Agent; +use anyhow::Result; +use jcode_agent_runtime::InterruptSignal; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Mutex, RwLock, broadcast}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +const RELOAD_DISCONNECT_MARKER_MAX_AGE: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DisconnectDisposition { + Closed, + Crashed, + Reloading, +} + +fn disconnect_disposition(disconnected_while_processing: bool) -> DisconnectDisposition { + if !disconnected_while_processing { + return DisconnectDisposition::Closed; + } + + if crate::server::reload_marker_active(RELOAD_DISCONNECT_MARKER_MAX_AGE) { + DisconnectDisposition::Reloading + } else { + DisconnectDisposition::Crashed + } +} + +async fn session_has_live_successor( + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + session_id: &str, +) -> bool { + client_connections + .read() + .await + .values() + .any(|info| info.session_id == session_id) +} + +#[expect( + clippy::too_many_arguments, + reason = "disconnect cleanup updates sessions, swarms, files, channels, debug state, and shutdown signals together" +)] +pub(super) async fn cleanup_client_connection( + sessions: &SessionAgents, + client_session_id: &str, + client_is_processing: bool, + processing_task: &mut Option<tokio::task::JoinHandle<()>>, + event_handle: tokio::task::JoinHandle<()>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + client_debug_state: &Arc<RwLock<ClientDebugState>>, + client_debug_id: &str, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + client_connection_id: &str, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) -> 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 mut debug_state = client_debug_state.write().await; + debug_state.unregister(client_debug_id); + } + { + let mut connections = client_connections.write().await; + connections.remove(client_connection_id); + } + 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 { + crate::logging::info(&format!( + "Skipping destructive disconnect cleanup for {} because another client is still attached", + client_session_id + )); + event_handle.abort(); + return Ok(()); + } + + { + let mut sessions_guard = sessions.write().await; + if let Some(agent_arc) = sessions_guard.remove(client_session_id) { + drop(sessions_guard); + let lock_result = + tokio::time::timeout(std::time::Duration::from_secs(2), agent_arc.lock()).await; + + match lock_result { + Ok(mut agent) => { + match disposition { + DisconnectDisposition::Closed => { + agent.mark_closed(); + } + DisconnectDisposition::Reloading => { + agent.mark_crashed(Some( + "Server reload interrupted processing".to_string(), + )); + } + DisconnectDisposition::Crashed => { + agent.mark_crashed(Some( + "Client disconnected while processing".to_string(), + )); + } + } + + let memory_enabled = agent.memory_enabled(); + let transcript = if memory_enabled { + Some(agent.build_transcript_for_extraction()) + } else { + None + }; + let sid = client_session_id.to_string(); + let working_dir = agent.working_dir().map(|dir| dir.to_string()); + drop(agent); + let event = match disposition { + DisconnectDisposition::Closed => { + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "session_closed", + "client_disconnected", + ) + } + DisconnectDisposition::Crashed => { + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "session_crashed", + "client_disconnected_while_processing", + ) + } + DisconnectDisposition::Reloading => { + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "session_reloading", + "server_reload_disconnect", + ) + } + } + .with_session_id(sid.clone()) + .force_attribution(); + crate::runtime_memory_log::emit_event(event); + if let Some(transcript) = transcript { + crate::memory_agent::trigger_final_extraction_with_dir( + transcript, + sid, + working_dir, + ); + } + } + Err(_) => { + crate::logging::warn(&format!( + "Session {} cleanup timed out waiting for agent lock (stuck task); skipping graceful shutdown", + client_session_id + )); + } + } + } + } + + { + let (status, detail) = match disposition { + DisconnectDisposition::Closed => ("stopped", Some("disconnected".to_string())), + DisconnectDisposition::Crashed => { + ("crashed", Some("disconnect while running".to_string())) + } + DisconnectDisposition::Reloading => { + ("stopped", Some("server reload in progress".to_string())) + } + }; + update_member_status( + client_session_id, + status, + detail, + swarm_members, + swarms_by_id, + Some(event_history), + Some(event_counter), + Some(swarm_event_tx), + ) + .await; + + let (swarm_id, removed_name) = { + let mut members = swarm_members.write().await; + if let Some(member) = members.remove(client_session_id) { + (member.swarm_id, member.friendly_name) + } else { + (None, None) + } + }; + crate::session_metrics::forget(client_session_id); + crate::session_effort::forget_session_effort(client_session_id); + + if let Some(ref swarm_id) = swarm_id { + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + client_session_id.to_string(), + removed_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::MemberChange { + action: "left".to_string(), + }, + ) + .await; + remove_session_from_swarm( + client_session_id, + swarm_id, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + ) + .await; + } + remove_session_channel_subscriptions( + client_session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + file_touch.clear_session(client_session_id).await; + } + + { + let mut signals = shutdown_signals.write().await; + signals.remove(client_session_id); + } + 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(); + } + + event_handle.abort(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{DisconnectDisposition, disconnect_disposition}; + + #[test] + fn idle_disconnect_is_closed() { + assert_eq!(disconnect_disposition(false), DisconnectDisposition::Closed); + } + + #[test] + fn running_disconnect_without_reload_is_crash() { + let _guard = crate::storage::lock_test_env(); + crate::server::clear_reload_marker(); + assert_eq!(disconnect_disposition(true), DisconnectDisposition::Crashed); + } + + #[test] + fn running_disconnect_during_reload_is_expected() { + let _guard = crate::storage::lock_test_env(); + let runtime = tempfile::TempDir::new().expect("create runtime dir"); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + crate::server::clear_reload_marker(); + crate::server::write_reload_state( + "test-request", + "test-hash", + crate::server::ReloadPhase::Starting, + None, + ); + assert_eq!( + disconnect_disposition(true), + DisconnectDisposition::Reloading + ); + crate::server::clear_reload_marker(); + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + + #[test] + fn running_disconnect_during_recent_socket_ready_reload_is_expected() { + let _guard = crate::storage::lock_test_env(); + let runtime = tempfile::TempDir::new().expect("create runtime dir"); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + crate::server::clear_reload_marker(); + crate::server::write_reload_state( + "test-request", + "test-hash", + crate::server::ReloadPhase::SocketReady, + None, + ); + assert_eq!( + disconnect_disposition(true), + DisconnectDisposition::Reloading + ); + crate::server::clear_reload_marker(); + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs new file mode 100644 index 0000000..5ce12d9 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -0,0 +1,3157 @@ +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, + handle_set_subagent_model, handle_split, handle_stdin_response, handle_transfer, + handle_trigger_memory_extraction, +}; +use super::client_comm::{ + handle_comm_channel_members, handle_comm_list, handle_comm_list_channels, handle_comm_message, + 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_lifecycle_logging::{ + ServerRequestLifecycleFields, interrupt_request_log_fields, request_payload_summary, + request_type_from_line, request_type_is_read_only, server_request_lifecycle_fields, +}; +use super::client_lightweight_control::{ + LightweightControlContext, handle_lightweight_control_request, parse_swarm_spawn_mode, +}; +use super::client_session::{ + handle_clear_session, handle_reload, handle_resume_session, handle_subscribe, +}; +use super::client_state::{ + handle_get_compacted_history, handle_get_history, handle_get_model_catalog, handle_get_state, +}; +use super::client_writer::write_direct_event; +use super::comm_await::{CommAwaitMembersContext, handle_comm_await_members}; +use super::comm_control::{ + handle_client_debug_command, handle_client_debug_response, handle_comm_assign_next, + handle_comm_assign_role, handle_comm_assign_task, handle_comm_task_control, +}; +use super::comm_plan::{ + handle_comm_approve_plan, handle_comm_propose_plan, handle_comm_reject_plan, +}; +use super::comm_session::{handle_comm_spawn, handle_comm_stop}; +use super::comm_sync::{ + CommResyncPlanContext, handle_comm_plan_status, handle_comm_read_context, + handle_comm_resync_plan, handle_comm_status, handle_comm_summary, +}; +use super::provider_control::{ + handle_cycle_model, handle_notify_auth_changed, handle_refresh_models, + handle_set_compaction_mode, handle_set_model, handle_set_premium_mode, + handle_set_reasoning_effort, handle_set_route, handle_set_service_tier, handle_set_transport, + handle_switch_anthropic_account, handle_switch_openai_account, + try_available_models_updated_event, +}; +use super::{ + AwaitMembersRuntime, ClientConnectionInfo, ClientDebugState, FileTouchService, + SessionControlHandle, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, + SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, + register_session_interrupt_queue, send_swarm_plan_to_session, truncate_detail, + update_member_status, update_member_status_with_report, update_member_status_with_report_tldr, +}; +use crate::agent::Agent; +use crate::bus::{Bus, BusEvent}; +use crate::id; +use crate::protocol::{Request, ServerEvent, decode_request, encode_event}; +use crate::provider::Provider; +use crate::tool::Registry; +use crate::transport::Stream; +use anyhow::Result; +use futures::FutureExt; +use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource, StreamError}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; +const RELOAD_STARTING_GUARD_MAX_AGE: Duration = Duration::from_secs(30); +const REQUEST_HANDLER_STALL_THRESHOLDS_MS: [u64; 3] = [2_000, 10_000, 60_000]; + +fn required_subscribe_working_dir(working_dir: Option<&str>) -> std::result::Result<&str, String> { + let working_dir = working_dir + .map(str::trim) + .filter(|dir| !dir.is_empty()) + .ok_or_else(|| "Subscribe requires the client's working directory".to_string())?; + if !Path::new(working_dir).is_absolute() { + return Err("Subscribe working_dir must be an absolute path".to_string()); + } + Ok(working_dir) +} + +fn initial_subscribe_working_dir(request: &Request) -> std::result::Result<String, String> { + match request { + Request::Subscribe { working_dir, .. } => { + required_subscribe_working_dir(working_dir.as_deref()).map(str::to_string) + } + _ => Err( + "Client must Subscribe with a working_dir before sending stateful requests".to_string(), + ), + } +} + +struct ProcessingMessage { + id: u64, + content: String, + images: Vec<(String, String)>, + system_reminder: Option<String>, +} + +struct ProcessingState<'a> { + client_is_processing: &'a mut bool, + message_id: &'a mut Option<u64>, + session_id: &'a mut Option<String>, + task: &'a mut Option<tokio::task::JoinHandle<()>>, +} + +struct SwarmStatusRefs<'a> { + members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &'a Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &'a Arc<std::sync::atomic::AtomicU64>, + event_tx: &'a broadcast::Sender<SwarmEvent>, +} + +struct RequestHandlerWatchdog { + done: Arc<AtomicBool>, +} + +struct RequestHandlerWatchdogContext { + request_id: u64, + request_kind: String, + client_session_id: String, + client_connection_id: String, + client_instance_id: Option<String>, + client_is_processing: bool, + message_id: Option<u64>, + processing_session_id: Option<String>, + line_bytes: usize, + lifecycle_logged: bool, +} + +impl RequestHandlerWatchdog { + fn spawn(ctx: RequestHandlerWatchdogContext) -> Self { + let done = Arc::new(AtomicBool::new(false)); + let done_for_task = Arc::clone(&done); + tokio::spawn(async move { + let started = Instant::now(); + let mut previous_threshold = Duration::ZERO; + for threshold_ms in REQUEST_HANDLER_STALL_THRESHOLDS_MS { + let threshold = Duration::from_millis(threshold_ms); + tokio::time::sleep(threshold.saturating_sub(previous_threshold)).await; + previous_threshold = threshold; + if done_for_task.load(Ordering::Acquire) { + return; + } + crate::logging::event_warn( + "SERVER_REQUEST_HANDLER_STALLED", + vec![ + ("request_id", ctx.request_id.to_string()), + ("request_kind", ctx.request_kind.clone()), + ("session_id", ctx.client_session_id.clone()), + ("client_connection_id", ctx.client_connection_id.clone()), + ( + "client_instance_id", + ctx.client_instance_id + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ("client_processing", ctx.client_is_processing.to_string()), + ( + "message_id", + ctx.message_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "none".to_string()), + ), + ( + "processing_session_id", + ctx.processing_session_id + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ("line_bytes", ctx.line_bytes.to_string()), + ("lifecycle_logged", ctx.lifecycle_logged.to_string()), + ("threshold_ms", threshold_ms.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + } + }); + Self { done } + } +} + +impl Drop for RequestHandlerWatchdog { + fn drop(&mut self) { + self.done.store(true, Ordering::Release); + } +} + +fn log_request_lifecycle_handled( + fields: ServerRequestLifecycleFields<'_>, + request_lifecycle_start: Instant, + request_decoded_at: Instant, +) { + let mut fields = server_request_lifecycle_fields(fields); + fields.push(( + "handler_total_ms".to_string(), + request_lifecycle_start.elapsed().as_millis().to_string(), + )); + fields.push(( + "since_decode_ms".to_string(), + request_decoded_at.elapsed().as_millis().to_string(), + )); + crate::logging::event_info("SERVER_REQUEST_LIFECYCLE", fields); +} + +fn reject_if_agent_busy_for_request( + request_id: u64, + request_kind: &'static str, + client_session_id: &str, + client_is_processing: bool, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) -> bool { + if agent.try_lock().is_ok() { + return false; + } + + crate::logging::event_warn( + "SERVER_REQUEST_BUSY_AGENT_REJECTED", + vec![ + ("request_id", request_id.to_string()), + ("request_kind", request_kind.to_string()), + ("session_id", client_session_id.to_string()), + ("client_processing", client_is_processing.to_string()), + ("reason", "agent_busy".to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::Error { + id: request_id, + message: format!( + "Cannot handle {request_kind} while the session is busy. Try again after the current turn finishes." + ), + retry_after_secs: Some(1), + }); + true +} + +fn server_reload_starting() -> bool { + matches!( + crate::server::recent_reload_state(RELOAD_STARTING_GUARD_MAX_AGE), + Some(state) if state.phase == crate::server::ReloadPhase::Starting + ) +} + +fn compaction_server_event(event: crate::compaction::CompactionEvent) -> ServerEvent { + ServerEvent::Compaction { + trigger: event.trigger, + pre_tokens: event.pre_tokens, + post_tokens: event.post_tokens, + tokens_saved: event.tokens_saved, + duration_ms: event.duration_ms, + messages_dropped: event.messages_dropped, + messages_compacted: event.messages_compacted, + summary_chars: event.summary_chars, + active_messages: event.active_messages, + } +} + +async fn poll_agent_compaction_completion(agent: Arc<Mutex<Agent>>) -> Option<ServerEvent> { + let mut agent_guard = agent.lock().await; + agent_guard + .poll_compaction_completion_event() + .map(compaction_server_event) +} + +async fn refresh_session_control_handle( + session_id: &str, + agent: &Arc<Mutex<Agent>>, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, +) -> SessionControlHandle { + let started = Instant::now(); + let agent_guard = match agent.try_lock() { + Ok(agent_guard) => agent_guard, + Err(_) => { + crate::logging::warn(&format!( + "refresh_session_control_handle: waiting for busy agent lock for session {}; cancel/control requests on this connection may be delayed", + session_id + )); + let fallback_stop_signal = shutdown_signals.read().await.get(session_id).cloned(); + let fallback_soft_interrupt_queue = + soft_interrupt_queues.read().await.get(session_id).cloned(); + if let Some(soft_interrupt_queue) = fallback_soft_interrupt_queue { + // A missing shutdown-signal registration (e.g. a session created + // through the headless spawn path) must not force this connection + // to block on the busy agent mutex: cancels fired through the + // handle fan out to the running turn's own signal via the + // turn-cancel registry (issue #428), so a detached signal is a + // safe stand-in. + let stop_signal = fallback_stop_signal.unwrap_or_else(|| { + crate::logging::warn(&format!( + "refresh_session_control_handle: no registered shutdown signal for busy session {}; using detached signal (cancels reach the running turn via the turn cancel registry)", + session_id + )); + InterruptSignal::new() + }); + crate::logging::warn(&format!( + "refresh_session_control_handle: using lock-free cancel-only control handle for busy session {} after {}ms", + session_id, + started.elapsed().as_millis() + )); + return SessionControlHandle::cancel_only( + session_id, + soft_interrupt_queue, + stop_signal, + ); + } + let agent_guard = agent.lock().await; + crate::logging::warn(&format!( + "refresh_session_control_handle: acquired agent lock for session {} after {}ms", + session_id, + started.elapsed().as_millis() + )); + agent_guard + } + }; + SessionControlHandle::new( + session_id, + agent_guard.soft_interrupt_queue(), + agent_guard.background_tool_signal(), + agent_guard.graceful_shutdown_signal(), + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "client lifecycle wiring spans sessions, swarm state, file state, channels, debug, and runtime coordination" +)] +pub(super) async fn handle_client( + stream: Stream, + sessions: SessionAgents, + _global_event_tx: broadcast::Sender<ServerEvent>, + provider_template: Arc<dyn Provider>, + _global_is_processing: Arc<RwLock<bool>>, + global_session_id: Arc<RwLock<String>>, + client_count: Arc<RwLock<usize>>, + client_connections: Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + file_touch: FileTouchService, + channel_subscriptions: ChannelSubscriptions, + channel_subscriptions_by_session: ChannelSubscriptions, + client_debug_state: Arc<RwLock<ClientDebugState>>, + client_debug_response_tx: broadcast::Sender<(u64, String)>, + event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + server_name: String, + server_icon: String, + mcp_pool: Arc<crate::mcp::SharedMcpPool>, + shutdown_signals: Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: SessionInterruptQueues, + await_members_runtime: AwaitMembersRuntime, + swarm_mutation_runtime: SwarmMutationRuntime, +) -> Result<()> { + let (reader, writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let writer = Arc::new(Mutex::new(writer)); + let mut line = String::new(); + + let initial_request = loop { + line.clear(); + let n = match reader.read_line(&mut line).await { + Ok(n) => n, + Err(error) => { + crate::logging::error(&format!( + "Client read error before initialization: {}", + error + )); + return Ok(()); + } + }; + if n == 0 { + return Ok(()); + } + if line.trim().is_empty() { + continue; + } + + match decode_request(&line) { + Ok(request) => { + if request.is_lightweight_control_request() { + handle_lightweight_control_request( + request, + Arc::clone(&writer), + LightweightControlContext { + sessions: &sessions, + global_session_id: &global_session_id, + provider_template: &provider_template, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + shared_context: &shared_context, + swarm_plans: &swarm_plans, + swarm_coordinators: &swarm_coordinators, + file_touch: &file_touch, + channel_subscriptions: &channel_subscriptions, + channel_subscriptions_by_session: &channel_subscriptions_by_session, + client_connections: &client_connections, + event_history: &event_history, + event_counter: &event_counter, + swarm_event_tx: &swarm_event_tx, + mcp_pool: &mcp_pool, + soft_interrupt_queues: &soft_interrupt_queues, + await_members_runtime: &await_members_runtime, + swarm_mutation_runtime: &swarm_mutation_runtime, + }, + ) + .await?; + return Ok(()); + } + break request; + } + Err(error) => { + write_direct_event( + &writer, + &ServerEvent::Error { + id: 0, + message: format!("Invalid request: {}", error), + retry_after_secs: None, + }, + ) + .await?; + } + } + }; + + 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(()); + } + }; + + // Per-client state + let mut client_is_processing = false; + let (processing_done_tx, mut processing_done_rx) = + mpsc::unbounded_channel::<(u64, Result<()>, Option<String>)>(); + let mut processing_task: Option<tokio::task::JoinHandle<()>> = None; + let mut processing_message_id: Option<u64> = None; + let mut processing_session_id: Option<String> = None; + let mut current_client_instance_id: Option<String> = None; + // 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 t0 = std::time::Instant::now(); + let registry = Registry::new(provider.clone()).await; + let registry_ms = t0.elapsed().as_millis(); + + let mut swarm_enabled = crate::config::config().features.swarm; + let mut last_available_models_snapshot: Option<String> = None; + const MAX_LIVE_AVAILABLE_MODELS_UPDATE_BYTES: usize = 64 * 1024; + + // 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 agent_new_ms = t0.elapsed().as_millis(); + + new_agent.set_memory_enabled(crate::config::config().features.memory); + + crate::logging::info(&format!( + "[TIMING] handle_client setup: registry={registry_ms}ms, agent_new={agent_new_ms}ms, total={}ms", + client_start.elapsed().as_millis() + )); + let mut client_session_id = new_agent.session_id().to_string(); + let friendly_name = new_agent.session_short_name().map(|s| s.to_string()); + let client_connection_id = id::new_id("conn"); + let connected_at = Instant::now(); + let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel::<()>(); + + { + let mut connections = client_connections.write().await; + connections.insert( + client_connection_id.clone(), + ClientConnectionInfo { + client_id: client_connection_id.clone(), + session_id: client_session_id.clone(), + client_instance_id: None, + debug_client_id: None, + connected_at, + last_seen: connected_at, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: disconnect_tx.clone(), + }, + ); + } + + { + let mut current = global_session_id.write().await; + if current.is_empty() || *current != client_session_id { + *current = client_session_id.clone(); + } + } + + // Get lock-free control-plane handles BEFORE wrapping in Mutex. + // This allows cancel/soft-interrupt/background-tool requests while the agent is processing. + let mut session_control = SessionControlHandle::new( + client_session_id.clone(), + new_agent.soft_interrupt_queue(), + new_agent.background_tool_signal(), + new_agent.graceful_shutdown_signal(), + ); + + // Register the shutdown signal in the server-level map so + // graceful_shutdown_sessions can signal it without locking the agent mutex + { + let mut signals = shutdown_signals.write().await; + signals.insert( + client_session_id.clone(), + session_control.stop_current_turn_signal(), + ); + } + register_session_interrupt_queue( + &soft_interrupt_queues, + &client_session_id, + new_agent.soft_interrupt_queue(), + ) + .await; + + let mut agent = Arc::new(Mutex::new(new_agent)); + { + let mut sessions_guard = sessions.write().await; + sessions_guard.insert(client_session_id.clone(), Arc::clone(&agent)); + } + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "session_created", + "new_live_session_attached", + ) + .with_session_id(client_session_id.clone()) + .force_attribution(), + ); + + // Per-client event channel (not shared with other clients) + let (client_event_tx, mut client_event_rx) = + tokio::sync::mpsc::unbounded_channel::<ServerEvent>(); + + // Spawn event forwarder for this client only + let writer_clone = Arc::clone(&writer); + let client_connection_id_for_events = client_connection_id.clone(); + let client_connections_for_events = Arc::clone(&client_connections); + let event_handle = tokio::spawn(async move { + while let Some(event) = client_event_rx.recv().await { + { + let mut connections = client_connections_for_events.write().await; + if let Some(info) = connections.get_mut(&client_connection_id_for_events) { + match &event { + ServerEvent::ToolStart { name, .. } => { + info.is_processing = true; + info.current_tool_name = Some(name.clone()); + } + ServerEvent::ToolDone { .. } => { + info.current_tool_name = None; + } + ServerEvent::Done { .. } + | ServerEvent::Error { .. } + | ServerEvent::Interrupted => { + info.is_processing = false; + info.current_tool_name = None; + } + _ => {} + } + } + } + let json = encode_event(&event); + let mut w = writer_clone.lock().await; + if let Err(error) = w.write_all(json.as_bytes()).await { + // A broken pipe here is routine (client reload/disconnect mid + // broadcast), so keep the line short: a full Debug dump of e.g. + // a SwarmStatus event prints every member and floods the log. + let event_desc = crate::logging::truncate_for_log(&format!("{:?}", event), 200); + crate::logging::warn(&format!( + "event_forwarder write failed for connection {} while sending {}: {}", + client_connection_id_for_events, event_desc, error + )); + break; + } + } + }); + + // Note: Don't send initial SessionId here - it's sent by the Subscribe handler + // Sending it via the channel causes race conditions where it can arrive after + // other events (like History) that are written directly to the socket. + + // Set up client debug command channel + // This client becomes the "active" debug client that receives client: commands + let (debug_cmd_tx, mut debug_cmd_rx) = mpsc::unbounded_channel::<(u64, String)>(); + let client_debug_id = id::new_id("client"); + { + let mut debug_state = client_debug_state.write().await; + debug_state.register(client_debug_id.clone(), debug_cmd_tx); + } + { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(&client_connection_id) { + info.debug_client_id = Some(client_debug_id.clone()); + } + } + + let stdin_responses: Arc<Mutex<HashMap<String, tokio::sync::oneshot::Sender<String>>>> = + Arc::new(Mutex::new(HashMap::new())); + + // Subscribe to bus events so we can forward ModelsUpdated to this client + // (e.g. when Copilot finishes async init after the initial History was sent) + let mut bus_rx = Bus::global().subscribe(); + + // Set up stdin request forwarding: tools send StdinInputRequest, we forward to TUI + let (stdin_req_tx, mut stdin_req_rx) = + tokio::sync::mpsc::unbounded_channel::<crate::tool::StdinInputRequest>(); + { + let mut agent_guard = agent.lock().await; + agent_guard.set_stdin_request_tx(stdin_req_tx); + } + let _stdin_forwarder = { + let client_event_tx = client_event_tx.clone(); + let stdin_responses = stdin_responses.clone(); + let tool_call_id = String::new(); + tokio::spawn(async move { + while let Some(req) = stdin_req_rx.recv().await { + let request_id = req.request_id.clone(); + stdin_responses + .lock() + .await + .insert(request_id.clone(), req.response_tx); + let _ = client_event_tx.send(ServerEvent::StdinRequest { + request_id, + prompt: req.prompt, + is_password: req.is_password, + tool_call_id: tool_call_id.clone(), + }); + } + }) + }; + + // Do not drain global bus traffic until the client has completed its first + // 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 pending_request = Some(initial_request); + + loop { + let request = if let Some(request) = pending_request.take() { + request + } else { + line.clear(); + tokio::select! { + biased; + // Prioritize direct client I/O so subscribe/ping/message requests do not get + // starved behind noisy background bus traffic. + n = reader.read_line(&mut line) => { + let n = match n { + Ok(n) => n, + Err(e) => { + crate::logging::error(&format!("Client read error: {}", e)); + break; + } + }; + if n == 0 { + break; // Client disconnected + } + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(&client_connection_id) { + info.last_seen = Instant::now(); + } + } + done = processing_done_rx.recv() => { + if let Some((done_id, result, completion_report)) = done { + if Some(done_id) != processing_message_id { + crate::logging::warn(&format!( + "Done event id={} doesn't match processing_message_id={:?}, dropping", + done_id, processing_message_id + )); + continue; + } + crate::logging::info(&format!( + "Processing done for message id={}, result={}", + done_id, + if result.is_ok() { "ok" } else { "err" } + )); + processing_message_id = None; + processing_task = None; + client_is_processing = false; + { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(&client_connection_id) { + info.is_processing = false; + info.current_tool_name = None; + } + } + + 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; + } + let _ = client_event_tx.send(ServerEvent::Done { id: done_id }); + } + 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::<StreamError>().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); + } + } + let _ = client_event_tx.send(ServerEvent::Error { + id: done_id, + message: crate::util::format_error_chain(&e), + retry_after_secs, + }); + } + } + } else { + break; + } + continue; + } + disconnect_signal = disconnect_rx.recv() => { + if disconnect_signal.is_some() { + crate::logging::info(&format!( + "Client connection {} was superseded; disconnecting old owner of session {}", + client_connection_id, client_session_id + )); + break; + } + continue; + } + // Forward bus events to this client + bus_event = bus_rx.recv(), if client_subscribed => { + match bus_event { + Ok(BusEvent::ModelsUpdated) => { + let Some(event) = try_available_models_updated_event(&agent) else { + crate::logging::info(&format!( + "Skipping ModelsUpdated push for busy connection {}", + client_connection_id + )); + continue; + }; + let encoded_event = crate::protocol::encode_event(&event); + if last_available_models_snapshot.as_ref() == Some(&encoded_event) { + continue; + } + let encoded_len = encoded_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 + // route expansion and ship a names-only snapshot; the TUI + // rebuilds fallback routes for missing models locally. + let slim_event = names_only_available_models_event(&event); + let slim_encoded = + slim_event.as_ref().map(crate::protocol::encode_event); + match (slim_event, slim_encoded) { + (Some(slim_event), Some(slim_encoded)) + if slim_encoded.len() + <= MAX_LIVE_AVAILABLE_MODELS_UPDATE_BYTES => + { + crate::logging::info(&format!( + "Downgrading oversized bus AvailableModelsUpdated frame to names-only for connection {} ({} -> {} bytes)", + client_connection_id, + encoded_len, + slim_encoded.len() + )); + let _ = client_event_tx.send(slim_event); + } + _ => { + crate::logging::warn(&format!( + "Skipping oversized bus AvailableModelsUpdated frame for connection {} ({} bytes)", + client_connection_id, encoded_len + )); + } + } + last_available_models_snapshot = Some(encoded_event); + continue; + } + let _ = client_event_tx.send(event); + last_available_models_snapshot = Some(encoded_event); + } + Ok(BusEvent::BatchProgress(progress)) => { + if progress.session_id == client_session_id { + let _ = client_event_tx.send(ServerEvent::BatchProgress { progress }); + } + } + Ok(BusEvent::SidePanelUpdated(update)) => { + if update.session_id == client_session_id { + let _ = client_event_tx.send(ServerEvent::SidePanelState { + snapshot: update.snapshot, + }); + } + } + Ok(BusEvent::CompactionFinished) => { + let agent = Arc::clone(&agent); + let tx = client_event_tx.clone(); + tokio::spawn(async move { + if let Some(event) = poll_agent_compaction_completion(agent).await { + let _ = tx.send(event); + } + }); + } + _ => {} + } + continue; + } + // Handle client debug commands from debug socket + debug_cmd = debug_cmd_rx.recv() => { + if let Some((request_id, command)) = debug_cmd + && client_event_tx + .send(ServerEvent::ClientDebugRequest { + id: request_id, + command, + }) + .is_err() + { + let _ = client_debug_response_tx.send(( + request_id, + "No TUI client connected".to_string(), + )); + } + continue; + } + } + + match decode_request(&line) { + Ok(r) => r, + Err(e) => { + let event = ServerEvent::Error { + id: 0, + message: format!("Invalid request: {}", e), + retry_after_secs: None, + }; + let json = encode_event(&event); + let mut w = writer.lock().await; + if w.write_all(json.as_bytes()).await.is_err() { + break; + } + continue; + } + } + }; + let request_decoded_at = Instant::now(); + let request_id = request.id(); + let request_kind = request_type_from_line(&line); + let request_lifecycle_logged = !request_type_is_read_only(&request_kind); + let request_lifecycle_start = Instant::now(); + let _request_watchdog = RequestHandlerWatchdog::spawn(RequestHandlerWatchdogContext { + request_id, + request_kind: request_kind.clone(), + client_session_id: client_session_id.clone(), + client_connection_id: client_connection_id.clone(), + client_instance_id: current_client_instance_id.clone(), + client_is_processing, + message_id: processing_message_id, + processing_session_id: processing_session_id.clone(), + line_bytes: line.len(), + lifecycle_logged: request_lifecycle_logged, + }); + if request_lifecycle_logged { + let mut fields = server_request_lifecycle_fields(ServerRequestLifecycleFields { + phase: "received", + request_id, + request_kind: &request_kind, + client_session_id: &client_session_id, + client_connection_id: &client_connection_id, + client_instance_id: current_client_instance_id.as_deref(), + client_is_processing, + message_id: processing_message_id, + processing_session_id: processing_session_id.as_deref(), + line_bytes: line.len(), + }); + fields.extend(request_payload_summary(&request_kind, &line)); + crate::logging::event_info("SERVER_REQUEST_LIFECYCLE", fields); + } + if let Some(fields) = interrupt_request_log_fields( + &request, + &client_session_id, + client_is_processing, + processing_message_id, + processing_task.is_some(), + line.len(), + ) { + crate::logging::info(&format!("SERVER_INTERRUPT_REQUEST_DECODED {}", fields)); + } + + // A cancellation request must never be gated on writing an Ack to the client. + // The normal Ack path takes the shared outbound writer before dispatching the + // request. During heavy streaming, history replay, or client-side backpressure, + // that writer can be busy long enough that an already-decoded cancel would sit + // behind outbound bytes instead of signalling the agent's lock-free cancel + // handle. Queue the Ack through the event channel and signal cancellation first. + if let Request::Cancel { id } = request { + let ack_queued = client_event_tx.send(ServerEvent::Ack { id }).is_ok(); + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_PRE_ACK_DISPATCH id={} session={} ack_queued={} decoded_to_dispatch_ms={}", + id, + client_session_id, + ack_queued, + request_decoded_at.elapsed().as_millis() + )); + let cancel_dispatch_start = Instant::now(); + cancel_processing_message( + &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, + }, + &session_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(id), + Some(request_decoded_at), + ) + .await; + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_PRE_ACK_DONE id={} session={} dispatch_ms={} total_since_decode_ms={}", + id, + client_session_id, + cancel_dispatch_start.elapsed().as_millis(), + request_decoded_at.elapsed().as_millis() + )); + 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; + info.current_tool_name = None; + } + } + if request_lifecycle_logged { + log_request_lifecycle_handled( + ServerRequestLifecycleFields { + phase: "handled", + request_id, + request_kind: &request_kind, + client_session_id: &client_session_id, + client_connection_id: &client_connection_id, + client_instance_id: current_client_instance_id.as_deref(), + client_is_processing, + message_id: processing_message_id, + processing_session_id: processing_session_id.as_deref(), + line_bytes: line.len(), + }, + request_lifecycle_start, + request_decoded_at, + ); + } + continue; + } + + // Send ack + let ack = ServerEvent::Ack { id: request.id() }; + let json = encode_event(&ack); + { + let ack_start = Instant::now(); + let mut w = writer.lock().await; + if w.write_all(json.as_bytes()).await.is_err() { + if request_lifecycle_logged { + let mut fields = + server_request_lifecycle_fields(ServerRequestLifecycleFields { + phase: "ack_write_failed", + request_id, + request_kind: &request_kind, + client_session_id: &client_session_id, + client_connection_id: &client_connection_id, + client_instance_id: current_client_instance_id.as_deref(), + client_is_processing, + message_id: processing_message_id, + processing_session_id: processing_session_id.as_deref(), + line_bytes: line.len(), + }); + fields.push(( + "ack_write_ms".to_string(), + ack_start.elapsed().as_millis().to_string(), + )); + crate::logging::event_warn("SERVER_REQUEST_LIFECYCLE", fields); + } + break; + } + if request_lifecycle_logged { + let mut fields = server_request_lifecycle_fields(ServerRequestLifecycleFields { + phase: "acked", + request_id, + request_kind: &request_kind, + client_session_id: &client_session_id, + client_connection_id: &client_connection_id, + client_instance_id: current_client_instance_id.as_deref(), + client_is_processing, + message_id: processing_message_id, + processing_session_id: processing_session_id.as_deref(), + line_bytes: line.len(), + }); + fields.push(( + "ack_write_ms".to_string(), + ack_start.elapsed().as_millis().to_string(), + )); + fields.push(( + "since_decode_ms".to_string(), + request_decoded_at.elapsed().as_millis().to_string(), + )); + crate::logging::event_info("SERVER_REQUEST_LIFECYCLE", fields); + } + } + + match request { + Request::Message { + id, + content, + images, + system_reminder, + } => { + if !client_is_processing { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(&client_connection_id) { + info.is_processing = true; + info.current_tool_name = None; + } + } + start_processing_message( + ProcessingMessage { + id, + content, + images, + system_reminder, + }, + &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, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ) + .await; + } + + Request::Cancel { id } => { + cancel_processing_message( + &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, + }, + &session_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(id), + Some(request_decoded_at), + ) + .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; + info.current_tool_name = None; + } + } + } + + Request::SoftInterrupt { + id, + content, + urgent, + } => { + queue_soft_interrupt( + id, + content, + urgent, + SoftInterruptSource::User, + &session_control, + &client_event_tx, + ); + } + + Request::CancelSoftInterrupts { id } => { + clear_soft_interrupts(id, &client_session_id, &session_control, &client_event_tx); + } + + Request::BackgroundTool { id } => { + move_tool_to_background(id, &session_control, &client_event_tx); + } + + Request::Clear { id } => { + if reject_if_agent_busy_for_request( + id, + "clear", + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) { + 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, + ) + .await; + session_control = refresh_session_control_handle( + &client_session_id, + &agent, + &shutdown_signals, + &soft_interrupt_queues, + ) + .await; + } + + Request::Rewind { id, message_index } => { + if client_is_processing { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Cannot rewind while a turn is processing.".to_string(), + retry_after_secs: None, + }); + continue; + } + + let rewind_result = { + let mut agent_guard = agent.lock().await; + agent_guard.rewind_to_message(message_index) + }; + + match rewind_result { + Ok(removed) => { + crate::logging::info(&format!( + "Rewound session {} to message {} (removed {})", + client_session_id, message_index, removed + )); + if handle_get_history( + id, + &client_session_id, + client_is_processing, + &agent, + &provider, + &sessions, + &client_connections, + &client_count, + &writer, + &server_name, + &server_icon, + None, + ) + .await + .is_err() + { + break; + } + // The truncated History replaces the client transcript + // (dropping the inline plan graph); re-send the plan so + // the diagram comes back. + send_swarm_plan_to_session( + &client_session_id, + &swarm_members, + &swarm_plans, + ) + .await; + } + Err(message) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + } + } + } + + Request::RewindUndo { id } => { + if client_is_processing { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Cannot undo rewind while a turn is processing.".to_string(), + retry_after_secs: None, + }); + continue; + } + + let undo_result = { + let mut agent_guard = agent.lock().await; + agent_guard.undo_rewind() + }; + + match undo_result { + Ok(restored) => { + crate::logging::info(&format!( + "Undid rewind for session {} (restored {})", + client_session_id, restored + )); + if handle_get_history( + id, + &client_session_id, + client_is_processing, + &agent, + &provider, + &sessions, + &client_connections, + &client_count, + &writer, + &server_name, + &server_icon, + None, + ) + .await + .is_err() + { + break; + } + // Same as rewind: restore the inline plan graph after + // the transcript replacement. + send_swarm_plan_to_session( + &client_session_id, + &swarm_members, + &swarm_plans, + ) + .await; + } + Err(message) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + } + } + } + + Request::Ping { id } => { + let json = encode_event(&ServerEvent::Pong { id }); + let mut w = writer.lock().await; + if w.write_all(json.as_bytes()).await.is_err() { + break; + } + } + + Request::GetState { id } => { + if handle_get_state( + id, + &client_session_id, + client_is_processing, + &sessions, + &writer, + ) + .await + .is_err() + { + break; + } + } + + Request::Subscribe { + id, + working_dir: subscribe_working_dir, + selfdev, + target_session_id, + client_instance_id, + client_has_local_history, + allow_session_takeover, + terminal_env, + } => { + if let Err(message) = + required_subscribe_working_dir(subscribe_working_dir.as_deref()) + { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + continue; + } + 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(); + } + } + } + if let Some(target_session_id) = target_session_id { + if crate::session::session_exists(&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, + ) + .await?; + session_control = refresh_session_control_handle( + &client_session_id, + &agent, + &shutdown_signals, + &soft_interrupt_queues, + ) + .await; + if client_session_id == target_session_id { + handle_subscribe( + id, + subscribe_working_dir, + selfdev, + false, + &mut client_selfdev, + &client_session_id, + &client_connection_id, + &friendly_name, + &agent, + ®istry, + swarm_enabled, + &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; + if let Some(snapshot) = try_available_models_snapshot(&agent) { + last_available_models_snapshot = Some(snapshot); + } + } else { + crate::logging::warn(&format!( + "Target-aware subscribe failed to bind {} from temporary {}; closing temporary client connection {}", + target_session_id, pre_resume_session_id, client_connection_id + )); + break; + } + } else { + handle_subscribe( + id, + subscribe_working_dir, + selfdev, + true, + &mut client_selfdev, + &client_session_id, + &client_connection_id, + &friendly_name, + &agent, + ®istry, + swarm_enabled, + &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; + } + } else { + handle_subscribe( + id, + subscribe_working_dir, + selfdev, + true, + &mut client_selfdev, + &client_session_id, + &client_connection_id, + &friendly_name, + &agent, + ®istry, + swarm_enabled, + &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; + if let Some(snapshot) = try_available_models_snapshot(&agent) { + last_available_models_snapshot = Some(snapshot); + } + } + client_subscribed = true; + } + + Request::GetHistory { id } => { + if handle_get_history( + id, + &client_session_id, + client_is_processing, + &agent, + &provider, + &sessions, + &client_connections, + &client_count, + &writer, + &server_name, + &server_icon, + None, + ) + .await + .is_err() + { + break; + } + // Follow the History payload with the current swarm plan: a + // session-changing History clears the client's plan snapshot + // (and the inline plan graph), so re-send it afterwards + // instead of leaving the graph blank until the next plan + // mutation broadcast. + send_swarm_plan_to_session(&client_session_id, &swarm_members, &swarm_plans).await; + if let Some(snapshot) = try_available_models_snapshot(&agent) { + last_available_models_snapshot = Some(snapshot); + } + } + + Request::GetModelCatalog { id } => { + if handle_get_model_catalog(id, &client_session_id, &agent, &provider, &writer) + .await + .is_err() + { + break; + } + if let Some(snapshot) = try_available_models_snapshot(&agent) { + last_available_models_snapshot = Some(snapshot); + } + } + + Request::GetCompactedHistory { + id, + visible_messages, + } => { + if handle_get_compacted_history( + id, + &client_session_id, + &agent, + &writer, + visible_messages, + ) + .await + .is_err() + { + break; + } + } + + Request::DebugCommand { id, .. } => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "debug_command is only supported on the debug socket".to_string(), + retry_after_secs: None, + }); + } + + Request::Reload { id, force } => { + handle_reload( + id, + force, + &client_session_id, + &agent, + &swarm_members, + &client_event_tx, + ) + .await; + } + + Request::ResumeSession { + id, + session_id, + client_instance_id, + client_has_local_history, + allow_session_takeover, + } => { + let resume_working_dir = { + let agent_guard = agent.lock().await; + agent_guard.working_dir().map(str::to_string) + }; + 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(); + } + } + 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, + ) + .await?; + session_control = refresh_session_control_handle( + &client_session_id, + &agent, + &shutdown_signals, + &soft_interrupt_queues, + ) + .await; + if let Some(snapshot) = try_available_models_snapshot(&agent) { + last_available_models_snapshot = Some(snapshot); + } + } + + Request::ResumeAllSessions { id } => { + super::client_actions::handle_resume_all_sessions( + id, + &sessions, + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + &client_event_tx, + ) + .await; + } + + Request::CycleModel { id, direction } => { + handle_cycle_model(id, direction, &agent, &client_event_tx).await; + } + + Request::RefreshModels { id } => { + handle_refresh_models(id, &provider, &agent, &client_event_tx).await; + } + + Request::SetPremiumMode { id, mode } => { + handle_set_premium_mode(id, mode, &agent, &client_event_tx).await; + } + + Request::SetModel { id, model } => { + handle_set_model(id, model, &agent, &client_event_tx).await; + } + + Request::SetRoute { id, selection } => { + handle_set_route(id, selection, &agent, &client_event_tx).await; + } + + Request::SetSubagentModel { id, model } => { + if reject_if_agent_busy_for_request( + id, + "set_subagent_model", + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) { + continue; + } + handle_set_subagent_model(id, model, &agent, &client_event_tx).await; + } + + Request::RunSubagent { + id, + prompt, + subagent_type, + model, + session_id, + } => { + handle_run_subagent( + id, + prompt, + subagent_type, + model, + session_id, + &agent, + &client_event_tx, + ); + } + + Request::SetReasoningEffort { + id, + effort, + target_session_id, + } => { + if let Some(target_session_id) = target_session_id { + let target_agent = { sessions.read().await.get(&target_session_id).cloned() }; + if let Some(target_agent) = target_agent { + handle_set_reasoning_effort(id, effort, &target_agent, &client_event_tx) + .await; + } else { + let _ = client_event_tx.send(ServerEvent::ReasoningEffortChanged { + id, + effort: None, + error: Some(format!("target session not found: {target_session_id}")), + }); + } + } else { + handle_set_reasoning_effort(id, effort, &agent, &client_event_tx).await; + } + } + + Request::SetServiceTier { id, service_tier } => { + handle_set_service_tier(id, service_tier, &agent, &client_event_tx).await; + } + + Request::SetTransport { id, transport } => { + handle_set_transport(id, transport, &agent, &client_event_tx).await; + } + + Request::SetCompactionMode { id, mode } => { + handle_set_compaction_mode(id, mode, &agent, &client_event_tx).await; + } + + Request::RenameSession { id, title } => { + if reject_if_agent_busy_for_request( + id, + "rename_session", + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) { + continue; + } + handle_rename_session( + id, + title, + &agent, + &client_session_id, + &swarm_members, + &client_event_tx, + ) + .await; + } + + Request::NotifyAuthChanged { + id, + provider: provider_hint, + auth, + } => { + handle_notify_auth_changed( + id, + provider_hint, + auth, + &provider, + &provider_template, + &sessions, + &client_session_id, + &agent, + &client_event_tx, + ) + .await; + } + + Request::SwitchAnthropicAccount { id, label } => { + handle_switch_anthropic_account(id, label, &agent, &client_event_tx).await; + } + + Request::SwitchOpenAiAccount { id, label } => { + handle_switch_openai_account(id, label, &agent, &client_event_tx).await; + } + + Request::SetFeature { + id, + feature, + enabled, + } => { + if reject_if_agent_busy_for_request( + id, + "set_feature", + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) { + continue; + } + handle_set_feature( + id, + feature, + enabled, + &agent, + &client_session_id, + &friendly_name, + &mut swarm_enabled, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &client_event_tx, + ) + .await; + } + + Request::Split { id } => { + handle_split(id, &client_session_id, &client_event_tx).await; + } + + Request::Transfer { id } => { + if reject_if_agent_busy_for_request( + id, + "transfer", + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) { + continue; + } + handle_transfer(id, &client_session_id, &agent, &client_event_tx).await; + } + + Request::Compact { id } => { + handle_compact(id, &agent, &client_event_tx); + } + + Request::TriggerMemoryExtraction { id } => { + if reject_if_agent_busy_for_request( + id, + "trigger_memory_extraction", + &client_session_id, + client_is_processing, + &agent, + &client_event_tx, + ) { + continue; + } + handle_trigger_memory_extraction(id, &agent, &client_event_tx).await; + } + + // Agent-to-agent communication + Request::AgentRegister { id, .. } => { + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + + Request::StdinResponse { + id, + request_id, + input, + } => { + handle_stdin_response(id, request_id, input, &stdin_responses, &client_event_tx) + .await; + } + + Request::AgentTask { id, task, .. } => { + handle_agent_task( + id, + task, + &client_session_id, + &agent, + &AgentTaskContext { + client_event_tx: &client_event_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + swarm_event_tx: &swarm_event_tx, + }, + ) + .await; + } + + Request::AgentCapabilities { id } => { + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + + Request::AgentContext { id } => { + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + + Request::NotifySession { + id, + session_id, + message, + } => { + handle_notify_session( + id, + session_id, + message, + NotifySessionContext { + sessions: &sessions, + soft_interrupt_queues: &soft_interrupt_queues, + client_connections: &client_connections, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + swarm_event_tx: &swarm_event_tx, + client_event_tx: &client_event_tx, + }, + ) + .await; + } + + Request::Transcript { + id, + text, + mode, + session_id, + } => { + match super::debug::inject_transcript( + id, + text, + mode, + session_id, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + { + Ok(event) => { + let _ = client_event_tx.send(event); + } + Err(error) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: error.to_string(), + retry_after_secs: None, + }); + } + } + } + + Request::InputShell { id, command } => { + handle_input_shell(id, command, &agent, &client_event_tx); + } + + // === Agent communication === + Request::CommShare { + id, + session_id: req_session_id, + key, + value, + append, + } => { + handle_comm_share( + id, + req_session_id, + key, + value, + append, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &shared_context, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommRead { + id, + session_id: req_session_id, + key, + } => { + handle_comm_read( + id, + req_session_id, + key, + &client_event_tx, + &swarm_members, + &shared_context, + ) + .await; + } + + Request::CommMessage { + id, + from_session, + message, + to_session, + channel, + delivery, + wake, + tldr, + } => { + handle_comm_message( + id, + from_session, + message, + to_session, + channel, + delivery, + wake, + tldr, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &swarm_members, + &swarms_by_id, + &channel_subscriptions, + &event_history, + &event_counter, + &swarm_event_tx, + &client_connections, + ) + .await; + } + + Request::CommList { + id, + session_id: req_session_id, + } => { + handle_comm_list( + id, + req_session_id, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &file_touch, + &sessions, + &client_connections, + ) + .await; + } + + Request::CommListChannels { + id, + session_id: req_session_id, + } => { + handle_comm_list_channels( + id, + req_session_id, + &client_event_tx, + &swarm_members, + &channel_subscriptions, + ) + .await; + } + + Request::CommChannelMembers { + id, + session_id: req_session_id, + channel, + } => { + handle_comm_channel_members( + id, + req_session_id, + channel, + &client_event_tx, + &swarm_members, + &channel_subscriptions, + ) + .await; + } + + Request::CommProposePlan { + id, + session_id: req_session_id, + items, + } => { + handle_comm_propose_plan( + id, + req_session_id, + items, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &shared_context, + &swarm_plans, + &swarm_coordinators, + &sessions, + &soft_interrupt_queues, + &event_history, + &event_counter, + &swarm_event_tx, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommApprovePlan { + id, + session_id: req_session_id, + proposer_session, + } => { + handle_comm_approve_plan( + id, + req_session_id, + proposer_session, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &shared_context, + &swarm_plans, + &swarm_coordinators, + &sessions, + &soft_interrupt_queues, + &event_history, + &event_counter, + &swarm_event_tx, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommRejectPlan { + id, + session_id: req_session_id, + proposer_session, + reason, + } => { + handle_comm_reject_plan( + id, + req_session_id, + proposer_session, + reason, + &client_event_tx, + &swarm_members, + &shared_context, + &swarm_coordinators, + &sessions, + &soft_interrupt_queues, + &event_history, + &event_counter, + &swarm_event_tx, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommSeedGraph { + id, + session_id: req_session_id, + mode, + nodes, + } => { + super::comm_graph::handle_comm_seed_graph( + id, + req_session_id, + mode, + nodes, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommExpandNode { + id, + session_id: req_session_id, + node_id, + children, + } => { + super::comm_graph::handle_comm_expand_node( + id, + req_session_id, + node_id, + children, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommCompleteNode { + id, + session_id: req_session_id, + node_id, + artifact_json, + } => { + super::comm_graph::handle_comm_complete_node( + id, + req_session_id, + node_id, + artifact_json, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommInjectGap { + id, + session_id: req_session_id, + gate_id, + nodes, + } => { + super::comm_graph::handle_comm_inject_gap( + id, + req_session_id, + gate_id, + nodes, + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommSpawn { + id, + session_id: req_session_id, + working_dir, + initial_message, + request_nonce, + spawn_mode, + model, + effort, + label, + } => { + let spawn_mode = match parse_swarm_spawn_mode(id, spawn_mode, &client_event_tx) { + Some(spawn_mode) => spawn_mode, + None => return Ok(()), + }; + handle_comm_spawn( + id, + req_session_id, + working_dir, + initial_message, + request_nonce, + spawn_mode, + model, + effort, + label, + &client_event_tx, + &sessions, + &global_session_id, + &provider_template, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + &channel_subscriptions, + &channel_subscriptions_by_session, + &event_history, + &event_counter, + &swarm_event_tx, + &mcp_pool, + &soft_interrupt_queues, + &swarm_mutation_runtime, + &client_connections, + ) + .await; + } + + Request::CommListModels { + id, + session_id: req_session_id, + } => { + super::comm_session::handle_comm_list_models( + id, + &req_session_id, + &sessions, + &provider_template, + |event| { + let _ = client_event_tx.send(event); + }, + ) + .await; + } + + Request::CommStop { + id, + session_id: req_session_id, + target_session, + force, + } => { + handle_comm_stop( + id, + req_session_id, + target_session, + force.unwrap_or(false), + &client_event_tx, + &sessions, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + &channel_subscriptions, + &channel_subscriptions_by_session, + &event_history, + &event_counter, + &swarm_event_tx, + &soft_interrupt_queues, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommAssignRole { + id, + session_id: req_session_id, + target_session, + role, + } => { + handle_comm_assign_role( + id, + req_session_id, + target_session, + role, + &client_event_tx, + &sessions, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + &event_history, + &event_counter, + &swarm_event_tx, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommSummary { + id, + session_id: req_session_id, + target_session, + limit, + } => { + handle_comm_summary( + id, + req_session_id, + target_session, + limit, + &sessions, + &swarm_members, + &client_event_tx, + ) + .await; + } + + Request::CommStatus { + id, + session_id: req_session_id, + target_session, + } => { + handle_comm_status( + id, + req_session_id, + target_session, + &sessions, + &swarm_members, + &client_connections, + &file_touch, + &client_event_tx, + ) + .await; + } + + Request::CommReport { + id, + session_id: req_session_id, + status, + message, + validation, + follow_up, + tldr, + } => { + let status = status.unwrap_or_else(|| "ready".to_string()); + let report = format_structured_completion_report( + &message, + validation.as_deref(), + follow_up.as_deref(), + ); + let detail = Some(truncate_detail(&message, 160)); + update_member_status_with_report_tldr( + &req_session_id, + &status, + detail, + Some(report), + tldr, + &swarm_members, + &swarms_by_id, + Some(&event_history), + Some(&event_counter), + Some(&swarm_event_tx), + ) + .await; + let _ = client_event_tx.send(ServerEvent::CommReportResponse { + id, + status, + message: "Report recorded and delivered to the coordinator when applicable." + .to_string(), + }); + } + + Request::CommPlanStatus { + id, + session_id: req_session_id, + } => { + handle_comm_plan_status( + id, + req_session_id, + &swarm_members, + &swarm_plans, + &client_event_tx, + ) + .await; + } + + Request::CommReadContext { + id, + session_id: req_session_id, + target_session, + } => { + handle_comm_read_context( + id, + req_session_id, + target_session, + &sessions, + &swarm_members, + &client_event_tx, + ) + .await; + } + + Request::CommResyncPlan { + id, + session_id: req_session_id, + } => { + handle_comm_resync_plan( + id, + req_session_id, + &CommResyncPlanContext { + client_event_tx: &client_event_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_plans: &swarm_plans, + swarm_coordinators: &swarm_coordinators, + event_history: &event_history, + event_counter: &event_counter, + swarm_event_tx: &swarm_event_tx, + }, + ) + .await; + } + + Request::CommAssignTask { + id, + session_id: req_session_id, + target_session, + task_id, + message, + } => { + handle_comm_assign_task( + id, + req_session_id, + target_session, + task_id, + message, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommAssignNext { + id, + session_id: req_session_id, + target_session, + working_dir, + prefer_spawn, + spawn_if_needed, + message, + model, + effort, + } => { + handle_comm_assign_next( + id, + req_session_id, + target_session, + working_dir, + prefer_spawn, + spawn_if_needed, + message, + model, + effort, + &client_event_tx, + &sessions, + &global_session_id, + &provider_template, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mcp_pool, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommTaskControl { + id, + session_id: req_session_id, + action, + task_id, + target_session, + message, + } => { + handle_comm_task_control( + id, + req_session_id, + action, + task_id, + target_session, + message, + &client_event_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &swarm_mutation_runtime, + ) + .await; + } + + Request::CommSubscribeChannel { + id, + session_id: req_session_id, + channel, + } => { + handle_comm_subscribe_channel( + id, + req_session_id, + channel, + &client_event_tx, + &swarm_members, + &channel_subscriptions, + &channel_subscriptions_by_session, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommUnsubscribeChannel { + id, + session_id: req_session_id, + channel, + } => { + handle_comm_unsubscribe_channel( + id, + req_session_id, + channel, + &client_event_tx, + &swarm_members, + &channel_subscriptions, + &channel_subscriptions_by_session, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + } + + Request::CommAwaitMembers { + id, + session_id: req_session_id, + target_status, + session_ids: requested_ids, + mode, + timeout_secs, + background, + notify, + wake, + } => { + handle_comm_await_members( + id, + req_session_id, + target_status, + requested_ids, + mode, + timeout_secs, + background, + notify, + wake, + CommAwaitMembersContext { + client_event_tx: &client_event_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_members_runtime, + }, + ) + .await; + } + + // These are handled via channels, not direct requests from TUI + Request::ClientDebugCommand { id, .. } => { + handle_client_debug_command(id, &client_event_tx).await; + } + Request::ClientDebugResponse { id, output } => { + handle_client_debug_response(id, output, &client_debug_response_tx); + } + } + if request_lifecycle_logged { + log_request_lifecycle_handled( + ServerRequestLifecycleFields { + phase: "handled", + request_id, + request_kind: &request_kind, + client_session_id: &client_session_id, + client_connection_id: &client_connection_id, + client_instance_id: current_client_instance_id.as_deref(), + client_is_processing, + message_id: processing_message_id, + processing_session_id: processing_session_id.as_deref(), + line_bytes: line.len(), + }, + request_lifecycle_start, + request_decoded_at, + ); + } + } + + 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, + ) + .await?; + Ok(()) +} + +async fn start_processing_message( + message: ProcessingMessage, + client_session_id: &str, + state: &mut ProcessingState<'_>, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + processing_done_tx: &mpsc::UnboundedSender<(u64, Result<()>, Option<String>)>, + swarm: &SwarmStatusRefs<'_>, +) { + let ProcessingMessage { + id, + content, + images, + system_reminder, + } = message; + if server_reload_starting() { + crate::logging::info(&format!( + "Rejecting new message for session {} because server reload is starting", + client_session_id + )); + let _ = client_event_tx.send(ServerEvent::Reloading { new_socket: None }); + return; + } + + if *state.client_is_processing { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Already processing a message".to_string(), + retry_after_secs: None, + }); + return; + } + + *state.client_is_processing = true; + *state.message_id = Some(id); + *state.session_id = Some(client_session_id.to_string()); + + if let Some(reminder) = system_reminder.as_deref() + && let Err(error) = super::reload_recovery::mark_delivered_if_matching_continuation( + client_session_id, + reminder, + "client_message_accepted", + ) + { + crate::logging::warn(&format!( + "Failed to mark reload recovery intent delivered for accepted message session={} id={}: {}", + client_session_id, id, error + )); + } + + update_member_status( + client_session_id, + "running", + Some(truncate_detail(&content, 120)), + swarm.members, + swarm.swarms_by_id, + Some(swarm.event_history), + Some(swarm.event_counter), + Some(swarm.event_tx), + ) + .await; + + let start_message_index = { + let agent_guard = agent.lock().await; + agent_guard.message_count() + }; + let agent = Arc::clone(agent); + let report_agent = Arc::clone(&agent); + let tx = client_event_tx.clone(); + let done_tx = processing_done_tx.clone(); + 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, + )) + .catch_unwind() + .await + { + Ok(result) => result, + Err(panic_payload) => { + let msg = if let Some(text) = panic_payload.downcast_ref::<&str>() { + text.to_string() + } else if let Some(text) = panic_payload.downcast_ref::<String>() { + text.clone() + } else { + "unknown panic".to_string() + }; + crate::logging::error(&format!( + "Processing task PANICKED for message id={}: {}", + id, msg + )); + Err(anyhow::anyhow!("Processing task panicked: {}", msg)) + } + }; + match &result { + Ok(()) => crate::logging::info(&format!( + "Processing task completed OK for message id={}", + id + )), + Err(error) => crate::logging::warn(&format!( + "Processing task completed with error for message id={}: {}", + id, error + )), + } + let completion_report = if result.is_ok() { + let agent = report_agent.lock().await; + agent.latest_assistant_text_after(start_message_index) + } else { + None + }; + let _ = done_tx.send((id, result, completion_report)); + })); +} + +async fn cancel_processing_message( + state: &mut ProcessingState<'_>, + session_control: &SessionControlHandle, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm: &SwarmStatusRefs<'_>, + request_id: Option<u64>, + request_decoded_at: Option<Instant>, +) { + let cancel_start = Instant::now(); + let session_label = state + .session_id + .as_deref() + .unwrap_or(session_control.session_id.as_str()) + .to_string(); + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_RECEIVED request_id={:?} session={} control_session={} client_processing={} message_id={:?} has_task={} decoded_age_ms={:?}", + request_id, + session_label, + session_control.session_id, + *state.client_is_processing, + *state.message_id, + state.task.is_some(), + request_decoded_at.map(|instant| instant.elapsed().as_millis()) + )); + if let Some(mut handle) = state.task.take() { + if handle.is_finished() { + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_IGNORED_FINISHED request_id={:?} session={} message_id={:?} total_ms={}", + request_id, + session_label, + *state.message_id, + cancel_start.elapsed().as_millis() + )); + *state.task = Some(handle); + return; + } + let cancel_epoch = session_control.request_cancel(); + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_SIGNALLED request_id={:?} session={} message_id={:?} wait_ms=500", + request_id, session_label, *state.message_id + )); + match tokio::time::timeout(std::time::Duration::from_millis(500), &mut handle).await { + Ok(_) => { + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_COOPERATIVE_DONE request_id={:?} session={} message_id={:?} elapsed_ms={}", + request_id, + session_label, + *state.message_id, + cancel_start.elapsed().as_millis() + )); + } + Err(_) => { + crate::logging::warn(&format!( + "SERVER_INTERRUPT_CANCEL_COOPERATIVE_TIMEOUT request_id={:?} session={} message_id={:?} elapsed_ms={} action=abort_task", + request_id, + session_label, + *state.message_id, + cancel_start.elapsed().as_millis() + )); + handle.abort(); + match tokio::time::timeout(std::time::Duration::from_millis(2000), handle).await { + Ok(_) => crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_ABORT_RELEASED request_id={:?} session={} elapsed_ms={}", + request_id, + session_label, + cancel_start.elapsed().as_millis() + )), + Err(_) => crate::logging::warn(&format!( + "SERVER_INTERRUPT_CANCEL_ABORT_RELEASE_TIMEOUT request_id={:?} session={} elapsed_ms={} wait_ms=2000", + request_id, + session_label, + cancel_start.elapsed().as_millis() + )), + } + } + } + // Only clear the cancel we fired: a newer cancel (repeated Esc, jade + // relay, another connection) must not be erased before its target + // observes it (issue #428). + session_control.reset_cancel_if_epoch(cancel_epoch); + *state.task = None; + *state.client_is_processing = false; + if let Some(session_id) = state.session_id.take() { + update_member_status( + &session_id, + "stopped", + Some("cancelled".to_string()), + swarm.members, + swarm.swarms_by_id, + Some(swarm.event_history), + Some(swarm.event_counter), + Some(swarm.event_tx), + ) + .await; + } + if let Some(message_id) = state.message_id.take() { + let _ = client_event_tx.send(ServerEvent::Interrupted); + let _ = client_event_tx.send(ServerEvent::Done { id: message_id }); + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_EVENTS_EMITTED request_id={:?} session={} interrupted=true done_id={} total_ms={}", + request_id, + session_label, + message_id, + cancel_start.elapsed().as_millis() + )); + } + } else { + crate::logging::warn(&format!( + "SERVER_INTERRUPT_CANCEL_NO_LOCAL_TASK request_id={:?} session={} control_session={} client_processing={} message_id={:?}; signalling session cancel handle anyway", + request_id, + session_label, + session_control.session_id, + *state.client_is_processing, + *state.message_id + )); + let cancel_epoch = session_control.request_cancel(); + let reset_control = session_control.clone(); + tokio::spawn(async move { + // The running turn is not owned by this connection (post-reload + // recovery, server-initiated turn, or attach), so we cannot await + // it. Clear the flag later so the *next* turn is not aborted by a + // stale cancel, but only if no newer cancel fired in the meantime: + // an unconditional reset here used to erase rapid repeated Esc + // cancels before the busy turn observed them (issue #428). + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + reset_control.reset_cancel_if_epoch(cancel_epoch); + }); + *state.client_is_processing = false; + let status_session_id = state + .session_id + .take() + .unwrap_or_else(|| session_control.session_id.clone()); + update_member_status( + &status_session_id, + "stopped", + Some("cancelled".to_string()), + swarm.members, + swarm.swarms_by_id, + Some(swarm.event_history), + Some(swarm.event_counter), + Some(swarm.event_tx), + ) + .await; + 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 }); + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_EVENTS_EMITTED request_id={:?} session={} interrupted=true done_id={} total_ms={}", + request_id, + session_label, + message_id, + cancel_start.elapsed().as_millis() + )); + } else { + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_EVENTS_EMITTED request_id={:?} session={} interrupted=true done_id=None total_ms={}", + request_id, + session_label, + cancel_start.elapsed().as_millis() + )); + } + } +} + +fn try_available_models_snapshot(agent: &Arc<Mutex<Agent>>) -> Option<String> { + let event = try_available_models_updated_event(agent)?; + Some(crate::protocol::encode_event(&event)) +} + +/// Build a names-only copy of an `AvailableModelsUpdated` event by dropping the +/// per-model route expansion. Used when the fully-routed frame exceeds the live +/// update size cap so clients still receive fresh model names. +fn names_only_available_models_event(event: &ServerEvent) -> Option<ServerEvent> { + let ServerEvent::AvailableModelsUpdated { + provider_name, + provider_model, + available_models, + .. + } = event + else { + return None; + }; + Some(ServerEvent::AvailableModelsUpdated { + provider_name: provider_name.clone(), + provider_model: provider_model.clone(), + available_models: available_models.clone(), + available_model_routes: Vec::new(), + }) +} + +fn queue_soft_interrupt( + id: u64, + content: String, + urgent: bool, + source: SoftInterruptSource, + session_control: &SessionControlHandle, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let content_bytes = content.len(); + let content_chars = content.chars().count(); + crate::logging::info(&format!( + "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 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={}", + id, session_control.session_id, queued, ack_queued + )); +} + +fn clear_soft_interrupts( + id: u64, + session_id: &str, + session_control: &SessionControlHandle, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + crate::logging::info(&format!( + "SERVER_SOFT_INTERRUPT_CLEAR_REQUEST id={} session={} control_session={}", + id, session_id, session_control.session_id + )); + session_control.clear_soft_interrupts(); + let persisted_clear = match crate::soft_interrupt_store::clear(session_id) { + Ok(()) => true, + Err(err) => { + crate::logging::warn(&format!( + "SERVER_SOFT_INTERRUPT_CLEAR_PERSISTED_FAILED id={} session={} error={}", + id, session_id, err + )); + false + } + }; + let ack_queued = client_event_tx.send(ServerEvent::Ack { id }).is_ok(); + crate::logging::info(&format!( + "SERVER_SOFT_INTERRUPT_CLEAR_RESULT id={} session={} persisted_clear={} ack_queued={}", + id, session_id, persisted_clear, ack_queued + )); +} + +fn move_tool_to_background( + id: u64, + session_control: &SessionControlHandle, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + crate::logging::info(&format!( + "SERVER_BACKGROUND_TOOL_REQUEST id={} session={}", + id, session_control.session_id + )); + let signalled = session_control.request_background_current_tool(); + let ack_queued = client_event_tx.send(ServerEvent::Ack { id }).is_ok(); + crate::logging::info(&format!( + "SERVER_BACKGROUND_TOOL_RESULT id={} session={} signalled={} ack_queued={}", + id, session_control.session_id, signalled, ack_queued + )); +} + +/// Process a message and stream events (mpsc channel - per-client) +pub(super) async fn process_message_streaming_mpsc( + agent: Arc<Mutex<Agent>>, + content: &str, + images: Vec<(String, String)>, + system_reminder: Option<String>, + event_tx: tokio::sync::mpsc::UnboundedSender<ServerEvent>, +) -> Result<()> { + let mut agent = agent.lock().await; + let session_id = agent.session_id().to_string(); + let result = agent + .run_once_streaming_mpsc(content, images, system_reminder, event_tx) + .await; + if result.is_ok() { + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "turn_completed", + "message_turn_finished", + ) + .with_session_id(session_id) + .force_attribution(), + ); + crate::process_memory::release_retained_heap_debounced( + "server_turn_completed", + std::time::Duration::from_secs(30), + ); + } + result +} + +#[cfg(test)] +#[path = "client_lifecycle_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/server/client_lifecycle_logging.rs b/crates/jcode-app-core/src/server/client_lifecycle_logging.rs new file mode 100644 index 0000000..91e5934 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_lifecycle_logging.rs @@ -0,0 +1,174 @@ +use crate::protocol::Request; + +pub(super) fn interrupt_request_log_fields( + request: &Request, + client_session_id: &str, + client_is_processing: bool, + message_id: Option<u64>, + has_task: bool, + line_bytes: usize, +) -> Option<String> { + let base = |kind: &str, id: u64| { + format!( + "kind={} id={} session={} client_processing={} message_id={:?} has_task={} line_bytes={}", + kind, id, client_session_id, client_is_processing, message_id, has_task, line_bytes + ) + }; + + match request { + Request::Cancel { id } => Some(base("cancel", *id)), + Request::SoftInterrupt { + id, + content, + urgent, + } => Some(format!( + "{} urgent={} content_bytes={} content_chars={}", + base("soft_interrupt", *id), + urgent, + content.len(), + content.chars().count() + )), + Request::CancelSoftInterrupts { id } => Some(base("cancel_soft_interrupts", *id)), + Request::BackgroundTool { id } => Some(base("background_tool", *id)), + _ => None, + } +} + +pub(super) fn request_type_from_line(line: &str) -> String { + serde_json::from_str::<serde_json::Value>(line.trim()) + .ok() + .and_then(|value| { + value + .get("type") + .and_then(|kind| kind.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub(super) fn request_type_is_read_only(kind: &str) -> bool { + matches!( + kind, + "ping" + | "state" + | "get_history" + | "get_model_catalog" + | "get_compacted_history" + | "agent_capabilities" + | "agent_context" + | "comm_read" + | "comm_list" + | "comm_list_channels" + | "comm_channel_members" + | "comm_summary" + | "comm_status" + | "comm_plan_status" + | "comm_read_context" + | "comm_await_members" + ) +} + +pub(super) fn request_payload_summary(kind: &str, line: &str) -> Vec<(String, String)> { + let mut fields = Vec::new(); + if let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) { + let bytes_chars = + |name: &str, value: &serde_json::Value, fields: &mut Vec<(String, String)>| { + if let Some(text) = value.get(name).and_then(|v| v.as_str()) { + fields.push((format!("{}_bytes", name), text.len().to_string())); + fields.push((format!("{}_chars", name), text.chars().count().to_string())); + } + }; + for name in [ + "content", "message", "prompt", "task", "command", "input", "value", + ] { + bytes_chars(name, &value, &mut fields); + } + if let Some(images) = value.get("images").and_then(|v| v.as_array()) { + fields.push(("image_count".to_string(), images.len().to_string())); + } + if let Some(session_id) = value.get("session_id").and_then(|v| v.as_str()) { + fields.push(("request_session_id".to_string(), session_id.to_string())); + } + if let Some(target_session) = value.get("target_session").and_then(|v| v.as_str()) { + fields.push(("target_session".to_string(), target_session.to_string())); + } + if let Some(client_instance_id) = value.get("client_instance_id").and_then(|v| v.as_str()) { + fields.push(( + "request_client_instance_id".to_string(), + client_instance_id.to_string(), + )); + } + if matches!(kind, "set_model" | "set_subagent_model") + && let Some(model) = value.get("model").and_then(|v| v.as_str()) + { + fields.push(("model".to_string(), model.to_string())); + } + if let Some(title) = value.get("title").and_then(|v| v.as_str()) { + fields.push(("title_chars".to_string(), title.chars().count().to_string())); + } + } + fields +} + +pub(super) struct ServerRequestLifecycleFields<'a> { + pub(super) phase: &'a str, + pub(super) request_id: u64, + pub(super) request_kind: &'a str, + pub(super) client_session_id: &'a str, + pub(super) client_connection_id: &'a str, + pub(super) client_instance_id: Option<&'a str>, + pub(super) client_is_processing: bool, + pub(super) message_id: Option<u64>, + pub(super) processing_session_id: Option<&'a str>, + pub(super) line_bytes: usize, +} + +pub(super) fn server_request_lifecycle_fields( + input: ServerRequestLifecycleFields<'_>, +) -> Vec<(String, String)> { + let ServerRequestLifecycleFields { + phase, + request_id, + request_kind, + client_session_id, + client_connection_id, + client_instance_id, + client_is_processing, + message_id, + processing_session_id, + line_bytes, + } = input; + let mut fields = vec![ + ("phase".to_string(), phase.to_string()), + ("request_id".to_string(), request_id.to_string()), + ("request_kind".to_string(), request_kind.to_string()), + ("session_id".to_string(), client_session_id.to_string()), + ( + "client_connection_id".to_string(), + client_connection_id.to_string(), + ), + ( + "client_instance_id".to_string(), + client_instance_id.unwrap_or("none").to_string(), + ), + ( + "client_processing".to_string(), + client_is_processing.to_string(), + ), + ( + "message_id".to_string(), + message_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "none".to_string()), + ), + ( + "processing_session_id".to_string(), + processing_session_id.unwrap_or("none").to_string(), + ), + ("line_bytes".to_string(), line_bytes.to_string()), + ]; + if let Some(ctx_session) = crate::logging::current_session() { + fields.push(("log_context_session_id".to_string(), ctx_session)); + } + fields +} diff --git a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs new file mode 100644 index 0000000..2b42775 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs @@ -0,0 +1,1062 @@ +use super::*; +use crate::message::{Message, StreamEvent, ToolDefinition}; +use crate::provider::{EventStream, Provider}; +use async_trait::async_trait; +use futures::stream; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +struct IsolatedRuntimeDir { + _prev_runtime: Option<std::ffi::OsString>, + _temp: tempfile::TempDir, +} + +struct IsolatedReloadRecoveryEnv { + prev_home: Option<std::ffi::OsString>, + prev_runtime: Option<std::ffi::OsString>, + _home: tempfile::TempDir, + _runtime: tempfile::TempDir, +} + +#[tokio::test] +async fn session_control_handle_does_not_wait_for_busy_agent_lock() { + let provider: Arc<dyn Provider> = 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 queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let background_signal = InterruptSignal::new(); + let stop_signal = InterruptSignal::new(); + let control = SessionControlHandle::new( + "session_control_test", + Arc::clone(&queue), + background_signal.clone(), + stop_signal.clone(), + ); + + let _busy_agent_lock = agent.lock().await; + + tokio::time::timeout(Duration::from_millis(100), async { + assert!(control.queue_soft_interrupt( + "please stop".to_string(), + true, + SoftInterruptSource::User, + )); + control.request_cancel(); + assert!(control.request_background_current_tool()); + control.clear_soft_interrupts(); + }) + .await + .expect("lock-free control operations should not wait for the agent mutex"); + + assert!(stop_signal.is_set()); + assert!(background_signal.is_set()); + assert!(queue.lock().expect("queue lock").is_empty()); +} + +#[tokio::test] +async fn refreshed_session_control_handle_does_not_wait_for_busy_agent_lock() { + let provider: Arc<dyn Provider> = Arc::new(PanicOnForkProvider { + forked: Arc::new(AtomicBool::new(false)), + }); + let registry = Registry::new(Arc::clone(&provider)).await; + let mut session = crate::session::Session::create_with_id( + "session_busy_control_refresh".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 stop_signal = InterruptSignal::new(); + let soft_interrupt_queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + "session_busy_control_refresh".to_string(), + stop_signal.clone(), + )]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::from([( + "session_busy_control_refresh".to_string(), + soft_interrupt_queue, + )]))); + + let _busy_agent_lock = agent.lock().await; + + tokio::time::timeout(Duration::from_millis(100), async { + let control = refresh_session_control_handle( + "session_busy_control_refresh", + &agent, + &shutdown_signals, + &soft_interrupt_queues, + ) + .await; + control.request_cancel(); + }) + .await + .expect("refreshing a session control handle must not wait for the busy agent mutex"); + + assert!(stop_signal.is_set()); +} + +#[tokio::test] +async fn busy_session_background_tool_signal_fires_via_registry_fallback() { + // Regression: pressing Alt+B/Ctrl+B while a turn owns the agent mutex (e.g. + // running `await_members`) used to silently no-op because the lock-free + // `cancel_only` control handle dropped the background-tool signal + // (BACKGROUND_TOOL_SIGNAL_FIRE result=no_signal_handle). Building a full + // SessionControlHandle now registers the signal in a process-global registry + // so the cancel-only fallback can still fire it without the agent lock. + let provider: Arc<dyn Provider> = Arc::new(PanicOnForkProvider { + forked: Arc::new(AtomicBool::new(false)), + }); + let registry = Registry::new(Arc::clone(&provider)).await; + let session_id = "session_busy_background_signal_registry"; + 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 background_signal = { + let agent_guard = agent.lock().await; + agent_guard.background_tool_signal() + }; + + // Build a full control handle once (registers the background signal), then + // simulate the busy-turn reconnect path which yields a cancel-only handle. + let stop_signal = InterruptSignal::new(); + let soft_interrupt_queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let _full = SessionControlHandle::new( + session_id, + Arc::clone(&soft_interrupt_queue), + background_signal.clone(), + stop_signal.clone(), + ); + + let cancel_only = + SessionControlHandle::cancel_only(session_id, soft_interrupt_queue, stop_signal); + + // The cancel-only handle has no directly-held background signal, yet it must + // still fire the registered one. + assert!(cancel_only.request_background_current_tool()); + assert!(background_signal.is_set()); + + // Cleanup so the global registry does not leak across tests. + crate::server::state::remove_background_tool_signal(session_id); +} + +#[tokio::test] +async fn busy_agent_request_rejection_does_not_wait_for_agent_lock() { + let provider: Arc<dyn Provider> = 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::<ServerEvent>(); + + let busy_agent_lock = agent.lock().await; + let rejected = tokio::time::timeout(Duration::from_millis(100), async { + reject_if_agent_busy_for_request( + 17, + "rename_session", + "session_busy_reject", + true, + &agent, + &client_event_tx, + ) + }) + .await + .expect("busy-agent request rejection must not wait for the agent mutex"); + assert!(rejected); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Error { + id: 17, + retry_after_secs: Some(1), + .. + }) + )); + + drop(busy_agent_lock); + assert!(!reject_if_agent_busy_for_request( + 18, + "rename_session", + "session_busy_reject", + false, + &agent, + &client_event_tx, + )); + assert!(client_event_rx.try_recv().is_err()); +} + +#[tokio::test] +async fn cancel_without_local_task_still_signals_session_control() { + let soft_interrupt_queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let stop_signal = InterruptSignal::new(); + let control = SessionControlHandle::cancel_only( + "session_detached_cancel", + soft_interrupt_queue, + stop_signal.clone(), + ); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + 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 = true; + let mut message_id = Some(99); + let mut session_id = Some("session_detached_cancel".to_string()); + let mut task = None; + + cancel_processing_message( + &mut ProcessingState { + client_is_processing: &mut client_is_processing, + message_id: &mut message_id, + session_id: &mut 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(99), + None, + ) + .await; + + assert!(stop_signal.is_set()); + assert!(!client_is_processing); + assert!(message_id.is_none()); + assert!(session_id.is_none()); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Interrupted) + )); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Done { id: 99 }) + )); +} + +/// Regression for issue #428: the detached-turn cancel path schedules a +/// deferred reset of the shared stop signal. That reset must be epoch-guarded: +/// if a newer cancel fires during the reset window (rapid repeated Esc), the +/// stale timer must not clear it, otherwise the running turn never observes +/// the interrupt and keeps generating. +#[tokio::test] +async fn deferred_cancel_reset_does_not_erase_newer_cancel() { + let soft_interrupt_queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let stop_signal = InterruptSignal::new(); + let control = SessionControlHandle::cancel_only( + "session_detached_cancel_race", + Arc::clone(&soft_interrupt_queue), + stop_signal.clone(), + ); + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + 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 cancel_via_no_task_path = async |request_id: u64| { + let mut client_is_processing = true; + let mut message_id = Some(request_id); + let mut session_id = Some("session_detached_cancel_race".to_string()); + let mut task = None; + cancel_processing_message( + &mut ProcessingState { + client_is_processing: &mut client_is_processing, + message_id: &mut message_id, + session_id: &mut 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(request_id), + None, + ) + .await; + }; + + // First Esc: fires the signal and schedules a 500ms deferred reset. + cancel_via_no_task_path(1).await; + assert!(stop_signal.is_set()); + + // 400ms later the user presses Esc again (turn still hasn't stopped). + tokio::time::sleep(Duration::from_millis(400)).await; + cancel_via_no_task_path(2).await; + assert!(stop_signal.is_set()); + + // The first press's timer expires now. It must NOT clear the second + // press's still-unobserved cancel. + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + stop_signal.is_set(), + "stale deferred reset erased a newer cancel (issue #428)" + ); + + // The second press's own timer may still clear it afterwards. + tokio::time::sleep(Duration::from_millis(600)).await; + assert!( + !stop_signal.is_set(), + "the newest cancel's deferred reset should eventually clear the flag" + ); +} + +impl IsolatedRuntimeDir { + fn new() -> Self { + let temp = tempfile::TempDir::new().expect("runtime dir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + crate::server::clear_reload_marker(); + Self { + _prev_runtime: prev_runtime, + _temp: temp, + } + } +} + +impl IsolatedReloadRecoveryEnv { + fn new() -> Self { + let home = tempfile::TempDir::new().expect("jcode home"); + let runtime = tempfile::TempDir::new().expect("runtime dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_HOME", home.path()); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + crate::server::clear_reload_marker(); + Self { + prev_home, + prev_runtime, + _home: home, + _runtime: runtime, + } + } +} + +impl Drop for IsolatedReloadRecoveryEnv { + fn drop(&mut self) { + crate::server::clear_reload_marker(); + if let Some(prev_home) = self.prev_home.take() { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + if let Some(prev_runtime) = self.prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +impl Drop for IsolatedRuntimeDir { + fn drop(&mut self) { + crate::server::clear_reload_marker(); + if let Some(prev_runtime) = self._prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +/// Regression for issue #428: a turn actively streaming in this session but +/// NOT owned by the cancelling connection (no local task handle: post-reload +/// reattach, server-initiated wake turns, headless recovery) must abort +/// promptly even when the control handle's stop signal is a *different +/// instance* from the streaming agent's own `graceful_shutdown` signal. +/// +/// Before the fix, `cancel_processing_message` hit the NO_LOCAL_TASK branch, +/// fired the stale handle-local signal (which nothing was listening to), +/// emitted `Interrupted` immediately, and the provider stream kept generating +/// for minutes ("Interrupting..." disappears, model keeps going, eventually +/// "Interrupted [x66]"). +#[test] +fn cancel_aborts_detached_streaming_turn_with_stale_stop_signal() -> anyhow::Result<()> { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedReloadRecoveryEnv::new(); + let session_id = "session_detached_streaming_cancel_428"; + + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let provider: Arc<dyn Provider> = Arc::new(NeverEndingStreamProvider); + 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("never-ending-stream".to_string()); + let agent = Arc::new(Mutex::new(Agent::new_with_session( + provider, registry, session, None, + ))); + + let (event_tx, mut event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + + // Start the turn the way server-initiated paths do: no entry in any + // connection's processing-task map. + let turn_agent = Arc::clone(&agent); + let turn = tokio::spawn(async move { + process_message_streaming_mpsc(turn_agent, "stream forever", Vec::new(), None, event_tx) + .await + }); + + // Wait until the provider stream is actively producing output. + loop { + match tokio::time::timeout(Duration::from_secs(5), event_rx.recv()).await { + Ok(Some(ServerEvent::TextDelta { .. })) => break, + Ok(Some(_)) => continue, + Ok(None) => panic!("event channel closed before streaming started"), + Err(_) => panic!("turn never started streaming"), + } + } + + // Esc arrives on a connection that does not own the task. Its control + // handle holds a stop signal instance that is NOT the streaming + // agent's graceful_shutdown signal (stale/lost registration). + let stale_stop_signal = InterruptSignal::new(); + let control = SessionControlHandle::cancel_only( + session_id, + Arc::new(std::sync::Mutex::new(Vec::new())), + stale_stop_signal.clone(), + ); + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + 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; + + 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; + + // The streaming turn must observe the cancel and stop promptly, not + // minutes later when the provider happens to finish (issue #428). + let result = tokio::time::timeout(Duration::from_secs(2), turn) + .await + .expect("streaming turn must abort promptly after cancel (issue #428)") + .expect("turn task join"); + result.expect("cancelled turn should checkpoint cleanly"); + + // The turn is over, so its cancel registration must be gone and the + // agent's own signal must be reset so the *next* turn is not aborted + // by the consumed cancel. + assert!( + crate::turn_cancel_registry::active_turn_signals(session_id).is_empty(), + "finished turn must unregister its cancel signal" + ); + let agent_signal = { + let agent_guard = agent.lock().await; + agent_guard.graceful_shutdown_signal() + }; + assert!( + !agent_signal.is_set(), + "consumed cancel must not leak into the next turn" + ); + }); + Ok(()) +} + +struct PanicOnForkProvider { + forked: Arc<AtomicBool>, +} + +/// Streams text deltas forever (one every 20ms) until dropped. Stands in for +/// a live provider stream that only stops when the turn observes a cancel. +struct NeverEndingStreamProvider; + +#[async_trait] +impl Provider for NeverEndingStreamProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Ok(Box::pin(stream::unfold(0u64, |n| async move { + tokio::time::sleep(Duration::from_millis(20)).await; + Some((Ok(StreamEvent::TextDelta(format!("token{} ", n))), n + 1)) + }))) + } + + fn name(&self) -> &str { + "never-ending-stream" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self) + } +} + +#[derive(Clone, Default)] +struct CompleteImmediatelyProvider; + +#[async_trait] +impl Provider for CompleteImmediatelyProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Ok(Box::pin(stream::iter(vec![Ok(StreamEvent::MessageEnd { + stop_reason: None, + })]))) + } + + fn name(&self) -> &str { + "complete-immediately" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self) + } +} + +#[async_trait] +impl Provider for PanicOnForkProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + panic!("complete should never run in lightweight control test") + } + + fn name(&self) -> &str { + "panic-on-fork" + } + + fn fork(&self) -> Arc<dyn Provider> { + self.forked.store(true, Ordering::SeqCst); + panic!("fork should not run for lightweight control requests") + } +} + +#[test] +fn ping_request_is_lightweight_control_request() { + assert!((Request::Ping { id: 1 }).is_lightweight_control_request()); +} + +fn subscribe_request(working_dir: Option<&str>) -> Request { + Request::Subscribe { + id: 1, + working_dir: working_dir.map(str::to_string), + selfdev: None, + target_session_id: None, + client_instance_id: None, + client_has_local_history: false, + allow_session_takeover: false, + terminal_env: Vec::new(), + } +} + +#[test] +fn initial_subscribe_requires_an_absolute_client_working_dir() { + for invalid in [None, Some(""), Some("relative/project")] { + let error = initial_subscribe_working_dir(&subscribe_request(invalid)) + .expect_err("invalid client cwd must be rejected before session creation"); + assert!(error.contains("working_dir") || error.contains("working directory")); + } + + let absolute = std::env::temp_dir().join("jcode-client-project"); + assert_eq!( + initial_subscribe_working_dir(&subscribe_request(absolute.to_str())) + .expect("absolute client cwd"), + absolute.to_string_lossy() + ); + + let error = initial_subscribe_working_dir(&Request::GetState { id: 2 }) + .expect_err("stateful requests must not create an unbound session"); + assert!(error.contains("must Subscribe")); +} + +#[tokio::test] +async fn new_client_agent_stamps_client_cwd_into_initial_context() { + let provider: Arc<dyn Provider> = Arc::new(CompleteImmediatelyProvider); + let registry = Registry::new(Arc::clone(&provider)).await; + let client_cwd = std::env::temp_dir().join("jcode-authoritative-client-project"); + let client_cwd = client_cwd.to_string_lossy(); + let agent = Agent::new_with_initial_working_dir(provider, registry, Some(&client_cwd)); + + assert_eq!(agent.working_dir(), Some(client_cwd.as_ref())); + let context = agent.messages()[0].content_preview(); + assert!( + context.contains(&format!("Working directory: {client_cwd}")), + "initial context must be created from the client cwd: {context}" + ); +} + +#[test] +fn server_reload_starting_is_true_only_for_recent_starting_marker() { + let _guard = crate::storage::lock_test_env(); + let _runtime = IsolatedRuntimeDir::new(); + + assert!(!server_reload_starting()); + + crate::server::write_reload_state( + "reload-lifecycle-test", + "test-hash", + crate::server::ReloadPhase::Starting, + Some("session_test_reload".to_string()), + ); + assert!(server_reload_starting()); + + crate::server::write_reload_state( + "reload-lifecycle-test", + "test-hash", + crate::server::ReloadPhase::SocketReady, + Some("session_test_reload".to_string()), + ); + assert!(!server_reload_starting()); +} + +#[test] +fn reload_starting_rejects_new_turn_without_spawning_processing_task() { + let _guard = crate::storage::lock_test_env(); + let _runtime = IsolatedRuntimeDir::new(); + crate::server::write_reload_state( + "reload-lifecycle-starting", + "test-hash", + crate::server::ReloadPhase::Starting, + Some("session_guard".to_string()), + ); + + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let forked = Arc::new(AtomicBool::new(false)); + let provider: Arc<dyn Provider> = 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_guard".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::<ServerEvent>(); + let (processing_done_tx, mut processing_done_rx) = mpsc::unbounded_channel(); + let mut client_is_processing = false; + let mut processing_message_id = None; + let mut processing_session_id = None; + let mut processing_task = None; + 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); + + start_processing_message( + ProcessingMessage { + id: 42, + content: "do not start during reload".to_string(), + images: Vec::new(), + system_reminder: None, + }, + "session_guard", + &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, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ) + .await; + + let event = client_event_rx + .recv() + .await + .expect("reload event should be sent to client"); + assert!(matches!(event, ServerEvent::Reloading { new_socket: None })); + assert!( + client_event_rx.try_recv().is_err(), + "reload guard should only emit the reload notification" + ); + assert!(!client_is_processing); + assert_eq!(processing_message_id, None); + assert_eq!(processing_session_id, None); + assert!(processing_task.is_none()); + assert!(processing_done_rx.try_recv().is_err()); + assert!( + !forked.load(Ordering::SeqCst), + "rejecting during reload should not fork or invoke provider work" + ); + }); +} + +#[test] +fn accepted_reload_recovery_continuation_marks_intent_delivered() -> anyhow::Result<()> { + let _lock = crate::storage::lock_test_env(); + let _env = IsolatedReloadRecoveryEnv::new(); + let session_id = "session_accepted_reload_recovery"; + let continuation = "stored continuation accepted by server"; + + super::super::reload_recovery::persist_intent( + "reload-accepted-continuation", + session_id, + super::super::reload_recovery::ReloadRecoveryRole::InterruptedPeer, + crate::tool::selfdev::ReloadRecoveryDirective { + reconnect_notice: Some("stored notice".to_string()), + continuation_message: continuation.to_string(), + }, + "synthetic accepted continuation test", + )?; + assert!(super::super::reload_recovery::has_pending_for_session( + session_id + )); + + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let provider: Arc<dyn Provider> = Arc::new(CompleteImmediatelyProvider); + 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("complete-immediately".to_string()); + let agent = Arc::new(Mutex::new(Agent::new_with_session( + provider, registry, session, None, + ))); + + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let (processing_done_tx, mut processing_done_rx) = mpsc::unbounded_channel(); + let mut client_is_processing = false; + let mut processing_message_id = None; + let mut processing_session_id = None; + let mut processing_task = None; + 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); + + start_processing_message( + ProcessingMessage { + id: 77, + content: "continue after reload".to_string(), + images: Vec::new(), + system_reminder: Some(continuation.to_string()), + }, + 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, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ) + .await; + + assert!(client_is_processing); + assert_eq!(processing_message_id, Some(77)); + assert_eq!(processing_session_id.as_deref(), Some(session_id)); + assert!(processing_task.is_some()); + assert!( + !super::super::reload_recovery::has_pending_for_session(session_id), + "server acceptance of the exact hidden continuation should consume the durable intent" + ); + + let (done_id, result, _report) = + tokio::time::timeout(std::time::Duration::from_secs(5), processing_done_rx.recv()) + .await + .expect("processing task should finish") + .expect("processing task should report completion"); + assert_eq!(done_id, 77); + result?; + if let Some(handle) = processing_task.take() { + handle.await.expect("processing task join"); + } + Ok::<(), anyhow::Error>(()) + })?; + + Ok(()) +} + +#[test] +fn reload_starting_rejects_new_turns_for_multiple_sessions() { + let _guard = crate::storage::lock_test_env(); + let _runtime = IsolatedRuntimeDir::new(); + crate::server::write_reload_state( + "reload-lifecycle-multi-starting", + "test-hash", + crate::server::ReloadPhase::Starting, + Some("session_alpha".to_string()), + ); + + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let forked = Arc::new(AtomicBool::new(false)); + let provider: Arc<dyn Provider> = Arc::new(PanicOnForkProvider { + forked: Arc::clone(&forked), + }); + let registry = Registry::new(Arc::clone(&provider)).await; + 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); + + for (message_id, session_id) in [ + (101, "session_alpha"), + (102, "session_beta"), + (103, "session_gamma"), + ] { + 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( + Arc::clone(&provider), + registry.clone(), + session, + None, + ))); + + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let (processing_done_tx, mut processing_done_rx) = mpsc::unbounded_channel(); + let mut client_is_processing = false; + let mut processing_message_id = None; + let mut processing_session_id = None; + let mut processing_task = None; + + start_processing_message( + ProcessingMessage { + id: message_id, + content: format!("do not start {session_id} during reload"), + images: Vec::new(), + system_reminder: None, + }, + 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, + &SwarmStatusRefs { + members: &swarm_members, + swarms_by_id: &swarms_by_id, + event_history: &event_history, + event_counter: &event_counter, + event_tx: &swarm_event_tx, + }, + ) + .await; + + let event = tokio::time::timeout( + std::time::Duration::from_millis(250), + client_event_rx.recv(), + ) + .await + .expect("reload guard should emit promptly for every session") + .expect("reload event should be sent to client"); + assert!( + matches!(event, ServerEvent::Reloading { new_socket: None }), + "expected Reloading event for {session_id}, got {event:?}" + ); + assert!( + client_event_rx.try_recv().is_err(), + "reload guard should only emit one reload notification for {session_id}" + ); + assert!( + !client_is_processing, + "{session_id} should not enter processing during reload" + ); + assert_eq!(processing_message_id, None); + assert_eq!(processing_session_id, None); + assert!( + processing_task.is_none(), + "{session_id} should not spawn a processing task during reload" + ); + assert!(processing_done_rx.try_recv().is_err()); + } + + assert!( + !forked.load(Ordering::SeqCst), + "rejecting multiple sessions during reload should not fork or invoke provider work" + ); + }); +} + +#[tokio::test] +async fn lightweight_comm_request_skips_full_session_initialization() { + let (server_stream, client_stream) = crate::transport::Stream::pair().expect("socket pair"); + let forked = Arc::new(AtomicBool::new(false)); + let provider_template: Arc<dyn Provider> = Arc::new(PanicOnForkProvider { + forked: Arc::clone(&forked), + }); + + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::new())); + let global_session_id = Arc::new(RwLock::new(String::new())); + let client_count = Arc::new(RwLock::new(0usize)); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); + let shared_context = Arc::new(RwLock::new(HashMap::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::new())); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let (_debug_response_tx, _) = broadcast::channel(8); + 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 (_global_event_tx, _) = broadcast::channel(8); + let global_is_processing = Arc::new(RwLock::new(false)); + let shutdown_signals = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let server_task = tokio::spawn(handle_client( + server_stream, + Arc::clone(&sessions), + _global_event_tx, + provider_template, + global_is_processing, + global_session_id, + client_count, + Arc::clone(&client_connections), + swarm_members, + swarms_by_id, + shared_context, + swarm_plans, + swarm_coordinators, + file_touch, + channel_subscriptions, + channel_subscriptions_by_session, + client_debug_state, + _debug_response_tx, + event_history, + event_counter, + swarm_event_tx, + "jcode-test".to_string(), + "🧪".to_string(), + mcp_pool, + shutdown_signals, + soft_interrupt_queues, + AwaitMembersRuntime::default(), + SwarmMutationRuntime::default(), + )); + + let (client_reader, mut client_writer) = client_stream.into_split(); + let mut client_reader = BufReader::new(client_reader); + let request = Request::CommList { + id: 7, + session_id: "not-in-swarm".to_string(), + }; + let payload = serde_json::to_string(&request).expect("serialize request") + "\n"; + client_writer + .write_all(payload.as_bytes()) + .await + .expect("write request"); + + let mut line = String::new(); + client_reader + .read_line(&mut line) + .await + .expect("read ack bytes"); + let ack = decode_request_or_event(&line); + assert!(matches!(ack, ServerEvent::Ack { id: 7 })); + + line.clear(); + client_reader + .read_line(&mut line) + .await + .expect("read terminal response"); + let response = decode_request_or_event(&line); + match response { + ServerEvent::Error { id, message, .. } => { + assert_eq!(id, 7); + assert!(message.contains("Not in a swarm")); + } + other => panic!("expected error response, got {other:?}"), + } + + drop(client_writer); + server_task + .await + .expect("server task join") + .expect("server task result"); + + assert!( + !forked.load(Ordering::SeqCst), + "lightweight control request should not fork a provider" + ); + assert!( + client_connections.read().await.is_empty(), + "lightweight control request should not register a live client session" + ); + assert!( + sessions.read().await.is_empty(), + "lightweight control request should not allocate a live agent session" + ); +} + +fn decode_request_or_event(line: &str) -> ServerEvent { + serde_json::from_str(line.trim()).expect("decode server event") +} diff --git a/crates/jcode-app-core/src/server/client_lightweight_control.rs b/crates/jcode-app-core/src/server/client_lightweight_control.rs new file mode 100644 index 0000000..2b47733 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_lightweight_control.rs @@ -0,0 +1,805 @@ +use super::client_comm::{ + handle_comm_channel_members, handle_comm_list, handle_comm_list_channels, handle_comm_message, + handle_comm_read, handle_comm_share, handle_comm_subscribe_channel, + handle_comm_unsubscribe_channel, +}; +use super::client_writer::write_direct_event; +use super::comm_await::{CommAwaitMembersContext, handle_comm_await_members}; +use super::comm_control::{ + handle_comm_assign_next, handle_comm_assign_role, handle_comm_assign_task, + handle_comm_task_control, +}; +use super::comm_plan::{ + handle_comm_approve_plan, handle_comm_propose_plan, handle_comm_reject_plan, +}; +use super::comm_session::{handle_comm_list_models, handle_comm_spawn, handle_comm_stop}; +use super::comm_sync::{ + CommResyncPlanContext, handle_comm_plan_status, handle_comm_read_context, + handle_comm_resync_plan, handle_comm_status, handle_comm_summary, +}; +use super::{ + AwaitMembersRuntime, ChannelSubscriptions, ClientConnectionInfo, FileTouchService, + SessionAgents, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, + SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, truncate_detail, + update_member_status_with_report_tldr, +}; +use crate::config::SwarmSpawnMode; +use crate::protocol::{Request, ServerEvent}; +use crate::provider::Provider; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +pub(super) fn parse_swarm_spawn_mode( + id: u64, + spawn_mode: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) -> Option<Option<SwarmSpawnMode>> { + match spawn_mode { + Some(value) => match SwarmSpawnMode::parse(&value) { + Some(mode) => Some(Some(mode)), + None => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Invalid spawn_mode '{value}'. Expected one of: visible, headless, inline, auto" + ), + retry_after_secs: None, + }); + None + } + }, + None => Some(None), + } +} + +pub(super) struct LightweightControlContext<'a> { + pub(super) sessions: &'a SessionAgents, + pub(super) global_session_id: &'a Arc<RwLock<String>>, + pub(super) provider_template: &'a Arc<dyn Provider>, + pub(super) swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + pub(super) swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub(super) shared_context: &'a Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + pub(super) swarm_plans: &'a Arc<RwLock<HashMap<String, VersionedPlan>>>, + pub(super) swarm_coordinators: &'a Arc<RwLock<HashMap<String, String>>>, + pub(super) file_touch: &'a FileTouchService, + pub(super) channel_subscriptions: &'a ChannelSubscriptions, + pub(super) channel_subscriptions_by_session: &'a ChannelSubscriptions, + pub(super) client_connections: &'a Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + pub(super) event_history: &'a Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + pub(super) event_counter: &'a Arc<std::sync::atomic::AtomicU64>, + pub(super) swarm_event_tx: &'a broadcast::Sender<SwarmEvent>, + pub(super) mcp_pool: &'a Arc<crate::mcp::SharedMcpPool>, + pub(super) soft_interrupt_queues: &'a SessionInterruptQueues, + pub(super) await_members_runtime: &'a AwaitMembersRuntime, + pub(super) swarm_mutation_runtime: &'a SwarmMutationRuntime, +} + +pub(super) async fn handle_lightweight_control_request( + request: Request, + writer: Arc<Mutex<crate::transport::WriteHalf>>, + context: LightweightControlContext<'_>, +) -> Result<()> { + let LightweightControlContext { + sessions, + global_session_id, + provider_template, + swarm_members, + swarms_by_id, + shared_context, + swarm_plans, + swarm_coordinators, + file_touch, + channel_subscriptions, + channel_subscriptions_by_session, + client_connections, + event_history, + event_counter, + swarm_event_tx, + mcp_pool, + soft_interrupt_queues, + await_members_runtime, + swarm_mutation_runtime, + } = context; + if let Request::Ping { id } = request { + write_direct_event(&writer, &ServerEvent::Pong { id }).await?; + return Ok(()); + } + + write_direct_event(&writer, &ServerEvent::Ack { id: request.id() }).await?; + + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let writer_clone = Arc::clone(&writer); + let event_handle = tokio::spawn(async move { + while let Some(event) = client_event_rx.recv().await { + if let Err(error) = write_direct_event(&writer_clone, &event).await { + // Routine on client reload/disconnect; avoid dumping the full + // event (an await response can embed whole completion reports). + let event_desc = crate::logging::truncate_for_log(&format!("{:?}", event), 200); + crate::logging::warn(&format!( + "lightweight control writer failed while sending {}: {}", + event_desc, error + )); + break; + } + } + }); + + match request { + Request::CommShare { + id, + session_id: req_session_id, + key, + value, + append, + } => { + handle_comm_share( + id, + req_session_id, + key, + value, + append, + &client_event_tx, + swarm_members, + swarms_by_id, + shared_context, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommRead { + id, + session_id: req_session_id, + key, + } => { + handle_comm_read( + id, + req_session_id, + key, + &client_event_tx, + swarm_members, + shared_context, + ) + .await; + } + Request::CommMessage { + id, + from_session, + message, + to_session, + channel, + delivery, + wake, + tldr, + } => { + handle_comm_message( + id, + from_session, + message, + to_session, + channel, + delivery, + wake, + tldr, + &client_event_tx, + sessions, + soft_interrupt_queues, + swarm_members, + swarms_by_id, + channel_subscriptions, + event_history, + event_counter, + swarm_event_tx, + client_connections, + ) + .await; + } + Request::CommList { + id, + session_id: req_session_id, + } => { + handle_comm_list( + id, + req_session_id, + &client_event_tx, + swarm_members, + swarms_by_id, + file_touch, + sessions, + client_connections, + ) + .await; + } + Request::CommListChannels { + id, + session_id: req_session_id, + } => { + handle_comm_list_channels( + id, + req_session_id, + &client_event_tx, + swarm_members, + channel_subscriptions, + ) + .await; + } + Request::CommChannelMembers { + id, + session_id: req_session_id, + channel, + } => { + handle_comm_channel_members( + id, + req_session_id, + channel, + &client_event_tx, + swarm_members, + channel_subscriptions, + ) + .await; + } + Request::CommProposePlan { + id, + session_id: req_session_id, + items, + } => { + handle_comm_propose_plan( + id, + req_session_id, + items, + &client_event_tx, + swarm_members, + swarms_by_id, + shared_context, + swarm_plans, + swarm_coordinators, + sessions, + soft_interrupt_queues, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + Request::CommApprovePlan { + id, + session_id: req_session_id, + proposer_session, + } => { + handle_comm_approve_plan( + id, + req_session_id, + proposer_session, + &client_event_tx, + swarm_members, + swarms_by_id, + shared_context, + swarm_plans, + swarm_coordinators, + sessions, + soft_interrupt_queues, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + Request::CommRejectPlan { + id, + session_id: req_session_id, + proposer_session, + reason, + } => { + handle_comm_reject_plan( + id, + req_session_id, + proposer_session, + reason, + &client_event_tx, + swarm_members, + shared_context, + swarm_coordinators, + sessions, + soft_interrupt_queues, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + Request::CommSeedGraph { + id, + session_id: req_session_id, + mode, + nodes, + } => { + super::comm_graph::handle_comm_seed_graph( + id, + req_session_id, + mode, + nodes, + &client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommExpandNode { + id, + session_id: req_session_id, + node_id, + children, + } => { + super::comm_graph::handle_comm_expand_node( + id, + req_session_id, + node_id, + children, + &client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommCompleteNode { + id, + session_id: req_session_id, + node_id, + artifact_json, + } => { + super::comm_graph::handle_comm_complete_node( + id, + req_session_id, + node_id, + artifact_json, + &client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommInjectGap { + id, + session_id: req_session_id, + gate_id, + nodes, + } => { + super::comm_graph::handle_comm_inject_gap( + id, + req_session_id, + gate_id, + nodes, + &client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommSpawn { + id, + session_id: req_session_id, + working_dir, + initial_message, + request_nonce, + spawn_mode, + model, + effort, + label, + } => { + let spawn_mode = match parse_swarm_spawn_mode(id, spawn_mode, &client_event_tx) { + Some(spawn_mode) => spawn_mode, + None => return Ok(()), + }; + handle_comm_spawn( + id, + req_session_id, + working_dir, + initial_message, + request_nonce, + spawn_mode, + model, + effort, + label, + &client_event_tx, + sessions, + global_session_id, + provider_template, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + channel_subscriptions, + channel_subscriptions_by_session, + event_history, + event_counter, + swarm_event_tx, + mcp_pool, + soft_interrupt_queues, + swarm_mutation_runtime, + client_connections, + ) + .await; + } + Request::CommListModels { + id, + session_id: req_session_id, + } => { + handle_comm_list_models(id, &req_session_id, sessions, provider_template, |event| { + let _ = client_event_tx.send(event); + }) + .await; + } + Request::CommStop { + id, + session_id: req_session_id, + target_session, + force, + } => { + handle_comm_stop( + id, + req_session_id, + target_session, + force.unwrap_or(false), + &client_event_tx, + sessions, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + channel_subscriptions, + channel_subscriptions_by_session, + event_history, + event_counter, + swarm_event_tx, + soft_interrupt_queues, + swarm_mutation_runtime, + ) + .await; + } + Request::CommAssignRole { + id, + session_id: req_session_id, + target_session, + role, + } => { + handle_comm_assign_role( + id, + req_session_id, + target_session, + role, + &client_event_tx, + sessions, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + Request::CommSummary { + id, + session_id: req_session_id, + target_session, + limit, + } => { + handle_comm_summary( + id, + req_session_id, + target_session, + limit, + sessions, + swarm_members, + &client_event_tx, + ) + .await; + } + Request::CommStatus { + id, + session_id: req_session_id, + target_session, + } => { + handle_comm_status( + id, + req_session_id, + target_session, + sessions, + swarm_members, + client_connections, + file_touch, + &client_event_tx, + ) + .await; + } + Request::CommReport { + id, + session_id: req_session_id, + status, + message, + validation, + follow_up, + tldr, + } => { + let status = status.unwrap_or_else(|| "ready".to_string()); + let report = format_structured_completion_report( + &message, + validation.as_deref(), + follow_up.as_deref(), + ); + let detail = Some(truncate_detail(&message, 160)); + update_member_status_with_report_tldr( + &req_session_id, + &status, + detail, + Some(report.clone()), + tldr, + swarm_members, + swarms_by_id, + Some(event_history), + Some(event_counter), + Some(swarm_event_tx), + ) + .await; + let _ = client_event_tx.send(ServerEvent::CommReportResponse { + id, + status, + message: "Report recorded and delivered to the coordinator when applicable." + .to_string(), + }); + } + Request::CommPlanStatus { + id, + session_id: req_session_id, + } => { + handle_comm_plan_status( + id, + req_session_id, + swarm_members, + swarm_plans, + &client_event_tx, + ) + .await; + } + Request::CommReadContext { + id, + session_id: req_session_id, + target_session, + } => { + handle_comm_read_context( + id, + req_session_id, + target_session, + sessions, + swarm_members, + &client_event_tx, + ) + .await; + } + Request::CommResyncPlan { + id, + session_id: req_session_id, + } => { + handle_comm_resync_plan( + id, + req_session_id, + &CommResyncPlanContext { + client_event_tx: &client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + }, + ) + .await; + } + Request::CommAssignTask { + id, + session_id: req_session_id, + target_session, + task_id, + message, + } => { + handle_comm_assign_task( + id, + req_session_id, + target_session, + task_id, + message, + &client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + Request::CommAssignNext { + id, + session_id: req_session_id, + target_session, + working_dir, + prefer_spawn, + spawn_if_needed, + message, + model, + effort, + } => { + handle_comm_assign_next( + id, + req_session_id, + target_session, + working_dir, + prefer_spawn, + spawn_if_needed, + message, + model, + effort, + &client_event_tx, + sessions, + global_session_id, + provider_template, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + mcp_pool, + swarm_mutation_runtime, + ) + .await; + } + Request::CommTaskControl { + id, + session_id: req_session_id, + action, + task_id, + target_session, + message, + } => { + handle_comm_task_control( + id, + req_session_id, + action, + task_id, + target_session, + message, + &client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + Request::CommSubscribeChannel { + id, + session_id: req_session_id, + channel, + } => { + handle_comm_subscribe_channel( + id, + req_session_id, + channel, + &client_event_tx, + swarm_members, + channel_subscriptions, + channel_subscriptions_by_session, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommUnsubscribeChannel { + id, + session_id: req_session_id, + channel, + } => { + handle_comm_unsubscribe_channel( + id, + req_session_id, + channel, + &client_event_tx, + swarm_members, + channel_subscriptions, + channel_subscriptions_by_session, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Request::CommAwaitMembers { + id, + session_id: req_session_id, + target_status, + session_ids: requested_ids, + mode, + timeout_secs, + background, + notify, + wake, + } => { + handle_comm_await_members( + id, + req_session_id, + target_status, + requested_ids, + mode, + timeout_secs, + background, + notify, + wake, + CommAwaitMembersContext { + client_event_tx: &client_event_tx, + swarm_members, + swarms_by_id, + swarm_event_tx, + await_members_runtime, + }, + ) + .await; + } + other => { + let _ = client_event_tx.send(ServerEvent::Error { + id: other.id(), + message: "unsupported lightweight control request".to_string(), + retry_after_secs: None, + }); + } + } + + drop(client_event_tx); + let _ = event_handle.await; + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session.rs b/crates/jcode-app-core/src/server/client_session.rs new file mode 100644 index 0000000..f90bc87 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session.rs @@ -0,0 +1,1508 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::client_state::{handle_get_history, spawn_model_prefetch_update}; +use super::{ + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, + SwarmMember, SwarmState, VersionedPlan, broadcast_swarm_status, fanout_live_client_event, + persist_swarm_state_for, register_background_tool_signal, register_session_event_sender, + 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, + unregister_session_event_sender, update_member_status, +}; +use crate::agent::Agent; +use crate::message::ContentBlock; +use crate::protocol::{NotificationType, ServerEvent}; +use crate::provider::Provider; +use crate::tool::Registry; +use crate::transport::WriteHalf; +use anyhow::Result; +use jcode_agent_runtime::InterruptSignal; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; +const RELOAD_RESTORE_MARKER_MAX_AGE: Duration = Duration::from_secs(60); + +pub(super) fn session_was_interrupted_by_reload(agent: &Agent) -> bool { + let messages = agent.messages(); + let Some(last) = messages.last() else { + return false; + }; + + last.content.iter().any(|block| match block { + ContentBlock::Text { text, .. } => { + text.ends_with("[generation interrupted - server reloading]") + } + ContentBlock::ToolResult { + content, is_error, .. + } => { + content == "Reload initiated. Process restarting..." + || (is_error.unwrap_or(false) + && (content.contains("interrupted by server reload") + || content.contains("Skipped - server reloading"))) + } + _ => false, + }) +} + +pub(super) fn restored_session_was_interrupted( + session_id: &str, + previous_status: &crate::session::SessionStatus, + agent: &Agent, +) -> bool { + let last_is_user = agent + .last_message_role() + .as_ref() + .map(|role| *role == crate::message::Role::User) + .unwrap_or(false); + let last_is_reload_interrupted = session_was_interrupted_by_reload(agent); + let closed_pending_user_during_reload = + matches!(previous_status, crate::session::SessionStatus::Closed) + && last_is_user + && crate::server::reload_marker_active(RELOAD_RESTORE_MARKER_MAX_AGE); + + if last_is_user && matches!(previous_status, crate::session::SessionStatus::Active) { + crate::logging::info(&format!( + "Session {} was Active with pending user message - treating as interrupted", + session_id + )); + } + + if last_is_reload_interrupted { + crate::logging::info(&format!( + "Session {} contains reload interruption markers - will auto-resume", + session_id + )); + } + + if closed_pending_user_during_reload { + crate::logging::info(&format!( + "Session {} was Closed with a pending user message during a recent reload - treating as interrupted", + session_id + )); + } + + matches!( + previous_status, + crate::session::SessionStatus::Crashed { .. } + ) || (matches!(previous_status, crate::session::SessionStatus::Active) && last_is_user) + || last_is_reload_interrupted + || closed_pending_user_during_reload +} + +fn mark_remote_reload_started(request_id: &str) { + crate::server::write_reload_state( + request_id, + jcode_build_meta::VERSION, + crate::server::ReloadPhase::Starting, + None, + ); +} + +async fn rename_shutdown_signal( + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + old_session_id: &str, + new_session_id: &str, +) { + if old_session_id == new_session_id { + return; + } + + let mut signals = shutdown_signals.write().await; + if let Some(signal) = signals.remove(old_session_id) { + signals.insert(new_session_id.to_string(), signal); + } + drop(signals); + rename_background_tool_signal(old_session_id, new_session_id); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_clear_session( + id: u64, + client_selfdev: bool, + client_session_id: &mut String, + client_connection_id: &str, + agent: &Arc<Mutex<Agent>>, + provider: &Arc<dyn Provider>, + registry: &Registry, + sessions: &SessionAgents, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let clear_start = Instant::now(); + let old_session_id = client_session_id.clone(); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "clear_start".to_string()), + ("request_id", id.to_string()), + ("session_id", old_session_id.clone()), + ("client_connection_id", client_connection_id.to_string()), + ("client_selfdev", client_selfdev.to_string()), + ], + ); + let (preserve_debug, working_dir) = { + let agent_guard = agent.lock().await; + ( + agent_guard.is_debug(), + agent_guard.working_dir().map(str::to_string), + ) + }; + + { + let mut agent_guard = agent.lock().await; + agent_guard.mark_closed(); + } + + let mut new_agent = Agent::new_with_initial_working_dir( + Arc::clone(provider), + registry.clone(), + working_dir.as_deref(), + ); + let new_id = new_agent.session_id().to_string(); + + if client_selfdev { + new_agent.set_canary("self-dev"); + } + if preserve_debug { + new_agent.set_debug(true); + } + + let mut agent_guard = agent.lock().await; + *agent_guard = new_agent; + drop(agent_guard); + + { + let mut sessions_guard = sessions.write().await; + sessions_guard.remove(client_session_id); + sessions_guard.insert(new_id.clone(), Arc::clone(agent)); + } + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "session_cleared", + "session_replaced_with_fresh_agent", + ) + .with_session_id(new_id.clone()) + .force_attribution(), + ); + { + let agent_guard = agent.lock().await; + register_session_interrupt_queue( + soft_interrupt_queues, + &new_id, + agent_guard.soft_interrupt_queue(), + ) + .await; + + let mut signals = shutdown_signals.write().await; + signals.remove(client_session_id); + signals.insert(new_id.clone(), agent_guard.graceful_shutdown_signal()); + drop(signals); + remove_background_tool_signal(client_session_id); + register_background_tool_signal(&new_id, agent_guard.background_tool_signal()); + } + remove_session_interrupt_queue(soft_interrupt_queues, client_session_id).await; + + let swarm_id_for_update = { + 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 + } + }; + 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()); + } + } + file_touch.clear_session(client_session_id).await; + remove_session_channel_subscriptions( + client_session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + update_member_status( + &new_id, + "ready", + None, + swarm_members, + swarms_by_id, + Some(event_history), + Some(event_counter), + Some(swarm_event_tx), + ) + .await; + if let Some(ref swarm_id) = swarm_id_for_update { + rename_plan_participant(swarm_id, client_session_id, &new_id, swarm_plans).await; + } + + *client_session_id = new_id.clone(); + { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(client_connection_id) { + info.session_id = new_id.clone(); + info.last_seen = Instant::now(); + } + } + let _ = client_event_tx.send(ServerEvent::SessionId { session_id: new_id }); + let _ = client_event_tx.send(ServerEvent::Done { id }); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "clear_done".to_string()), + ("request_id", id.to_string()), + ("old_session_id", old_session_id), + ("new_session_id", client_session_id.clone()), + ("client_connection_id", client_connection_id.to_string()), + ("preserve_debug", preserve_debug.to_string()), + ( + "swarm_id_updated", + swarm_id_for_update.is_some().to_string(), + ), + ("elapsed_ms", clear_start.elapsed().as_millis().to_string()), + ], + ); +} + +#[allow(clippy::too_many_arguments)] +async fn ensure_client_swarm_member( + client_session_id: &str, + client_connection_id: &str, + friendly_name: &Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + agent: &Arc<Mutex<Agent>>, + swarm_enabled: bool, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) -> 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); + let derived_swarm_id = if swarm_enabled { + swarm_id_for_dir(working_dir.clone()) + } else { + None + }; + let fallback_name = agent_guard + .session_short_name() + .map(|value| value.to_string()); + (working_dir, derived_swarm_id, fallback_name) + }; + + // Prefer the currently restored agent/session identity over the temporary + // name captured at raw socket accept time. During resume/reconnect bursts, + // the temporary pre-resume session name can otherwise leak onto the real + // resumed session and corrupt swarm metadata. + let member_name = fallback_name.or_else(|| friendly_name.clone()); + let mut inserted = false; + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(client_session_id) { + member.event_tx = client_event_tx.clone(); + member + .event_txs + .insert(client_connection_id.to_string(), client_event_tx.clone()); + member.swarm_enabled = swarm_enabled; + member.is_headless = false; + if member_name.is_some() { + member.friendly_name = member_name.clone(); + } + } else { + let now = Instant::now(); + members.insert( + client_session_id.to_string(), + SwarmMember { + session_id: client_session_id.to_string(), + event_tx: client_event_tx.clone(), + event_txs: HashMap::from([( + client_connection_id.to_string(), + client_event_tx.clone(), + )]), + working_dir: working_dir.clone(), + swarm_id: derived_swarm_id.clone(), + swarm_enabled, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: member_name.clone(), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + ); + inserted = true; + } + } + + if inserted && let Some(ref swarm_id_ref) = derived_swarm_id { + let mut swarms = swarms_by_id.write().await; + swarms + .entry(swarm_id_ref.to_string()) + .or_insert_with(HashSet::new) + .insert(client_session_id.to_string()); + drop(swarms); + super::record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + client_session_id.to_string(), + member_name, + Some(swarm_id_ref.to_string()), + crate::server::SwarmEventType::MemberChange { + action: "joined".to_string(), + }, + ) + .await; + } + + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "swarm_member_registered".to_string()), + ("session_id", client_session_id.to_string()), + ("client_connection_id", client_connection_id.to_string()), + ("inserted", inserted.to_string()), + ("swarm_enabled", swarm_enabled.to_string()), + ( + "swarm_id", + derived_swarm_id.unwrap_or_else(|| "none".to_string()), + ), + ], + ); + + inserted +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_subscribe( + id: u64, + subscribe_working_dir: Option<String>, + selfdev: Option<bool>, + register_mcp_tools: bool, + client_selfdev: &mut bool, + client_session_id: &str, + client_connection_id: &str, + friendly_name: &Option<String>, + agent: &Arc<Mutex<Agent>>, + registry: &Registry, + swarm_enabled: bool, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + mcp_pool: &Arc<crate::mcp::SharedMcpPool>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let subscribe_start = Instant::now(); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "subscribe_start".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("client_connection_id", client_connection_id.to_string()), + ( + "working_dir_set", + subscribe_working_dir.is_some().to_string(), + ), + ("register_mcp_tools", register_mcp_tools.to_string()), + ("swarm_enabled", swarm_enabled.to_string()), + ], + ); + ensure_client_swarm_member( + client_session_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; + + if let Some(ref dir) = subscribe_working_dir { + let mut agent_guard = agent.lock().await; + agent_guard.set_working_dir(dir); + drop(agent_guard); + + let new_path = PathBuf::from(dir); + let new_swarm_id = swarm_id_for_dir(Some(new_path.clone())); + let mut old_swarm_id: Option<String> = None; + let mut updated_swarm_id: Option<String> = 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(); + member.working_dir = Some(new_path); + member.swarm_id = if member.swarm_enabled { + new_swarm_id.clone() + } else { + None + }; + updated_swarm_id = member.swarm_id.clone(); + } + } + + if let Some(ref old_id) = old_swarm_id { + if updated_swarm_id.as_ref() != Some(old_id) { + remove_session_channel_subscriptions( + client_session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + } + let mut swarms = swarms_by_id.write().await; + if let Some(swarm) = swarms.get_mut(old_id) { + swarm.remove(client_session_id); + if swarm.is_empty() { + swarms.remove(old_id); + } + } + } + + if let Some(ref new_id) = updated_swarm_id { + let mut swarms = swarms_by_id.write().await; + swarms + .entry(new_id.clone()) + .or_insert_with(HashSet::new) + .insert(client_session_id.to_string()); + } + + if updated_swarm_id != old_swarm_id { + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "subscribe_swarm_changed".to_string()), + ("session_id", client_session_id.to_string()), + ("client_connection_id", client_connection_id.to_string()), + ( + "old_swarm_id", + old_swarm_id.clone().unwrap_or_else(|| "none".to_string()), + ), + ( + "new_swarm_id", + updated_swarm_id + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ], + ); + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(client_session_id) { + member.role = "agent".to_string(); + } + } + + if let Some(old_id) = old_swarm_id.clone() { + let was_coordinator = { + let coordinators = swarm_coordinators.read().await; + coordinators + .get(&old_id) + .map(|session_id| session_id == client_session_id) + .unwrap_or(false) + }; + if was_coordinator { + let mut new_coordinator: Option<String> = None; + { + let swarms = swarms_by_id.read().await; + if let Some(swarm) = swarms.get(&old_id) { + new_coordinator = swarm.iter().min().cloned(); + } + } + { + let mut coordinators = swarm_coordinators.write().await; + coordinators.remove(&old_id); + if let Some(ref new_id) = new_coordinator { + coordinators.insert(old_id.clone(), new_id.clone()); + } + } + if let Some(new_id) = new_coordinator.clone() { + let members = swarm_members.read().await; + if let Some(member) = members.get(&new_id) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: new_id.clone(), + from_name: member.friendly_name.clone(), + notification_type: NotificationType::Message { + scope: Some("swarm".to_string()), + channel: None, + tldr: None, + }, + message: "You are now the coordinator for this swarm.".to_string(), + }); + } + } + } + } + + if let Some(old_id) = old_swarm_id.clone() { + if updated_swarm_id.as_ref() != Some(&old_id) { + remove_plan_participant(&old_id, client_session_id, swarm_plans).await; + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&old_id, &swarm_state).await; + } + broadcast_swarm_status(&old_id, swarm_members, swarms_by_id).await; + } + if let Some(new_id) = updated_swarm_id + && old_swarm_id.as_ref() != Some(&new_id) + { + broadcast_swarm_status(&new_id, swarm_members, swarms_by_id).await; + } + } + + let should_selfdev = *client_selfdev || matches!(selfdev, Some(true)); + + 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); + registry.register_selfdev_tools().await; + } + + let mcp_register_ms = if register_mcp_tools { + let mcp_register_start = Instant::now(); + // Resolve project-local MCP config against the session working dir, + // 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) + } + }; + registry + .register_mcp_tools_for_dir( + Some(client_event_tx.clone()), + Some(Arc::clone(mcp_pool)), + Some(client_session_id.to_string()), + mcp_working_dir, + ) + .await; + mcp_register_start.elapsed().as_millis() + } else { + 0 + }; + + crate::logging::info(&format!( + "[TIMING] handle_subscribe: session={}, working_dir_set={}, selfdev={}, mcp_register={}ms, total={}ms", + client_session_id, + subscribe_working_dir.is_some(), + should_selfdev, + mcp_register_ms, + subscribe_start.elapsed().as_millis(), + )); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "subscribe_done".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("client_connection_id", client_connection_id.to_string()), + ("mcp_register_ms", mcp_register_ms.to_string()), + ( + "elapsed_ms", + subscribe_start.elapsed().as_millis().to_string(), + ), + ], + ); + + if subscribe_should_mark_ready(client_session_id, swarm_members).await { + update_member_status( + client_session_id, + "ready", + None, + swarm_members, + swarms_by_id, + Some(event_history), + Some(event_counter), + Some(swarm_event_tx), + ) + .await; + } + + // Re-send the current swarm plan so a reconnecting client renders the + // plan graph immediately instead of waiting for the next plan mutation. + send_swarm_plan_to_session(client_session_id, swarm_members, swarm_plans).await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +async fn subscribe_should_mark_ready( + client_session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> bool { + let members = swarm_members.read().await; + members + .get(client_session_id) + .is_none_or(|member| member.status != "running") +} + +pub(super) async fn handle_reload( + id: u64, + force: bool, + client_session_id: &str, + agent: &Arc<Mutex<Agent>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + // A non-forced reload (e.g. `jcode server reload`) is a graceful upgrade + // request: only reload when this server is provably running older code than + // an available reload candidate. This keeps us from downgrading a newer + // server (such as a self-dev daemon next to an older release client) and + // from re-entering the reload-loop family (#277), where a server that merely + // "differs" can never make the difference go away by reloading. + if !force && !super::server_has_newer_binary() { + crate::logging::info(&format!( + "handle_reload: skipping non-forced reload for client_session_id={} (no strictly-newer binary)", + client_session_id + )); + // Tell the requester this was a deliberate no-op (not a silent success) + // so callers like `jcode server reload` can report "already up to date" + // distinctly from an actual reload. + let _ = client_event_tx.send(ServerEvent::ReloadProgress { + step: "skip".to_string(), + message: "Server already running the newest binary; no reload needed.".to_string(), + success: Some(true), + output: None, + }); + let _ = client_event_tx.send(ServerEvent::Done { id }); + return; + } + + let request_id = crate::id::new_id("reload"); + mark_remote_reload_started(&request_id); + + let (triggering_session, prefer_selfdev_binary) = match agent.try_lock() { + Ok(agent_guard) => ( + Some(agent_guard.session_id().to_string()), + agent_guard.is_canary(), + ), + Err(_) => { + crate::logging::warn(&format!( + "SERVER_RELOAD_AGENT_BUSY request_id={} client_session_id={} fallback_triggering_session={} prefer_selfdev_binary=false", + request_id, client_session_id, client_session_id + )); + (Some(client_session_id.to_string()), false) + } + }; + + let live_sessions = { + let members = swarm_members.read().await; + members + .iter() + .filter_map(|(session_id, member)| { + if member.event_txs.is_empty() { + None + } else { + Some(session_id.clone()) + } + }) + .collect::<Vec<_>>() + }; + + let mut delivered = 0; + for session_id in &live_sessions { + delivered += fanout_live_client_event( + swarm_members, + session_id, + ServerEvent::Reloading { new_socket: None }, + ) + .await; + } + if delivered == 0 { + let _ = client_event_tx.send(ServerEvent::Reloading { new_socket: None }); + } + + let hash = jcode_build_meta::GIT_HASH.to_string(); + let signal_request_id = + crate::server::send_reload_signal(hash, triggering_session.clone(), prefer_selfdev_binary); + + crate::logging::info(&format!( + "handle_reload: queued reload signal {} from remote client request {} (triggering_session={:?}, prefer_selfdev_binary={}, reload_notified_sessions={}, reload_notified_clients={})", + signal_request_id, + request_id, + triggering_session, + prefer_selfdev_binary, + live_sessions.len(), + delivered + )); + + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +#[allow(clippy::too_many_arguments)] +async fn cleanup_detached_source_session_if_unused( + old_session_id: &str, + client_connection_id: &str, + source_agent: &Arc<Mutex<Agent>>, + sessions: &SessionAgents, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) { + unregister_session_event_sender(swarm_members, old_session_id, client_connection_id).await; + + if !remove_detached_source_if_unclaimed( + old_session_id, + client_connection_id, + source_agent, + sessions, + client_connections, + ) + .await + { + return; + } + + { + let mut agent_guard = source_agent.lock().await; + agent_guard.mark_closed(); + } + + { + let mut signals = shutdown_signals.write().await; + signals.remove(old_session_id); + } + remove_background_tool_signal(old_session_id); + remove_session_interrupt_queue(soft_interrupt_queues, old_session_id).await; + remove_session_channel_subscriptions( + old_session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + file_touch.clear_session(old_session_id).await; + + let removed_swarm_id = { + let mut members = swarm_members.write().await; + members + .remove(old_session_id) + .and_then(|member| member.swarm_id) + }; + if let Some(swarm_id) = removed_swarm_id { + remove_session_from_swarm( + old_session_id, + &swarm_id, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + ) + .await; + } +} + +/// Removes a detached source only while holding the same connection-registry +/// write lock used to claim a live resume target. The connection registry is +/// the attachment authority, so the lock order for transitions is always +/// `client_connections` then `sessions`. +async fn remove_detached_source_if_unclaimed( + old_session_id: &str, + client_connection_id: &str, + source_agent: &Arc<Mutex<Agent>>, + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, +) -> bool { + let connections = client_connections.write().await; + if connections + .values() + .any(|info| info.client_id != client_connection_id && info.session_id == old_session_id) + { + return false; + } + + let mut sessions_guard = sessions.write().await; + let owns_source = sessions_guard + .get(old_session_id) + .map(|existing| Arc::ptr_eq(existing, source_agent)) + .unwrap_or(false); + if owns_source { + sessions_guard.remove(old_session_id); + } + owns_source +} + +/// Atomically reserves an existing live target for this connection. +/// +/// Reserving under the connection write lock prevents another connection's +/// detached-source cleanup from observing no users after we have selected the +/// target but before our connection record is updated. +async fn claim_live_target_agent( + session_id: &str, + client_connection_id: &str, + client_instance_id: Option<&str>, + source_agent: &Arc<Mutex<Agent>>, + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, +) -> Option<Arc<Mutex<Agent>>> { + let mut connections = client_connections.write().await; + let sessions_guard = sessions.read().await; + let target = sessions_guard + .get(session_id) + .filter(|existing| !Arc::ptr_eq(existing, source_agent)) + .cloned()?; + + let info = connections.get_mut(client_connection_id)?; + info.session_id = session_id.to_string(); + info.client_instance_id = client_instance_id.map(str::to_string); + info.last_seen = Instant::now(); + Some(target) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_resume_session( + id: u64, + session_id: String, + working_dir_override: Option<&str>, + client_instance_id: Option<&str>, + client_has_local_history: bool, + allow_session_takeover: bool, + client_selfdev: &mut bool, + client_session_id: &mut String, + client_connection_id: &str, + agent: &Arc<Mutex<Agent>>, + provider: &Arc<dyn Provider>, + registry: &Registry, + sessions: &SessionAgents, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + client_debug_state: &Arc<RwLock<ClientDebugState>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + client_count: &Arc<RwLock<usize>>, + writer: &Arc<Mutex<WriteHalf>>, + server_name: &str, + server_icon: &str, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + mcp_pool: &Arc<crate::mcp::SharedMcpPool>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) -> Result<Arc<Mutex<Agent>>> { + let resume_start = Instant::now(); + let incoming_client_instance_id = client_instance_id.map(str::to_string); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "resume_start".to_string()), + ("request_id", id.to_string()), + ("source_session_id", client_session_id.clone()), + ("target_session_id", session_id.clone()), + ("client_connection_id", client_connection_id.to_string()), + ( + "client_instance_id", + incoming_client_instance_id + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ( + "client_has_local_history", + client_has_local_history.to_string(), + ), + ("allow_takeover", allow_session_takeover.to_string()), + ], + ); + let live_target_agent = claim_live_target_agent( + &session_id, + client_connection_id, + incoming_client_instance_id.as_deref(), + agent, + sessions, + client_connections, + ) + .await; + + if let Some(live_target_agent) = live_target_agent.as_ref() { + let old_session_id = client_session_id.clone(); + + let conflicting_live_client = { + let connections = client_connections.read().await; + connections + .values() + .find(|info| { + info.client_id != client_connection_id && info.session_id == session_id + }) + .cloned() + }; + let live_target_busy = live_target_agent.try_lock().is_err(); + crate::logging::info(&format!( + "Resume attach to existing live session {} from temporary {} on connection {}: live_target_busy={}, conflict_owner={}, conflict_processing={}, allow_takeover={}, local_history={}, incoming_instance={:?}", + session_id, + old_session_id, + client_connection_id, + live_target_busy, + conflicting_live_client + .as_ref() + .map(|info| info.client_id.as_str()) + .unwrap_or("<none>"), + conflicting_live_client + .as_ref() + .map(|info| info.is_processing) + .unwrap_or(false), + allow_session_takeover, + client_has_local_history, + incoming_client_instance_id + )); + + cleanup_detached_source_session_if_unused( + &old_session_id, + client_connection_id, + agent, + sessions, + shutdown_signals, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + file_touch, + channel_subscriptions, + channel_subscriptions_by_session, + swarm_plans, + swarm_coordinators, + ) + .await; + + if let Some(conflict) = conflicting_live_client { + let incoming_instance_id = incoming_client_instance_id.as_deref(); + let existing_instance_id = conflict.client_instance_id.as_deref(); + let distinct_client_instances = incoming_instance_id + .zip(existing_instance_id) + .map(|(incoming, existing)| incoming != existing) + .unwrap_or(false); + let can_take_over_live_session = + allow_session_takeover && client_has_local_history && !distinct_client_instances; + + if can_take_over_live_session { + let (disconnect_tx, debug_client_id, transferred_processing, transferred_tool_name) = { + let mut connections = client_connections.write().await; + let removed = connections.remove(&conflict.client_id); + if let Some(info) = removed { + ( + Some(info.disconnect_tx), + info.debug_client_id, + info.is_processing, + info.current_tool_name, + ) + } else { + ( + None, + conflict.debug_client_id, + conflict.is_processing, + conflict.current_tool_name, + ) + } + }; + if transferred_processing { + crate::logging::warn(&format!( + "Taking over live session {} from {} while old owner reports processing; new connection receives status/tool metadata but not the old processing task handle", + session_id, conflict.client_id + )); + } else { + crate::logging::info(&format!( + "Taking over live session {} from idle owner {}", + session_id, conflict.client_id + )); + } + + { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(client_connection_id) { + info.is_processing = transferred_processing; + info.current_tool_name = transferred_tool_name; + } + } + + if let Some(debug_client_id) = debug_client_id.as_deref() { + let mut debug_state = client_debug_state.write().await; + debug_state.unregister(debug_client_id); + } + + if let Some(disconnect_tx) = disconnect_tx { + let _ = disconnect_tx.send(()); + } + } + } + + register_session_event_sender( + swarm_members, + &session_id, + client_connection_id, + client_event_tx.clone(), + ) + .await; + + let is_canary = live_target_agent + .try_lock() + .ok() + .map(|agent_guard| agent_guard.is_canary()) + .or_else(|| { + crate::session::Session::load_startup_stub(&session_id) + .ok() + .map(|session| session.is_canary) + }) + .unwrap_or(false); + if is_canary { + *client_selfdev = true; + registry.register_selfdev_tools().await; + } + + *client_session_id = session_id.clone(); + + handle_get_history( + id, + &session_id, + false, + live_target_agent, + provider, + sessions, + client_connections, + client_count, + writer, + server_name, + server_icon, + None, + ) + .await?; + let _ = client_event_tx.send(ServerEvent::Done { id }); + // Resolve project-local MCP config against the resumed session's + // working dir, not the server process cwd (issue #420). + // Do not block on the agent lock here: the target agent may be busy + // mid-turn (lock held), and awaiting it would deadlock the resume. + let mcp_working_dir = working_dir_override.map(PathBuf::from).or_else(|| { + live_target_agent + .try_lock() + .ok() + .and_then(|agent_guard| agent_guard.working_dir().map(PathBuf::from)) + .or_else(|| { + crate::session::Session::load_startup_stub(&session_id) + .ok() + .and_then(|session| session.working_dir.map(PathBuf::from)) + }) + }); + registry + .register_mcp_tools_for_dir( + Some(client_event_tx.clone()), + Some(Arc::clone(mcp_pool)), + Some(session_id.clone()), + mcp_working_dir, + ) + .await; + spawn_model_prefetch_update(Arc::clone(provider), Arc::clone(live_target_agent)); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "resume_live_attach_done".to_string()), + ("request_id", id.to_string()), + ("old_session_id", old_session_id), + ("target_session_id", session_id.clone()), + ("client_connection_id", client_connection_id.to_string()), + ("live_target_busy", live_target_busy.to_string()), + ("elapsed_ms", resume_start.elapsed().as_millis().to_string()), + ], + ); + return Ok(Arc::clone(live_target_agent)); + } + + let conflicting_live_client = { + let connections = client_connections.read().await; + connections + .values() + .find(|info| info.client_id != client_connection_id && info.session_id == session_id) + .cloned() + }; + + if let Some(conflict) = conflicting_live_client { + let incoming_instance_id = incoming_client_instance_id.as_deref(); + let existing_instance_id = conflict.client_instance_id.as_deref(); + let same_client_instance = incoming_instance_id + .zip(existing_instance_id) + .map(|(incoming, existing)| incoming == existing) + .unwrap_or(false); + let distinct_client_instances = incoming_instance_id + .zip(existing_instance_id) + .map(|(incoming, existing)| incoming != existing) + .unwrap_or(false); + let can_take_over_live_session = allow_session_takeover + && (same_client_instance || (client_has_local_history && !distinct_client_instances)); + + crate::logging::info(&format!( + "Resume attach decision for session {} on connection {}: allow_takeover={}, local_history={}, same_client_instance={}, distinct_client_instances={}, incoming_instance={:?}, existing_instance={:?}, existing_owner={}", + session_id, + client_connection_id, + allow_session_takeover, + client_has_local_history, + same_client_instance, + distinct_client_instances, + incoming_client_instance_id, + conflict.client_instance_id, + conflict.client_id, + )); + + if can_take_over_live_session { + crate::logging::info(&format!( + "Taking over live session {} on connection {} by superseding {}", + session_id, client_connection_id, conflict.client_id + )); + + let (disconnect_tx, debug_client_id, transferred_processing, transferred_tool_name) = { + let mut connections = client_connections.write().await; + let removed = connections.remove(&conflict.client_id); + if let Some(info) = removed { + ( + Some(info.disconnect_tx), + info.debug_client_id, + info.is_processing, + info.current_tool_name, + ) + } else { + ( + None, + conflict.debug_client_id, + conflict.is_processing, + conflict.current_tool_name, + ) + } + }; + + { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(client_connection_id) { + info.is_processing = transferred_processing; + info.current_tool_name = transferred_tool_name; + } + } + + if let Some(debug_client_id) = debug_client_id.as_deref() { + let mut debug_state = client_debug_state.write().await; + debug_state.unregister(debug_client_id); + } + + if let Some(disconnect_tx) = disconnect_tx { + let _ = disconnect_tx.send(()); + } + } else { + if allow_session_takeover && distinct_client_instances { + crate::logging::warn(&format!( + "Rejecting reconnect takeover for session {} on connection {} because the incoming client is a different live instance from the current owner; incoming_instance={:?}, existing_instance={:?}, existing live owner is {}", + session_id, + client_connection_id, + incoming_client_instance_id, + conflict.client_instance_id, + conflict.client_id + )); + } else if allow_session_takeover && !client_has_local_history && !same_client_instance { + crate::logging::warn(&format!( + "Rejecting reconnect takeover for session {} on connection {} because the incoming client does not match the existing owner instance and has no local history; incoming_instance={:?}, existing_instance={:?}, existing live owner is {}", + session_id, + client_connection_id, + incoming_client_instance_id, + conflict.client_instance_id, + conflict.client_id + )); + } else { + crate::logging::warn(&format!( + "Rejecting duplicate live attach for session {} on connection {} because {} is already attached", + session_id, client_connection_id, conflict.client_id + )); + } + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Session '{}' is already live but could not be shared safely with this connection.", + session_id + ), + retry_after_secs: Some(1), + }); + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "resume_rejected".to_string()), + ("request_id", id.to_string()), + ("target_session_id", session_id.clone()), + ("client_connection_id", client_connection_id.to_string()), + ("conflict_client_id", conflict.client_id), + ("elapsed_ms", resume_start.elapsed().as_millis().to_string()), + ], + ); + return Ok(Arc::clone(agent)); + } + } + + { + let mut agent_guard = agent.lock().await; + agent_guard.mark_closed(); + } + + let (result, is_canary) = { + let mut agent_guard = agent.lock().await; + let result = + agent_guard.restore_session_with_working_dir(&session_id, working_dir_override); + if *client_selfdev { + agent_guard.set_canary("self-dev"); + } + let is_canary = agent_guard.is_canary(); + (result, is_canary) + }; + + let was_interrupted = match &result { + Ok(status) => { + let agent_guard = agent.lock().await; + restored_session_was_interrupted(&session_id, status, &agent_guard) + } + Err(_) => false, + }; + + if result.is_ok() && is_canary { + *client_selfdev = true; + registry.register_selfdev_tools().await; + } + + match result { + Ok(_prev_status) => { + let old_session_id = client_session_id.clone(); + *client_session_id = session_id.clone(); + + { + let mut sessions_guard = sessions.write().await; + sessions_guard.remove(&old_session_id); + sessions_guard.insert(session_id.clone(), Arc::clone(agent)); + } + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "session_resumed", + "existing_session_attached", + ) + .with_session_id(session_id.clone()) + .force_attribution(), + ); + rename_shutdown_signal(shutdown_signals, &old_session_id, &session_id).await; + rename_session_interrupt_queue(soft_interrupt_queues, &old_session_id, &session_id) + .await; + { + let mut connections = client_connections.write().await; + if let Some(info) = connections.get_mut(client_connection_id) { + info.session_id = session_id.clone(); + info.client_instance_id = incoming_client_instance_id.clone(); + info.last_seen = Instant::now(); + } + } + + { + 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()); + } + } + } + remove_session_channel_subscriptions( + &old_session_id, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + file_touch.clear_session(&old_session_id).await; + { + let mut coordinators = swarm_coordinators.write().await; + for coordinator in coordinators.values_mut() { + if *coordinator == old_session_id { + *coordinator = session_id.clone(); + } + } + } + update_member_status( + &session_id, + "ready", + None, + swarm_members, + swarms_by_id, + Some(event_history), + Some(event_counter), + Some(swarm_event_tx), + ) + .await; + if let Some(swarm_id) = { + let members = swarm_members.read().await; + members + .get(&session_id) + .and_then(|member| member.swarm_id.clone()) + } { + rename_plan_participant(&swarm_id, &old_session_id, &session_id, swarm_plans).await; + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + } + + register_session_event_sender( + swarm_members, + &session_id, + client_connection_id, + client_event_tx.clone(), + ) + .await; + + handle_get_history( + id, + &session_id, + false, + agent, + provider, + sessions, + client_connections, + client_count, + writer, + server_name, + server_icon, + Some(was_interrupted), + ) + .await?; + let _ = client_event_tx.send(ServerEvent::Done { id }); + // Re-send the swarm plan AFTER the History payload: the client + // clears its plan snapshot on session change, so without this the + // plan graph would stay blank until the next plan mutation. + send_swarm_plan_to_session(&session_id, swarm_members, swarm_plans).await; + // Resolve project-local MCP config against the restored session's + // working dir, not the server process cwd (issue #420). + let mcp_working_dir = { + let agent_guard = agent.lock().await; + agent_guard.working_dir().map(PathBuf::from) + }; + registry + .register_mcp_tools_for_dir( + Some(client_event_tx.clone()), + Some(Arc::clone(mcp_pool)), + Some(session_id.clone()), + mcp_working_dir, + ) + .await; + spawn_model_prefetch_update(Arc::clone(provider), Arc::clone(agent)); + crate::logging::event_info( + "SESSION_LIFECYCLE", + vec![ + ("phase", "resume_restored_done".to_string()), + ("request_id", id.to_string()), + ("old_session_id", old_session_id), + ("target_session_id", session_id.clone()), + ("client_connection_id", client_connection_id.to_string()), + ("was_interrupted", was_interrupted.to_string()), + ("elapsed_ms", resume_start.elapsed().as_millis().to_string()), + ], + ); + } + Err(error) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Failed to restore session: {}", + crate::util::format_error_chain(&error) + ), + retry_after_secs: None, + }); + crate::logging::event_warn( + "SESSION_LIFECYCLE", + vec![ + ("phase", "resume_restore_failed".to_string()), + ("request_id", id.to_string()), + ("target_session_id", session_id), + ("client_connection_id", client_connection_id.to_string()), + ("error", crate::util::format_error_chain(&error)), + ("elapsed_ms", resume_start.elapsed().as_millis().to_string()), + ], + ); + } + } + + Ok(Arc::clone(agent)) +} + +#[cfg(test)] +#[path = "client_session_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/server/client_session_tests.rs b/crates/jcode-app-core/src/server/client_session_tests.rs new file mode 100644 index 0000000..3879f85 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests.rs @@ -0,0 +1,250 @@ +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, +}; +use crate::agent::Agent; +use crate::message::ContentBlock; +use crate::message::{Message, ToolDefinition}; +use crate::protocol::ServerEvent; +use crate::provider::{EventStream, Provider}; +use crate::server::{ + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, + SwarmMember, VersionedPlan, +}; +use crate::tool::Registry; +use anyhow::Result; +use async_trait::async_trait; +use jcode_agent_runtime::InterruptSignal; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +struct MockProvider; + +fn test_swarm_member(session_id: &str, status: &str) -> SwarmMember { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some("swarm-test".to_string()), + swarm_enabled: true, + status: status.to_string(), + detail: None, + task_label: None, + friendly_name: Some(session_id.to_string()), + report_back_to_session_id: Some("coord".to_string()), + 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(), + } +} + +#[tokio::test] +async fn subscribe_does_not_mark_running_startup_worker_ready() { + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "worker".to_string(), + test_swarm_member("worker", "running"), + )]))); + assert!(!subscribe_should_mark_ready("worker", &swarm_members).await); +} + +#[tokio::test] +async fn subscribe_marks_non_running_member_ready() { + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "worker".to_string(), + test_swarm_member("worker", "spawned"), + )]))); + assert!(subscribe_should_mark_ready("worker", &swarm_members).await); +} + +#[async_trait] +impl Provider for MockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "mock provider complete should not be called in client_session tests" + )) + } + + fn name(&self) -> &str { + "mock" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(MockProvider) + } +} + +fn test_agent(messages: Vec<crate::session::StoredMessage>) -> Agent { + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let _guard = rt.enter(); + let registry = rt.block_on(Registry::new(provider.clone())); + build_test_agent(provider, registry, messages) +} + +fn build_test_agent( + provider: Arc<dyn Provider>, + registry: Registry, + messages: Vec<crate::session::StoredMessage>, +) -> Agent { + let mut session = + crate::session::Session::create_with_id("session_test_reload".to_string(), None, None); + session.model = Some("mock".to_string()); + session.replace_messages(messages); + Agent::new_with_session(provider, registry, session, None) +} + +fn build_test_agent_with_id( + provider: Arc<dyn Provider>, + registry: Registry, + session_id: &str, + messages: Vec<crate::session::StoredMessage>, +) -> Agent { + let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None); + session.model = Some("mock".to_string()); + session.replace_messages(messages); + Agent::new_with_session(provider, registry, session, None) +} + +async fn collect_events_until_done( + client_event_rx: &mut mpsc::UnboundedReceiver<ServerEvent>, + done_id: u64, +) -> Vec<ServerEvent> { + let mut events = Vec::new(); + for _ in 0..16 { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), client_event_rx.recv()) + .await + .expect("timed out waiting for server event") + .expect("expected server event"); + let is_done = matches!(event, ServerEvent::Done { id } if id == done_id); + events.push(event); + if is_done { + break; + } + } + events +} + +#[tokio::test] +async fn live_target_claim_is_atomic_with_detached_source_cleanup() { + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + + for iteration in 0..32 { + let target_id = format!("session_atomic_target_{iteration}"); + let source_id = format!("session_atomic_source_{iteration}"); + let target_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + registry.clone(), + &target_id, + Vec::new(), + ))); + let source_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + registry.clone(), + &source_id, + Vec::new(), + ))); + let sessions = Arc::new(RwLock::new(HashMap::from([( + target_id.clone(), + Arc::clone(&target_agent), + )]))); + let now = Instant::now(); + let (disconnect_tx, _disconnect_rx) = mpsc::unbounded_channel(); + let connections = Arc::new(RwLock::new(HashMap::from([( + "incoming".to_string(), + ClientConnectionInfo { + client_id: "incoming".to_string(), + session_id: source_id, + 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, + }, + )]))); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + + let claim = { + let barrier = Arc::clone(&barrier); + let sessions = Arc::clone(&sessions); + let connections = Arc::clone(&connections); + let source_agent = Arc::clone(&source_agent); + let target_id = target_id.clone(); + tokio::spawn(async move { + barrier.wait().await; + claim_live_target_agent( + &target_id, + "incoming", + Some("instance-a"), + &source_agent, + &sessions, + &connections, + ) + .await + .is_some() + }) + }; + let cleanup = { + let barrier = Arc::clone(&barrier); + let sessions = Arc::clone(&sessions); + let connections = Arc::clone(&connections); + let target_agent = Arc::clone(&target_agent); + let target_id = target_id.clone(); + tokio::spawn(async move { + barrier.wait().await; + remove_detached_source_if_unclaimed( + &target_id, + "cleanup", + &target_agent, + &sessions, + &connections, + ) + .await + }) + }; + + barrier.wait().await; + let claimed = claim.await.expect("claim task should complete"); + let removed = cleanup.await.expect("cleanup task should complete"); + assert_ne!(claimed, removed, "exactly one transition must win"); + assert_eq!( + sessions.read().await.contains_key(&target_id), + claimed, + "a successful claim must keep its target registered" + ); + if claimed { + let connections = connections.read().await; + let incoming = connections.get("incoming").expect("incoming connection"); + assert_eq!(incoming.session_id, target_id); + assert_eq!(incoming.client_instance_id.as_deref(), Some("instance-a")); + } + } +} + +#[path = "client_session_tests/clear.rs"] +mod clear_tests; +#[path = "client_session_tests/reload.rs"] +mod reload_tests; +#[path = "client_session_tests/resume.rs"] +mod resume_tests; 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 new file mode 100644 index 0000000..55fd254 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/clear.rs @@ -0,0 +1,156 @@ +use super::*; +use anyhow::{Result, anyhow}; + +#[tokio::test] +async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_registration() +-> Result<()> { + let _guard = crate::storage::lock_test_env(); + + let old_session_id = "session_before_clear"; + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + registry.clone(), + old_session_id, + Vec::new(), + ))); + + let old_queue = { + let guard = agent.lock().await; + guard.soft_interrupt_queue() + }; + let old_background_signal = { + let guard = agent.lock().await; + guard.background_tool_signal() + }; + let old_cancel_signal = { + let guard = agent.lock().await; + guard.graceful_shutdown_signal() + }; + + let sessions = Arc::new(RwLock::new(HashMap::from([( + old_session_id.to_string(), + Arc::clone(&agent), + )]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + old_session_id.to_string(), + old_cancel_signal.clone(), + )]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::from([( + old_session_id.to_string(), + old_queue.clone(), + )]))); + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "conn_clear".to_string(), + ClientConnectionInfo { + client_id: "conn_clear".to_string(), + session_id: old_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_clear".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + + let mut client_session_id = old_session_id.to_string(); + handle_clear_session( + 7, + false, + &mut client_session_id, + "conn_clear", + &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; + + assert_ne!(client_session_id, old_session_id); + + old_queue + .lock() + .map_err(|_| anyhow!("old queue lock"))? + .push(jcode_agent_runtime::SoftInterruptMessage { + content: "stale queued message".to_string(), + urgent: false, + source: jcode_agent_runtime::SoftInterruptSource::User, + }); + old_background_signal.fire(); + old_cancel_signal.fire(); + + let (new_queue, new_background_signal, new_cancel_signal) = { + let guard = agent.lock().await; + ( + guard.soft_interrupt_queue(), + guard.background_tool_signal(), + guard.graceful_shutdown_signal(), + ) + }; + + assert!(!Arc::ptr_eq(&old_queue, &new_queue)); + assert!(!new_background_signal.is_set()); + assert!(!new_cancel_signal.is_set()); + assert!(!agent.lock().await.has_soft_interrupts()); + + let queue_map = soft_interrupt_queues.read().await; + assert!(!queue_map.contains_key(old_session_id)); + assert!(queue_map.contains_key(&client_session_id)); + drop(queue_map); + + let signals = shutdown_signals.read().await; + assert!(!signals.contains_key(old_session_id)); + let registered_signal = signals + .get(&client_session_id) + .ok_or_else(|| anyhow!("new session should have shutdown signal"))? + .clone(); + drop(signals); + registered_signal.fire(); + assert!(new_cancel_signal.is_set()); + + let first = client_event_rx + .recv() + .await + .ok_or_else(|| anyhow!("session id event"))?; + assert!(matches!(first, ServerEvent::SessionId { .. })); + let second = client_event_rx + .recv() + .await + .ok_or_else(|| anyhow!("done event"))?; + assert!(matches!(second, ServerEvent::Done { id: 7 })); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/reload.rs b/crates/jcode-app-core/src/server/client_session_tests/reload.rs new file mode 100644 index 0000000..708e98a --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/reload.rs @@ -0,0 +1,495 @@ +use super::*; +use anyhow::{Result, anyhow}; + +#[test] +fn detects_reload_interrupted_generation_text() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_1".to_string(), + role: crate::message::Role::Assistant, + content: vec![ContentBlock::Text { + text: "partial\n\n[generation interrupted - server reloading]".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(session_was_interrupted_by_reload(&agent)); +} + +#[test] +fn detects_reload_interrupted_tool_result() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_2".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool_1".to_string(), + content: "[Tool 'bash' interrupted by server reload after 0.2s]".to_string(), + is_error: Some(true), + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(session_was_interrupted_by_reload(&agent)); +} + +#[test] +fn detects_reload_skipped_tool_result() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_3".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool_2".to_string(), + content: "[Skipped - server reloading]".to_string(), + is_error: Some(true), + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(session_was_interrupted_by_reload(&agent)); +} + +#[test] +fn detects_selfdev_reload_tool_result_even_when_not_marked_error() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_3b".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool_2b".to_string(), + content: "Reload initiated. Process restarting...".to_string(), + is_error: Some(false), + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(session_was_interrupted_by_reload(&agent)); +} + +#[test] +fn ignores_normal_tool_errors() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_4".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool_3".to_string(), + content: "Error: file not found".to_string(), + is_error: Some(true), + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(!session_was_interrupted_by_reload(&agent)); +} + +#[test] +fn restored_closed_session_with_reload_marker_still_counts_as_interrupted() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_5".to_string(), + role: crate::message::Role::Assistant, + content: vec![ContentBlock::Text { + text: "partial\n\n[generation interrupted - server reloading]".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(restored_session_was_interrupted( + "session_test_reload", + &crate::session::SessionStatus::Closed, + &agent, + )); +} + +#[test] +fn restored_closed_session_with_pending_user_message_during_reload_should_count_as_interrupted() +-> Result<()> { + let _guard = crate::storage::lock_test_env(); + let runtime = tempfile::TempDir::new().map_err(|e| anyhow!(e))?; + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + crate::server::write_reload_state( + "reload-pending-user", + "test-hash", + crate::server::ReloadPhase::Starting, + Some("session_test_reload".to_string()), + ); + + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_pending_reload".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::Text { + text: "continue this after reload".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + let interrupted = restored_session_was_interrupted( + "session_test_reload", + &crate::session::SessionStatus::Closed, + &agent, + ); + + crate::server::clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + + assert!( + interrupted, + "a session closed by reload cleanup while processing should be auto-resumed" + ); + Ok(()) +} + +#[test] +fn restored_closed_session_with_pending_user_message_during_socket_ready_handoff_counts_as_interrupted() +-> Result<()> { + let _guard = crate::storage::lock_test_env(); + let runtime = tempfile::TempDir::new().map_err(|e| anyhow!(e))?; + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + crate::server::write_reload_state( + "reload-pending-user-ready", + "test-hash", + crate::server::ReloadPhase::SocketReady, + Some("session_test_reload".to_string()), + ); + + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_pending_reload_ready".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::Text { + text: "continue this after socket-ready handoff".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + let interrupted = restored_session_was_interrupted( + "session_test_reload", + &crate::session::SessionStatus::Closed, + &agent, + ); + + crate::server::clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + + assert!( + interrupted, + "a pending user turn closed during socket-ready handoff should still be auto-resumed" + ); + Ok(()) +} + +#[test] +fn restored_closed_session_with_pending_user_message_without_reload_marker_is_not_interrupted() { + let _guard = crate::storage::lock_test_env(); + let runtime = tempfile::TempDir::new().expect("runtime dir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + crate::server::clear_reload_marker(); + + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_pending_normal_close".to_string(), + role: crate::message::Role::User, + content: vec![ContentBlock::Text { + text: "normal pending user text".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + let interrupted = restored_session_was_interrupted( + "session_test_reload", + &crate::session::SessionStatus::Closed, + &agent, + ); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + + assert!(!interrupted); +} + +#[test] +fn restored_closed_session_without_reload_marker_is_not_interrupted() { + let agent = test_agent(vec![crate::session::StoredMessage { + id: "msg_6".to_string(), + role: crate::message::Role::Assistant, + content: vec![ContentBlock::Text { + text: "finished normally".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }]); + + assert!(!restored_session_was_interrupted( + "session_test_reload", + &crate::session::SessionStatus::Closed, + &agent, + )); +} + +#[test] +fn mark_remote_reload_started_writes_starting_marker() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().map_err(|e| anyhow!(e))?; + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + mark_remote_reload_started("reload-test"); + + let state = crate::server::recent_reload_state(std::time::Duration::from_secs(5)) + .ok_or_else(|| anyhow!("reload state should exist"))?; + assert_eq!(state.request_id, "reload-test"); + assert_eq!(state.phase, crate::server::ReloadPhase::Starting); + + crate::server::clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + Ok(()) +} + +#[test] +fn handle_reload_queues_signal_for_canary_session() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().map_err(|e| anyhow!(e))?; + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let rt = tokio::runtime::Runtime::new().map_err(|e| anyhow!(e))?; + rt.block_on(async { + let mut rx = crate::server::subscribe_reload_signal_for_tests(); + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let mut agent = build_test_agent(provider, registry, Vec::new()); + agent.set_canary("self-dev"); + let agent = Arc::new(Mutex::new(agent)); + let (tx, mut events) = mpsc::unbounded_channel::<ServerEvent>(); + let (peer_tx, mut peer_events) = mpsc::unbounded_channel::<ServerEvent>(); + let now = Instant::now(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ( + "session_test_reload".to_string(), + SwarmMember { + session_id: "session_test_reload".to_string(), + event_tx: tx.clone(), + event_txs: HashMap::from([("conn-trigger".to_string(), tx.clone())]), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("trigger".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + ), + ( + "session_peer".to_string(), + SwarmMember { + session_id: "session_peer".to_string(), + event_tx: peer_tx.clone(), + event_txs: HashMap::from([("conn-peer".to_string(), peer_tx.clone())]), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("peer".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + ), + ]))); + + handle_reload(7, true, "session_test_reload", &agent, &swarm_members, &tx).await; + + let reloading = events + .recv() + .await + .ok_or_else(|| anyhow!("reloading event"))?; + assert!(matches!(reloading, ServerEvent::Reloading { .. })); + let peer_reloading = peer_events + .recv() + .await + .ok_or_else(|| anyhow!("peer reloading event"))?; + assert!(matches!(peer_reloading, ServerEvent::Reloading { .. })); + let done = events.recv().await.ok_or_else(|| anyhow!("done event"))?; + assert!(matches!(done, ServerEvent::Done { id: 7 })); + + tokio::time::timeout(std::time::Duration::from_secs(1), rx.changed()) + .await + .map_err(|_| anyhow!("reload signal timeout"))? + .map_err(|e| anyhow!("reload signal should be delivered: {e}"))?; + let signal = rx + .borrow_and_update() + .clone() + .ok_or_else(|| anyhow!("reload signal payload should exist"))?; + assert_eq!( + signal.triggering_session.as_deref(), + Some("session_test_reload") + ); + assert!(signal.prefer_selfdev_binary); + assert_eq!(signal.hash, jcode_build_meta::GIT_HASH); + + let state = crate::server::recent_reload_state(std::time::Duration::from_secs(5)) + .ok_or_else(|| anyhow!("reload state should exist"))?; + assert_eq!(state.phase, crate::server::ReloadPhase::Starting); + Ok::<_, anyhow::Error>(()) + })?; + + crate::server::clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + Ok(()) +} + +#[tokio::test] +async fn handle_reload_does_not_wait_for_busy_agent_lock() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().map_err(|e| anyhow!(e))?; + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + let mut rx = crate::server::subscribe_reload_signal_for_tests(); + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = build_test_agent(provider, registry, Vec::new()); + let agent = Arc::new(Mutex::new(agent)); + let busy_agent_lock = agent.lock().await; + + let (tx, mut events) = mpsc::unbounded_channel::<ServerEvent>(); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + + tokio::time::timeout( + std::time::Duration::from_millis(100), + handle_reload( + 11, + true, + "session_fallback_reload", + &agent, + &swarm_members, + &tx, + ), + ) + .await + .map_err(|_| anyhow!("handle_reload waited for a busy agent lock"))?; + + let reloading = events + .recv() + .await + .ok_or_else(|| anyhow!("reloading event"))?; + assert!(matches!(reloading, ServerEvent::Reloading { .. })); + let done = events.recv().await.ok_or_else(|| anyhow!("done event"))?; + assert!(matches!(done, ServerEvent::Done { id: 11 })); + + tokio::time::timeout(std::time::Duration::from_secs(1), rx.changed()) + .await + .map_err(|_| anyhow!("reload signal timeout"))? + .map_err(|e| anyhow!("reload signal should be delivered: {e}"))?; + let signal = rx + .borrow_and_update() + .clone() + .ok_or_else(|| anyhow!("reload signal payload should exist"))?; + assert_eq!( + signal.triggering_session.as_deref(), + Some("session_fallback_reload") + ); + assert!( + !signal.prefer_selfdev_binary, + "busy fallback must not wait for canary state" + ); + + drop(busy_agent_lock); + crate::server::clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + Ok(()) +} + +#[tokio::test] +async fn rename_shutdown_signal_moves_registration_to_restored_session() -> Result<()> { + let signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + "session_old".to_string(), + signal.clone(), + )]))); + + rename_shutdown_signal(&shutdown_signals, "session_old", "session_restored").await; + + let signals = shutdown_signals.read().await; + assert!(!signals.contains_key("session_old")); + let renamed = signals + .get("session_restored") + .ok_or_else(|| anyhow!("restored session should retain shutdown signal"))?; + renamed.fire(); + assert!(signal.is_set()); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume.rs b/crates/jcode-app-core/src/server/client_session_tests/resume.rs new file mode 100644 index 0000000..9bfb215 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume.rs @@ -0,0 +1,32 @@ +use super::*; +use crate::transport::WriteHalf; +use anyhow::{Result, anyhow}; + +fn setup_runtime_dir() -> Result<(tempfile::TempDir, Option<std::ffi::OsString>)> { + let runtime = tempfile::TempDir::new().map_err(|e| anyhow!(e))?; + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path()); + Ok((runtime, prev_runtime)) +} + +fn restore_runtime_dir(prev_runtime: Option<std::ffi::OsString>) { + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +fn test_writer() -> Result<(Arc<Mutex<WriteHalf>>, crate::transport::Stream)> { + let (stream_a, stream_b) = crate::transport::stream_pair().map_err(|e| anyhow!(e))?; + let (_reader, writer_half) = stream_a.into_split(); + Ok((Arc::new(Mutex::new(writer_half)), stream_b)) +} + +include!("resume/multiple_live_attach.rs"); +include!("resume/busy_existing_attach.rs"); +include!("resume/reconnect_takeover_with_history.rs"); +include!("resume/attach_without_local_history.rs"); +include!("resume/different_client_attach.rs"); +include!("resume/live_events_before_history.rs"); +include!("resume/same_client_takeover.rs"); diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs new file mode 100644 index 0000000..04908db --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs @@ -0,0 +1,174 @@ +#[tokio::test] +async fn handle_resume_session_allows_attach_without_local_history() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_existing_live_takeover_rejected"; + let temp_session_id = "session_temp_connecting_takeover_rejected"; + + let mut persisted = crate::session::Session::create_with_id( + target_session_id.to_string(), + None, + Some("Reconnect Takeover Rejected".to_string()), + ); + persisted.save()?; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let existing_registry = Registry::new(provider.clone()).await; + let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + existing_registry, + target_session_id, + Vec::new(), + ))); + + let new_registry = Registry::new(provider.clone()).await; + let new_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + new_registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (target_session_id.to_string(), Arc::clone(&existing_agent)), + (temp_session_id.to_string(), Arc::clone(&new_agent)), + ]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn_existing".to_string(), + ClientConnectionInfo { + client_id: "conn_existing".to_string(), + session_id: target_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_existing".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx, + }, + ), + ( + "conn_new".to_string(), + ClientConnectionInfo { + client_id: "conn_new".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_new".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(2usize)); + let (writer, _peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + + handle_resume_session( + 44, + target_session_id.to_string(), + None, + None, + false, + true, + &mut client_selfdev, + &mut client_session_id, + "conn_new", + &new_agent, + &provider, + &new_registry, + &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, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await?; + + let events = collect_events_until_done(&mut client_event_rx, 44).await; + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 44)), + "expected Done event for live attach, got {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ServerEvent::Error { .. })), + "attach should not emit error events: {events:?}" + ); + + assert_eq!(client_session_id, target_session_id); + assert!( + disconnect_rx.try_recv().is_err(), + "existing live client must not be kicked" + ); + let connections = client_connections.read().await; + assert!(connections.contains_key("conn_existing")); + assert_eq!( + connections + .get("conn_new") + .map(|info| info.session_id.as_str()), + Some(target_session_id) + ); + drop(connections); + let sessions_guard = sessions.read().await; + assert!(Arc::ptr_eq( + sessions_guard + .get(target_session_id) + .ok_or_else(|| anyhow!("existing live session should remain mapped"))?, + &existing_agent + )); + assert!(!sessions_guard.contains_key(temp_session_id)); + + restore_runtime_dir(prev_runtime); + 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 new file mode 100644 index 0000000..de52a4a --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs @@ -0,0 +1,178 @@ +#[tokio::test] +async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_existing_live_busy"; + let temp_session_id = "session_temp_connecting_busy"; + + let persisted_message = crate::session::StoredMessage { + id: "msg-live-busy".to_string(), + role: crate::message::Role::User, + content: vec![crate::message::ContentBlock::Text { + text: "persisted busy attach history".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let existing_registry = Registry::new(provider.clone()).await; + let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + existing_registry, + target_session_id, + vec![persisted_message], + ))); + + let new_registry = Registry::new(provider.clone()).await; + let new_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + new_registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (target_session_id.to_string(), Arc::clone(&existing_agent)), + (temp_session_id.to_string(), Arc::clone(&new_agent)), + ]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn_existing".to_string(), + ClientConnectionInfo { + client_id: "conn_existing".to_string(), + session_id: target_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_existing".to_string()), + connected_at: now, + last_seen: now, + is_processing: true, + current_tool_name: Some("bash".to_string()), + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ( + "conn_new".to_string(), + ClientConnectionInfo { + client_id: "conn_new".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_new".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(2usize)); + let (writer, peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + let _busy_guard = existing_agent.lock().await; + + handle_resume_session( + 77, + target_session_id.to_string(), + None, + None, + false, + false, + &mut client_selfdev, + &mut client_session_id, + "conn_new", + &new_agent, + &provider, + &new_registry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &Arc::new(RwLock::new(ClientDebugState::default())), + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_count, + &writer, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await?; + + let events = collect_events_until_done(&mut client_event_rx, 77).await; + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 77)), + "expected Done event for busy live attach, got {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ServerEvent::Error { .. })), + "busy live attach should not emit error events: {events:?}" + ); + + let mut peer_reader = tokio::io::BufReader::new(peer_stream); + let mut line = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + tokio::io::AsyncBufReadExt::read_line(&mut peer_reader, &mut line), + ) + .await + .expect("history should be written promptly")?; + let event: ServerEvent = serde_json::from_str(line.trim())?; + match event { + ServerEvent::History { + session_id, + messages, + .. + } => { + assert_eq!(session_id, target_session_id); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "persisted busy attach history"); + } + other => panic!("expected history event, got {other:?}"), + } + + restore_runtime_dir(prev_runtime); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs new file mode 100644 index 0000000..b9f497c --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs @@ -0,0 +1,174 @@ +#[tokio::test] +async fn handle_resume_session_allows_attach_from_different_client_instance() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_existing_live_local_history_rejected"; + let temp_session_id = "session_temp_connecting_local_history_rejected"; + + let mut persisted = crate::session::Session::create_with_id( + target_session_id.to_string(), + None, + Some("Reconnect Local History Rejected".to_string()), + ); + persisted.save()?; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let existing_registry = Registry::new(provider.clone()).await; + let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + existing_registry, + target_session_id, + Vec::new(), + ))); + + let new_registry = Registry::new(provider.clone()).await; + let new_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + new_registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (target_session_id.to_string(), Arc::clone(&existing_agent)), + (temp_session_id.to_string(), Arc::clone(&new_agent)), + ]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn_existing".to_string(), + ClientConnectionInfo { + client_id: "conn_existing".to_string(), + session_id: target_session_id.to_string(), + client_instance_id: Some("client_instance_existing".to_string()), + debug_client_id: Some("debug_existing".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx, + }, + ), + ( + "conn_new".to_string(), + ClientConnectionInfo { + client_id: "conn_new".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: Some("client_instance_new".to_string()), + debug_client_id: Some("debug_new".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(2usize)); + let (writer, _peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + + handle_resume_session( + 45, + target_session_id.to_string(), + None, + Some("client_instance_new"), + true, + true, + &mut client_selfdev, + &mut client_session_id, + "conn_new", + &new_agent, + &provider, + &new_registry, + &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, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await?; + + let events = collect_events_until_done(&mut client_event_rx, 45).await; + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 45)), + "expected Done event for live attach, got {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ServerEvent::Error { .. })), + "attach should not emit error events: {events:?}" + ); + + assert_eq!(client_session_id, target_session_id); + assert!( + disconnect_rx.try_recv().is_err(), + "existing live client must not be kicked" + ); + let connections = client_connections.read().await; + assert!(connections.contains_key("conn_existing")); + assert_eq!( + connections + .get("conn_new") + .map(|info| (info.session_id.as_str(), info.client_instance_id.as_deref())), + Some((target_session_id, Some("client_instance_new"))) + ); + drop(connections); + let sessions_guard = sessions.read().await; + assert!(Arc::ptr_eq( + sessions_guard + .get(target_session_id) + .ok_or_else(|| anyhow!("existing live session should remain mapped"))?, + &existing_agent + )); + assert!(!sessions_guard.contains_key(temp_session_id)); + + restore_runtime_dir(prev_runtime); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs new file mode 100644 index 0000000..4705807 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs @@ -0,0 +1,206 @@ +#[tokio::test] +async fn handle_resume_session_registers_live_events_before_history_replay() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_restore_target"; + let temp_session_id = "session_restore_temp"; + + let mut persisted = crate::session::Session::create_with_id( + target_session_id.to_string(), + None, + Some("Resume Registration Ordering".to_string()), + ); + persisted.save()?; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([( + temp_session_id.to_string(), + Arc::clone(&agent), + )]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "conn_restore".to_string(), + ClientConnectionInfo { + client_id: "conn_restore".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_restore".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + )]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let (placeholder_event_tx, _placeholder_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + temp_session_id.to_string(), + SwarmMember { + session_id: temp_session_id.to_string(), + event_tx: placeholder_event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some("restore".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + )]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(1usize)); + let (writer, _peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + let writer_guard = writer.lock().await; + + let resume_task = tokio::spawn({ + let agent = Arc::clone(&agent); + let provider = Arc::clone(&provider); + let registry = registry.clone(); + let sessions = Arc::clone(&sessions); + let shutdown_signals = Arc::clone(&shutdown_signals); + let soft_interrupt_queues = Arc::clone(&soft_interrupt_queues); + let client_connections = Arc::clone(&client_connections); + let client_debug_state = Arc::clone(&client_debug_state); + let swarm_members = Arc::clone(&swarm_members); + let swarms_by_id = Arc::clone(&swarms_by_id); + let file_touch = file_touch.clone(); + let channel_subscriptions = Arc::clone(&channel_subscriptions); + let channel_subscriptions_by_session = Arc::clone(&channel_subscriptions_by_session); + let swarm_plans = Arc::clone(&swarm_plans); + let swarm_coordinators = Arc::clone(&swarm_coordinators); + let client_count = Arc::clone(&client_count); + let writer = Arc::clone(&writer); + let client_event_tx = client_event_tx.clone(); + let mcp_pool = Arc::clone(&mcp_pool); + let event_history = Arc::clone(&event_history); + let event_counter = Arc::clone(&event_counter); + let swarm_event_tx = swarm_event_tx.clone(); + async move { + handle_resume_session( + 46, + target_session_id.to_string(), + None, + None, + false, + false, + &mut client_selfdev, + &mut client_session_id, + "conn_restore", + &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, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await + } + }); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let registered = { + let members = swarm_members.read().await; + members + .get(target_session_id) + .map(|member| member.event_txs.contains_key("conn_restore")) + .unwrap_or(false) + }; + if registered { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| anyhow!("live event sender should register before history replay completes"))?; + + assert!( + !resume_task.is_finished(), + "resume should still be blocked on history replay while writer is locked" + ); + + drop(writer_guard); + + resume_task + .await + .map_err(|e| anyhow!("resume task join: {e}"))??; + + let events = collect_events_until_done(&mut client_event_rx, 46).await; + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 46)), + "expected Done event for restore resume, got {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ServerEvent::Error { .. })), + "restore resume should not emit error events: {events:?}" + ); + + restore_runtime_dir(prev_runtime); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs new file mode 100644 index 0000000..a72a677 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs @@ -0,0 +1,160 @@ +#[tokio::test] +async fn handle_resume_session_allows_multiple_live_tui_attach() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_existing_live"; + let temp_session_id = "session_temp_connecting"; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let existing_registry = Registry::new(provider.clone()).await; + let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + existing_registry, + target_session_id, + Vec::new(), + ))); + + let new_registry = Registry::new(provider.clone()).await; + let new_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + new_registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (target_session_id.to_string(), Arc::clone(&existing_agent)), + (temp_session_id.to_string(), Arc::clone(&new_agent)), + ]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn_existing".to_string(), + ClientConnectionInfo { + client_id: "conn_existing".to_string(), + session_id: target_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_existing".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ( + "conn_new".to_string(), + ClientConnectionInfo { + client_id: "conn_new".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_new".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(2usize)); + let (writer, _peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + + handle_resume_session( + 42, + target_session_id.to_string(), + None, + None, + false, + false, + &mut client_selfdev, + &mut client_session_id, + "conn_new", + &new_agent, + &provider, + &new_registry, + &sessions, + &shutdown_signals, + &soft_interrupt_queues, + &client_connections, + &Arc::new(RwLock::new(ClientDebugState::default())), + &swarm_members, + &swarms_by_id, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &swarm_plans, + &swarm_coordinators, + &client_count, + &writer, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await?; + + let events = collect_events_until_done(&mut client_event_rx, 42).await; + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 42)), + "expected Done event for live attach, got {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ServerEvent::Error { .. })), + "attach should not emit error events: {events:?}" + ); + + assert_eq!(client_session_id, target_session_id); + let sessions_guard = sessions.read().await; + let mapped_agent = sessions_guard + .get(target_session_id) + .ok_or_else(|| anyhow!("existing live session should remain mapped"))?; + assert!(Arc::ptr_eq(mapped_agent, &existing_agent)); + assert!(!sessions_guard.contains_key(temp_session_id)); + drop(sessions_guard); + + let connections = client_connections.read().await; + assert!(connections.contains_key("conn_existing")); + assert_eq!( + connections + .get("conn_new") + .map(|info| info.session_id.as_str()), + Some(target_session_id) + ); + + restore_runtime_dir(prev_runtime); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs new file mode 100644 index 0000000..d0dfcdd --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs @@ -0,0 +1,160 @@ +#[tokio::test] +async fn handle_resume_session_allows_reconnect_takeover_with_local_history() -> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_existing_live_takeover"; + let temp_session_id = "session_temp_connecting_takeover"; + + let mut persisted = crate::session::Session::create_with_id( + target_session_id.to_string(), + None, + Some("Reconnect Takeover".to_string()), + ); + persisted.save()?; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let existing_registry = Registry::new(provider.clone()).await; + let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + existing_registry, + target_session_id, + Vec::new(), + ))); + + let new_registry = Registry::new(provider.clone()).await; + let new_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + new_registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (target_session_id.to_string(), Arc::clone(&existing_agent)), + (temp_session_id.to_string(), Arc::clone(&new_agent)), + ]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn_existing".to_string(), + ClientConnectionInfo { + client_id: "conn_existing".to_string(), + session_id: target_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_existing".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx, + }, + ), + ( + "conn_new".to_string(), + ClientConnectionInfo { + client_id: "conn_new".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: None, + debug_client_id: Some("debug_new".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(2usize)); + let (writer, _peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + + handle_resume_session( + 43, + target_session_id.to_string(), + None, + None, + true, + true, + &mut client_selfdev, + &mut client_session_id, + "conn_new", + &new_agent, + &provider, + &new_registry, + &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, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await?; + + while let Ok(event) = client_event_rx.try_recv() { + assert!( + !matches!(event, ServerEvent::Error { .. }), + "resume takeover should not queue an error event: {event:?}" + ); + } + assert_eq!(client_session_id, target_session_id); + + let disconnect_signal = disconnect_rx.recv().await; + assert!( + disconnect_signal.is_some(), + "old client should be told to disconnect" + ); + + let connections = client_connections.read().await; + assert!(!connections.contains_key("conn_existing")); + assert_eq!( + connections + .get("conn_new") + .map(|info| info.session_id.as_str()), + Some(target_session_id) + ); + + restore_runtime_dir(prev_runtime); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs new file mode 100644 index 0000000..dea5742 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs @@ -0,0 +1,177 @@ +#[tokio::test] +async fn handle_resume_session_allows_same_client_instance_takeover_without_local_history() +-> Result<()> { + let _guard = crate::storage::lock_test_env(); + let (_runtime, prev_runtime) = setup_runtime_dir()?; + + let target_session_id = "session_existing_live_same_instance_takeover"; + let temp_session_id = "session_temp_connecting_same_instance_takeover"; + let shared_instance_id = "client_instance_same_window"; + + let mut persisted = crate::session::Session::create_with_id( + target_session_id.to_string(), + None, + Some("Reconnect Same Instance Takeover".to_string()), + ); + persisted.save()?; + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let existing_registry = Registry::new(provider.clone()).await; + let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + existing_registry, + target_session_id, + Vec::new(), + ))); + + let new_registry = Registry::new(provider.clone()).await; + let new_agent = Arc::new(Mutex::new(build_test_agent_with_id( + provider.clone(), + new_registry.clone(), + temp_session_id, + Vec::new(), + ))); + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (target_session_id.to_string(), Arc::clone(&existing_agent)), + (temp_session_id.to_string(), Arc::clone(&new_agent)), + ]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new())); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let now = Instant::now(); + let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn_existing".to_string(), + ClientConnectionInfo { + client_id: "conn_existing".to_string(), + session_id: target_session_id.to_string(), + client_instance_id: Some(shared_instance_id.to_string()), + debug_client_id: Some("debug_existing".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx, + }, + ), + ( + "conn_new".to_string(), + ClientConnectionInfo { + client_id: "conn_new".to_string(), + session_id: temp_session_id.to_string(), + client_instance_id: Some(shared_instance_id.to_string()), + debug_client_id: Some("debug_new".to_string()), + connected_at: now, + last_seen: now, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new())); + let file_touch = FileTouchService::new(); + let channel_subscriptions = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::< + String, + HashMap<String, HashSet<String>>, + >::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new())); + let client_count = Arc::new(RwLock::new(2usize)); + let (writer, _peer_stream) = test_writer()?; + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new())); + let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + let mut client_selfdev = false; + let mut client_session_id = temp_session_id.to_string(); + + handle_resume_session( + 45, + target_session_id.to_string(), + None, + Some(shared_instance_id), + false, + true, + &mut client_selfdev, + &mut client_session_id, + "conn_new", + &new_agent, + &provider, + &new_registry, + &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, + "test-server", + "🌿", + &client_event_tx, + &mcp_pool, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await?; + + let events = collect_events_until_done(&mut client_event_rx, 45).await; + assert!( + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { id } if *id == 45)), + "expected Done event for live attach, got {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, ServerEvent::Error { .. })), + "same-instance attach should not queue an error event: {events:?}" + ); + assert_eq!(client_session_id, target_session_id); + + assert!( + disconnect_rx.try_recv().is_err(), + "existing live client should remain connected" + ); + + let connections = client_connections.read().await; + assert!(connections.contains_key("conn_existing")); + assert_eq!( + connections + .get("conn_new") + .map(|info| (info.session_id.as_str(), info.client_instance_id.as_deref())), + Some((target_session_id, Some(shared_instance_id))) + ); + drop(connections); + let sessions_guard = sessions.read().await; + assert!(Arc::ptr_eq( + sessions_guard + .get(target_session_id) + .ok_or_else(|| anyhow!("existing live session should remain mapped"))?, + &existing_agent + )); + assert!(!sessions_guard.contains_key(temp_session_id)); + + restore_runtime_dir(prev_runtime); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_state.rs b/crates/jcode-app-core/src/server/client_state.rs new file mode 100644 index 0000000..4d0ec0e --- /dev/null +++ b/crates/jcode-app-core/src/server/client_state.rs @@ -0,0 +1,928 @@ +use super::ClientConnectionInfo; +use super::server_has_newer_binary; +use crate::agent::Agent; +use crate::bus::Bus; +use crate::message::{ContentBlock, Role}; +use crate::protocol::{ + HistoryMessage, ServerEvent, SessionActivitySnapshot, TokenUsageTotals, encode_event, +}; +use crate::provider::Provider; +use crate::session::{Session, SessionStatus}; +use crate::transport::WriteHalf; +use anyhow::Result; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; +use std::time::{Duration, Instant}; +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum HistoryPayloadMode { + Full, +} +use tokio::io::AsyncWriteExt; +use tokio::sync::{Mutex, RwLock}; + +const ATTACH_MODEL_PREFETCH_DEBOUNCE_SECS: u64 = 15; +const RELOAD_RESTORE_MARKER_MAX_AGE: Duration = Duration::from_secs(60); + +fn optional_token_usage_totals(totals: TokenUsageTotals) -> Option<TokenUsageTotals> { + (totals.messages_with_token_usage > 0).then_some(totals) +} + +fn optional_total_tokens(totals: TokenUsageTotals) -> Option<(u64, u64)> { + (totals.messages_with_token_usage > 0).then_some((totals.input_tokens, totals.output_tokens)) +} + +static LAST_ATTACH_MODEL_PREFETCH: LazyLock<StdMutex<HashMap<String, Instant>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +fn should_debounce_attach_model_prefetch(provider_name: &str) -> bool { + let Ok(mut guard) = LAST_ATTACH_MODEL_PREFETCH.lock() else { + return false; + }; + + let now = Instant::now(); + if let Some(last_run) = guard.get(provider_name) + && now.duration_since(*last_run) < Duration::from_secs(ATTACH_MODEL_PREFETCH_DEBOUNCE_SECS) + { + return true; + } + + guard.insert(provider_name.to_string(), now); + false +} + +fn history_provider_name_from_session(session: &crate::session::Session) -> Option<String> { + let key = session.provider_key.as_deref()?.trim(); + if key.is_empty() { + return None; + } + + let label = match key.to_ascii_lowercase().as_str() { + "openai" => "OpenAI".to_string(), + "claude" | "anthropic" => "Anthropic".to_string(), + "openrouter" => "OpenRouter".to_string(), + "copilot" => "GitHub Copilot".to_string(), + "cursor" => "Cursor".to_string(), + "gemini" => "Gemini".to_string(), + "bedrock" => "Bedrock".to_string(), + "antigravity" => "Antigravity".to_string(), + "jcode" => "Jcode".to_string(), + other => other.to_string(), + }; + + Some(label) +} + +pub(super) async fn handle_get_state( + id: u64, + client_session_id: &str, + client_is_processing: bool, + sessions: &SessionAgents, + writer: &Arc<Mutex<WriteHalf>>, +) -> Result<()> { + let session_count = { + let sessions_guard = sessions.read().await; + sessions_guard.len() + }; + + write_event( + writer, + &ServerEvent::State { + id, + session_id: client_session_id.to_string(), + message_count: session_count, + is_processing: client_is_processing, + }, + ) + .await +} + +#[expect( + clippy::too_many_arguments, + reason = "history fetch needs session state, client activity, provider handle, and server identity metadata" +)] +pub(super) async fn handle_get_history( + id: u64, + client_session_id: &str, + client_is_processing: bool, + agent: &Arc<Mutex<Agent>>, + provider: &Arc<dyn Provider>, + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + client_count: &Arc<RwLock<usize>>, + writer: &Arc<Mutex<WriteHalf>>, + server_name: &str, + server_icon: &str, + was_interrupted: Option<bool>, +) -> Result<()> { + let history_start = Instant::now(); + let activity = + session_activity_snapshot(client_connections, client_session_id, client_is_processing) + .await; + + if agent.try_lock().is_err() { + crate::logging::info(&format!( + "handle_get_history: session {} busy, falling back to persisted remote-startup snapshot", + client_session_id + )); + send_history_from_persisted_session( + id, + client_session_id, + provider, + sessions, + client_count, + writer, + server_name, + server_icon, + was_interrupted, + activity, + ) + .await?; + crate::logging::info(&format!( + "[TIMING] handle_get_history: session={}, persisted_fallback total={}ms", + client_session_id, + history_start.elapsed().as_millis(), + )); + return Ok(()); + } + + send_history( + id, + client_session_id, + agent, + sessions, + client_count, + writer, + server_name, + server_icon, + was_interrupted, + activity, + HistoryPayloadMode::Full, + true, + ) + .await?; + let send_history_ms = history_start.elapsed().as_millis(); + + let prefetch_start = Instant::now(); + spawn_model_prefetch_update(Arc::clone(provider), Arc::clone(agent)); + crate::logging::info(&format!( + "[TIMING] handle_get_history: session={}, send_history={}ms, prefetch_spawn={}ms, total={}ms", + client_session_id, + send_history_ms, + prefetch_start.elapsed().as_millis(), + history_start.elapsed().as_millis(), + )); + Ok(()) +} + +pub(super) async fn handle_get_model_catalog( + id: u64, + session_id: &str, + agent: &Arc<Mutex<Agent>>, + provider: &Arc<dyn Provider>, + writer: &Arc<Mutex<WriteHalf>>, +) -> Result<()> { + let started = Instant::now(); + let build_started = Instant::now(); + let ( + provider_name, + provider_model, + available_models, + available_model_routes, + resolved_credential, + source, + ) = { + match agent.try_lock() { + Ok(agent_guard) => ( + Some(agent_guard.provider_name()), + Some(agent_guard.provider_model()), + agent_guard.available_models_display(), + agent_guard.model_routes(), + agent_guard.active_resolved_credential(), + "live", + ), + Err(_) => { + crate::logging::warn(&format!( + "handle_get_model_catalog: session {} busy, using provider/persisted fallback", + session_id + )); + let persisted = Session::load_for_remote_startup(session_id) + .or_else(|_| Session::load_startup_stub(session_id)) + .ok(); + let persisted_model = persisted.as_ref().and_then(|session| session.model.clone()); + ( + Some(provider.name().to_string()), + persisted_model.or_else(|| Some(provider.model())), + provider.available_models_display(), + provider.model_routes(), + provider.active_resolved_credential(), + "fallback", + ) + } + } + }; + let build_ms = build_started.elapsed().as_millis(); + + let encode_started = Instant::now(); + let event = ServerEvent::History { + id, + session_id: session_id.to_string(), + messages: Vec::new(), + images: Vec::new(), + provider_name, + provider_model, + available_models, + available_model_routes, + mcp_servers: Vec::new(), + skills: Vec::new(), + total_tokens: None, + token_usage_totals: None, + all_sessions: Vec::new(), + client_count: None, + is_canary: None, + server_version: None, + server_name: None, + server_icon: None, + server_has_update: None, + was_interrupted: None, + reload_recovery: None, + connection_type: None, + status_detail: None, + upstream_provider: None, + resolved_credential, + reasoning_effort: None, + service_tier: None, + subagent_model: None, + autoreview_enabled: None, + autojudge_enabled: None, + compaction_mode: Default::default(), + activity: None, + side_panel: Default::default(), + }; + let json = encode_event(&event); + let encode_ms = encode_started.elapsed().as_millis(); + let write_started = Instant::now(); + let mut writer_guard = writer.lock().await; + let writer_lock_ms = write_started.elapsed().as_millis(); + writer_guard.write_all(json.as_bytes()).await?; + let write_ms = write_started + .elapsed() + .as_millis() + .saturating_sub(writer_lock_ms); + crate::logging::info(&format!( + "[TIMING] handle_get_model_catalog: session={}, source={}, bytes={}, build={}ms, encode={}ms, writer_lock={}ms, write={}ms, total={}ms", + session_id, + source, + json.len(), + build_ms, + encode_ms, + writer_lock_ms, + write_ms, + started.elapsed().as_millis() + )); + Ok(()) +} + +pub(super) async fn handle_get_compacted_history( + id: u64, + session_id: &str, + agent: &Arc<Mutex<Agent>>, + writer: &Arc<Mutex<WriteHalf>>, + visible_messages: usize, +) -> Result<()> { + let started = Instant::now(); + let (messages, images, compacted_info, source) = match agent.try_lock() { + Ok(agent_guard) => { + let (messages, images, info) = agent_guard + .get_history_and_rendered_images_with_compacted_history(visible_messages); + (messages, images, info, "live") + } + Err(_) => { + let session = crate::session::Session::load_for_remote_startup(session_id) + .or_else(|_| crate::session::Session::load_startup_stub(session_id))?; + let (rendered_messages, images, info) = + crate::session::render_messages_and_images_with_compacted_history( + &session, + visible_messages, + ); + ( + rendered_messages + .into_iter() + .map(rendered_to_history_message) + .collect(), + images, + info, + "persisted", + ) + } + }; + + let compacted_info = compacted_info.unwrap_or(crate::session::RenderedCompactedHistoryInfo { + total_messages: 0, + visible_messages: 0, + remaining_messages: 0, + hidden_user_prompts: 0, + }); + crate::logging::info(&format!( + "[TIMING] get_compacted_history: session={}, source={}, requested={}, visible={}, remaining={}, messages={}, total={}ms", + session_id, + source, + visible_messages, + compacted_info.visible_messages, + compacted_info.remaining_messages, + messages.len(), + started.elapsed().as_millis(), + )); + + write_event( + writer, + &ServerEvent::CompactedHistory { + id, + session_id: session_id.to_string(), + messages, + images, + compacted_total: compacted_info.total_messages, + compacted_visible: compacted_info.visible_messages, + compacted_remaining: compacted_info.remaining_messages, + compacted_hidden_prompts: compacted_info.hidden_user_prompts, + }, + ) + .await +} + +fn rendered_to_history_message(msg: crate::session::RenderedMessage) -> HistoryMessage { + HistoryMessage { + role: msg.role, + content: msg.content, + tool_calls: if msg.tool_calls.is_empty() { + None + } else { + Some(msg.tool_calls) + }, + tool_data: msg.tool_data, + } +} + +fn history_reload_recovery_snapshot( + session_id: &str, + was_interrupted: Option<bool>, +) -> Option<crate::protocol::ReloadRecoverySnapshot> { + match super::reload_recovery::pending_directive_for_session(session_id) { + Ok(Some(directive)) => { + crate::logging::info(&format!( + "history_reload_recovery_snapshot: attaching server-owned recovery intent for session={} without marking delivered", + session_id + )); + return Some(directive); + } + Ok(None) => {} + Err(err) => crate::logging::warn(&format!( + "history_reload_recovery_snapshot: failed to read server-owned recovery intent for session={}: {}", + session_id, err + )), + } + + let reload_ctx = crate::tool::selfdev::ReloadContext::peek_for_session(session_id) + .ok() + .flatten(); + let inferred_interrupted = was_interrupted + .unwrap_or_else(|| infer_persisted_session_interrupted_by_reload(session_id)); + let directive = crate::tool::selfdev::ReloadContext::recovery_directive_for_session( + session_id, + reload_ctx.as_ref(), + inferred_interrupted, + None, + ); + crate::logging::info(&format!( + "history_reload_recovery_snapshot: session={} explicit_was_interrupted={:?} inferred_was_interrupted={} has_reload_ctx={} directive={}", + session_id, + was_interrupted, + inferred_interrupted, + reload_ctx.is_some(), + directive.is_some() + )); + directive +} + +fn persisted_session_has_reload_interruption_marker(session: &Session) -> bool { + let Some(last) = session.messages.last() else { + return false; + }; + + last.content.iter().any(|block| match block { + ContentBlock::Text { text, .. } => { + text.ends_with("[generation interrupted - server reloading]") + } + ContentBlock::ToolResult { + content, is_error, .. + } => { + content == "Reload initiated. Process restarting..." + || (is_error.unwrap_or(false) + && (content.contains("interrupted by server reload") + || content.contains("Skipped - server reloading"))) + } + _ => false, + }) +} + +fn infer_persisted_session_interrupted_by_reload(session_id: &str) -> bool { + let session = match Session::load_for_remote_startup(session_id) + .or_else(|_| Session::load_startup_stub(session_id)) + { + Ok(session) => session, + Err(err) => { + crate::logging::warn(&format!( + "history_reload_recovery_snapshot: could not inspect persisted session {} for reload interruption fallback: {}", + session_id, err + )); + return false; + } + }; + + let last_is_user = session + .messages + .last() + .map(|message| message.role == Role::User) + .unwrap_or(false); + let marker_active = crate::server::reload_marker_active(RELOAD_RESTORE_MARKER_MAX_AGE); + let interrupted = matches!(session.status, SessionStatus::Crashed { .. }) + || (matches!(session.status, SessionStatus::Active) && last_is_user && marker_active) + || (matches!(session.status, SessionStatus::Closed) && last_is_user && marker_active) + || persisted_session_has_reload_interruption_marker(&session); + + crate::logging::info(&format!( + "history_reload_recovery_snapshot: fallback inspect session={} status={} last_is_user={} marker_active={} interrupted={}", + session_id, + session.status.display(), + last_is_user, + marker_active, + interrupted + )); + + interrupted +} + +#[expect( + clippy::too_many_arguments, + reason = "persisted history fallback still needs session/client/server metadata for a usable bootstrap payload" +)] +async fn send_history_from_persisted_session( + id: u64, + session_id: &str, + provider: &Arc<dyn Provider>, + sessions: &SessionAgents, + client_count: &Arc<RwLock<usize>>, + writer: &Arc<Mutex<WriteHalf>>, + server_name: &str, + server_icon: &str, + was_interrupted: Option<bool>, + activity: Option<SessionActivitySnapshot>, +) -> Result<()> { + let session = crate::session::Session::load_for_remote_startup(session_id) + .or_else(|_| crate::session::Session::load_startup_stub(session_id))?; + let token_usage_totals = session.token_usage_totals(); + let (rendered_messages, images) = crate::session::render_messages_and_images(&session); + // Extract the small metadata fields we need, then drop the full Session + // (including its message transcript) before building and serializing the + // large History event, so we do not hold Session + rendered payload + + // serialized wire bytes simultaneously. + let provider_name = + history_provider_name_from_session(&session).or_else(|| Some(provider.name().to_string())); + let provider_model = session.model.clone().or_else(|| Some(provider.model())); + let subagent_model = session.subagent_model.clone(); + let autoreview_enabled = session.autoreview_enabled; + let autojudge_enabled = session.autojudge_enabled; + let is_canary = session.is_canary; + let reasoning_effort = session + .reasoning_effort + .clone() + .or_else(|| provider.reasoning_effort()); + drop(session); + + let messages = rendered_messages + .into_iter() + .map(rendered_to_history_message) + .collect(); + let side_panel = crate::side_panel::snapshot_for_session(session_id).unwrap_or_default(); + + let (all_sessions, current_client_count) = { + let sessions_guard = sessions.read().await; + let mut all: Vec<String> = sessions_guard.keys().cloned().collect(); + all.sort(); + let count = *client_count.read().await; + (all, count) + }; + + let history_event = ServerEvent::History { + id, + session_id: session_id.to_string(), + messages, + images, + provider_name, + provider_model, + subagent_model, + autoreview_enabled, + autojudge_enabled, + available_models: Vec::new(), + available_model_routes: Vec::new(), + mcp_servers: Vec::new(), + skills: Vec::new(), + total_tokens: optional_total_tokens(token_usage_totals), + token_usage_totals: optional_token_usage_totals(token_usage_totals), + all_sessions, + client_count: Some(current_client_count), + is_canary: Some(is_canary), + server_version: Some(jcode_build_meta::VERSION.to_string()), + server_name: Some(server_name.to_string()), + server_icon: Some(server_icon.to_string()), + server_has_update: Some(server_has_newer_binary()), + was_interrupted, + reload_recovery: history_reload_recovery_snapshot(session_id, was_interrupted), + connection_type: None, + status_detail: None, + upstream_provider: None, + resolved_credential: provider.active_resolved_credential(), + reasoning_effort, + service_tier: None, + compaction_mode: crate::config::config().compaction.mode.clone(), + activity, + side_panel, + }; + + write_event(writer, &history_event).await +} + +#[expect( + clippy::too_many_arguments, + reason = "history payload assembly includes agent state, sessions, counts, writer, activity, payload mode, and server identity" +)] +pub(super) async fn send_history( + id: u64, + session_id: &str, + agent: &Arc<Mutex<Agent>>, + sessions: &SessionAgents, + client_count: &Arc<RwLock<usize>>, + writer: &Arc<Mutex<WriteHalf>>, + server_name: &str, + server_icon: &str, + was_interrupted: Option<bool>, + activity: Option<SessionActivitySnapshot>, + payload_mode: HistoryPayloadMode, + include_model_catalog: bool, +) -> Result<()> { + let history_start = Instant::now(); + let agent_lock_start = Instant::now(); + let ( + messages, + images, + is_canary, + provider_name, + provider_model, + subagent_model, + autoreview_enabled, + autojudge_enabled, + available_models, + available_model_routes, + skills, + tool_names, + upstream_provider, + resolved_credential, + connection_type, + status_detail, + reasoning_effort, + service_tier, + compaction_mode, + token_usage_totals, + agent_lock_ms, + history_snapshot_ms, + image_render_ms, + tool_names_ms, + available_models_ms, + model_routes_ms, + skills_ms, + provider_meta_ms, + compaction_mode_ms, + ) = { + let agent_guard = agent.lock().await; + let agent_lock_ms = agent_lock_start.elapsed().as_millis(); + let provider = agent_guard.provider_handle(); + + let history_snapshot_start = Instant::now(); + let (messages, images) = agent_guard.get_history_and_rendered_images(); + let history_snapshot_ms = history_snapshot_start.elapsed().as_millis(); + let image_render_ms = 0; + + let tool_names_start = Instant::now(); + let tool_names = agent_guard.tool_names().await; + let tool_names_ms = tool_names_start.elapsed().as_millis(); + + let (available_models, available_models_ms) = if include_model_catalog { + let available_models_start = Instant::now(); + let available_models = agent_guard.available_models_display(); + ( + available_models, + available_models_start.elapsed().as_millis(), + ) + } else { + (Vec::new(), 0) + }; + + // Model-route expansion can be relatively expensive (provider/account routing, + // endpoint cache reads, etc.). The TUI already supports later + // AvailableModelsUpdated events, so keep the initial History payload fast and + // let the background refresh populate detailed routes asynchronously. + let available_model_routes = Vec::new(); + let model_routes_ms = 0; + + let skills_start = Instant::now(); + let skills = agent_guard.available_skill_names(); + let skills_ms = skills_start.elapsed().as_millis(); + + let provider_meta_start = Instant::now(); + let reasoning_effort = provider.reasoning_effort(); + let service_tier = provider.service_tier(); + let provider_meta_ms = provider_meta_start.elapsed().as_millis(); + + let compaction_mode_start = Instant::now(); + let compaction_mode = agent_guard.compaction_mode().await; + let compaction_mode_ms = compaction_mode_start.elapsed().as_millis(); + + ( + messages, + images, + agent_guard.is_canary(), + agent_guard.provider_name(), + agent_guard.provider_model(), + agent_guard.subagent_model(), + agent_guard.autoreview_enabled(), + agent_guard.autojudge_enabled(), + available_models, + available_model_routes, + skills, + tool_names, + agent_guard.last_upstream_provider(), + agent_guard.active_resolved_credential(), + agent_guard.last_connection_type(), + agent_guard.last_status_detail(), + reasoning_effort, + service_tier, + compaction_mode, + agent_guard.token_usage_totals(), + agent_lock_ms, + history_snapshot_ms, + image_render_ms, + tool_names_ms, + available_models_ms, + model_routes_ms, + skills_ms, + provider_meta_ms, + compaction_mode_ms, + ) + }; + + let side_panel_start = Instant::now(); + let side_panel = crate::side_panel::snapshot_for_session(session_id).unwrap_or_default(); + let side_panel_ms = side_panel_start.elapsed().as_millis(); + + let mut mcp_map: BTreeMap<String, usize> = BTreeMap::new(); + for name in &tool_names { + if let Some(rest) = name.strip_prefix("mcp__") + && let Some((server, _tool)) = rest.split_once("__") + { + *mcp_map.entry(server.to_string()).or_default() += 1; + } + } + let mcp_servers: Vec<String> = mcp_map + .into_iter() + .map(|(name, count)| format!("{name}:{count}")) + .collect(); + + let (all_sessions, current_client_count) = { + let sessions_snapshot_start = Instant::now(); + let sessions_guard = sessions.read().await; + let all: Vec<String> = sessions_guard.keys().cloned().collect(); + let count = *client_count.read().await; + let sessions_snapshot_ms = sessions_snapshot_start.elapsed().as_millis(); + crate::logging::info(&format!( + "[TIMING] send_history prep: session={}, mode={:?}, messages={}, images={}, mcp_servers={}, agent_lock={}ms, history={}ms, images={}ms, tool_names={}ms, models={}ms, routes={}ms, skills={}ms, provider_meta={}ms, compaction={}ms, side_panel={}ms, sessions={}ms, total={}ms", + session_id, + payload_mode, + messages.len(), + images.len(), + mcp_servers.len(), + agent_lock_ms, + history_snapshot_ms, + image_render_ms, + tool_names_ms, + available_models_ms, + model_routes_ms, + skills_ms, + provider_meta_ms, + compaction_mode_ms, + side_panel_ms, + sessions_snapshot_ms, + history_start.elapsed().as_millis(), + )); + (all, count) + }; + + let history_event = ServerEvent::History { + id, + session_id: session_id.to_string(), + messages, + images, + provider_name: Some(provider_name), + provider_model: Some(provider_model), + subagent_model, + autoreview_enabled, + autojudge_enabled, + available_models, + available_model_routes, + mcp_servers, + skills, + total_tokens: optional_total_tokens(token_usage_totals), + token_usage_totals: optional_token_usage_totals(token_usage_totals), + all_sessions, + client_count: Some(current_client_count), + is_canary: Some(is_canary), + server_version: Some(jcode_build_meta::VERSION.to_string()), + server_name: Some(server_name.to_string()), + server_icon: Some(server_icon.to_string()), + server_has_update: Some(server_has_newer_binary()), + was_interrupted, + reload_recovery: history_reload_recovery_snapshot(session_id, was_interrupted), + connection_type, + status_detail, + upstream_provider, + resolved_credential, + reasoning_effort, + service_tier, + compaction_mode, + activity, + side_panel, + }; + let encode_start = Instant::now(); + let json = encode_event(&history_event); + // Free the structured event as soon as the wire bytes exist so only ~1x + // the payload stays resident across the awaited socket write. + drop(history_event); + let json_len = json.len(); + let encode_ms = encode_start.elapsed().as_millis(); + let writer_lock_start = Instant::now(); + let mut writer_guard = writer.lock().await; + let writer_lock_ms = writer_lock_start.elapsed().as_millis(); + let write_start = Instant::now(); + let result = writer_guard.write_all(json.as_bytes()).await; + drop(writer_guard); + // Release the serialized payload before any further work (logging below + // only needs the captured length). + drop(json); + let write_ms = write_start.elapsed().as_millis(); + + crate::logging::info(&format!( + "[TIMING] send_history write: session={}, bytes={}, encode={}ms, writer_lock={}ms, write={}ms, total={}ms", + session_id, + json_len, + encode_ms, + writer_lock_ms, + write_ms, + history_start.elapsed().as_millis(), + )); + + result.map_err(Into::into) +} + +pub(super) async fn session_activity_snapshot( + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + session_id: &str, + fallback_processing: bool, +) -> Option<SessionActivitySnapshot> { + let snapshot = { + let connections = client_connections.read().await; + let mut processing_without_tool = false; + let mut tool_name = None; + for info in connections.values() { + if info.session_id != session_id || !info.is_processing { + continue; + } + if let Some(current_tool_name) = info.current_tool_name.clone() { + tool_name = Some(current_tool_name); + break; + } + processing_without_tool = true; + } + + tool_name + .map(|current_tool_name| SessionActivitySnapshot { + is_processing: true, + current_tool_name: Some(current_tool_name), + }) + .or_else(|| { + processing_without_tool.then_some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: None, + }) + }) + }; + + snapshot.or_else(|| { + fallback_processing.then_some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: None, + }) + }) +} + +async fn write_event(writer: &Arc<Mutex<WriteHalf>>, event: &ServerEvent) -> Result<()> { + // Serialize straight to bytes with the same framing as encode_event + // (JSON body, "{}" on serialize failure, trailing newline) and drop the + // buffer as soon as the bytes are written so the serialized copy does not + // outlive the socket write. + let mut buf = serde_json::to_vec(event).unwrap_or_else(|_| b"{}".to_vec()); + buf.push(b'\n'); + let mut writer = writer.lock().await; + writer.write_all(&buf).await?; + drop(buf); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with_provider_key(key: Option<&str>) -> crate::session::Session { + let mut session = crate::session::Session::create_with_id( + "test_history_provider_name".to_string(), + None, + None, + ); + session.provider_key = key.map(str::to_string); + session + } + + #[test] + fn history_provider_name_prefers_persisted_openai_key() { + let session = session_with_provider_key(Some("openai")); + assert_eq!( + history_provider_name_from_session(&session).as_deref(), + Some("OpenAI") + ); + } + + #[test] + fn history_provider_name_preserves_unknown_runtime_profile() { + let session = session_with_provider_key(Some("opencode-go")); + assert_eq!( + history_provider_name_from_session(&session).as_deref(), + Some("opencode-go") + ); + } +} + +pub(super) fn spawn_model_prefetch_update(provider: Arc<dyn Provider>, agent: Arc<Mutex<Agent>>) { + tokio::spawn(async move { + let (provider_name, initial_models) = { + let agent_guard = agent.lock().await; + ( + agent_guard.provider_name(), + agent_guard.available_models_display(), + ) + }; + + if !initial_models.is_empty() { + return; + } + + if should_debounce_attach_model_prefetch(&provider_name) { + crate::logging::info(&format!( + "Skipping attach-time model prefetch for {} because a recent refresh already ran", + provider_name + )); + return; + } + + if provider.prefetch_models().await.is_err() { + return; + } + + let refreshed = { + let agent_guard = agent.lock().await; + ( + agent_guard.available_models_display(), + agent_guard.model_routes(), + ) + }; + + if refreshed.0 == initial_models && refreshed.1.is_empty() { + return; + } + + let _ = refreshed; + Bus::global().publish_models_updated(); + }); +} + +#[cfg(test)] +#[path = "client_state_tests.rs"] +mod client_state_tests; diff --git a/crates/jcode-app-core/src/server/client_state_tests.rs b/crates/jcode-app-core/src/server/client_state_tests.rs new file mode 100644 index 0000000..243c8e9 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_state_tests.rs @@ -0,0 +1,448 @@ +use super::handle_get_history; +use super::handle_get_model_catalog; +use super::session_activity_snapshot; +use crate::agent::Agent; +use crate::message::{Message, ToolDefinition}; +use crate::provider::{EventStream, Provider}; +use crate::server::ClientConnectionInfo; +use crate::tool::Registry; +use anyhow::Result; +use async_trait::async_trait; +use std::collections::HashMap; +use std::io::BufRead as _; +use std::sync::Arc; +use std::time::Instant; +use tokio::io::AsyncReadExt; +use tokio::sync::{Mutex, RwLock, mpsc}; + +struct MockProvider; + +#[async_trait] +impl Provider for MockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "mock provider complete should not be called in client_state tests" + )) + } + + fn name(&self) -> &str { + "mock" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self) + } + + fn model(&self) -> String { + "mock-model".to_string() + } +} + +#[tokio::test] +async fn session_activity_snapshot_prefers_live_tool_name_for_target_session() { + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn-idle".to_string(), + ClientConnectionInfo { + client_id: "conn-idle".to_string(), + session_id: "other-session".to_string(), + client_instance_id: None, + debug_client_id: None, + connected_at: now, + last_seen: now, + is_processing: true, + current_tool_name: Some("bash".to_string()), + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ( + "conn-target".to_string(), + ClientConnectionInfo { + client_id: "conn-target".to_string(), + session_id: "target-session".to_string(), + client_instance_id: None, + debug_client_id: None, + connected_at: now, + last_seen: now, + is_processing: true, + current_tool_name: Some("batch".to_string()), + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + }, + ), + ]))); + + let snapshot = session_activity_snapshot(&client_connections, "target-session", false) + .await + .expect("activity snapshot"); + + assert!(snapshot.is_processing); + assert_eq!(snapshot.current_tool_name.as_deref(), Some("batch")); +} + +#[tokio::test] +async fn session_activity_snapshot_uses_fallback_when_no_live_connection_is_marked_busy() { + let client_connections = Arc::new(RwLock::new(HashMap::<String, ClientConnectionInfo>::new())); + + let snapshot = session_activity_snapshot(&client_connections, "target-session", true) + .await + .expect("fallback snapshot"); + + assert!(snapshot.is_processing); + assert_eq!(snapshot.current_tool_name, None); +} + +#[tokio::test] +#[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() { + 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"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let session_id = "session_busy_history_fallback"; + let mut session = crate::session::Session::create_with_id( + session_id.to_string(), + None, + Some("busy fallback".to_string()), + ); + session.model = Some("mock-model".to_string()); + session.append_stored_message(crate::session::StoredMessage { + id: "msg-busy-fallback".to_string(), + role: crate::message::Role::User, + content: vec![crate::message::ContentBlock::Text { + text: "persisted fallback history".to_string(), + cache_control: None, + }], + display_role: None, + timestamp: None, + tool_duration_ms: None, + token_usage: None, + }); + session.save().expect("save session"); + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::empty(); + let mut live_session = session.clone(); + live_session.title = Some("live agent".to_string()); + let agent = Arc::new(Mutex::new(Agent::new_with_session( + provider.clone(), + registry, + live_session, + None, + ))); + let busy_guard = agent.lock().await; + + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.to_string(), + Arc::clone(&agent), + )]))); + let client_connections = Arc::new(RwLock::new(HashMap::<String, ClientConnectionInfo>::new())); + let client_count = Arc::new(RwLock::new(1usize)); + + let (stream_a, mut stream_b) = crate::transport::stream_pair().expect("stream pair"); + let (_reader_a, writer_a) = stream_a.into_split(); + let writer = Arc::new(Mutex::new(writer_a)); + + handle_get_history( + 42, + session_id, + true, + &agent, + &provider, + &sessions, + &client_connections, + &client_count, + &writer, + "server-name", + "🔥", + None, + ) + .await + .expect("history should be written from persisted fallback"); + + drop(busy_guard); + drop(writer); + + let mut bytes = Vec::new(); + stream_b + .read_to_end(&mut bytes) + .await + .expect("read history event bytes"); + let mut cursor = std::io::Cursor::new(bytes); + let mut line = String::new(); + cursor.read_line(&mut line).expect("read first line"); + let event: crate::protocol::ServerEvent = + serde_json::from_str(line.trim()).expect("decode history event"); + + match event { + crate::protocol::ServerEvent::History { + id, + session_id: returned_session_id, + messages, + activity, + .. + } => { + assert_eq!(id, 42); + assert_eq!(returned_session_id, session_id); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "persisted fallback history"); + let activity = activity.expect("fallback activity snapshot"); + assert!(activity.is_processing); + } + other => panic!("expected history event, got {:?}", other), + } + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +#[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() { + 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"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let session_id = "session_busy_model_catalog_fallback"; + let mut session = crate::session::Session::create_with_id( + session_id.to_string(), + None, + Some("busy model catalog".to_string()), + ); + session.model = Some("persisted-model".to_string()); + session.save().expect("save session"); + + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let agent = Arc::new(Mutex::new(Agent::new_with_session( + provider.clone(), + Registry::empty(), + session.clone(), + None, + ))); + let busy_guard = agent.lock().await; + + let (stream_a, mut stream_b) = crate::transport::stream_pair().expect("stream pair"); + let (_reader_a, writer_a) = stream_a.into_split(); + let writer = Arc::new(Mutex::new(writer_a)); + + tokio::time::timeout( + std::time::Duration::from_millis(100), + handle_get_model_catalog(43, session_id, &agent, &provider, &writer), + ) + .await + .expect("model catalog must not wait for busy agent mutex") + .expect("model catalog fallback should write history event"); + + drop(busy_guard); + drop(writer); + + let mut bytes = Vec::new(); + stream_b + .read_to_end(&mut bytes) + .await + .expect("read model catalog event bytes"); + let mut cursor = std::io::Cursor::new(bytes); + let mut line = String::new(); + cursor.read_line(&mut line).expect("read first line"); + let event: crate::protocol::ServerEvent = + serde_json::from_str(line.trim()).expect("decode model catalog event"); + + match event { + crate::protocol::ServerEvent::History { + id, + session_id: returned_session_id, + provider_name, + provider_model, + .. + } => { + 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")); + } + other => panic!("expected history event, got {:?}", other), + } + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +struct ReloadHistoryEnvGuard { + prev_home: Option<std::ffi::OsString>, + prev_runtime: Option<std::ffi::OsString>, +} + +impl ReloadHistoryEnvGuard { + fn new(home: &std::path::Path, runtime: &std::path::Path) -> Self { + let prev_home = std::env::var_os("JCODE_HOME"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_HOME", home); + crate::env::set_var("JCODE_RUNTIME_DIR", runtime); + Self { + prev_home, + prev_runtime, + } + } +} + +impl Drop for ReloadHistoryEnvGuard { + fn drop(&mut self) { + crate::server::clear_reload_marker(); + if let Some(prev_home) = self.prev_home.take() { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + if let Some(prev_runtime) = self.prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +fn write_pending_user_session( + session_id: &str, + status: crate::session::SessionStatus, +) -> Result<()> { + let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None); + session.status = status; + session.add_message( + crate::message::Role::User, + vec![crate::message::ContentBlock::Text { + text: "continue this after reload".to_string(), + cache_control: None, + }], + ); + session.save() +} + +#[test] +fn history_reload_recovery_infers_pending_active_user_turn_during_reload() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let home = tempfile::TempDir::new()?; + let runtime = tempfile::TempDir::new()?; + let _guard = ReloadHistoryEnvGuard::new(home.path(), runtime.path()); + let session_id = "session_history_reload_fallback"; + write_pending_user_session(session_id, crate::session::SessionStatus::Active)?; + crate::server::write_reload_state( + "reload-history-fallback", + "test-hash", + crate::server::ReloadPhase::SocketReady, + Some(session_id.to_string()), + ); + + let snapshot = super::history_reload_recovery_snapshot(session_id, None); + assert!( + snapshot.is_some(), + "pending user turn during reload should get recovery directive" + ); + let Some(snapshot) = snapshot else { + return Ok(()); + }; + + assert!( + snapshot + .continuation_message + .contains("interrupted by a server reload") + ); + Ok(()) +} + +#[test] +fn history_reload_recovery_does_not_infer_pending_user_turn_without_reload_marker() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let home = tempfile::TempDir::new()?; + let runtime = tempfile::TempDir::new()?; + let _guard = ReloadHistoryEnvGuard::new(home.path(), runtime.path()); + let session_id = "session_history_no_reload_fallback"; + write_pending_user_session(session_id, crate::session::SessionStatus::Active)?; + + assert!(super::history_reload_recovery_snapshot(session_id, None).is_none()); + Ok(()) +} + +#[test] +fn history_reload_recovery_does_not_mark_delivered_until_continuation_is_accepted() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let home = tempfile::TempDir::new()?; + let runtime = tempfile::TempDir::new()?; + let _guard = ReloadHistoryEnvGuard::new(home.path(), runtime.path()); + let session_id = "session_history_store_owned"; + super::super::reload_recovery::persist_intent( + "reload-store-owned", + session_id, + super::super::reload_recovery::ReloadRecoveryRole::InterruptedPeer, + crate::tool::selfdev::ReloadRecoveryDirective { + reconnect_notice: Some("stored notice".to_string()), + continuation_message: "stored continuation".to_string(), + }, + "test store intent", + )?; + + let Some(snapshot) = super::history_reload_recovery_snapshot(session_id, None) else { + anyhow::bail!("server-owned recovery intent should be used"); + }; + assert_eq!(snapshot.continuation_message, "stored continuation"); + assert!( + super::super::reload_recovery::has_pending_for_session(session_id), + "building a History payload must not consume the intent; the client may disconnect before queuing it" + ); + + let Some(snapshot_again) = super::history_reload_recovery_snapshot(session_id, None) else { + anyhow::bail!("pending server-owned recovery intent should be re-emitted until accepted"); + }; + assert_eq!(snapshot_again.continuation_message, "stored continuation"); + + assert!( + !super::super::reload_recovery::mark_delivered_if_matching_continuation( + session_id, + "different continuation", + "unit_test_mismatch", + )?, + "mismatched reminders must not consume a pending reload recovery intent" + ); + assert!(super::super::reload_recovery::has_pending_for_session( + session_id + )); + + assert!( + super::super::reload_recovery::mark_delivered_if_matching_continuation( + session_id, + "stored continuation", + "unit_test_accept", + )?, + "matching accepted continuation should mark the recovery intent delivered" + ); + assert!( + !super::super::reload_recovery::has_pending_for_session(session_id), + "accepted continuation should consume the durable pending intent" + ); + assert!( + super::history_reload_recovery_snapshot(session_id, None).is_none(), + "delivered server-owned recovery intent should no longer be emitted" + ); + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/client_writer.rs b/crates/jcode-app-core/src/server/client_writer.rs new file mode 100644 index 0000000..20f0eb2 --- /dev/null +++ b/crates/jcode-app-core/src/server/client_writer.rs @@ -0,0 +1,15 @@ +use crate::protocol::{ServerEvent, encode_event}; +use anyhow::Result; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +pub(super) async fn write_direct_event( + writer: &Arc<Mutex<crate::transport::WriteHalf>>, + event: &ServerEvent, +) -> Result<()> { + let json = encode_event(event); + let mut w = writer.lock().await; + w.write_all(json.as_bytes()).await?; + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/comm_await.rs b/crates/jcode-app-core/src/server/comm_await.rs new file mode 100644 index 0000000..ad33a9f --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_await.rs @@ -0,0 +1,680 @@ +use super::await_members_state::{ + PersistedAwaitMembersState, all_pending_await_members_including_expired, ensure_pending_state, + load_state, persist_final_response, request_key, save_state, +}; +use super::{AwaitMembersRuntime, SwarmEvent, SwarmMember}; +use crate::bus::{Bus, BusEvent, SwarmAwaitCompleted, UiActivity}; +use crate::protocol::{AwaitedMemberStatus, ServerEvent, format_comm_awaited_members_with_reports}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::{RwLock, broadcast, mpsc}; + +pub(super) async fn awaited_member_statuses( + req_session_id: &str, + swarm_id: &str, + requested_ids: &[String], + target_status: &[String], + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) -> Vec<AwaitedMemberStatus> { + let watch_ids: Vec<String> = if requested_ids.is_empty() { + let mut watch_ids: Vec<String> = { + let swarms = swarms_by_id.read().await; + swarms + .get(swarm_id) + .map(|sessions| { + sessions + .iter() + .filter(|session_id| session_id.as_str() != req_session_id) + .cloned() + .collect() + }) + .unwrap_or_default() + }; + watch_ids.sort(); + watch_ids + } else { + requested_ids.to_vec() + }; + + let members = swarm_members.read().await; + watch_ids + .iter() + .map(|session_id| { + let (name, status, completion_report) = members + .get(session_id) + .map(|member| { + ( + member.friendly_name.clone(), + member.status.clone(), + member.latest_completion_report.clone(), + ) + }) + .unwrap_or((None, "unknown".to_string(), None)); + let done = target_status.contains(&status) + || (status == "unknown" + && (target_status.contains(&"stopped".to_string()) + || target_status.contains(&"completed".to_string()))); + AwaitedMemberStatus { + session_id: session_id.clone(), + friendly_name: name, + status, + done, + completion_report, + } + }) + .collect() +} + +fn short_member_name(member: &AwaitedMemberStatus) -> String { + member + .friendly_name + .clone() + .unwrap_or_else(|| member.session_id[..8.min(member.session_id.len())].to_string()) +} + +pub(super) fn timeout_summary(member_statuses: &[AwaitedMemberStatus]) -> String { + let pending: Vec<String> = member_statuses + .iter() + .filter(|member| !member.done) + .map(|member| format!("{} ({})", short_member_name(member), member.status)) + .collect(); + format!("Timed out. Still waiting on: {}", pending.join(", ")) +} + +fn completion_summary(member_statuses: &[AwaitedMemberStatus]) -> String { + let done_names: Vec<String> = member_statuses.iter().map(short_member_name).collect(); + format!( + "All {} members are done: {}", + done_names.len(), + done_names.join(", ") + ) +} + +pub(super) fn completion_mode(mode: Option<&str>) -> &str { + match mode { + Some("any") => "any", + _ => "all", + } +} + +pub(super) fn mode_satisfied(member_statuses: &[AwaitedMemberStatus], mode: Option<&str>) -> bool { + match completion_mode(mode) { + "any" => member_statuses.iter().any(|status| status.done), + _ => member_statuses.iter().all(|status| status.done), + } +} + +pub(super) fn mode_summary(member_statuses: &[AwaitedMemberStatus], mode: Option<&str>) -> String { + match completion_mode(mode) { + "any" => { + let matching: Vec<String> = member_statuses + .iter() + .filter(|member| member.done) + .map(short_member_name) + .collect(); + format!( + "Matched {} member{}: {}", + matching.len(), + if matching.len() == 1 { "" } else { "s" }, + matching.join(", ") + ) + } + _ => completion_summary(member_statuses), + } +} + +pub(super) fn deadline_to_instant(deadline_unix_ms: u64) -> tokio::time::Instant { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + tokio::time::Instant::now() + Duration::from_millis(deadline_unix_ms.saturating_sub(now_ms)) +} + +pub(super) async fn respond_to_waiters( + runtime: &AwaitMembersRuntime, + key: &str, + completed: bool, + members: Vec<AwaitedMemberStatus>, + summary: String, +) { + for (request_id, client_event_tx) in runtime.take_waiters(key).await { + let _ = client_event_tx.send(ServerEvent::CommAwaitMembersResponse { + id: request_id, + completed, + members: members.clone(), + summary: summary.clone(), + background_started: false, + }); + } + runtime.clear_active(key).await; +} + +/// Build the swarm-flavored completion notification body delivered to the +/// requesting agent when a backgrounded await finishes. Reuses the same +/// member-status + completion-report rendering as the blocking tool result so +/// the agent sees consistent output whether it waited inline or in the +/// background. +fn background_completion_notification( + completed: bool, + summary: &str, + members: &[AwaitedMemberStatus], +) -> String { + let reports = HashMap::new(); + let body = format_comm_awaited_members_with_reports(completed, summary, members, &reports); + format!("🐝 **Swarm await finished**\n\n{}", body) +} + +/// Reload the latest persisted pending state for `state.key`, if any. Delivery +/// prefs (background/notify/wake) can be updated by duplicate requests after a +/// watcher captured its own copy at spawn, so re-reading before exit/finalize +/// keeps the watcher in sync with what the requesting tool was last told. +fn refresh_pending_state(state: &PersistedAwaitMembersState) -> Option<PersistedAwaitMembersState> { + load_state(&state.key).filter(PersistedAwaitMembersState::is_pending) +} + +/// Persist the terminal result, reply to any blocking socket waiters, and, when +/// the await was started in background mode, publish a `SwarmAwaitCompleted` +/// bus event so the server's bus monitor can wake/notify the requesting agent +/// the same way background tasks do. +async fn finalize_await( + runtime: &AwaitMembersRuntime, + state: &PersistedAwaitMembersState, + completed: bool, + members: Vec<AwaitedMemberStatus>, + summary: String, +) { + // Deliver with the latest persisted prefs: a duplicate request may have + // changed background/notify/wake after the caller captured this copy. + let state = refresh_pending_state(state).unwrap_or_else(|| state.clone()); + let _ = persist_final_response(&state, completed, members.clone(), summary.clone()); + + if state.background && (state.notify || state.wake) { + let notification = background_completion_notification(completed, &summary, &members); + Bus::global().publish(BusEvent::SwarmAwaitCompleted(SwarmAwaitCompleted { + session_id: state.session_id.clone(), + completed, + summary: summary.clone(), + notification, + notify: state.notify, + wake: state.wake, + })); + } + + respond_to_waiters(runtime, &state.key, completed, members, summary).await; +} + +pub(super) async fn spawn_or_resume_await_members( + state: PersistedAwaitMembersState, + req_session_id: String, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + await_members_runtime: AwaitMembersRuntime, +) { + let key = state.key.clone(); + let swarm_id = state.swarm_id.clone(); + let requested_ids = state.requested_ids.clone(); + let target_status = state.target_status.clone(); + let mode = state.mode.clone(); + + tokio::spawn(async move { + let mut event_rx = swarm_event_tx.subscribe(); + let deadline = deadline_to_instant(state.deadline_unix_ms); + + loop { + let member_statuses = awaited_member_statuses( + &req_session_id, + &swarm_id, + &requested_ids, + &target_status, + &swarm_members, + &swarms_by_id, + ) + .await; + + if member_statuses.is_empty() { + let summary = "No other members in swarm to wait for.".to_string(); + finalize_await(&await_members_runtime, &state, true, vec![], summary).await; + return; + } + + if mode_satisfied(&member_statuses, mode.as_deref()) { + let summary = mode_summary(&member_statuses, mode.as_deref()); + finalize_await( + &await_members_runtime, + &state, + true, + member_statuses, + summary, + ) + .await; + return; + } + + // Blocking waits stop watching once every socket waiter has + // disconnected. Background watchers have no socket waiter, so they + // keep running until they resolve or hit the deadline, delivering + // the result via notify/wake. Re-read the persisted prefs here: a + // duplicate request may have upgraded this wait to background mode + // after this watcher was spawned with a blocking-state copy. + let is_background = refresh_pending_state(&state) + .map(|latest| latest.background) + .unwrap_or(state.background); + if !is_background && await_members_runtime.retain_open_waiters(&key).await == 0 { + await_members_runtime.clear_active(&key).await; + return; + } + + tokio::select! { + _ = tokio::time::sleep_until(deadline) => { + let summary = timeout_summary(&member_statuses); + finalize_await(&await_members_runtime, &state, false, member_statuses, summary).await; + return; + } + event = event_rx.recv() => { + match event { + Ok(event) => { + if event.swarm_id.as_deref() != Some(swarm_id.as_str()) { + continue; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + // Dropped events are recoverable: the loop re-reads + // member statuses from shared state at the top, so + // just keep watching instead of orphaning the wait. + crate::logging::info(&format!( + "await_members watcher lagged by {} swarm events; re-checking statuses", + n + )); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + await_members_runtime.clear_active(&key).await; + return; + } + } + } + } + } + }); +} + +pub(super) struct CommAwaitMembersContext<'a> { + pub client_event_tx: &'a mpsc::UnboundedSender<ServerEvent>, + pub swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + pub swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub swarm_event_tx: &'a broadcast::Sender<SwarmEvent>, + pub await_members_runtime: &'a AwaitMembersRuntime, +} + +#[expect( + clippy::too_many_arguments, + reason = "await request carries protocol fields plus delivery flags; grouping would churn many call sites" +)] +pub(super) async fn handle_comm_await_members( + id: u64, + req_session_id: String, + target_status: Vec<String>, + requested_ids: Vec<String>, + mode: Option<String>, + timeout_secs: Option<u64>, + background: bool, + notify: bool, + wake: bool, + ctx: CommAwaitMembersContext<'_>, +) { + let swarm_id = { + let members = ctx.swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()) + }; + + if let Some(swarm_id) = swarm_id { + let key = request_key( + &req_session_id, + &swarm_id, + &requested_ids, + &target_status, + mode.as_deref(), + ); + let mut persisted = load_state(&key); + + let initial_statuses = awaited_member_statuses( + &req_session_id, + &swarm_id, + &requested_ids, + &target_status, + ctx.swarm_members, + ctx.swarms_by_id, + ) + .await; + + if let Some(final_response) = persisted + .as_ref() + .and_then(|state| state.final_response.clone()) + { + let current_still_satisfies = + initial_statuses.is_empty() || mode_satisfied(&initial_statuses, mode.as_deref()); + if current_still_satisfies { + let _ = ctx + .client_event_tx + .send(ServerEvent::CommAwaitMembersResponse { + id, + completed: final_response.completed, + members: final_response.members, + summary: final_response.summary, + background_started: false, + }); + return; + } + + persisted = None; + } + + if initial_statuses.is_empty() { + let _ = ctx + .client_event_tx + .send(ServerEvent::CommAwaitMembersResponse { + id, + completed: true, + members: vec![], + summary: "No other members in swarm to wait for.".to_string(), + background_started: false, + }); + return; + } + + // Already satisfied right now: answer inline regardless of background + // mode. There is nothing to wait for, so the agent should get the + // result immediately instead of a "watching in background" stub. + if mode_satisfied(&initial_statuses, mode.as_deref()) { + let summary = mode_summary(&initial_statuses, mode.as_deref()); + let _ = ctx + .client_event_tx + .send(ServerEvent::CommAwaitMembersResponse { + id, + completed: true, + members: initial_statuses, + summary, + background_started: false, + }); + return; + } + + let requested_deadline = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + + Duration::from_secs(timeout_secs.unwrap_or(3600)).as_millis() as u64; + let mut state = persisted.unwrap_or_else(|| { + ensure_pending_state( + &key, + &req_session_id, + &swarm_id, + &requested_ids, + &target_status, + mode.as_deref(), + requested_deadline, + background, + notify, + wake, + ) + }); + + // When reusing a persisted pending state (e.g. a resumed call after + // reload, or a duplicate request), let the latest call's delivery prefs + // win so the watcher and tool response stay in sync. The deadline is + // intentionally preserved from the original request. + if state.background != background || state.notify != notify || state.wake != wake { + state.background = background; + state.notify = notify; + state.wake = wake; + save_state(&state); + } + + let already_expired = state.deadline_unix_ms + <= SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Background mode: hand off to a detached watcher and answer the tool + // immediately so the requesting turn stays responsive. Completion is + // delivered later via notify/wake. + if background { + if already_expired { + let summary = timeout_summary(&initial_statuses); + finalize_await( + ctx.await_members_runtime, + &state, + false, + initial_statuses.clone(), + summary.clone(), + ) + .await; + // Answer the requesting tool call directly: no waiter was + // registered for this request (waiters are only added in the + // blocking branch), so without this the socket call would hang + // until its client-side timeout. + let _ = ctx + .client_event_tx + .send(ServerEvent::CommAwaitMembersResponse { + id, + completed: false, + members: initial_statuses, + summary, + background_started: false, + }); + return; + } + + if ctx.await_members_runtime.mark_active_if_new(&key).await { + publish_await_started_card(&state, &initial_statuses); + spawn_or_resume_await_members( + state, + req_session_id, + ctx.swarm_members.clone(), + ctx.swarms_by_id.clone(), + ctx.swarm_event_tx.clone(), + ctx.await_members_runtime.clone(), + ) + .await; + } + + let summary = background_started_summary(&initial_statuses, mode.as_deref(), wake); + let _ = ctx + .client_event_tx + .send(ServerEvent::CommAwaitMembersResponse { + id, + completed: false, + members: initial_statuses, + summary, + background_started: true, + }); + return; + } + + // Blocking mode: register a socket waiter that the watcher resolves. + ctx.await_members_runtime + .add_waiter(&key, id, ctx.client_event_tx) + .await; + + if already_expired { + let summary = timeout_summary(&initial_statuses); + let _ = + persist_final_response(&state, false, initial_statuses.clone(), summary.clone()); + respond_to_waiters( + ctx.await_members_runtime, + &key, + false, + initial_statuses, + summary, + ) + .await; + return; + } + + if ctx.await_members_runtime.mark_active_if_new(&key).await { + spawn_or_resume_await_members( + state, + req_session_id, + ctx.swarm_members.clone(), + ctx.swarms_by_id.clone(), + ctx.swarm_event_tx.clone(), + ctx.await_members_runtime.clone(), + ) + .await; + } + } else { + let _ = ctx.client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(), + retry_after_secs: None, + }); + } +} + +/// One-line summary returned to the tool when a wait is handed off to a +/// background watcher. +fn background_started_summary( + member_statuses: &[AwaitedMemberStatus], + mode: Option<&str>, + wake: bool, +) -> String { + let pending: Vec<String> = member_statuses + .iter() + .filter(|member| !member.done) + .map(short_member_name) + .collect(); + let scope = match completion_mode(mode) { + "any" => "any of", + _ => "all of", + }; + let delivery = if wake { + "You'll be woken with the result when it resolves." + } else { + "A notification will appear when it resolves." + }; + if pending.is_empty() { + format!("Watching swarm members in the background. {}", delivery) + } else { + format!( + "Watching {} {} in the background. {}", + scope, + pending.join(", "), + delivery + ) + } +} + +/// Emit a swarm-flavored "await started" activity card so attached clients show +/// that a background watcher is now running for this session. +fn publish_await_started_card( + state: &PersistedAwaitMembersState, + member_statuses: &[AwaitedMemberStatus], +) { + if !state.notify { + return; + } + let pending: Vec<String> = member_statuses + .iter() + .filter(|member| !member.done) + .map(short_member_name) + .collect(); + let watching = if pending.is_empty() { + "swarm members".to_string() + } else { + pending.join(", ") + }; + Bus::global().publish(BusEvent::UiActivity(UiActivity::background( + Some(state.session_id.clone()), + format!( + "🐝 **Swarm await started** · watching `{}`\n\nJcode is waiting for these members in the background and will report back when they finish.", + watching + ), + Some(format!("Swarm await started · {}", watching)), + ))); +} + +/// Re-spawn detached watchers for every pending background `await_members` +/// state after a server (re)start. Blocking waits are intentionally skipped: +/// their requesting tool call is parked on a socket that no longer exists, and +/// the agent is told to rerun the wait after reload. Background waits, by +/// contrast, deliver via notify/wake, so they can resume transparently. +pub(super) async fn resume_background_awaits( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + await_members_runtime: &AwaitMembersRuntime, +) { + let pending: Vec<PersistedAwaitMembersState> = all_pending_await_members_including_expired() + .into_iter() + .filter(|state| state.background) + .collect(); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let mut resumed = 0usize; + let mut expired = 0usize; + for state in pending { + // Deadline passed while the server was down: the wait can never + // resolve, so finalize it as a timeout now so the promised + // notify/wake still fires instead of the await silently vanishing. + if state.deadline_unix_ms <= now_ms { + let member_statuses = awaited_member_statuses( + &state.session_id, + &state.swarm_id, + &state.requested_ids, + &state.target_status, + swarm_members, + swarms_by_id, + ) + .await; + let (completed, summary) = if member_statuses.is_empty() { + (true, "No other members in swarm to wait for.".to_string()) + } else if mode_satisfied(&member_statuses, state.mode.as_deref()) { + (true, mode_summary(&member_statuses, state.mode.as_deref())) + } else { + (false, timeout_summary(&member_statuses)) + }; + finalize_await( + await_members_runtime, + &state, + completed, + member_statuses, + summary, + ) + .await; + expired += 1; + continue; + } + + let key = state.key.clone(); + if await_members_runtime.mark_active_if_new(&key).await { + let req_session_id = state.session_id.clone(); + spawn_or_resume_await_members( + state, + req_session_id, + swarm_members.clone(), + swarms_by_id.clone(), + swarm_event_tx.clone(), + await_members_runtime.clone(), + ) + .await; + resumed += 1; + } + } + + if resumed > 0 || expired > 0 { + crate::logging::info(&format!( + "Resumed {} background swarm await watcher(s) after startup ({} finalized as expired)", + resumed, expired + )); + } +} diff --git a/crates/jcode-app-core/src/server/comm_control.rs b/crates/jcode-app-core/src/server/comm_control.rs new file mode 100644 index 0000000..3b18ba7 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control.rs @@ -0,0 +1,2625 @@ +#![cfg_attr(test, allow(clippy::items_after_test_module))] + +use super::append_swarm_completion_report_instructions; +use super::swarm::{ + now_unix_ms, swarm_task_heartbeat_interval, swarm_task_stale_after, touch_swarm_task_progress, +}; +use super::swarm_mutation_state::{ + PersistedSwarmMutationResponse, begin_or_join_in_flight as begin_swarm_mutation_no_replay, + begin_or_replay as begin_swarm_mutation_or_replay, + finish_request as finish_swarm_mutation_request, request_key as swarm_mutation_request_key, +}; +use super::{ + ClientConnectionInfo, SwarmEvent, SwarmEventType, SwarmMember, SwarmMutationRuntime, + SwarmState, SwarmTaskProgress, VersionedPlan, broadcast_swarm_plan, + broadcast_swarm_plan_with_previous, broadcast_swarm_status, fanout_session_event, + persist_swarm_state_for, queue_soft_interrupt_for_session, record_swarm_event, + set_member_task_label, truncate_detail, update_member_status, update_member_status_with_report, +}; +use crate::agent::Agent; +use crate::plan::{ + TaskControlAction, assignment_affinities_for_task, assignment_loads, + build_control_assignment_text, combine_assignment_text, explicit_task_blocked_reason, + next_unassigned_runnable_item_id, task_control_action_allows_status, task_control_status_error, + task_control_target_item_id, +}; +use crate::protocol::{NotificationType, PlanGraphStatus, ServerEvent}; +use jcode_agent_runtime::SoftInterruptSource; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc, watch}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +/// Eligible auto-assignment targets for a swarm task. +/// +/// Auto-pick must only land on sessions that will actually *execute* the work +/// without further human action. In a shared swarm there can be many foreign +/// members: independent human TUIs and stale "zombie" sessions left over from +/// other runs. Assigning to those silently strands the task (a human session is +/// never auto-driven; a zombie has no live agent at all) and stalls `run_plan`. +/// +/// So a member is only a free worker for *automatic* selection when it is a +/// worker this run owns and can drive: +/// +/// - `is_headless`: a spawned in-process worker (always auto-driven), or +/// - owned by the requester (`report_back_to_session_id == req`): a worker this +/// coordinator spawned, including reusable ones that already returned `ready`. +/// +/// Everything else (foreign humans, zombies) must be addressed with an explicit +/// `target_session`, which bypasses this filter; this only governs auto-pick. +fn filter_swarm_agent_candidates<'a>( + members: &'a HashMap<String, SwarmMember>, + req_session_id: &str, + swarm_id: &str, +) -> Vec<&'a SwarmMember> { + members + .values() + .filter(|member| { + member.session_id != req_session_id + && member.swarm_id.as_deref() == Some(swarm_id) + && member.role == "agent" + && matches!(member.status.as_str(), "ready" | "completed") + && is_drivable_auto_worker(member, req_session_id) + }) + .collect() +} + +/// Whether `member` can be auto-assigned a task and be relied on to run it. +/// See [`filter_swarm_agent_candidates`] for the rationale. +fn is_drivable_auto_worker(member: &SwarmMember, req_session_id: &str) -> bool { + member.is_headless || member.report_back_to_session_id.as_deref() == Some(req_session_id) +} + +/// Whether a candidate already holds an incomplete plan assignment. +/// +/// Auto-pick must treat such a member as busy rather than reusable: assigning +/// more work to it queues large tasks serially on one agent while the swarm +/// still has spawn capacity (`spawn_if_needed` exists precisely to spawn a +/// fresh agent in that case). `loads` counts non-terminal plan items per +/// assignee (see [`assignment_loads`]). +fn member_has_active_assignment(session_id: &str, loads: &HashMap<String, usize>) -> bool { + loads.get(session_id).copied().unwrap_or(0) > 0 +} + +/// Safety-net expiry for auto-pick claims that never reach an explicit +/// release (e.g. a request that dies between the pick and the plan write). +const AUTO_ASSIGN_CLAIM_TTL: std::time::Duration = std::time::Duration::from_secs(15); + +/// In-process claims for auto-picked assignment targets, keyed by +/// `swarm_id\nsession_id`. +/// +/// The busy check reads the *plan* (`assignment_loads`), but the plan only +/// records an assignment at a write that happens several awaits after the +/// target is picked. Concurrent `assign_task`/`assign_next` requests resolve +/// their targets inside that window (observed live: three auto-picks within +/// ~100ms all stacking onto the same worker), so the pick itself must be a +/// claim: the first resolver wins the member, later ones skip it and fall +/// back to another candidate or to `spawn_if_needed`. Claims are released +/// once the plan write records (or abandons) the assignment; the TTL only +/// reaps leaked claims. +fn auto_assign_claims() -> &'static std::sync::Mutex<HashMap<String, std::time::Instant>> { + static CLAIMS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, std::time::Instant>>> = + std::sync::OnceLock::new(); + CLAIMS.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +fn auto_assign_claim_key(swarm_id: &str, session_id: &str) -> String { + format!("{swarm_id}\n{session_id}") +} + +/// Claim `session_id` as an auto-pick target. Returns false when another +/// in-flight request already picked it (and that claim has not expired). +fn try_claim_auto_assign_target(swarm_id: &str, session_id: &str) -> bool { + let mut claims = auto_assign_claims() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let now = std::time::Instant::now(); + claims.retain(|_, claimed_at| now.duration_since(*claimed_at) < AUTO_ASSIGN_CLAIM_TTL); + match claims.entry(auto_assign_claim_key(swarm_id, session_id)) { + std::collections::hash_map::Entry::Occupied(_) => false, + std::collections::hash_map::Entry::Vacant(vacant) => { + vacant.insert(now); + true + } + } +} + +fn release_auto_assign_claim(swarm_id: &str, session_id: &str) { + let mut claims = auto_assign_claims() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + claims.remove(&auto_assign_claim_key(swarm_id, session_id)); +} + +/// Error for an auto-pick that found no assignable worker. The leading +/// sentence is a stable contract: `spawn_if_needed`/`run_plan` match on it to +/// decide to spawn a fresh agent instead of failing the assignment. +fn no_auto_target_error(busy_skipped: usize) -> String { + let mut message = + "No ready or completed swarm agents are available for automatic task assignment." + .to_string(); + if busy_skipped > 0 { + message.push_str(&format!( + " Skipped {busy_skipped} worker(s) that already have an active assignment or an \ + in-flight pick; spawn a fresh agent (spawn_if_needed/prefer_spawn) or wait for one \ + to finish." + )); + } + message +} + +/// Pick the first idle, unclaimed candidate from `candidates` (already ranked +/// by the caller) and claim it. +/// +/// Members that already hold an incomplete plan assignment are skipped, as are +/// members another in-flight request just picked, so repeated auto-assignments +/// fan out across workers (or trigger a fresh spawn) instead of stacking +/// serially on one agent. +fn select_and_claim_auto_target( + swarm_id: &str, + candidates: &[&SwarmMember], + loads: &HashMap<String, usize>, +) -> Result<String, String> { + let mut busy_skipped = 0usize; + for member in candidates { + if member_has_active_assignment(&member.session_id, loads) { + busy_skipped += 1; + continue; + } + if try_claim_auto_assign_target(swarm_id, &member.session_id) { + return Ok(member.session_id.clone()); + } + busy_skipped += 1; + } + Err(no_auto_target_error(busy_skipped)) +} + +/// A double-assignment conflict: the task is already assigned and its +/// assignee shows recent activity. +struct ActiveAssignmentConflict { + assignee: String, + active_ago_ms: u64, +} + +/// Guard predicate for double assignment. +/// +/// Returns `Some(conflict)` when a direct `assign_task` must be rejected +/// because the item is already assigned and actively worked: it carries an +/// assignee, its status is in-flight (`queued`/`running`, not stale and not +/// terminal), and the assignment shows activity (heartbeat, start, or the +/// assignment itself) within `active_within_ms`. +/// +/// Everything else stays assignable so legitimate recovery keeps working: +/// unassigned items, terminal items (explicit re-open), `running_stale` items +/// (the stale-assignee path), and assigned items whose last activity is older +/// than the window (the sweep just has not flipped them to stale yet). An +/// assigned item with no recorded activity at all is treated as stale, +/// mirroring `refresh_swarm_task_staleness`. +fn active_assignment_conflict( + status: &str, + assigned_to: Option<&str>, + progress: Option<&SwarmTaskProgress>, + now_ms: u64, + active_within_ms: u64, +) -> Option<ActiveAssignmentConflict> { + let assignee = assigned_to?; + if !matches!(status, "queued" | "running") { + return None; + } + let last_activity_ms = progress.and_then(|progress| { + progress + .last_heartbeat_unix_ms + .or(progress.started_at_unix_ms) + .or(progress.assigned_at_unix_ms) + })?; + let active_ago_ms = now_ms.saturating_sub(last_activity_ms); + (active_ago_ms < active_within_ms).then(|| ActiveAssignmentConflict { + assignee: assignee.to_string(), + active_ago_ms, + }) +} + +/// Rejection message for [`active_assignment_conflict`], naming the current +/// assignee and pointing at the explicit takeover paths. +fn active_assignment_error(task_id: &str, conflict: &ActiveAssignmentConflict) -> String { + format!( + "Task '{}' is already assigned to '{}' (active {}s ago); refusing to double-assign. \ + Use task_control reassign/replace to take over (the displaced worker is told to stand \ + down), retry to re-dispatch to the same assignee, or wait for the assignment to finish \ + or go stale.", + task_id, + conflict.assignee, + conflict.active_ago_ms / 1000 + ) +} + +/// Decide whether a worker's just-finished turn should auto-mark its assigned +/// node `done`. +/// +/// A turn must NOT force-complete a node when the worker decomposed it into a +/// composite (`expanded`): that node is now a synthesis/join point that has to +/// wait for its children (and, in deep mode, its critique/verify gate) before it +/// can close, and it will be re-woken to synthesize. Likewise a node the worker +/// already drove to a terminal status (e.g. via `complete_node`, or that failed) +/// must not be reopened/reclosed. A node that is `queued` at turn end was +/// re-queued mid-turn by someone else (`inject_gap` re-queuing its gate, a +/// reassign, a requeue): it is no longer this worker's to close, and force-doing +/// so would bypass gate artifact validation and strand injected gap nodes. Only +/// a plain, still-running atomic turn auto-completes. +/// What to do with a node whose worker turn ended while the node is still +/// marked running. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TurnEndDisposition { + /// Light mode: the worker just ran an atomic node; mark it done. + AutoComplete, + /// Deep mode, first offense: the worker never called `complete_node`, so + /// re-queue the node for a fresh worker. Deep completion is artifact-or- + /// nothing; silently marking it done would bypass artifact validation and + /// every gate rule. + RequeueNoArtifact, + /// Deep mode, repeated offense: fail the node loudly instead of cycling + /// workers forever. `task_control retry` / `requeue_failed` remain the + /// recovery paths. + FailNoArtifact, + /// The node is already terminal, queued (expanded or gap-injected this + /// turn), or otherwise not this turn's responsibility. + LeaveAlone, +} + +/// Decide the turn-end disposition for a node. +/// +/// Light mode keeps the historical lenient behavior: a running atomic node +/// auto-completes, an expanded composite stays open for synthesis. Deep mode +/// abolishes auto-complete entirely — the typed artifact contract is only real +/// if there is no path to "done" that skips it. A running node at turn end +/// (atomic without `complete_node`, or a re-woken composite synthesis that +/// never synthesized) gets one fresh attempt, then fails. +fn turn_end_disposition( + is_deep: bool, + status: &str, + expanded: bool, + prior_no_artifact_requeues: u32, +) -> TurnEndDisposition { + let running = matches!(status, "running" | "running_stale"); + if !running { + return TurnEndDisposition::LeaveAlone; + } + if is_deep { + return if prior_no_artifact_requeues == 0 { + TurnEndDisposition::RequeueNoArtifact + } else { + TurnEndDisposition::FailNoArtifact + }; + } + if expanded { + // The worker decomposed the node; it must stay open to synthesize later. + return TurnEndDisposition::LeaveAlone; + } + TurnEndDisposition::AutoComplete +} + +#[cfg(test)] +fn turn_end_should_auto_complete(status: &str, expanded: bool) -> bool { + turn_end_disposition(false, status, expanded, 0) == TurnEndDisposition::AutoComplete +} + +/// Assignment content for a (re-)dispatched node. +/// +/// For a re-woken composite (`is_composite_synthesis`), the node's original +/// content is the now-stale decomposition brief, so replace it with an explicit +/// synthesis instruction that tells the planner to integrate its children and +/// finish with `complete_node`. Otherwise the original content is used verbatim. +fn composite_synthesis_content( + item_id: &str, + raw_content: &str, + is_composite_synthesis: bool, +) -> String { + if is_composite_synthesis { + format!( + "Synthesis turn for composite node '{item_id}'. Its children (and the deep-mode \ + critique/verify gate) are complete; their outputs are provided below. Read them, \ + write one synthesized result, and finish by calling `swarm complete_node` with \ + node_id=\"{item_id}\" and an artifact summarizing the integrated findings. Do NOT \ + call expand_node again. Original brief: {raw_content}" + ) + } else { + raw_content.to_string() + } +} + +#[derive(Clone, Debug)] +struct TaskSnapshot { + content: String, + status: String, + assigned_to: Option<String>, + progress: Option<SwarmTaskProgress>, +} + +/// Attach the deep-mode execution contract to an assignment's content when the +/// plan is running deep. +/// +/// This is the mechanism that makes the swarm's large agent budget actually get +/// used: every dispatched node carries an in-band directive telling the worker +/// it may `expand_node` into MANY parallel children and must close with a typed +/// artifact, and every gate carries the `inject_gap`-or-pass contract. Without +/// it, only the seeding session (which ran at `swarm-deep` effort) knows the +/// deep workflow, and freshly spawned workers execute serially. A re-woken +/// composite synthesis keeps its dedicated synthesis brief instead, since +/// re-expanding there would loop. +fn deep_mode_assignment_content( + plan: &VersionedPlan, + item_id: &str, + is_composite_synthesis: bool, + content: &str, +) -> String { + if !plan.mode.eq_ignore_ascii_case("deep") || is_composite_synthesis { + return content.to_string(); + } + let is_gate = plan + .node_meta + .get(item_id) + .map(|meta| meta.is_gate) + .unwrap_or(false); + if is_gate { + // The gate's audit scope is its non-gate dependencies (composite gates + // audit their siblings; the root gate audits the whole root set). The + // server rejects a pass whose artifact does not account for each of + // these by id, so the directive enumerates them up front instead of + // letting the gate discover the rejection by trial and error. + let audited_ids: Vec<String> = plan + .items + .iter() + .find(|item| item.id == item_id) + .map(|item| { + item.blocked_by + .iter() + .filter(|dep| { + plan.node_meta + .get(dep.as_str()) + .map(|meta| !meta.is_gate) + .unwrap_or(true) + }) + .cloned() + .collect() + }) + .unwrap_or_default(); + // Completed scope nodes whose artifacts self-reported low confidence: + // the strictest debts, named as priority probe targets. + let audited: HashSet<&str> = audited_ids.iter().map(String::as_str).collect(); + let low_confidence_siblings: Vec<String> = + jcode_plan::bridge::low_confidence_completed_ids(plan) + .into_iter() + .filter(|id| audited.contains(id.as_str())) + .collect(); + jcode_swarm_core::append_deep_gate_instructions( + content, + item_id, + &audited_ids, + &low_confidence_siblings, + ) + } else { + jcode_swarm_core::append_deep_node_instructions(content, item_id) + } +} + +async fn task_snapshot_for( + swarm_id: &str, + task_id: &str, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Option<TaskSnapshot> { + let plans = swarm_plans.read().await; + let plan = plans.get(swarm_id)?; + let item = plan.items.iter().find(|item| item.id == task_id)?; + // Hydrate with forward dataflow from completed upstream dependencies so + // resume/start/wake re-injects the same artifact context an initial + // assignment would carry, then attach the deep-mode contract the same way + // the initial assignment path does. + let hydrated = jcode_plan::bridge::hydrate_assignment(plan, task_id, &item.content); + let is_composite_synthesis = plan + .node_meta + .get(task_id) + .map(|meta| meta.expanded && !meta.is_gate) + .unwrap_or(false); + Some(TaskSnapshot { + content: deep_mode_assignment_content(plan, task_id, is_composite_synthesis, &hydrated), + status: item.status.clone(), + assigned_to: item.assigned_to.clone(), + progress: plan.task_progress.get(task_id).cloned(), + }) +} + +async fn plan_graph_status_for( + swarm_id: &str, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> PlanGraphStatus { + let plans = swarm_plans.read().await; + let plan = plans.get(swarm_id); + if let Some(plan) = plan { + PlanGraphStatus::from_versioned_plan(swarm_id, plan, Some(8), Vec::new()) + } else { + PlanGraphStatus::empty_for_swarm(swarm_id) + } +} + +/// Re-queue a task on its existing assignee for a task-control restart +/// (currently only `resume` of a running/stale task reaches this). +/// +/// The prior run's history (`started_at`, heartbeats, checkpoints, last +/// detail) is preserved rather than replaced: the requeue is a lifecycle +/// transition of the same assignment, and wiping the record would blind +/// staleness monitors and salvage flows to everything the previous run did. +/// Only the assignment-scoped fields are refreshed, and the terminal/stale +/// markers are cleared because the task is queued again. +async fn requeue_existing_assignment( + swarm_id: &str, + req_session_id: &str, + assignee_session: &str, + task_id: &str, + assignment_summary: String, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Option<(String, HashSet<String>, usize)> { + let now_ms = now_unix_ms(); + let mut plans = swarm_plans.write().await; + let plan = plans.get_mut(swarm_id)?; + let item = plan.items.iter_mut().find(|item| item.id == task_id)?; + item.assigned_to = Some(assignee_session.to_string()); + item.status = "queued".to_string(); + let progress = plan.task_progress.entry(task_id.to_string()).or_default(); + progress.assigned_session_id = Some(assignee_session.to_string()); + progress.assignment_summary = Some(truncate_detail(&assignment_summary, 120)); + progress.assigned_at_unix_ms = Some(now_ms); + progress.completed_at_unix_ms = None; + progress.stale_since_unix_ms = None; + plan.version += 1; + plan.participants.insert(req_session_id.to_string()); + plan.participants.insert(assignee_session.to_string()); + Some(( + item.content.clone(), + plan.participants.clone(), + plan.items.len(), + )) +} + +async fn active_swarm_member( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<SwarmMember> { + let members = swarm_members.read().await; + members.get(session_id).cloned() +} + +async fn task_agent_session( + session_id: &str, + sessions: &SessionAgents, +) -> Option<Arc<Mutex<Agent>>> { + let guard = sessions.read().await; + guard.get(session_id).cloned() +} + +async fn resolve_assignment_target_session( + req_session_id: &str, + swarm_id: &str, + requested_target: Option<&str>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Result<String, String> { + let members = swarm_members.read().await; + + if let Some(target) = requested_target { + if target == req_session_id { + return Err("Coordinator cannot assign a swarm task to itself.".to_string()); + } + let Some(member) = members.get(target) else { + return Err(format!("Unknown session '{target}'")); + }; + if member.swarm_id.as_deref() != Some(swarm_id) { + return Err(format!( + "Session '{}' is not in swarm '{}' and cannot receive this task.", + target, swarm_id + )); + } + return Ok(target.to_string()); + } + + let assignment_counts = { + let plans = swarm_plans.read().await; + plans + .get(swarm_id) + .map(assignment_loads) + .unwrap_or_default() + }; + + let mut candidates = filter_swarm_agent_candidates(&members, req_session_id, swarm_id); + + candidates.sort_by(|left, right| { + let left_load = assignment_counts + .get(&left.session_id) + .copied() + .unwrap_or(0); + let right_load = assignment_counts + .get(&right.session_id) + .copied() + .unwrap_or(0); + let left_rank = if left.status == "ready" { 0 } else { 1 }; + let right_rank = if right.status == "ready" { 0 } else { 1 }; + left_load + .cmp(&right_load) + .then_with(|| left_rank.cmp(&right_rank)) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + + select_and_claim_auto_target(swarm_id, &candidates, &assignment_counts) +} + +async fn task_id_for_target_session( + swarm_id: &str, + target_session: &str, + action: TaskControlAction, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Result<String, String> { + let plans = swarm_plans.read().await; + let Some(plan) = plans.get(swarm_id) else { + return Err("No swarm plan exists for this swarm.".to_string()); + }; + task_control_target_item_id(&plan.items, target_session, action) +} + +/// Test-only re-export of the private assignment resolver so the e2e tests can +/// assert composite re-wake routing without going through the full assign path. +#[cfg(test)] +pub(super) async fn resolve_assignment_target_for_task_test_hook( + req_session_id: &str, + swarm_id: &str, + task_id: &str, + requested_target: Option<&str>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Result<String, String> { + resolve_assignment_target_for_task( + req_session_id, + swarm_id, + task_id, + requested_target, + swarm_members, + swarm_plans, + ) + .await +} + +async fn next_unassigned_runnable_task_id( + swarm_id: &str, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Option<String> { + let plans = swarm_plans.read().await; + let plan = plans.get(swarm_id)?; + next_unassigned_runnable_item_id(plan) +} + +/// Like [`next_unassigned_runnable_task_id`], but when no unassigned runnable +/// item exists, look for a runnable item *stranded* on a dead assignee (a +/// member whose lifecycle status is terminal, or that is no longer a swarm +/// member at all) and reclaim it so the caller can dispatch it normally. +/// +/// This is the requeue-pickup path: `task_control retry` re-dispatches to the +/// existing assignee, so when that session died (e.g. an auth-failure wave) +/// the node sits `queued` + assigned-to-a-corpse, invisible to +/// `next_unassigned_runnable_item_id`, and a still-running `run_plan` driver +/// reports "No runnable unassigned tasks" forever. Reclaims are capped +/// per-node by [`crate::plan::MAX_DEAD_ASSIGNEE_RECLAIMS`] to respect the +/// repeat-failure policy: beyond the cap only explicit `retry`/`assign_task` +/// move the node. +async fn next_runnable_task_id_reclaiming_stranded( + swarm_id: &str, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + if let Some(task_id) = next_unassigned_runnable_task_id(swarm_id, swarm_plans).await { + return Some(task_id); + } + + // Snapshot member liveness first so the plans write lock is not held + // across the members read lock (avoids lock-order inversions with paths + // that lock members before plans). + let member_statuses: HashMap<String, String> = { + let members = swarm_members.read().await; + members + .values() + .filter(|member| member.swarm_id.as_deref() == Some(swarm_id)) + .map(|member| (member.session_id.clone(), member.status.clone())) + .collect() + }; + let assignee_is_dead = move |session_id: &str| -> bool { + match member_statuses.get(session_id) { + Some(status) => matches!(status.as_str(), "failed" | "stopped" | "crashed"), + // Not a member of this swarm anymore: nothing can drive it. + None => true, + } + }; + + let mut plans = swarm_plans.write().await; + let plan = plans.get_mut(swarm_id)?; + let stranded_id = crate::plan::next_stranded_runnable_item_id(plan, &assignee_is_dead)?; + if crate::plan::reclaim_stranded_assignment(plan, &stranded_id) { + crate::logging::info(&format!( + "swarm {}: reclaimed stranded task '{}' from dead assignee for re-dispatch", + swarm_id, stranded_id + )); + Some(stranded_id) + } else { + None + } +} + +async fn resolve_assignment_target_for_task( + req_session_id: &str, + swarm_id: &str, + task_id: &str, + requested_target: Option<&str>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Result<String, String> { + if requested_target.is_some() { + return resolve_assignment_target_session( + req_session_id, + swarm_id, + requested_target, + swarm_members, + swarm_plans, + ) + .await; + } + + // Composite owner re-wake affinity: a composite (expanded) node that has + // become runnable again is the synthesis/join step. Prefer routing it back to + // the agent that planned the decomposition (its recorded owner), so the same + // planner integrates its children rather than a fresh worker. Only honor this + // when that owner is still a live, eligible swarm member. + { + let plans = swarm_plans.read().await; + if let Some(plan) = plans.get(swarm_id) { + let planner = plan.node_meta.get(task_id).and_then(|meta| { + (meta.expanded && !meta.is_gate) + .then(|| meta.planner.clone()) + .flatten() + }); + if let Some(owner) = planner + && owner != req_session_id + { + let members = swarm_members.read().await; + let owner_eligible = + filter_swarm_agent_candidates(&members, req_session_id, swarm_id) + .iter() + .any(|member| member.session_id == owner); + // The planner affinity deliberately bypasses the busy check (the + // synthesis needs its decomposition context), but not the race + // claim: if another in-flight pick just took this member, fall + // through to normal selection instead of double-booking it. + if owner_eligible && try_claim_auto_assign_target(swarm_id, &owner) { + return Ok(owner); + } + } + } + } + + let affinities = { + let plans = swarm_plans.read().await; + let Some(plan) = plans.get(swarm_id) else { + return Err("No runnable unassigned tasks are available in the swarm plan".to_string()); + }; + assignment_affinities_for_task(plan, task_id)? + }; + + let members = swarm_members.read().await; + let mut candidates = filter_swarm_agent_candidates(&members, req_session_id, swarm_id); + + candidates.sort_by(|left, right| { + let left_carry = affinities + .dependency_carryover + .get(&left.session_id) + .copied() + .unwrap_or(0); + let right_carry = affinities + .dependency_carryover + .get(&right.session_id) + .copied() + .unwrap_or(0); + let left_meta = affinities + .metadata_carryover + .get(&left.session_id) + .copied() + .unwrap_or(0); + let right_meta = affinities + .metadata_carryover + .get(&right.session_id) + .copied() + .unwrap_or(0); + let left_load = affinities.loads.get(&left.session_id).copied().unwrap_or(0); + let right_load = affinities + .loads + .get(&right.session_id) + .copied() + .unwrap_or(0); + let left_rank = if left.status == "ready" { 0 } else { 1 }; + let right_rank = if right.status == "ready" { 0 } else { 1 }; + right_carry + .cmp(&left_carry) + .then_with(|| right_meta.cmp(&left_meta)) + .then_with(|| left_load.cmp(&right_load)) + .then_with(|| left_rank.cmp(&right_rank)) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + + select_and_claim_auto_target(swarm_id, &candidates, &affinities.loads) +} + +#[expect( + clippy::too_many_arguments, + reason = "task execution restart needs session state, plan state, and event sinks together" +)] +fn spawn_assigned_task_run( + agent_arc: Arc<Mutex<Agent>>, + target_session: String, + swarm_id: String, + task_id: String, + assignment_text: String, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, +) { + let assignment_text = append_swarm_completion_report_instructions(&assignment_text); + tokio::spawn(async move { + { + let now_ms = now_unix_ms(); + let mut plans = swarm_plans.write().await; + if let Some(plan) = plans.get_mut(&swarm_id) + && let Some(item) = plan.items.iter_mut().find(|item| item.id == task_id) + { + item.status = "running".to_string(); + let progress = plan.task_progress.entry(task_id.clone()).or_default(); + progress.assigned_session_id = Some(target_session.clone()); + progress.assignment_summary = Some(truncate_detail(&assignment_text, 120)); + progress.started_at_unix_ms = Some(now_ms); + progress.last_heartbeat_unix_ms = Some(now_ms); + progress.last_detail = Some(truncate_detail(&assignment_text, 120)); + progress.last_checkpoint_unix_ms = Some(now_ms); + progress.checkpoint_summary = Some("task started".to_string()); + progress.completed_at_unix_ms = None; + progress.stale_since_unix_ms = None; + progress.heartbeat_count = Some(progress.heartbeat_count.unwrap_or(0) + 1); + progress.checkpoint_count = Some(progress.checkpoint_count.unwrap_or(0) + 1); + plan.version += 1; + } + } + let swarm_state = SwarmState { + members: Arc::clone(&swarm_members), + swarms_by_id: Arc::clone(&swarms_by_id), + plans: Arc::clone(&swarm_plans), + coordinators: Arc::clone(&swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + broadcast_swarm_plan( + &swarm_id, + Some("task_running".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + set_member_task_label(&target_session, &assignment_text, &swarm_members).await; + update_member_status( + &target_session, + "running", + Some(truncate_detail(&assignment_text, 120)), + &swarm_members, + &swarms_by_id, + Some(&event_history), + Some(&event_counter), + Some(&swarm_event_tx), + ) + .await; + + let (heartbeat_stop_tx, mut heartbeat_stop_rx) = watch::channel(false); + let heartbeat_task = { + let target_session = target_session.clone(); + let swarm_id = swarm_id.clone(); + let task_id = task_id.clone(); + let swarm_members = Arc::clone(&swarm_members); + let swarms_by_id = Arc::clone(&swarms_by_id); + let swarm_plans = Arc::clone(&swarm_plans); + let swarm_coordinators = Arc::clone(&swarm_coordinators); + tokio::spawn(async move { + let mut interval = tokio::time::interval(swarm_task_heartbeat_interval()); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval.tick().await; + loop { + tokio::select! { + _ = interval.tick() => { + let revived = touch_swarm_task_progress( + &swarm_id, + &task_id, + Some(&target_session), + None, + None, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + if revived { + broadcast_swarm_plan( + &swarm_id, + Some("task_heartbeat".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + } + } + changed = heartbeat_stop_rx.changed() => { + if changed.is_err() || *heartbeat_stop_rx.borrow() { + break; + } + } + } + } + }) + }; + + let event_tx = task_progress_event_sender( + target_session.clone(), + swarm_id.clone(), + task_id.clone(), + Arc::clone(&swarm_members), + Arc::clone(&swarms_by_id), + Arc::clone(&swarm_plans), + Arc::clone(&swarm_coordinators), + Arc::clone(&event_history), + Arc::clone(&event_counter), + swarm_event_tx.clone(), + ); + let start_message_index = { + let agent = agent_arc.lock().await; + agent.message_count() + }; + let result = super::client_lifecycle::process_message_streaming_mpsc( + Arc::clone(&agent_arc), + &assignment_text, + vec![], + None, + event_tx, + ) + .await; + let completion_report = if result.is_ok() { + let agent = agent_arc.lock().await; + agent.latest_assistant_text_after(start_message_index) + } else { + None + }; + let _ = heartbeat_stop_tx.send(true); + let _ = heartbeat_task.await; + + match result { + Ok(_) => { + let previous_items = { + let plans = swarm_plans.read().await; + plans + .get(&swarm_id) + .map(|plan| plan.items.clone()) + .unwrap_or_default() + }; + let mut applied_disposition = TurnEndDisposition::LeaveAlone; + { + let now_ms = now_unix_ms(); + let mut plans = swarm_plans.write().await; + if let Some(plan) = plans.get_mut(&swarm_id) + && let Some(item) = plan.items.iter_mut().find(|item| item.id == task_id) + { + // A worker turn ends in one of three ways for its node: + // 1. it decomposed the node via `expand_node` -> the node is + // now a composite synthesis/join point that must stay + // in-progress until its children (and deep-mode gate) + // finish; it is re-woken later to synthesize. + // 2. it already finished the node via `complete_node` -> the + // node is terminal and owned by no one. + // 3. it just ran and the node is still `running`. + // Case 3 is mode-dependent: light mode auto-completes + // (cheap fan-out, artifacts optional), deep mode never + // does — a deep node only closes through `complete_node` + // with a validated artifact, so an artifact-less turn is + // re-queued once to a fresh worker and then failed. + let expanded = plan + .node_meta + .get(&task_id) + .map(|m| m.expanded && !m.is_gate) + .unwrap_or(false); + let is_deep = plan.mode.eq_ignore_ascii_case("deep"); + let prior_requeues = plan + .task_progress + .get(&task_id) + .and_then(|p| p.no_artifact_requeues) + .unwrap_or(0); + match turn_end_disposition(is_deep, &item.status, expanded, prior_requeues) + { + TurnEndDisposition::AutoComplete => { + applied_disposition = TurnEndDisposition::AutoComplete; + item.status = "done".to_string(); + let progress = + plan.task_progress.entry(task_id.clone()).or_default(); + progress.last_heartbeat_unix_ms = Some(now_ms); + progress.last_checkpoint_unix_ms = Some(now_ms); + progress.checkpoint_summary = Some("task completed".to_string()); + progress.completed_at_unix_ms = Some(now_ms); + progress.stale_since_unix_ms = None; + progress.checkpoint_count = + Some(progress.checkpoint_count.unwrap_or(0) + 1); + plan.version += 1; + } + TurnEndDisposition::RequeueNoArtifact => { + applied_disposition = TurnEndDisposition::RequeueNoArtifact; + item.status = "queued".to_string(); + item.assigned_to = None; + let progress = + plan.task_progress.entry(task_id.clone()).or_default(); + progress.assigned_session_id = None; + progress.no_artifact_requeues = Some(prior_requeues + 1); + progress.last_heartbeat_unix_ms = Some(now_ms); + progress.last_checkpoint_unix_ms = Some(now_ms); + progress.checkpoint_summary = Some( + "requeued: deep-mode turn ended without a complete_node \ + artifact" + .to_string(), + ); + progress.stale_since_unix_ms = None; + progress.checkpoint_count = + Some(progress.checkpoint_count.unwrap_or(0) + 1); + plan.version += 1; + } + TurnEndDisposition::FailNoArtifact => { + applied_disposition = TurnEndDisposition::FailNoArtifact; + item.status = "failed".to_string(); + let progress = + plan.task_progress.entry(task_id.clone()).or_default(); + progress.last_heartbeat_unix_ms = Some(now_ms); + progress.last_checkpoint_unix_ms = Some(now_ms); + progress.checkpoint_summary = Some( + "failed: repeated deep-mode turns ended without a \ + complete_node artifact" + .to_string(), + ); + progress.completed_at_unix_ms = Some(now_ms); + progress.stale_since_unix_ms = None; + progress.checkpoint_count = + Some(progress.checkpoint_count.unwrap_or(0) + 1); + plan.version += 1; + } + TurnEndDisposition::LeaveAlone => {} + } + } + } + let swarm_state = SwarmState { + members: Arc::clone(&swarm_members), + swarms_by_id: Arc::clone(&swarms_by_id), + plans: Arc::clone(&swarm_plans), + coordinators: Arc::clone(&swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + let plan_reason = match applied_disposition { + TurnEndDisposition::RequeueNoArtifact => "task_requeued_no_artifact", + TurnEndDisposition::FailNoArtifact => "task_failed_no_artifact", + _ => "task_completed", + }; + broadcast_swarm_plan_with_previous( + &swarm_id, + Some(plan_reason.to_string()), + Some(&previous_items), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + // The worker's member status reflects its own turn (it ran to + // completion) even when its node was requeued/failed for missing + // an artifact: lifecycle and node state are separate axes, and a + // "completed" worker is reusable for the requeued node. + update_member_status_with_report( + &target_session, + "completed", + None, + completion_report, + &swarm_members, + &swarms_by_id, + Some(&event_history), + Some(&event_counter), + Some(&swarm_event_tx), + ) + .await; + } + Err(error) => { + { + let now_ms = now_unix_ms(); + let mut plans = swarm_plans.write().await; + if let Some(plan) = plans.get_mut(&swarm_id) + && let Some(item) = plan.items.iter_mut().find(|item| item.id == task_id) + { + item.status = "failed".to_string(); + let progress = plan.task_progress.entry(task_id.clone()).or_default(); + progress.last_heartbeat_unix_ms = Some(now_ms); + progress.last_checkpoint_unix_ms = Some(now_ms); + progress.checkpoint_summary = + Some(truncate_detail(&format!("task failed: {}", error), 120)); + progress.completed_at_unix_ms = Some(now_ms); + progress.stale_since_unix_ms = None; + progress.checkpoint_count = + Some(progress.checkpoint_count.unwrap_or(0) + 1); + plan.version += 1; + } + } + let swarm_state = SwarmState { + members: Arc::clone(&swarm_members), + swarms_by_id: Arc::clone(&swarms_by_id), + plans: Arc::clone(&swarm_plans), + coordinators: Arc::clone(&swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + broadcast_swarm_plan( + &swarm_id, + Some("task_failed".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + update_member_status( + &target_session, + "failed", + Some(truncate_detail(&error.to_string(), 120)), + &swarm_members, + &swarms_by_id, + Some(&event_history), + Some(&event_counter), + Some(&swarm_event_tx), + ) + .await; + } + } + }); +} + +fn format_salvage_message( + source_session: &str, + source_name: Option<&str>, + summaries: &[crate::protocol::ToolCallSummary], + extra_message: Option<&str>, +) -> String { + let label = source_name.unwrap_or(source_session); + let mut output = format!( + "Salvage prior progress from {}. Review this before continuing the task.\n\n", + label + ); + if summaries.is_empty() { + output.push_str("No recorded tool call summary was available from the previous assignee."); + } else { + output.push_str("Recent prior activity:\n"); + for call in summaries.iter().take(12) { + let result = if call.brief_output.trim().is_empty() { + "no result summary" + } else { + call.brief_output.as_str() + }; + output.push_str(&format!( + "- {}: {}\n", + call.tool_name, + truncate_detail(result, 180) + )); + } + } + if let Some(extra) = extra_message { + output.push_str("\n\nAdditional coordinator instructions:\n"); + output.push_str(extra); + } + output +} + +#[expect( + clippy::too_many_arguments, + reason = "task progress fanout needs plan state, swarm membership, and event sinks together" +)] +fn task_progress_event_sender( + session_id: String, + swarm_id: String, + task_id: String, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, +) -> mpsc::UnboundedSender<ServerEvent> { + let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let (detail, checkpoint_summary) = match &event { + ServerEvent::StatusDetail { detail } => (Some(detail.clone()), None), + ServerEvent::ToolStart { name, .. } => { + let summary = format!("tool start: {name}"); + (Some(summary.clone()), Some(summary)) + } + ServerEvent::ToolDone { name, error, .. } => { + let summary = if error.is_some() { + format!("tool error: {name}") + } else { + format!("tool done: {name}") + }; + (Some(summary.clone()), Some(summary)) + } + _ => (None, None), + }; + + if detail.is_some() || checkpoint_summary.is_some() { + let revived = touch_swarm_task_progress( + &swarm_id, + &task_id, + Some(&session_id), + detail.clone(), + checkpoint_summary, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + if let Some(detail) = detail { + update_member_status( + &session_id, + "running", + Some(truncate_detail(&detail, 120)), + &swarm_members, + &swarms_by_id, + Some(&event_history), + Some(&event_counter), + Some(&swarm_event_tx), + ) + .await; + } + if revived { + broadcast_swarm_plan( + &swarm_id, + Some("task_heartbeat".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + } + } + + let _ = fanout_session_event(&swarm_members, &session_id, event).await; + } + }); + tx +} + +#[expect( + clippy::too_many_arguments, + reason = "role assignment coordinates sessions, swarm membership, coordinators, and event history" +)] +pub(super) async fn handle_comm_assign_role( + id: u64, + req_session_id: String, + target_session: String, + role: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + let (swarm_id, is_coordinator) = { + let members = swarm_members.read().await; + let swarm_id = members + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()); + + let is_coordinator = if let Some(ref sid) = swarm_id { + let coordinators = swarm_coordinators.read().await; + let current_coordinator = coordinators.get(sid).cloned(); + drop(coordinators); + + crate::logging::info(&format!( + "[CommAssignRole] req={} target={} role={} swarm={} current_coord={:?}", + req_session_id, target_session, role, sid, current_coordinator + )); + + if current_coordinator.as_deref() == Some(req_session_id.as_str()) { + true + } else if role == "coordinator" && target_session == req_session_id { + drop(members); + if let Some(ref coord_id) = current_coordinator { + let (channel_closed, coord_is_headless) = { + let members = swarm_members.read().await; + members + .get(coord_id) + .map(|member| (member.event_tx.is_closed(), member.is_headless)) + .unwrap_or((true, false)) + }; + let not_in_sessions = !sessions.read().await.contains_key(coord_id); + channel_closed || not_in_sessions || coord_is_headless + } else { + true + } + } else { + false + } + } else { + false + }; + (swarm_id, is_coordinator) + }; + + if !is_coordinator { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Only the coordinator can assign roles. (Tip: if the coordinator has disconnected, use assign_role with target_session set to your own session ID to self-promote.)".to_string(), + retry_after_secs: None, + }); + return; + } + + let swarm_id = match swarm_id { + Some(swarm_id) => swarm_id, + None => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + return; + } + }; + + let mutation_key = swarm_mutation_request_key( + &req_session_id, + "assign_role", + &[swarm_id.clone(), target_session.clone(), role.clone()], + ); + let Some(mutation_state) = begin_swarm_mutation_or_replay( + swarm_mutation_runtime, + &mutation_key, + "assign_role", + &req_session_id, + id, + client_event_tx, + ) + .await + else { + return; + }; + + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(&target_session) { + member.role = role.clone(); + } else { + finish_swarm_mutation_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message: format!("Unknown session '{}'", target_session), + retry_after_secs: None, + }, + ) + .await; + return; + } + } + + if role == "coordinator" { + { + let mut coordinators = swarm_coordinators.write().await; + coordinators.insert(swarm_id.clone(), target_session.clone()); + } + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(&req_session_id) + && member.session_id != target_session + { + member.role = "agent".to_string(); + } + } + + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + + broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id, + None, + Some(swarm_id), + SwarmEventType::Notification { + notification_type: "role_assignment".to_string(), + message: format!("{} -> {}", target_session, role), + }, + ) + .await; + finish_swarm_mutation_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Done, + ) + .await; +} + +/// How an assign_task request interacts with the durable mutation dedup layer. +#[derive(Clone, Copy, PartialEq, Eq)] +enum AssignDedupMode { + /// Direct client requests: an identical request within the final-state + /// TTL replays the persisted response instead of re-dispatching. This + /// absorbs client-side retries of the same logical request. + ReplayFinal, + /// Task-control-driven dispatches (retry/reassign/replace/salvage): each + /// invocation is a deliberate new attempt, so a persisted success from a + /// previous identical attempt must not swallow the re-dispatch (a worker + /// failing within the TTL would otherwise make the coordinator's retry a + /// silent no-op). Concurrent in-flight duplicates still coalesce. + AlwaysDispatch, +} + +#[expect( + clippy::too_many_arguments, + reason = "task assignment coordinates sessions, interrupts, connections, swarm plan state, and event history" +)] +pub(super) async fn handle_comm_assign_task( + id: u64, + req_session_id: String, + target_session: Option<String>, + task_id: Option<String>, + message: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + soft_interrupt_queues: &super::SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + handle_comm_assign_task_with_mode( + id, + req_session_id, + target_session, + task_id, + message, + AssignDedupMode::ReplayFinal, + client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "task assignment coordinates sessions, interrupts, connections, swarm plan state, and event history" +)] +async fn handle_comm_assign_task_with_mode( + id: u64, + req_session_id: String, + target_session: Option<String>, + task_id: Option<String>, + message: Option<String>, + dedup_mode: AssignDedupMode, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + soft_interrupt_queues: &super::SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + let requested_target_session = target_session.and_then(|target| { + let trimmed = target.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }); + let requested_task_id = task_id.and_then(|task_id| { + let trimmed = task_id.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }); + + let swarm_id = match require_plan_driver_swarm( + id, + &req_session_id, + "Only the coordinator can assign tasks.", + client_event_tx, + swarm_members, + swarm_plans, + swarm_coordinators, + ) + .await + { + Some(swarm_id) => swarm_id, + None => return, + }; + + let mutation_key = swarm_mutation_request_key( + &req_session_id, + "assign_task", + &[ + swarm_id.clone(), + requested_target_session + .clone() + .unwrap_or_else(|| "__next_available__".to_string()), + requested_task_id + .clone() + .unwrap_or_else(|| "__next_runnable__".to_string()), + message.clone().unwrap_or_default(), + ], + ); + let mutation_state = match dedup_mode { + AssignDedupMode::ReplayFinal => { + begin_swarm_mutation_or_replay( + swarm_mutation_runtime, + &mutation_key, + "assign_task", + &req_session_id, + id, + client_event_tx, + ) + .await + } + AssignDedupMode::AlwaysDispatch => { + begin_swarm_mutation_no_replay( + swarm_mutation_runtime, + &mutation_key, + "assign_task", + &req_session_id, + id, + client_event_tx, + ) + .await + } + }; + let Some(mutation_state) = mutation_state else { + return; + }; + + let target_session = match resolve_assignment_target_session( + &req_session_id, + &swarm_id, + requested_target_session.as_deref(), + swarm_members, + swarm_plans, + ) + .await + { + Ok(target_session) => target_session, + Err(message) => { + finish_swarm_mutation_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message, + retry_after_secs: None, + }, + ) + .await; + return; + } + }; + + let (selected_task_id, task_content, participant_ids, plan_item_count, blocked_reason) = { + let now_ms = now_unix_ms(); + let mut plans = swarm_plans.write().await; + let plan = plans + .entry(swarm_id.clone()) + .or_insert_with(VersionedPlan::new); + let selected_task_id = requested_task_id + .clone() + .or_else(|| next_unassigned_runnable_item_id(plan)); + // Double-assignment guard: a direct assign_task naming an item that is + // already assigned and actively worked is a coordination bug (observed + // live: run_plan dispatched a node, then an explicit assign_task + // silently re-assigned it to a second worker and both edited the same + // files for minutes). Deliberate re-dispatch goes through task_control + // (retry/reassign/replace/salvage), which uses AlwaysDispatch and is + // exempt. Auto-selection only picks unassigned items, so only an + // explicit task_id can conflict. + let conflict_reason = if dedup_mode == AssignDedupMode::ReplayFinal { + requested_task_id.as_deref().and_then(|task_id| { + plan.items + .iter() + .find(|item| item.id == task_id) + .and_then(|item| { + active_assignment_conflict( + &item.status, + item.assigned_to.as_deref(), + plan.task_progress.get(task_id), + now_ms, + swarm_task_stale_after().as_millis() as u64, + ) + }) + .map(|conflict| active_assignment_error(task_id, &conflict)) + }) + } else { + None + }; + let blocked_reason = conflict_reason.or_else(|| { + requested_task_id + .as_deref() + .and_then(|task_id| explicit_task_blocked_reason(plan, task_id)) + }); + let found_idx = if blocked_reason.is_some() { + None + } else { + selected_task_id.as_ref().and_then(|selected_task_id| { + plan.items + .iter() + .position(|item| item.id == *selected_task_id) + }) + }; + if let Some(found_idx) = found_idx { + // Resolve identity + forward-dataflow context before taking the + // mutable borrow, so hydration can read sibling artifacts immutably. + let item_id = plan.items[found_idx].id.clone(); + let raw_content = plan.items[found_idx].content.clone(); + // A re-woken composite is the synthesis/join step: its original content + // was the (now-stale) decomposition brief, so replace it with an explicit + // synthesis instruction. Without this the planner replays the old "expand + // me" prompt and reports instead of calling `complete_node`, leaving the + // composite `running_stale` forever. + let is_composite_synthesis = plan + .node_meta + .get(&item_id) + .map(|meta| meta.expanded && !meta.is_gate) + .unwrap_or(false); + let effective_content = + composite_synthesis_content(&item_id, &raw_content, is_composite_synthesis); + let hydrated = + jcode_plan::bridge::hydrate_assignment(plan, &item_id, &effective_content); + let content = + deep_mode_assignment_content(plan, &item_id, is_composite_synthesis, &hydrated); + + // Index resolved under this same plan lock, so it stays valid. + let item = &mut plan.items[found_idx]; + item.assigned_to = Some(target_session.clone()); + item.status = "queued".to_string(); + plan.task_progress.insert( + item_id.clone(), + SwarmTaskProgress { + assigned_session_id: Some(target_session.clone()), + assignment_summary: Some(truncate_detail( + &combine_assignment_text(&content, message.as_deref()), + 120, + )), + assigned_at_unix_ms: Some(now_ms), + ..SwarmTaskProgress::default() + }, + ); + plan.version += 1; + plan.participants.insert(req_session_id.clone()); + plan.participants.insert(target_session.clone()); + ( + Some(item_id.clone()), + Some(content), + plan.participants.clone(), + plan.items.len(), + None, + ) + } else { + (None, None, HashSet::new(), 0, blocked_reason) + } + }; + + // The plan write above either recorded the assignment (from here + // `assignment_loads` marks the target busy) or abandoned it (no runnable + // task / blocked), so an auto-picked target's in-flight claim is released + // in both cases. Explicit targets never claimed; `assign_next` releases + // its own pre-claimed pick after this handler returns. + if requested_target_session.is_none() { + release_auto_assign_claim(&swarm_id, &target_session); + } + + let Some(selected_task_id) = selected_task_id else { + let message = blocked_reason.unwrap_or_else(|| { + requested_task_id.as_ref().map_or_else( + || "No runnable unassigned tasks are available in the swarm plan".to_string(), + |task_id| format!("Task '{}' not found in swarm plan", task_id), + ) + }); + finish_swarm_mutation_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message, + retry_after_secs: None, + }, + ) + .await; + return; + }; + let Some(content) = task_content else { + finish_swarm_mutation_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message: format!( + "Task '{}' could not be assigned because its content was unavailable.", + selected_task_id + ), + retry_after_secs: None, + }, + ) + .await; + return; + }; + + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + + broadcast_swarm_plan( + &swarm_id, + Some("task_assigned".to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + None, + Some(swarm_id.clone()), + SwarmEventType::PlanUpdate { + swarm_id: swarm_id.clone(), + item_count: plan_item_count, + }, + ) + .await; + + let coordinator_name = { + let members = swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.friendly_name.clone()) + }; + let notification = if let Some(ref extra) = message { + format!( + "Task assigned to you by coordinator: {} — {}", + content, extra + ) + } else { + format!("Task assigned to you by coordinator: {}", content) + }; + let queued_task_prompt = append_swarm_completion_report_instructions(¬ification); + let assignment_text = combine_assignment_text(&content, message.as_deref()); + set_member_task_label(&target_session, &assignment_text, swarm_members).await; + update_member_status( + &target_session, + "queued", + Some(truncate_detail(&assignment_text, 120)), + swarm_members, + swarms_by_id, + Some(event_history), + Some(event_counter), + Some(swarm_event_tx), + ) + .await; + + let target_agent = { + let agent_sessions = sessions.read().await; + agent_sessions.get(&target_session).cloned() + }; + let _ = queue_soft_interrupt_for_session( + &target_session, + queued_task_prompt, + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + if let Some(member) = swarm_members.read().await.get(&target_session) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: coordinator_name.clone(), + notification_type: NotificationType::Message { + scope: Some("dm".to_string()), + channel: None, + tldr: None, + }, + message: notification, + }); + } + + let target_has_client = { + let connections = client_connections.read().await; + connections + .values() + .any(|connection| connection.session_id == target_session) + }; + if !target_has_client && let Some(agent_arc) = target_agent { + let target_session_for_run = target_session.clone(); + let swarm_members_for_run = Arc::clone(swarm_members); + let swarms_for_run = Arc::clone(swarms_by_id); + let swarm_plans_for_run = Arc::clone(swarm_plans); + let swarm_coordinators_for_run = Arc::clone(swarm_coordinators); + let swarm_id_for_run = swarm_id.clone(); + let task_id_for_run = selected_task_id.clone(); + let event_history_for_run = Arc::clone(event_history); + let event_counter_for_run = Arc::clone(event_counter); + let swarm_event_tx_for_run = swarm_event_tx.clone(); + spawn_assigned_task_run( + agent_arc, + target_session_for_run, + swarm_id_for_run, + task_id_for_run, + assignment_text, + swarm_members_for_run, + swarms_for_run, + swarm_plans_for_run, + swarm_coordinators_for_run, + event_history_for_run, + event_counter_for_run, + swarm_event_tx_for_run, + ); + } + + let plan_msg = format!( + "Plan updated: task '{}' assigned to {}.", + selected_task_id, target_session + ); + let members = swarm_members.read().await; + for sid in participant_ids { + if sid == target_session || sid == req_session_id { + continue; + } + if let Some(member) = members.get(&sid) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: coordinator_name.clone(), + notification_type: NotificationType::Message { + scope: Some("plan".to_string()), + channel: None, + tldr: None, + }, + message: plan_msg.clone(), + }); + } + } + + finish_swarm_mutation_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::AssignTask { + task_id: selected_task_id, + target_session, + }, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "assign_next reuses task assignment orchestration and forwards the same runtime dependencies" +)] +pub(super) async fn handle_comm_assign_next( + id: u64, + req_session_id: String, + target_session: Option<String>, + working_dir: Option<String>, + prefer_spawn: Option<bool>, + spawn_if_needed: Option<bool>, + message: Option<String>, + model: Option<String>, + effort: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + global_session_id: &Arc<RwLock<String>>, + provider_template: &Arc<dyn crate::provider::Provider>, + soft_interrupt_queues: &super::SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + mcp_pool: &Arc<crate::mcp::SharedMcpPool>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + if target_session.is_none() { + let swarm_id = match require_plan_driver_swarm( + id, + &req_session_id, + "Only the coordinator can assign tasks.", + client_event_tx, + swarm_members, + swarm_plans, + swarm_coordinators, + ) + .await + { + Some(swarm_id) => swarm_id, + None => return, + }; + + let Some(selected_task_id) = + next_runnable_task_id_reclaiming_stranded(&swarm_id, swarm_plans, swarm_members).await + else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "No runnable unassigned tasks are available in the swarm plan".to_string(), + retry_after_secs: None, + }); + return; + }; + + let preferred_target = resolve_assignment_target_for_task( + &req_session_id, + &swarm_id, + &selected_task_id, + None, + swarm_members, + swarm_plans, + ) + .await; + + if (prefer_spawn.unwrap_or(false) || spawn_if_needed.unwrap_or(false)) + && (prefer_spawn.unwrap_or(false) || preferred_target.is_err()) + { + // A prefer_spawn run can reach here with a successfully resolved + // (and therefore claimed) reuse target it will never use; free it + // for concurrent picks. + if let Ok(unused_target) = &preferred_target { + release_auto_assign_claim(&swarm_id, unused_target); + } + match super::comm_session::spawn_swarm_agent( + &req_session_id, + &swarm_id, + working_dir.clone(), + None, + None, + model.clone(), + effort.clone(), + None, + sessions, + global_session_id, + provider_template, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + event_history, + event_counter, + swarm_event_tx, + mcp_pool, + soft_interrupt_queues, + client_connections, + ) + .await + { + Ok(spawned_session) => { + handle_comm_assign_task( + id, + req_session_id, + Some(spawned_session), + Some(selected_task_id), + message, + client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + return; + } + Err(error) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to spawn preferred worker: {error}"), + retry_after_secs: None, + }); + return; + } + } + } + + match preferred_target { + Ok(target_session) => { + handle_comm_assign_task( + id, + req_session_id, + Some(target_session.clone()), + Some(selected_task_id), + message, + client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + // The pick was claimed at resolve time; by now the assignment + // either landed in the plan (busy via assignment_loads) or was + // rejected, so the in-flight claim is spent either way. + release_auto_assign_claim(&swarm_id, &target_session); + } + Err(message) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + } + } + return; + } + + handle_comm_assign_task( + id, + req_session_id, + target_session, + None, + message, + client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "task control checks assignment state, delivery, and safe recovery paths together" +)] +pub(super) async fn handle_comm_task_control( + id: u64, + req_session_id: String, + action: String, + task_id: String, + target_session: Option<String>, + message: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + soft_interrupt_queues: &super::SessionInterruptQueues, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + let Some(action) = TaskControlAction::parse(&action) else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Unknown task control action. Use start, wake, resume, retry, reassign, replace, or salvage.".to_string(), + retry_after_secs: None, + }); + return; + }; + + let swarm_id = match require_plan_driver_swarm( + id, + &req_session_id, + "Only the coordinator can control assigned tasks.", + client_event_tx, + swarm_members, + swarm_plans, + swarm_coordinators, + ) + .await + { + Some(swarm_id) => swarm_id, + None => return, + }; + + let task_id = if task_id.trim().is_empty() { + let Some(target_session) = target_session.as_deref() else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "task_id is required for {} unless target_session uniquely identifies an assigned task.", + action.as_str() + ), + retry_after_secs: None, + }); + return; + }; + match task_id_for_target_session(&swarm_id, target_session, action, swarm_plans).await { + Ok(task_id) => task_id, + Err(message) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + return; + } + } + } else { + task_id + }; + + let Some(snapshot) = task_snapshot_for(&swarm_id, &task_id, swarm_plans).await else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Task '{}' not found in swarm plan", task_id), + retry_after_secs: None, + }); + return; + }; + + if !task_control_action_allows_status(action, &snapshot.status) { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: task_control_status_error(action, &snapshot.status, &task_id), + retry_after_secs: None, + }); + return; + } + + let current_assignee = snapshot.assigned_to.clone(); + let require_assignee = matches!( + action, + TaskControlAction::Start + | TaskControlAction::Wake + | TaskControlAction::Resume + | TaskControlAction::Retry + | TaskControlAction::Replace + | TaskControlAction::Salvage + | TaskControlAction::Reassign + ); + if require_assignee && current_assignee.is_none() { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' is not currently assigned. Use assign_task to create the first assignment.", + task_id + ), + retry_after_secs: None, + }); + return; + } + + match action { + TaskControlAction::Start | TaskControlAction::Wake | TaskControlAction::Resume => { + let Some(assignee) = current_assignee.clone() else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' no longer has an assignee. Use assign_task to create the first assignment.", + task_id + ), + retry_after_secs: None, + }); + return; + }; + if let Some(ref requested_target) = target_session + && requested_target != &assignee + { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' is assigned to '{}', not '{}'. Use reassign or replace to change ownership.", + task_id, assignee, requested_target + ), + retry_after_secs: None, + }); + return; + } + + let assignment_text = + build_control_assignment_text(action, &snapshot.content, message.as_deref()); + // Validate the assignee is actually available BEFORE mutating any + // plan state. Resuming a plain-'running' task used to requeue it + // (flipping it to 'queued' and rewriting its progress record) + // first and only then discover the agent was missing or busy, + // leaving a live task falsely queued with its run history mangled + // even though the request was rejected. + let Some(agent_arc) = task_agent_session(&assignee, sessions).await else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Assigned session '{}' is not available. Use replace or salvage to move the task to another agent.", + assignee + ), + retry_after_secs: None, + }); + return; + }; + let Some(_member) = active_swarm_member(&assignee, swarm_members).await else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Assigned session '{}' is no longer in the swarm. Use replace or salvage to move the task.", + assignee + ), + retry_after_secs: None, + }); + return; + }; + + let agent_is_idle = match agent_arc.try_lock() { + Ok(guard) => { + drop(guard); + true + } + Err(_) => false, + }; + + if agent_is_idle { + if snapshot.status != "queued" + && requeue_existing_assignment( + &swarm_id, + &req_session_id, + &assignee, + &task_id, + assignment_text.clone(), + swarm_plans, + ) + .await + .is_some() + { + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + broadcast_swarm_plan( + &swarm_id, + Some(format!("task_{}", action.as_str())), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + } + + spawn_assigned_task_run( + agent_arc, + assignee.clone(), + swarm_id.clone(), + task_id.clone(), + assignment_text, + Arc::clone(swarm_members), + Arc::clone(swarms_by_id), + Arc::clone(swarm_plans), + Arc::clone(swarm_coordinators), + Arc::clone(event_history), + Arc::clone(event_counter), + swarm_event_tx.clone(), + ); + let summary = plan_graph_status_for(&swarm_id, swarm_plans).await; + let _ = client_event_tx.send(ServerEvent::CommTaskControlResponse { + id, + action: action.as_str().to_string(), + task_id: task_id.clone(), + target_session: Some(assignee.clone()), + status: "running".to_string(), + summary, + }); + return; + } + + if action == TaskControlAction::Wake { + let assignment_text = append_swarm_completion_report_instructions(&assignment_text); + let wake_message = format!( + "Coordinator requested you wake and continue task '{}'.\n\n{}", + task_id, assignment_text + ); + let _ = queue_soft_interrupt_for_session( + &assignee, + wake_message, + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + let summary = plan_graph_status_for(&swarm_id, swarm_plans).await; + let _ = client_event_tx.send(ServerEvent::CommTaskControlResponse { + id, + action: action.as_str().to_string(), + task_id: task_id.clone(), + target_session: Some(assignee.clone()), + status: "queued".to_string(), + summary, + }); + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Assigned session '{}' is currently busy. Use wake to queue the task, or retry once the agent is idle.", + assignee + ), + retry_after_secs: Some(1), + }); + } + } + TaskControlAction::Retry => { + let Some(assignee) = current_assignee.clone() else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' no longer has an assignee. Use assign_task to create the first assignment.", + task_id + ), + retry_after_secs: None, + }); + return; + }; + let retry_note = message.as_ref().map_or_else( + || "Retry this assignment.".to_string(), + |extra| { + format!( + "Retry this assignment.\n\nAdditional coordinator instructions:\n{}", + extra + ) + }, + ); + handle_comm_assign_task_with_mode( + id, + req_session_id, + Some(assignee), + Some(task_id), + Some(retry_note), + AssignDedupMode::AlwaysDispatch, + client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + } + TaskControlAction::Reassign | TaskControlAction::Replace | TaskControlAction::Salvage => { + let Some(assignee) = current_assignee.clone() else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' no longer has an assignee. Use assign_task to create the first assignment.", + task_id + ), + retry_after_secs: None, + }); + return; + }; + let Some(new_target) = target_session else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("'target_session' is required for {}.", action.as_str()), + retry_after_secs: None, + }); + return; + }; + + if new_target == assignee { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Task '{}' is already assigned to '{}'.", task_id, assignee), + retry_after_secs: None, + }); + return; + } + + if snapshot.status == "running" { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' is actively running on '{}'. Wait, wake, or stop that agent before handing the task off.", + task_id, assignee + ), + retry_after_secs: Some(1), + }); + return; + } + + if action == TaskControlAction::Replace + && !matches!( + snapshot.status.as_str(), + "queued" | "failed" | "stopped" | "crashed" | "running_stale" + ) + { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Task '{}' is '{}' and cannot be safely replaced.", + task_id, snapshot.status + ), + retry_after_secs: None, + }); + return; + } + + let forwarded_message = if action == TaskControlAction::Salvage { + let prior_name = active_swarm_member(&assignee, swarm_members) + .await + .and_then(|member| member.friendly_name); + let summaries = + if let Some(agent_arc) = task_agent_session(&assignee, sessions).await { + if let Ok(agent) = agent_arc.try_lock() { + agent.get_tool_call_summaries(12) + } else { + vec![] + } + } else { + vec![] + }; + let mut salvage = format_salvage_message( + &assignee, + prior_name.as_deref(), + &summaries, + message.as_deref(), + ); + if let Some(progress) = snapshot.progress.as_ref() { + if let Some(summary) = progress.checkpoint_summary.as_deref() { + salvage.push_str("\n\nLatest checkpoint summary:\n"); + salvage.push_str(summary); + } + if let Some(detail) = progress.last_detail.as_deref() { + salvage.push_str("\n\nLatest recorded detail:\n"); + salvage.push_str(detail); + } + } + Some(salvage) + } else if action == TaskControlAction::Replace { + Some(message.as_ref().map_or_else( + || format!("This task is replacing prior assignee '{}'.", assignee), + |extra| format!( + "This task is replacing prior assignee '{}'.\n\nAdditional coordinator instructions:\n{}", + assignee, extra + ), + )) + } else { + message + }; + + let displaced_task_id = task_id.clone(); + let displaced_new_target = new_target.clone(); + let displaced_req_session = req_session_id.clone(); + handle_comm_assign_task_with_mode( + id, + req_session_id, + Some(new_target), + Some(task_id), + forwarded_message, + AssignDedupMode::AlwaysDispatch, + client_event_tx, + sessions, + soft_interrupt_queues, + client_connections, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + swarm_mutation_runtime, + ) + .await; + + // Tell the displaced worker to stand down, but only when the + // takeover actually landed (the re-dispatch above can still fail, + // e.g. on an unknown target session, and then the prior assignee + // keeps the task). Without this the displaced worker keeps + // editing the same files as its replacement until a human DMs it. + let takeover_landed = { + let swarm_plans = swarm_plans.read().await; + swarm_plans + .get(&swarm_id) + .and_then(|plan| plan.items.iter().find(|item| item.id == displaced_task_id)) + .is_some_and(|item| { + item.assigned_to.as_deref() == Some(displaced_new_target.as_str()) + }) + }; + if takeover_landed { + let stand_down = format!( + "Task '{}' has been handed off to '{}' by the coordinator ({}). Stop working \ + on it immediately: do not make further edits or commits for that task. If \ + you have uncommitted progress worth keeping, note it in a brief message to \ + the coordinator, then stand down.", + displaced_task_id, + displaced_new_target, + action.as_str() + ); + let _ = queue_soft_interrupt_for_session( + &assignee, + stand_down.clone(), + true, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + if let Some(member) = swarm_members.read().await.get(&assignee) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: displaced_req_session, + from_name: None, + notification_type: NotificationType::Message { + scope: Some("dm".to_string()), + channel: None, + tldr: None, + }, + message: stand_down, + }); + } + } + } + } +} + +#[cfg(test)] +#[path = "comm_control_tests.rs"] +mod tests; + +pub(super) async fn handle_client_debug_command( + id: u64, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "ClientDebugCommand is for internal use only".to_string(), + retry_after_secs: None, + }); +} + +pub(super) fn handle_client_debug_response( + id: u64, + output: String, + client_debug_response_tx: &broadcast::Sender<(u64, String)>, +) { + let _ = client_debug_response_tx.send((id, output)); +} + +/// Authorize a session to drive task dispatch for its swarm plan. +/// +/// Light mode keeps the single-coordinator rule: a coordinator is the one driver, +/// which matches the cheap fan-out preset. Deep mode follows the task-DAG +/// ownership model (see `docs/SWARM_TASK_GRAPH.md` section 2): the plan is a tree +/// of ownership over a graph, and the agent that seeded/participates in the graph +/// must be able to dispatch it even when another session already holds the +/// swarm-level coordinator slot. Without this, a deep-mode agent that joins a +/// shared swarm can seed a graph but is then blocked from spawning/assigning any +/// of it, so nothing ever runs. +/// +/// Returns the swarm id when the caller is the coordinator, or (deep mode only) a +/// participant of the swarm's plan. +async fn require_plan_driver_swarm( + id: u64, + req_session_id: &str, + permission_error: &str, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) -> Option<String> { + let swarm_id = { + let members = swarm_members.read().await; + members + .get(req_session_id) + .and_then(|member| member.swarm_id.clone()) + }; + let Some(swarm_id) = swarm_id else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + return None; + }; + + let is_coordinator = { + let coordinators = swarm_coordinators.read().await; + coordinators + .get(&swarm_id) + .map(|coordinator| coordinator == req_session_id) + .unwrap_or(false) + }; + if is_coordinator { + return Some(swarm_id); + } + + // Deep mode: any participant of the plan may drive its own task graph. + let is_deep_participant = { + let plans = swarm_plans.read().await; + plans + .get(&swarm_id) + .map(|plan| { + jcode_plan::bridge::parse_mode(&plan.mode) == jcode_plan::dag::Mode::Deep + && plan.participants.contains(req_session_id) + }) + .unwrap_or(false) + }; + if is_deep_participant { + return Some(swarm_id); + } + + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: permission_error.to_string(), + retry_after_secs: None, + }); + None +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests.rs b/crates/jcode-app-core/src/server/comm_control_tests.rs new file mode 100644 index 0000000..541e640 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests.rs @@ -0,0 +1,166 @@ +use super::{handle_comm_assign_next, handle_comm_assign_task, handle_comm_task_control}; +use crate::agent::Agent; +use crate::message::{Message, StreamEvent, ToolDefinition}; +use crate::plan::PlanItem; +use crate::protocol::ServerEvent; +use crate::provider::{EventStream, Provider}; +use crate::server::comm_await::{CommAwaitMembersContext, handle_comm_await_members}; +use crate::server::{ + AwaitMembersRuntime, SwarmEvent, SwarmEventType, SwarmMember, SwarmMutationRuntime, + VersionedPlan, +}; +use crate::tool::Registry; +use anyhow::Result; +use async_trait::async_trait; +use futures::stream; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +struct RuntimeEnvGuard { + _guard: std::sync::MutexGuard<'static, ()>, + prev_runtime: Option<std::ffi::OsString>, +} + +impl RuntimeEnvGuard { + fn new() -> (Self, tempfile::TempDir) { + let guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create runtime dir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + ( + Self { + _guard: guard, + prev_runtime, + }, + temp, + ) + } +} + +impl Drop for RuntimeEnvGuard { + fn drop(&mut self) { + if let Some(prev_runtime) = self.prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +fn member(session_id: &str, swarm_id: &str, status: &str) -> SwarmMember { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.to_string()), + swarm_enabled: true, + status: status.to_string(), + detail: None, + friendly_name: Some(session_id.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, + } +} + +/// A swarm worker owned by `owner` (its spawning coordinator). Auto-assignment +/// only targets such drivable workers, so test fixtures that model a spawned +/// worker should use this rather than a bare `member()` (which represents a +/// foreign/independent session and is intentionally not auto-assignable). +fn owned_member(session_id: &str, swarm_id: &str, status: &str, owner: &str) -> SwarmMember { + let mut m = member(session_id, swarm_id, status); + m.report_back_to_session_id = Some(owner.to_string()); + m +} + +fn plan_item(id: &str, status: &str, priority: &str, blocked_by: &[&str]) -> PlanItem { + PlanItem { + content: format!("task {id}"), + status: status.to_string(), + priority: priority.to_string(), + id: id.to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: blocked_by.iter().map(|value| value.to_string()).collect(), + assigned_to: None, + } +} + +fn swarm_event(session_id: &str, swarm_id: &str, event: SwarmEventType) -> SwarmEvent { + SwarmEvent { + id: 1, + session_id: session_id.to_string(), + session_name: Some(session_id.to_string()), + swarm_id: Some(swarm_id.to_string()), + event, + timestamp: Instant::now(), + absolute_time: SystemTime::now(), + } +} + +#[derive(Default)] +struct TestProvider; + +#[async_trait] +impl Provider for TestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Ok(Box::pin(stream::iter(vec![Ok(StreamEvent::MessageEnd { + stop_reason: None, + })]))) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self) + } +} + +async fn test_agent() -> Arc<Mutex<Agent>> { + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + Arc::new(Mutex::new(Agent::new(provider, registry))) +} + +include!("comm_control_tests/assign_task.rs"); +include!("comm_control_tests/assign_blocked.rs"); +include!("comm_control_tests/assign_double.rs"); +include!("comm_control_tests/assign_ready_agent.rs"); +include!("comm_control_tests/assign_less_loaded.rs"); +include!("comm_control_tests/assign_busy_skip.rs"); +include!("comm_control_tests/task_control.rs"); +include!("comm_control_tests/assign_next_dependency.rs"); +include!("comm_control_tests/assign_next_metadata.rs"); +include!("comm_control_tests/await_late_joiners.rs"); +include!("comm_control_tests/await_disconnect.rs"); +include!("comm_control_tests/await_any.rs"); +include!("comm_control_tests/await_reload_deadline.rs"); +include!("comm_control_tests/await_reload_final.rs"); +include!("comm_control_tests/await_lagged.rs"); +include!("comm_control_tests/await_resume_expired.rs"); +include!("comm_control_tests/await_background_expired.rs"); +include!("comm_control_tests/await_upgrade_background.rs"); +include!("comm_control_tests/dag_e2e.rs"); +include!("comm_control_tests/auto_worker_filter.rs"); +include!("comm_control_tests/client_attached_dispatch.rs"); diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_blocked.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_blocked.rs new file mode 100644 index 0000000..7e10173 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_blocked.rs @@ -0,0 +1,88 @@ +#[tokio::test] +async fn assign_task_rejects_explicit_blocked_task() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-blocked"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let worker_agent = test_agent().await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + worker.to_string(), + worker_agent, + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![ + plan_item("setup", "completed", "high", &[]), + plan_item("blocked", "queued", "high", &["missing-prereq"]), + ], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 88, + requester.to_string(), + Some(worker.to_string()), + Some("blocked".to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::Error { message, .. } => { + assert!(message.contains("missing dependencies") || message.contains("blocked")); + } + other => panic!("expected error for blocked task assignment, got {other:?}"), + } + + let plans = swarm_plans.read().await; + let blocked = plans[swarm_id] + .items + .iter() + .find(|item| item.id == "blocked") + .expect("blocked task exists"); + assert!( + blocked.assigned_to.is_none(), + "blocked task should stay unassigned" + ); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_busy_skip.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_busy_skip.rs new file mode 100644 index 0000000..32fef24 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_busy_skip.rs @@ -0,0 +1,193 @@ +// Worker-stacking regression tests for auto-assignment target selection. +// +// Observed live: three `assign_task spawn_if_needed=true` calls within ~100ms +// all auto-picked the SAME reusable worker, queueing three large tasks +// serially on one agent while the swarm had spawn capacity. Two invariants +// pin the fix: +// 1. a member that already holds an incomplete plan assignment is busy, not +// reusable, so auto-pick skips it (and the caller falls back to spawning); +// 2. the pick itself is an in-process claim, so concurrent picks that race +// ahead of the plan write cannot select the same member twice. +// +// Included into the `comm_control::tests` module, so the parent's private +// selection helpers are in scope. + +use super::{ + member_has_active_assignment, release_auto_assign_claim, select_and_claim_auto_target, +}; + +#[test] +fn member_with_nonzero_plan_load_is_busy() { + let loads = HashMap::from([("busy".to_string(), 1usize)]); + assert!(member_has_active_assignment("busy", &loads)); + assert!(!member_has_active_assignment("idle", &loads)); +} + +#[test] +fn auto_pick_skips_busy_worker_and_selects_idle() { + let swarm_id = "swarm-busy-skip"; + let busy = owned_member("busy-worker", swarm_id, "ready", "coord"); + let idle = owned_member("idle-worker", swarm_id, "ready", "coord"); + // Caller-ranked order puts the busy worker first; selection must still + // land on the idle one. + let candidates = vec![&busy, &idle]; + let loads = HashMap::from([("busy-worker".to_string(), 2usize)]); + + let picked = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("pick idle"); + assert_eq!(picked, "idle-worker"); + release_auto_assign_claim(swarm_id, &picked); +} + +#[test] +fn auto_pick_with_only_busy_workers_reports_no_target_for_spawn_fallback() { + let swarm_id = "swarm-busy-only"; + let busy = owned_member("busy-worker", swarm_id, "ready", "coord"); + let candidates = vec![&busy]; + let loads = HashMap::from([("busy-worker".to_string(), 1usize)]); + + let err = select_and_claim_auto_target(swarm_id, &candidates, &loads).unwrap_err(); + // The leading sentence is the stable contract that spawn_if_needed / + // run_plan match on to spawn a fresh agent instead of stacking. + assert!( + err.starts_with("No ready or completed swarm agents are available"), + "unexpected error: {err}" + ); + assert!(err.contains("Skipped 1 worker(s)"), "unexpected error: {err}"); +} + +#[test] +fn concurrent_auto_picks_do_not_stack_on_one_member() { + let swarm_id = "swarm-race-claim"; + let a = owned_member("worker-a", swarm_id, "ready", "coord"); + let b = owned_member("worker-b", swarm_id, "ready", "coord"); + let candidates = vec![&a, &b]; + // No plan write has landed yet, so the plan-derived loads see everyone as + // idle. This models the observed race: back-to-back picks resolving inside + // the window before the first assignment is recorded. + let loads = HashMap::new(); + + let first = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("first pick"); + let second = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("second pick"); + assert_ne!(first, second, "two racing picks must not share a member"); + + let third = select_and_claim_auto_target(swarm_id, &candidates, &loads).unwrap_err(); + assert!( + third.starts_with("No ready or completed swarm agents are available"), + "third racing pick should demand a spawn, got: {third}" + ); + + release_auto_assign_claim(swarm_id, &first); + release_auto_assign_claim(swarm_id, &second); +} + +#[test] +fn released_claim_makes_member_pickable_again() { + let swarm_id = "swarm-claim-release"; + let a = owned_member("worker-a", swarm_id, "ready", "coord"); + let candidates = vec![&a]; + let loads = HashMap::new(); + + let first = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("first pick"); + assert_eq!(first, "worker-a"); + // The assign path releases the claim once the plan write records (or + // abandons) the assignment; with the plan still showing zero load, the + // member is genuinely reusable again. + release_auto_assign_claim(swarm_id, &first); + let again = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("re-pick"); + assert_eq!(again, "worker-a"); + release_auto_assign_claim(swarm_id, &again); +} + +/// Handler-level regression: when the only worker already holds an incomplete +/// assignment, an auto `assign_task` must refuse (so `spawn_if_needed` spawns) +/// instead of stacking a second task onto it. +#[tokio::test] +async fn assign_task_does_not_stack_on_busy_worker() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-no-stack"; + let requester = "coord"; + let busy_worker = "worker-busy-solo"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + ( + busy_worker.to_string(), + // "ready" lifecycle status but with an incomplete plan assignment: + // exactly the state the stacked worker was in. + owned_member(busy_worker, swarm_id, "ready", requester), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), busy_worker.to_string()]), + )]))); + let mut in_flight = plan_item("in-flight", "queued", "high", &[]); + in_flight.assigned_to = Some(busy_worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![in_flight, plan_item("next", "queued", "high", &[])], + version: 1, + participants: HashSet::from([requester.to_string(), busy_worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 104, + requester.to_string(), + None, + None, + Some("Do not stack this onto the busy worker".to_string()), + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::Error { message, .. } => { + assert!( + message.starts_with("No ready or completed swarm agents are available"), + "expected the spawn-fallback trigger, got: {message}" + ); + } + other => panic!("expected Error refusing to stack, got {other:?}"), + } + + // The busy worker must still hold exactly its original assignment. + let plans = swarm_plans.read().await; + let assigned: Vec<&str> = plans[swarm_id] + .items + .iter() + .filter(|item| item.assigned_to.as_deref() == Some(busy_worker)) + .map(|item| item.id.as_str()) + .collect(); + assert_eq!(assigned, vec!["in-flight"]); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_double.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_double.rs new file mode 100644 index 0000000..d0b5bd6 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_double.rs @@ -0,0 +1,469 @@ +// Double-assignment guard: a direct assign_task naming a node that is already +// assigned and actively worked must be rejected with an error naming the +// current assignee. Incident: run_plan dispatched a node to a spawned worker, +// and 16 seconds later an explicit `assign_task task_id=<same node>` silently +// re-assigned it to a fresh worker; both edited the same files for ~7 minutes. + +/// Pure guard predicate: assigned+fresh -> conflict, assigned+stale -> allow, +/// unassigned -> allow, stale/terminal statuses -> allow. +#[test] +fn active_assignment_conflict_detects_only_assigned_and_fresh_items() { + let now = 1_000_000_u64; + let window = 45_000_u64; + let progress_with_heartbeat = |heartbeat: Option<u64>| crate::server::SwarmTaskProgress { + assigned_session_id: Some("snail".to_string()), + last_heartbeat_unix_ms: heartbeat, + ..Default::default() + }; + + // Unassigned -> allow, regardless of progress freshness. + assert!( + super::active_assignment_conflict( + "queued", + None, + Some(&progress_with_heartbeat(Some(now - 1_000))), + now, + window, + ) + .is_none(), + "unassigned items are always assignable" + ); + + // Assigned + fresh heartbeat -> reject, naming the assignee and age. + let conflict = super::active_assignment_conflict( + "running", + Some("snail"), + Some(&progress_with_heartbeat(Some(now - 12_000))), + now, + window, + ) + .expect("assigned + fresh heartbeat must conflict"); + assert_eq!(conflict.assignee, "snail"); + assert_eq!(conflict.active_ago_ms, 12_000); + let message = super::active_assignment_error("mem-impl-attribution", &conflict); + assert!( + message.contains("'mem-impl-attribution'") + && message.contains("'snail'") + && message.contains("12s ago") + && message.contains("reassign"), + "error must name the task, assignee, activity age, and takeover path: {message}" + ); + + // Assigned + queued (dispatch pending) also counts as actively worked. + assert!( + super::active_assignment_conflict( + "queued", + Some("snail"), + Some(&progress_with_heartbeat(Some(now - 1_000))), + now, + window, + ) + .is_some(), + "a freshly queued assignment is in-flight, not reassignable" + ); + + // Assigned + heartbeat at/over the stale window -> allow (stale path). + assert!( + super::active_assignment_conflict( + "running", + Some("snail"), + Some(&progress_with_heartbeat(Some(now - window))), + now, + window, + ) + .is_none(), + "stale assignments stay reassignable" + ); + + // Assigned with no progress record or no timestamps -> allow (treated + // stale, mirroring refresh_swarm_task_staleness). + assert!(super::active_assignment_conflict("running", Some("snail"), None, now, window).is_none()); + assert!( + super::active_assignment_conflict( + "running", + Some("snail"), + Some(&progress_with_heartbeat(None)), + now, + window, + ) + .is_none() + ); + + // started_at / assigned_at count as activity when no heartbeat landed yet. + let just_assigned = crate::server::SwarmTaskProgress { + assigned_session_id: Some("snail".to_string()), + assigned_at_unix_ms: Some(now - 5_000), + ..Default::default() + }; + assert!( + super::active_assignment_conflict("queued", Some("snail"), Some(&just_assigned), now, window) + .is_some(), + "an assignment made moments ago is active even before its first heartbeat" + ); + + // running_stale and terminal statuses -> allow (existing recovery paths). + for status in ["running_stale", "failed", "stopped", "crashed", "completed", "done"] { + assert!( + super::active_assignment_conflict( + status, + Some("snail"), + Some(&progress_with_heartbeat(Some(now - 1_000))), + now, + window, + ) + .is_none(), + "status '{status}' must not trigger the double-assignment guard" + ); + } +} + +#[allow(clippy::type_complexity)] +fn double_assign_fixture( + swarm_id: &str, + requester: &str, + holder: &str, + intruder: &str, + contested: PlanItem, + progress: crate::server::SwarmTaskProgress, +) -> ( + Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>, + Arc<RwLock<HashMap<String, SwarmMember>>>, + Arc<RwLock<HashMap<String, HashSet<String>>>>, + Arc<RwLock<HashMap<String, VersionedPlan>>>, + Arc<RwLock<HashMap<String, String>>>, +) { + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (holder.to_string(), member(holder, swarm_id, "running")), + (intruder.to_string(), member(intruder, swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + requester.to_string(), + holder.to_string(), + intruder.to_string(), + ]), + )]))); + let task_id = contested.id.clone(); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![contested], + version: 1, + participants: HashSet::from([requester.to_string(), holder.to_string()]), + task_progress: HashMap::from([(task_id, progress)]), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let sessions = Arc::new(RwLock::new(HashMap::new())); + ( + sessions, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + ) +} + +fn unix_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_millis() as u64 +} + +/// Live path: explicit assign_task against an assigned-and-active node is +/// rejected and the plan keeps the original assignee. +#[tokio::test] +async fn assign_task_rejects_double_assignment_of_actively_worked_task() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-double-assign"; + let (requester, holder, intruder) = ("coord", "snail", "penguin"); + let mut contested = plan_item("contested", "running", "high", &[]); + contested.assigned_to = Some(holder.to_string()); + let (sessions, swarm_members, swarms_by_id, swarm_plans, swarm_coordinators) = + double_assign_fixture( + swarm_id, + requester, + holder, + intruder, + contested, + crate::server::SwarmTaskProgress { + assigned_session_id: Some(holder.to_string()), + last_heartbeat_unix_ms: Some(unix_now_ms()), + ..Default::default() + }, + ); + sessions + .write() + .await + .insert(intruder.to_string(), test_agent().await); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 91, + requester.to_string(), + Some(intruder.to_string()), + Some("contested".to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::Error { message, .. } => { + assert!( + message.contains("already assigned to 'snail'"), + "error must name the current assignee: {message}" + ); + assert!( + message.contains("reassign"), + "error must point at the explicit takeover path: {message}" + ); + } + other => panic!("expected double-assignment rejection, got {other:?}"), + } + + let plans = swarm_plans.read().await; + let item = plans[swarm_id] + .items + .iter() + .find(|item| item.id == "contested") + .expect("contested task exists"); + assert_eq!( + item.assigned_to.as_deref(), + Some(holder), + "rejected double assignment must not steal the task" + ); + assert_eq!(item.status, "running", "lifecycle status untouched"); +} + +/// Live path: an assignment whose heartbeat is far past the stale window is +/// legitimately reassignable (dead-assignee recovery must keep working). +#[tokio::test] +async fn assign_task_allows_taking_over_stale_assignment() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-double-assign-stale"; + let (requester, holder, intruder) = ("coord", "snail", "penguin"); + let mut stalled = plan_item("stalled", "queued", "high", &[]); + stalled.assigned_to = Some(holder.to_string()); + let (sessions, swarm_members, swarms_by_id, swarm_plans, swarm_coordinators) = + double_assign_fixture( + swarm_id, + requester, + holder, + intruder, + stalled, + crate::server::SwarmTaskProgress { + assigned_session_id: Some(holder.to_string()), + // Far beyond any configured stale window. + last_heartbeat_unix_ms: Some(unix_now_ms().saturating_sub(3_600_000)), + ..Default::default() + }, + ); + sessions + .write() + .await + .insert(intruder.to_string(), test_agent().await); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 92, + requester.to_string(), + Some(intruder.to_string()), + Some("stalled".to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + task_id, + target_session, + .. + } => { + assert_eq!(task_id, "stalled"); + assert_eq!(target_session, intruder); + } + other => panic!("stale assignment takeover should succeed, got {other:?}"), + } + + let plans = swarm_plans.read().await; + let item = plans[swarm_id] + .items + .iter() + .find(|item| item.id == "stalled") + .expect("stalled task exists"); + assert_eq!(item.assigned_to.as_deref(), Some(intruder)); +} + +/// Takeover path: task_control reassign moves the task AND tells the displaced +/// worker to stand down (soft interrupt + DM), so it stops editing the same +/// files as its replacement. +#[tokio::test] +async fn task_control_reassign_tells_displaced_worker_to_stand_down() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-reassign-stand-down"; + let (requester, holder, intruder) = ("coord", "snail", "penguin"); + let mut contested = plan_item("contested", "running_stale", "high", &[]); + contested.assigned_to = Some(holder.to_string()); + let (sessions, swarm_members, swarms_by_id, swarm_plans, swarm_coordinators) = + double_assign_fixture( + swarm_id, + requester, + holder, + intruder, + contested, + crate::server::SwarmTaskProgress { + assigned_session_id: Some(holder.to_string()), + last_heartbeat_unix_ms: Some(unix_now_ms().saturating_sub(3_600_000)), + ..Default::default() + }, + ); + // Capture the displaced worker's server-event stream to observe the DM. + let (holder_tx, mut holder_rx) = mpsc::unbounded_channel(); + swarm_members + .write() + .await + .get_mut(holder) + .expect("holder member") + .event_tx = holder_tx; + sessions + .write() + .await + .insert(holder.to_string(), test_agent().await); + sessions + .write() + .await + .insert(intruder.to_string(), test_agent().await); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_task_control( + 93, + requester.to_string(), + "reassign".to_string(), + "contested".to_string(), + Some(intruder.to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + task_id, + target_session, + .. + } => { + assert_eq!(task_id, "contested"); + assert_eq!(target_session, intruder); + } + other => panic!("reassign should re-dispatch the task, got {other:?}"), + } + + { + let plans = swarm_plans.read().await; + let item = plans[swarm_id] + .items + .iter() + .find(|item| item.id == "contested") + .expect("contested task exists"); + assert_eq!(item.assigned_to.as_deref(), Some(intruder)); + } + + // The displaced worker's soft-interrupt queue carries the stand-down order. + let stand_down = { + let queues = soft_interrupt_queues.read().await; + queues.get(holder).and_then(|queue| { + queue.lock().ok().and_then(|pending| { + pending + .iter() + .map(|msg| msg.content.clone()) + .find(|content| content.contains("handed off")) + }) + }) + }; + let stand_down = + stand_down.expect("displaced worker must receive a stand-down soft interrupt"); + assert!( + stand_down.contains("'contested'") && stand_down.contains("'penguin'"), + "stand-down order must name the task and the new assignee: {stand_down}" + ); + assert!( + stand_down.contains("Stop working"), + "stand-down order must tell the worker to stop: {stand_down}" + ); + + // And the DM notification reaches its event stream. + let mut saw_dm = false; + while let Ok(event) = holder_rx.try_recv() { + if let ServerEvent::Notification { message, .. } = event + && message.contains("handed off") + { + saw_dm = true; + } + } + assert!(saw_dm, "displaced worker must receive a stand-down DM"); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_less_loaded.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_less_loaded.rs new file mode 100644 index 0000000..b01d3ae --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_less_loaded.rs @@ -0,0 +1,98 @@ +#[tokio::test] +async fn assign_task_without_target_prefers_less_loaded_ready_agent() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-auto-target-load"; + let requester = "coord"; + let less_loaded = "worker-light"; + let more_loaded = "worker-busy"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + ( + less_loaded.to_string(), + owned_member(less_loaded, swarm_id, "ready", requester), + ), + ( + more_loaded.to_string(), + owned_member(more_loaded, swarm_id, "ready", requester), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + requester.to_string(), + less_loaded.to_string(), + more_loaded.to_string(), + ]), + )]))); + let mut busy_existing = plan_item("busy-existing", "running", "high", &[]); + busy_existing.assigned_to = Some(more_loaded.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![ + plan_item("setup", "completed", "high", &[]), + busy_existing, + plan_item("next", "queued", "high", &["setup"]), + ], + version: 1, + participants: HashSet::from([ + requester.to_string(), + less_loaded.to_string(), + more_loaded.to_string(), + ]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 100, + requester.to_string(), + None, + None, + Some("Pick the least-loaded worker".to_string()), + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 100); + assert_eq!(task_id, "next"); + assert_eq!(target_session, less_loaded); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_next_dependency.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_next_dependency.rs new file mode 100644 index 0000000..15f50c4 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_next_dependency.rs @@ -0,0 +1,104 @@ +#[tokio::test] +async fn assign_next_prefers_worker_with_dependency_context() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-context-score"; + let requester = "coord"; + let context_worker = "worker-context"; + let other_worker = "worker-other"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + ( + context_worker.to_string(), + owned_member(context_worker, swarm_id, "ready", requester), + ), + ( + other_worker.to_string(), + owned_member(other_worker, swarm_id, "ready", requester), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + requester.to_string(), + context_worker.to_string(), + other_worker.to_string(), + ]), + )]))); + let mut dependency = plan_item("dep", "completed", "high", &[]); + dependency.assigned_to = Some(context_worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![dependency, plan_item("next", "queued", "high", &["dep"])], + version: 1, + participants: HashSet::from([ + requester.to_string(), + context_worker.to_string(), + other_worker.to_string(), + ]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let global_session_id = Arc::new(RwLock::new(String::new())); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + handle_comm_assign_next( + 102, + requester.to_string(), + None, + None, + None, + None, + None, + None, + None, + &client_tx, + &sessions, + &global_session_id, + &provider, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mcp_pool, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 102); + assert_eq!(task_id, "next"); + assert_eq!(target_session, context_worker); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_next_metadata.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_next_metadata.rs new file mode 100644 index 0000000..502ab32 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_next_metadata.rs @@ -0,0 +1,109 @@ +#[tokio::test] +async fn assign_next_prefers_worker_with_matching_subsystem_metadata() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-metadata-score"; + let requester = "coord"; + let metadata_worker = "worker-metadata"; + let other_worker = "worker-other"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + ( + metadata_worker.to_string(), + owned_member(metadata_worker, swarm_id, "ready", requester), + ), + ( + other_worker.to_string(), + owned_member(other_worker, swarm_id, "ready", requester), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + requester.to_string(), + metadata_worker.to_string(), + other_worker.to_string(), + ]), + )]))); + let mut prior = plan_item("prior", "completed", "high", &[]); + prior.subsystem = Some("parser".to_string()); + prior.file_scope = vec!["src/parser.rs".to_string()]; + prior.assigned_to = Some(metadata_worker.to_string()); + let mut next = plan_item("next", "queued", "high", &[]); + next.subsystem = Some("parser".to_string()); + next.file_scope = vec!["src/parser.rs".to_string()]; + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![prior, next], + version: 1, + participants: HashSet::from([ + requester.to_string(), + metadata_worker.to_string(), + other_worker.to_string(), + ]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let global_session_id = Arc::new(RwLock::new(String::new())); + let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config()); + + handle_comm_assign_next( + 103, + requester.to_string(), + None, + None, + None, + None, + None, + None, + None, + &client_tx, + &sessions, + &global_session_id, + &provider, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mcp_pool, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 103); + assert_eq!(task_id, "next"); + assert_eq!(target_session, metadata_worker); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_ready_agent.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_ready_agent.rs new file mode 100644 index 0000000..163e02b --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_ready_agent.rs @@ -0,0 +1,102 @@ +#[tokio::test] +async fn assign_task_without_target_picks_ready_agent() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-auto-target"; + let requester = "coord"; + let ready_worker = "worker-ready"; + let completed_worker = "worker-completed"; + let running_worker = "worker-running"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + ( + ready_worker.to_string(), + owned_member(ready_worker, swarm_id, "ready", requester), + ), + ( + completed_worker.to_string(), + owned_member(completed_worker, swarm_id, "completed", requester), + ), + ( + running_worker.to_string(), + owned_member(running_worker, swarm_id, "running", requester), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + requester.to_string(), + ready_worker.to_string(), + completed_worker.to_string(), + running_worker.to_string(), + ]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![ + plan_item("setup", "completed", "high", &[]), + plan_item("next", "queued", "high", &["setup"]), + ], + version: 1, + participants: HashSet::from([ + requester.to_string(), + ready_worker.to_string(), + completed_worker.to_string(), + running_worker.to_string(), + ]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 99, + requester.to_string(), + None, + None, + Some("Pick a task and worker".to_string()), + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 99); + assert_eq!(task_id, "next"); + assert_eq!(target_session, ready_worker); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/assign_task.rs b/crates/jcode-app-core/src/server/comm_control_tests/assign_task.rs new file mode 100644 index 0000000..1d99604 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/assign_task.rs @@ -0,0 +1,197 @@ +#[tokio::test] +async fn assign_task_without_task_id_picks_highest_priority_runnable_task() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-assign"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![ + plan_item("done", "completed", "high", &[]), + plan_item("blocked", "queued", "high", &["high-ready"]), + plan_item("low-ready", "queued", "low", &["done"]), + plan_item("high-ready", "queued", "high", &["done"]), + ], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 77, + requester.to_string(), + Some(worker.to_string()), + None, + Some("Pick the next task".to_string()), + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + let response = client_rx.recv().await.expect("response"); + match response { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 77); + assert_eq!(task_id, "high-ready"); + assert_eq!(target_session, worker); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } + + let plans = swarm_plans.read().await; + let plan = plans.get(swarm_id).expect("plan exists"); + let selected = plan + .items + .iter() + .find(|item| item.id == "high-ready") + .expect("selected task exists"); + assert_eq!(selected.assigned_to.as_deref(), Some(worker)); + assert_eq!(selected.status, "queued"); + + let blocked = plan + .items + .iter() + .find(|item| item.id == "blocked") + .expect("blocked task exists"); + assert!( + blocked.assigned_to.is_none(), + "blocked task should not be auto-assigned" + ); + + let members = swarm_members.read().await; + let worker_member = members.get(worker).expect("worker member exists"); + assert_eq!( + worker_member.status, "queued", + "assigned worker should stop looking completed/ready before async execution starts" + ); +} + +#[tokio::test] +async fn assign_task_marks_completed_worker_queued_before_returning() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-assign-completed-worker"; + let requester = "coord"; + let worker = "worker-completed"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "completed")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![plan_item("next", "queued", "high", &[])], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 78, + requester.to_string(), + Some(worker.to_string()), + Some("next".to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 78); + assert_eq!(task_id, "next"); + assert_eq!(target_session, worker); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } + + let members = swarm_members.read().await; + let worker_member = members.get(worker).expect("worker member exists"); + assert_eq!(worker_member.status, "queued"); + assert!( + worker_member + .detail + .as_deref() + .is_some_and(|detail| detail.contains("task next")), + "queued member should include the assigned task in its detail" + ); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/auto_worker_filter.rs b/crates/jcode-app-core/src/server/comm_control_tests/auto_worker_filter.rs new file mode 100644 index 0000000..672f673 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/auto_worker_filter.rs @@ -0,0 +1,216 @@ +// Auto-assignment must only target *drivable* workers. An independent, +// client-attached human session that happens to share the swarm is NOT driven by +// `spawn_assigned_task_run` (that only fires when the target has no live client), +// so auto-picking it would strand the task and stall `run_plan`. These tests pin +// the candidate filter so reuse of owned/headless workers keeps working while +// foreign client-attached sessions are excluded (leaving room for a fresh spawn). +// +// Included into the `comm_control::tests` module, so the parent's private +// `filter_swarm_agent_candidates` / `is_drivable_auto_worker` are in scope. + +use super::{filter_swarm_agent_candidates, is_drivable_auto_worker}; + +fn agent_member(session_id: &str, swarm_id: &str) -> SwarmMember { + member(session_id, swarm_id, "ready") +} + +#[test] +fn headless_worker_is_drivable() { + let mut m = agent_member("w", "s"); + m.is_headless = true; + // No owner, but headless workers are always auto-driven in-process. + assert!(is_drivable_auto_worker(&m, "coord")); +} + +#[test] +fn worker_owned_by_requester_is_drivable_even_with_live_client() { + let mut m = agent_member("w", "s"); + m.is_headless = false; + m.report_back_to_session_id = Some("coord".to_string()); + // Simulate a live client attachment; ownership still makes it reusable. + let (tx, _rx) = mpsc::unbounded_channel(); + m.event_txs.insert("conn-1".to_string(), tx); + assert!(is_drivable_auto_worker(&m, "coord")); +} + +#[test] +fn unowned_session_with_live_client_is_not_drivable() { + let mut m = agent_member("human", "s"); + m.is_headless = false; + m.report_back_to_session_id = None; // independent user session + let (tx, _rx) = mpsc::unbounded_channel(); + m.event_txs.insert("conn-1".to_string(), tx); + assert!(!is_drivable_auto_worker(&m, "coord")); +} + +#[test] +fn unowned_session_is_not_auto_drivable() { + // A foreign session that this run does not own is never auto-assignable, even + // if it currently has no client attachment: it may be a stale "zombie" with no + // live agent loop (the run_plan stall we are guarding against). Such sessions + // require an explicit target_session. + let mut m = agent_member("zombie", "s"); + m.is_headless = false; + m.report_back_to_session_id = None; + // No client attachment, yet still not auto-drivable because it is unowned. + assert!(!is_drivable_auto_worker(&m, "coord")); +} + +#[tokio::test] +async fn auto_candidate_filter_excludes_foreign_client_attached_session() { + let swarm_id = "swarm-filter"; + let coord = "coord"; + let owned = "owned-worker"; + let headless = "headless-worker"; + let foreign = "foreign-human"; + + let mut owned_member = agent_member(owned, swarm_id); + owned_member.report_back_to_session_id = Some(coord.to_string()); + let (otx, _orx) = mpsc::unbounded_channel(); + owned_member.event_txs.insert("c".to_string(), otx); // owned + attached, still ok + + let mut headless_member = agent_member(headless, swarm_id); + headless_member.is_headless = true; + + let mut foreign_member = agent_member(foreign, swarm_id); + let (ftx, _frx) = mpsc::unbounded_channel(); + foreign_member.event_txs.insert("c".to_string(), ftx); // unowned + attached -> excluded + + let members: HashMap<String, SwarmMember> = HashMap::from([ + (coord.to_string(), { + let mut m = agent_member(coord, swarm_id); + m.role = "coordinator".to_string(); + m + }), + (owned.to_string(), owned_member), + (headless.to_string(), headless_member), + (foreign.to_string(), foreign_member), + ]); + + let candidates = filter_swarm_agent_candidates(&members, coord, swarm_id); + let ids: std::collections::HashSet<&str> = + candidates.iter().map(|m| m.session_id.as_str()).collect(); + assert!(ids.contains(owned), "owned worker should be eligible"); + assert!(ids.contains(headless), "headless worker should be eligible"); + assert!( + !ids.contains(foreign), + "foreign client-attached session must be excluded from auto-assign" + ); +} + +// ----- composite-aware turn completion ----- + +use super::turn_end_should_auto_complete; + +#[test] +fn atomic_turn_auto_completes() { + // A plain running atomic node that the worker just ran should be + // auto-marked done. `running_stale` (revived after a reload) counts too. + assert!(turn_end_should_auto_complete("running", false)); + assert!(turn_end_should_auto_complete("running_stale", false)); +} + +#[test] +fn requeued_node_does_not_auto_complete() { + // A node that is `queued` at turn end was re-queued mid-turn by someone + // else: a gate that called inject_gap (re-queued behind its gap nodes), or + // a reassign/requeue. Force-closing it would bypass gate artifact + // validation and strand the injected gap nodes. + assert!(!turn_end_should_auto_complete("queued", false)); +} + +#[test] +fn expanded_composite_turn_does_not_auto_complete() { + // The worker decomposed the node; it must stay open to synthesize later, even + // though its own turn ended. This is the bug that stranded composite subtrees. + assert!(!turn_end_should_auto_complete("running", true)); + assert!(!turn_end_should_auto_complete("queued", true)); +} + +#[test] +fn already_terminal_turn_is_not_reclosed() { + // If the worker already completed/failed the node itself, the turn end must + // not stomp that status. + assert!(!turn_end_should_auto_complete("completed", false)); + assert!(!turn_end_should_auto_complete("done", false)); + assert!(!turn_end_should_auto_complete("failed", false)); + assert!(!turn_end_should_auto_complete("stopped", false)); +} + +// ----- deep mode: artifact-or-nothing at turn end ----- + +use super::{TurnEndDisposition, turn_end_disposition}; + +#[test] +fn deep_turn_without_artifact_requeues_then_fails() { + // Deep mode never auto-completes: the typed-artifact contract is only real + // if there is no path to "done" that skips complete_node. First offense is + // a requeue to a fresh worker; a repeat fails the node loudly. + assert_eq!( + turn_end_disposition(true, "running", false, 0), + TurnEndDisposition::RequeueNoArtifact + ); + assert_eq!( + turn_end_disposition(true, "running_stale", false, 0), + TurnEndDisposition::RequeueNoArtifact + ); + assert_eq!( + turn_end_disposition(true, "running", false, 1), + TurnEndDisposition::FailNoArtifact + ); + // A re-woken composite synthesis that never synthesized is equally guilty. + assert_eq!( + turn_end_disposition(true, "running", true, 0), + TurnEndDisposition::RequeueNoArtifact + ); +} + +#[test] +fn deep_turn_leaves_non_running_nodes_alone() { + // Terminal states were set by complete_node/fail; queued means the node was + // re-queued mid-turn (expand_node or inject_gap). None are this turn's + // responsibility. + for status in ["queued", "completed", "done", "failed", "stopped"] { + assert_eq!( + turn_end_disposition(true, status, false, 0), + TurnEndDisposition::LeaveAlone, + "status {status}" + ); + } +} + +#[test] +fn light_turn_disposition_matches_legacy_auto_complete() { + assert_eq!( + turn_end_disposition(false, "running", false, 0), + TurnEndDisposition::AutoComplete + ); + assert_eq!( + turn_end_disposition(false, "running", true, 0), + TurnEndDisposition::LeaveAlone + ); + assert_eq!( + turn_end_disposition(false, "queued", false, 0), + TurnEndDisposition::LeaveAlone + ); +} + +// ----- composite synthesis assignment content ----- + +use super::composite_synthesis_content; + +#[test] +fn composite_synthesis_content_injects_complete_node_instruction() { + let out = composite_synthesis_content("root", "explore the thing", true); + assert!(out.contains("Synthesis turn for composite node 'root'")); + assert!(out.contains("complete_node")); + assert!(out.contains("Do NOT")); + // The original brief is preserved for context. + assert!(out.contains("explore the thing")); +} + +#[test] +fn non_composite_content_is_verbatim() { + let out = composite_synthesis_content("leaf", "just do this", false); + assert_eq!(out, "just do this"); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_any.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_any.rs new file mode 100644 index 0000000..af3e888 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_any.rs @@ -0,0 +1,85 @@ +#[tokio::test] +async fn await_members_any_mode_returns_after_first_match() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-any"; + let requester = "req"; + let peer_a = "peer-a"; + let peer_b = "peer-b"; + let await_runtime = AwaitMembersRuntime::default(); + + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer_a.to_string(), member(peer_a, swarm_id, "running")), + (peer_b.to_string(), member(peer_b, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([ + requester.to_string(), + peer_a.to_string(), + peer_b.to_string(), + ]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + Some("any".to_string()), + Some(60), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + { + let mut members = swarm_members.write().await; + members.get_mut(peer_a).expect("peer a exists").status = "completed".to_string(); + } + let _ = swarm_event_tx.send(swarm_event( + peer_a, + swarm_id, + SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "completed".to_string(), + }, + )); + + let response = tokio::time::timeout(Duration::from_secs(1), client_rx.recv()) + .await + .expect("response should arrive") + .expect("channel should stay open"); + + match response { + ServerEvent::CommAwaitMembersResponse { + completed, + members, + summary, + .. + } => { + assert!( + completed, + "await any should complete after first member matches" + ); + assert!( + summary.contains("peer-a"), + "summary should mention matched member" + ); + let done_members: Vec<_> = members.into_iter().filter(|member| member.done).collect(); + assert_eq!(done_members.len(), 1); + assert_eq!(done_members[0].session_id, peer_a); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_background_expired.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_background_expired.rs new file mode 100644 index 0000000..154b65e --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_background_expired.rs @@ -0,0 +1,93 @@ +#[tokio::test] +async fn await_members_background_already_expired_answers_tool_call() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-bg-expired"; + let requester = "req"; + let peer = "peer-1"; + let key = crate::server::await_members_state::request_key( + requester, + swarm_id, + &[], + &["completed".to_string()], + None, + ); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + // A pending background state whose deadline already passed. A retry of the + // same request must get an explicit timeout response instead of hanging + // until the client-side socket timeout. + crate::server::await_members_state::save_state( + &crate::server::await_members_state::PersistedAwaitMembersState { + key, + session_id: requester.to_string(), + swarm_id: swarm_id.to_string(), + target_status: vec!["completed".to_string()], + requested_ids: vec![], + mode: None, + created_at_unix_ms: now_ms.saturating_sub(120_000), + deadline_unix_ms: now_ms.saturating_sub(1_000), + background: true, + notify: false, + wake: false, + final_response: None, + }, + ); + + let await_runtime = AwaitMembersRuntime::default(); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + handle_comm_await_members( + 7, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + true, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + let response = tokio::time::timeout(Duration::from_secs(1), client_rx.recv()) + .await + .expect("expired background await must answer the tool call") + .expect("channel should stay open"); + + match response { + ServerEvent::CommAwaitMembersResponse { + id, + completed, + summary, + background_started, + .. + } => { + assert_eq!(id, 7); + assert!(!completed, "expired await should report a timeout"); + assert!(summary.contains("Timed out"), "summary: {summary}"); + assert!( + !background_started, + "an already-expired wait should not claim a background watcher started" + ); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_disconnect.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_disconnect.rs new file mode 100644 index 0000000..1b5ef03 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_disconnect.rs @@ -0,0 +1,54 @@ +#[tokio::test] +async fn await_members_stops_when_requesting_client_disconnects() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-b"; + let requester = "req"; + let peer = "peer-1"; + let await_runtime = AwaitMembersRuntime::default(); + + let (client_tx, client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + let (swarm_event_tx, swarm_event_rx) = broadcast::channel(32); + drop(swarm_event_rx); + let baseline_receivers = swarm_event_tx.receiver_count(); + + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + drop(client_rx); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if swarm_event_tx.receiver_count() == baseline_receivers { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("await task should unsubscribe promptly after client disconnect"); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_lagged.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_lagged.rs new file mode 100644 index 0000000..8fdab5a --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_lagged.rs @@ -0,0 +1,86 @@ +#[tokio::test] +async fn await_members_watcher_survives_broadcast_lag() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-lag"; + let requester = "req"; + let peer = "peer-1"; + let await_runtime = AwaitMembersRuntime::default(); + + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + // Tiny capacity so a burst of unconsumed events deterministically lags the + // watcher's broadcast receiver. + let (swarm_event_tx, swarm_event_rx) = broadcast::channel(1); + drop(swarm_event_rx); + + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + // Wait until the watcher has subscribed, then give it a beat to finish its + // first status check and park in recv(). + tokio::time::timeout(Duration::from_secs(1), async { + while swarm_event_tx.receiver_count() == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("watcher should subscribe to swarm events"); + tokio::time::sleep(Duration::from_millis(50)).await; + + // Mark the peer done, then flood the channel without yielding so the + // parked watcher observes RecvError::Lagged instead of the actual events. + { + let mut members = swarm_members.write().await; + members.get_mut(peer).expect("peer exists").status = "completed".to_string(); + } + for _ in 0..4 { + let _ = swarm_event_tx.send(swarm_event( + peer, + swarm_id, + SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "completed".to_string(), + }, + )); + } + + let response = tokio::time::timeout(Duration::from_secs(1), client_rx.recv()) + .await + .expect("lagged watcher should recover and respond instead of exiting") + .expect("channel should stay open"); + + match response { + ServerEvent::CommAwaitMembersResponse { + completed, members, .. + } => { + assert!(completed, "await should complete after lag recovery"); + assert_eq!(members.len(), 1); + assert_eq!(members[0].session_id, peer); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_late_joiners.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_late_joiners.rs new file mode 100644 index 0000000..86cd0f2 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_late_joiners.rs @@ -0,0 +1,111 @@ +#[tokio::test] +async fn await_members_includes_late_joiners_when_watching_swarm() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-a"; + let requester = "req"; + let initial_peer = "peer-1"; + let late_peer = "peer-2"; + let await_runtime = AwaitMembersRuntime::default(); + + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + ( + initial_peer.to_string(), + member(initial_peer, swarm_id, "running"), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), initial_peer.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(2), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + { + let mut members = swarm_members.write().await; + members.insert( + late_peer.to_string(), + member(late_peer, swarm_id, "running"), + ); + } + { + let mut swarms = swarms_by_id.write().await; + swarms + .get_mut(swarm_id) + .expect("swarm exists") + .insert(late_peer.to_string()); + } + let _ = swarm_event_tx.send(swarm_event( + late_peer, + swarm_id, + SwarmEventType::MemberChange { + action: "joined".to_string(), + }, + )); + + { + let mut members = swarm_members.write().await; + members + .get_mut(initial_peer) + .expect("initial peer exists") + .status = "completed".to_string(); + } + let _ = swarm_event_tx.send(swarm_event( + initial_peer, + swarm_id, + SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "completed".to_string(), + }, + )); + + { + let mut members = swarm_members.write().await; + members.get_mut(late_peer).expect("late peer exists").status = "completed".to_string(); + } + let _ = swarm_event_tx.send(swarm_event( + late_peer, + swarm_id, + SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "completed".to_string(), + }, + )); + + let response = tokio::time::timeout(std::time::Duration::from_secs(1), client_rx.recv()) + .await + .expect("response should arrive") + .expect("channel should stay open"); + + match response { + ServerEvent::CommAwaitMembersResponse { + completed, members, .. + } => { + assert!(completed, "await should complete after both peers finish"); + let watched: HashSet<String> = members.into_iter().map(|m| m.session_id).collect(); + assert!(watched.contains(initial_peer)); + assert!(watched.contains(late_peer)); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_reload_deadline.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_reload_deadline.rs new file mode 100644 index 0000000..7b91e2c --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_reload_deadline.rs @@ -0,0 +1,87 @@ +#[tokio::test] +async fn await_members_reuses_persisted_deadline_after_reload_retry() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-c"; + let requester = "req"; + let peer = "peer-1"; + let key = crate::server::await_members_state::request_key( + requester, + swarm_id, + &[], + &["completed".to_string()], + None, + ); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + crate::server::await_members_state::save_state( + &crate::server::await_members_state::PersistedAwaitMembersState { + key, + session_id: requester.to_string(), + swarm_id: swarm_id.to_string(), + target_status: vec!["completed".to_string()], + requested_ids: vec![], + mode: None, + created_at_unix_ms: now_ms, + deadline_unix_ms: now_ms + 150, + background: false, + notify: false, + wake: false, + final_response: None, + }, + ); + + let await_runtime = AwaitMembersRuntime::default(); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + let started = Instant::now(); + let response = tokio::time::timeout(Duration::from_secs(1), client_rx.recv()) + .await + .expect("response should arrive") + .expect("channel should stay open"); + + assert!( + started.elapsed() < Duration::from_secs(1), + "persisted deadline should win over new timeout" + ); + + match response { + ServerEvent::CommAwaitMembersResponse { + completed, summary, .. + } => { + assert!(!completed, "persisted expired wait should time out"); + assert!(summary.contains("Timed out")); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_reload_final.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_reload_final.rs new file mode 100644 index 0000000..56d9679 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_reload_final.rs @@ -0,0 +1,210 @@ +#[tokio::test] +async fn await_members_returns_persisted_final_response_after_reload_retry() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-d"; + let requester = "req"; + let key = crate::server::await_members_state::request_key( + requester, + swarm_id, + &[], + &["completed".to_string()], + None, + ); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + crate::server::await_members_state::save_state( + &crate::server::await_members_state::PersistedAwaitMembersState { + key, + session_id: requester.to_string(), + swarm_id: swarm_id.to_string(), + target_status: vec!["completed".to_string()], + requested_ids: vec![], + mode: None, + created_at_unix_ms: now_ms, + deadline_unix_ms: now_ms + 60_000, + background: false, + notify: false, + wake: false, + final_response: Some( + crate::server::await_members_state::PersistedAwaitMembersResult { + completed: true, + members: vec![crate::protocol::AwaitedMemberStatus { + session_id: "peer-1".to_string(), + friendly_name: Some("peer-1".to_string()), + status: "completed".to_string(), + done: true, + completion_report: None, + }], + summary: "All 1 members are done: peer-1".to_string(), + resolved_at_unix_ms: now_ms, + }, + ), + }, + ); + + let await_runtime = AwaitMembersRuntime::default(); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + requester.to_string(), + member(requester, swarm_id, "ready"), + )]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + match client_rx.recv().await.expect("response should arrive") { + ServerEvent::CommAwaitMembersResponse { + completed, + summary, + members, + .. + } => { + assert!(completed); + assert_eq!(summary, "All 1 members are done: peer-1"); + assert_eq!(members.len(), 1); + assert_eq!(members[0].session_id, "peer-1"); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} + +#[tokio::test] +async fn await_members_ignores_persisted_final_when_requested_member_is_queued_again() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-requeue"; + let requester = "req"; + let peer = "peer-1"; + let target_status = vec!["completed".to_string()]; + let requested_ids = vec![peer.to_string()]; + let key = crate::server::await_members_state::request_key( + requester, + swarm_id, + &requested_ids, + &target_status, + None, + ); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + crate::server::await_members_state::save_state( + &crate::server::await_members_state::PersistedAwaitMembersState { + key, + session_id: requester.to_string(), + swarm_id: swarm_id.to_string(), + target_status: target_status.clone(), + requested_ids: requested_ids.clone(), + mode: None, + created_at_unix_ms: now_ms, + deadline_unix_ms: now_ms + 60_000, + background: false, + notify: false, + wake: false, + final_response: Some( + crate::server::await_members_state::PersistedAwaitMembersResult { + completed: true, + members: vec![crate::protocol::AwaitedMemberStatus { + session_id: peer.to_string(), + friendly_name: Some(peer.to_string()), + status: "completed".to_string(), + done: true, + completion_report: None, + }], + summary: "All 1 members are done: peer-1".to_string(), + resolved_at_unix_ms: now_ms, + }, + ), + }, + ); + + let await_runtime = AwaitMembersRuntime::default(); + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "queued")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + handle_comm_await_members( + 2, + requester.to_string(), + target_status, + requested_ids, + None, + Some(5), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &client_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + assert!( + tokio::time::timeout(Duration::from_millis(50), client_rx.recv()) + .await + .is_err(), + "stale persisted final response should not be replayed while the worker is queued again" + ); + + { + let mut members = swarm_members.write().await; + members.get_mut(peer).expect("peer exists").status = "completed".to_string(); + } + let _ = swarm_event_tx.send(swarm_event( + peer, + swarm_id, + SwarmEventType::StatusChange { + old_status: "queued".to_string(), + new_status: "completed".to_string(), + }, + )); + + match tokio::time::timeout(Duration::from_secs(1), client_rx.recv()) + .await + .expect("await response should arrive after completion") + .expect("response should be sent") + { + ServerEvent::CommAwaitMembersResponse { + completed, members, .. + } => { + assert!(completed); + assert_eq!(members.len(), 1); + assert_eq!(members[0].session_id, peer); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_resume_expired.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_resume_expired.rs new file mode 100644 index 0000000..da6452e --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_resume_expired.rs @@ -0,0 +1,90 @@ +#[tokio::test] +async fn resume_background_awaits_finalizes_states_expired_while_down() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-resume-expired"; + let requester = "req-resume-expired"; + let peer = "peer-1"; + let key = crate::server::await_members_state::request_key( + requester, + swarm_id, + &[], + &["completed".to_string()], + None, + ); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + // A background await whose deadline passed "while the server was down". + crate::server::await_members_state::save_state( + &crate::server::await_members_state::PersistedAwaitMembersState { + key: key.clone(), + session_id: requester.to_string(), + swarm_id: swarm_id.to_string(), + target_status: vec!["completed".to_string()], + requested_ids: vec![], + mode: None, + created_at_unix_ms: now_ms.saturating_sub(120_000), + deadline_unix_ms: now_ms.saturating_sub(60_000), + background: true, + notify: true, + wake: true, + final_response: None, + }, + ); + + let await_runtime = AwaitMembersRuntime::default(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + let mut bus_rx = crate::bus::Bus::global().subscribe(); + + crate::server::comm_await::resume_background_awaits( + &swarm_members, + &swarms_by_id, + &swarm_event_tx, + &await_runtime, + ) + .await; + + // The expired state must be finalized as a timeout so the promised + // notify/wake fires. + let event = tokio::time::timeout(Duration::from_secs(2), async { + loop { + match bus_rx.recv().await { + Ok(crate::bus::BusEvent::SwarmAwaitCompleted(event)) + if event.session_id == requester => + { + return event; + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("bus closed before SwarmAwaitCompleted arrived") + } + } + } + }) + .await + .expect("expired background await should publish SwarmAwaitCompleted on resume"); + + assert!(!event.completed, "expired await should finalize as timeout"); + assert!(event.summary.contains("Timed out"), "summary: {}", event.summary); + assert!(event.notify); + assert!(event.wake); + + let final_state = crate::server::await_members_state::load_state(&key) + .expect("state should still be persisted"); + let final_response = final_state + .final_response + .expect("expired await should have a persisted final response"); + assert!(!final_response.completed); + assert!(final_response.summary.contains("Timed out")); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/await_upgrade_background.rs b/crates/jcode-app-core/src/server/comm_control_tests/await_upgrade_background.rs new file mode 100644 index 0000000..2eb4b1b --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/await_upgrade_background.rs @@ -0,0 +1,136 @@ +#[tokio::test] +async fn await_members_blocking_to_background_upgrade_survives_waiter_disconnect() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-upgrade"; + let requester = "req-upgrade"; + let peer = "peer-1"; + let await_runtime = AwaitMembersRuntime::default(); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), member(requester, swarm_id, "ready")), + (peer.to_string(), member(peer, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), peer.to_string()]), + )]))); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + + let mut bus_rx = crate::bus::Bus::global().subscribe(); + + // First request: blocking. Spawns the watcher with a blocking-state copy. + let (blocking_tx, blocking_rx) = mpsc::unbounded_channel(); + handle_comm_await_members( + 1, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + false, + false, + false, + CommAwaitMembersContext { + client_event_tx: &blocking_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + // Duplicate request upgrades the same wait to background delivery. The + // watcher is already active, so no new one spawns; only the persisted + // prefs change. + let (bg_tx, mut bg_rx) = mpsc::unbounded_channel(); + handle_comm_await_members( + 2, + requester.to_string(), + vec!["completed".to_string()], + vec![], + None, + Some(60), + true, + true, + true, + CommAwaitMembersContext { + client_event_tx: &bg_tx, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + swarm_event_tx: &swarm_event_tx, + await_members_runtime: &await_runtime, + }, + ) + .await; + + match tokio::time::timeout(Duration::from_secs(1), bg_rx.recv()) + .await + .expect("background upgrade should answer immediately") + .expect("channel should stay open") + { + ServerEvent::CommAwaitMembersResponse { + background_started, .. + } => { + assert!( + background_started, + "duplicate background request should report a running background watcher" + ); + } + other => panic!("expected CommAwaitMembersResponse, got {other:?}"), + } + + // The original blocking waiter disconnects. The upgraded watcher must keep + // running instead of exiting on waiter disconnect without finalizing. + drop(blocking_rx); + drop(blocking_tx); + tokio::time::sleep(Duration::from_millis(50)).await; + + // A non-satisfying event forces the watcher through its waiter check while + // zero socket waiters remain. Before the fix it exited here silently. + let _ = swarm_event_tx.send(swarm_event( + peer, + swarm_id, + SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "working".to_string(), + }, + )); + tokio::time::sleep(Duration::from_millis(50)).await; + + { + let mut members = swarm_members.write().await; + members.get_mut(peer).expect("peer exists").status = "completed".to_string(); + } + let _ = swarm_event_tx.send(swarm_event( + peer, + swarm_id, + SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "completed".to_string(), + }, + )); + + let event = tokio::time::timeout(Duration::from_secs(2), async { + loop { + match bus_rx.recv().await { + Ok(crate::bus::BusEvent::SwarmAwaitCompleted(event)) + if event.session_id == requester => + { + return event; + } + Ok(_) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("bus closed before SwarmAwaitCompleted arrived") + } + } + } + }) + .await + .expect("upgraded background await should deliver SwarmAwaitCompleted despite waiter disconnect"); + + assert!(event.completed, "await should complete once peer is done"); + assert!(event.notify); + assert!(event.wake); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/client_attached_dispatch.rs b/crates/jcode-app-core/src/server/comm_control_tests/client_attached_dispatch.rs new file mode 100644 index 0000000..57761c3 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/client_attached_dispatch.rs @@ -0,0 +1,186 @@ +// Regression pins for the client-attached (visible, live-client) assignee path. +// +// `handle_comm_assign_task` deliberately skips `spawn_assigned_task_run` when the +// target session has a live client connection (the client owns the turn loop). +// The assignment is delivered as a soft interrupt + DM instead, and the plan +// item stays `queued`. Critically, there is NO server-side turn-end hook for +// this path: when the client-driven turn finishes, `handle_client` / +// `CommReport` only update *member* status (`ready`/`failed`), never the plan +// item. Terminalizing the node is the assignee's own job (`complete_node`, +// which `claim_queued_node_for_actor` allows from `queued`), or the +// coordinator's via task control / reassignment. These tests document that +// contract so an accidental removal of the live-client skip (double-driving a +// session) or a silent behavior change in the no-auto-flip gap is caught. + +#[tokio::test] +async fn assign_task_to_client_attached_session_skips_server_side_run() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-client-attached"; + let requester = "coord"; + let worker = "worker-attached"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + + // The worker has a live server-side agent AND a live client connection: + // the agent exists, so the only reason to skip the server-side run is the + // client attachment. + let worker_agent = test_agent().await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + worker.to_string(), + Arc::clone(&worker_agent), + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + + let (disconnect_tx, _disconnect_rx) = mpsc::unbounded_channel(); + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "conn-1".to_string(), + crate::server::ClientConnectionInfo { + client_id: "conn-1".to_string(), + session_id: worker.to_string(), + 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::new(), + disconnect_tx, + }, + )]))); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + // Owned visible worker: drivable for auto-pick, but client-attached. + ( + worker.to_string(), + owned_member(worker, swarm_id, "ready", requester), + ), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![plan_item("solo", "queued", "high", &[])], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_assign_task( + 91, + requester.to_string(), + Some(worker.to_string()), + Some("solo".to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + } => { + assert_eq!(id, 91); + assert_eq!(task_id, "solo"); + assert_eq!(target_session, worker); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } + + // Give any (incorrectly) spawned server-side run a chance to flip state. + tokio::time::sleep(Duration::from_millis(50)).await; + + { + let plans = swarm_plans.read().await; + let item = &plans[swarm_id].items[0]; + assert_eq!( + item.status, "queued", + "client-attached dispatch must not start a server-side run (no running/done flip)" + ); + assert_eq!(item.assigned_to.as_deref(), Some(worker)); + } + + // The assignment was handed to the live client as a soft interrupt. + assert!( + worker_agent.lock().await.has_soft_interrupts(), + "assignment must be queued as a soft interrupt for the live client to inject" + ); + + // Now simulate the client-driven turn finishing: handle_client's done path + // only calls update_member_status_with_report(..., "ready", ...). Pin that + // this does NOT terminalize the plan item — there is no turn-end hook for + // client-attached assignees; the node must be closed by the assignee + // (complete_node) or the coordinator. + crate::server::swarm::update_member_status_with_report( + worker, + "ready", + None, + Some("finished my turn".to_string()), + &swarm_members, + &swarms_by_id, + Some(&event_history), + Some(&event_counter), + Some(&swarm_event_tx), + ) + .await; + + { + let plans = swarm_plans.read().await; + let item = &plans[swarm_id].items[0]; + assert_eq!( + item.status, "queued", + "member-status turn-end flip must not (silently) complete the plan item; \ + if you add an auto-complete hook for client-attached assignees, update \ + run_plan's strand handling and this pin together" + ); + } + + // Consequence check: the plan is now stranded from run_plan's perspective — + // the task is runnable-but-assigned (so assign_next skips it) and the member + // is no longer in flight. Pin the ingredients of that stall so the contract + // stays visible. + { + let plans = swarm_plans.read().await; + assert!( + crate::plan::next_unassigned_runnable_item_id(&plans[swarm_id]).is_none(), + "assigned queued task must not be offered to assign_next" + ); + let summary = crate::plan::summarize_plan_graph(&plans[swarm_id].items); + assert!( + summary.terminal_ids.is_empty(), + "plan must not be terminal while the assigned task is still queued" + ); + let members = swarm_members.read().await; + assert_eq!(members[worker].status, "ready"); + } +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/dag_e2e.rs b/crates/jcode-app-core/src/server/comm_control_tests/dag_e2e.rs new file mode 100644 index 0000000..39bb307 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/dag_e2e.rs @@ -0,0 +1,1332 @@ +// End-to-end task-DAG flow through the real server handlers and assignment loop. +// +// Unlike the engine unit tests (which exercise `jcode_plan::dag` in isolation), +// this drives the live `comm_graph` handlers against real server state +// (swarm_members / swarms_by_id / swarm_plans / coordinators) and then the real +// `handle_comm_assign_task` path, proving the substrate works request-to-plan and +// that forward dataflow reaches a downstream assignment. + +use crate::protocol::TaskGraphNodeSpec; +use crate::server::comm_graph::{ + handle_comm_complete_node, handle_comm_expand_node, handle_comm_seed_graph, +}; + +fn node_spec(id: &str, kind: &str, deps: &[&str]) -> TaskGraphNodeSpec { + TaskGraphNodeSpec { + id: id.to_string(), + content: format!("task {id}"), + kind: Some(kind.to_string()), + depends_on: deps.iter().map(|d| d.to_string()).collect(), + priority: 0, + } +} + +/// Shared fixture: a two-member swarm (coordinator + worker) with an empty plan. +struct GraphFixture { + swarm_id: String, + coord: String, + worker: String, + client_tx: mpsc::UnboundedSender<ServerEvent>, + client_rx: mpsc::UnboundedReceiver<ServerEvent>, + sessions: crate::server::SessionAgents, + soft_interrupt_queues: crate::server::SessionInterruptQueues, + client_connections: Arc<RwLock<HashMap<String, crate::server::ClientConnectionInfo>>>, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + event_history: Arc<RwLock<VecDeque<SwarmEvent>>>, + event_counter: Arc<AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + mutation_runtime: SwarmMutationRuntime, +} + +async fn graph_fixture() -> GraphFixture { + graph_fixture_named("swarm-dag", "coord", "worker").await +} + +async fn graph_fixture_named(swarm_id: &str, coord: &str, worker: &str) -> GraphFixture { + let swarm_id = swarm_id.to_string(); + let coord = coord.to_string(); + let worker = worker.to_string(); + let (client_tx, client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::from([ + (coord.clone(), test_agent().await), + (worker.clone(), test_agent().await), + ]))); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (coord.clone(), { + let mut m = member(&coord, &swarm_id, "ready"); + m.role = "coordinator".to_string(); + m + }), + (worker.clone(), member(&worker, &swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([coord.clone(), worker.clone()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + VersionedPlan::new(), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + coord.clone(), + )]))); + GraphFixture { + swarm_id, + coord, + worker, + client_tx, + client_rx, + sessions, + soft_interrupt_queues: Arc::new(RwLock::new(HashMap::new())), + client_connections: Arc::new(RwLock::new(HashMap::new())), + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history: Arc::new(RwLock::new(VecDeque::new())), + event_counter: Arc::new(AtomicU64::new(1)), + swarm_event_tx: broadcast::channel(64).0, + mutation_runtime: SwarmMutationRuntime::default(), + } +} + +impl GraphFixture { + async fn seed(&mut self, mode: &str, nodes: Vec<TaskGraphNodeSpec>) { + handle_comm_seed_graph( + 1, + self.coord.clone(), + Some(mode.to_string()), + nodes, + &self.client_tx, + &self.swarm_members, + &self.swarms_by_id, + &self.swarm_plans, + &self.swarm_coordinators, + &self.event_history, + &self.event_counter, + &self.swarm_event_tx, + ) + .await; + } + + /// Seed with no explicit mode, so the handler must fall back to the seeder's + /// recorded reasoning effort to decide deep vs light. + async fn seed_without_mode(&mut self, nodes: Vec<TaskGraphNodeSpec>) { + handle_comm_seed_graph( + 1, + self.coord.clone(), + None, + nodes, + &self.client_tx, + &self.swarm_members, + &self.swarms_by_id, + &self.swarm_plans, + &self.swarm_coordinators, + &self.event_history, + &self.event_counter, + &self.swarm_event_tx, + ) + .await; + } +} + +/// Regression for the deep-swarm trigger gap: a session running at `swarm-deep` +/// effort that seeds a graph but *forgets* to pass `mode:"deep"` must still get a +/// deep plan (gates + strict artifact validation), not a silent light downgrade. +/// The mode is resolved from the seeder's recorded effort via the deadlock-free +/// `session_effort` side-table. +#[tokio::test] +async fn e2e_seed_defaults_to_deep_when_seeder_effort_is_swarm_deep() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-deep-default", "coord-deep-default", "worker-dd").await; + + // The coordinator is the seeder; record its effort as the deep sentinel. + crate::session_effort::record_session_effort(&fx.coord, Some("swarm-deep")); + + fx.seed_without_mode(vec![node_spec("explore", "explore", &[])]) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!( + plan.mode, "deep", + "a swarm-deep seeder that omits mode must still get a deep plan" + ); + + crate::session_effort::forget_session_effort(&fx.coord); +} + +/// Counterpart: without a deep effort recorded (or with a plain reasoning level), +/// an omitted mode falls back to the engine default (light), preserving legacy +/// behaviour for non-deep sessions. +#[tokio::test] +async fn e2e_seed_defaults_to_light_when_seeder_effort_is_not_deep() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = + graph_fixture_named("swarm-light-default", "coord-light-default", "worker-ld").await; + + crate::session_effort::record_session_effort(&fx.coord, Some("high")); + + fx.seed_without_mode(vec![node_spec("explore", "explore", &[])]) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!( + plan.mode, "light", + "a non-deep seeder that omits mode keeps the light default" + ); + + crate::session_effort::forget_session_effort(&fx.coord); +} + +/// An explicit `mode` always wins over the effort-derived default, so a deep +/// session can still deliberately opt a particular graph into light fan-out. +#[tokio::test] +async fn e2e_explicit_mode_overrides_seeder_effort() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = + graph_fixture_named("swarm-explicit-mode", "coord-explicit-mode", "worker-em").await; + + crate::session_effort::record_session_effort(&fx.coord, Some("swarm-deep")); + + fx.seed("light", vec![node_spec("explore", "explore", &[])]) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!( + plan.mode, "light", + "an explicit mode must override the effort-derived default" + ); + + crate::session_effort::forget_session_effort(&fx.coord); +} + +#[tokio::test] +async fn e2e_seed_creates_plan_with_kinds_and_edges() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + fx.seed( + "deep", + vec![ + node_spec("explore", "explore", &[]), + node_spec("synth", "synthesize", &["explore"]), + ], + ) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!(plan.mode, "deep"); + // 2 seeded nodes + the auto-inserted plan-wide root gate. + assert_eq!(plan.items.len(), 3); + assert_eq!(plan.node_meta["explore"].kind.as_deref(), Some("explore")); + assert_eq!(plan.node_meta["synth"].kind.as_deref(), Some("synthesize")); + let synth = plan.items.iter().find(|i| i.id == "synth").unwrap(); + assert_eq!(synth.blocked_by, vec!["explore".to_string()]); + // The root gate audits every seeded root node and blocks plan completion + // until the final adversarial pass succeeds. + let root_gate = plan + .items + .iter() + .find(|i| { + plan.node_meta + .get(&i.id) + .map(|m| m.is_gate && m.parent.is_none()) + .unwrap_or(false) + }) + .expect("deep seed must insert a plan-wide root gate"); + assert!(root_gate.blocked_by.contains(&"explore".to_string())); + assert!(root_gate.blocked_by.contains(&"synth".to_string())); + assert_eq!( + plan.node_meta[&root_gate.id].origin.as_deref(), + Some("gate") + ); +} + +#[tokio::test] +async fn e2e_identical_seed_replay_succeeds_without_version_or_node_churn() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-seed-replay", "coord-replay", "worker-replay").await; + let nodes = vec![ + node_spec("explore", "explore", &[]), + node_spec("synth", "synthesize", &["explore"]), + ]; + + fx.seed("deep", nodes.clone()).await; + while fx.client_rx.try_recv().is_ok() {} + let (version, item_count) = { + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + (plan.version, plan.items.len()) + }; + + fx.seed("deep", nodes).await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!(plan.version, version, "a replay must not bump plan version"); + assert_eq!(plan.items.len(), item_count, "a replay must not add nodes"); + drop(plans); + let events: Vec<_> = std::iter::from_fn(|| fx.client_rx.try_recv().ok()).collect(); + assert!( + events.iter().all(|event| !matches!(event, ServerEvent::Error { .. })), + "an identical replay must acknowledge success: {events:?}" + ); + assert!(events.iter().any(|event| matches!(event, ServerEvent::Done { .. }))); +} + +#[tokio::test] +async fn e2e_seed_rejects_conflicting_existing_definition_without_mutation() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-seed-conflict", "coord-conflict", "worker-conflict").await; + fx.seed("light", vec![node_spec("shared", "explore", &[])]) + .await; + while fx.client_rx.try_recv().is_ok() {} + let (before_version, before_items, before_content) = { + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + ( + plan.version, + plan.items.len(), + plan.items + .iter() + .find(|item| item.id == "shared") + .expect("seeded node") + .content + .clone(), + ) + }; + let mut conflicting = node_spec("shared", "explore", &[]); + conflicting.content = "a different task using the same id".to_string(); + + fx.seed("light", vec![conflicting]).await; + + let plans = fx.swarm_plans.read().await; + let after = &plans[&fx.swarm_id]; + assert_eq!(after.version, before_version); + assert_eq!(after.items.len(), before_items); + assert_eq!( + after + .items + .iter() + .find(|item| item.id == "shared") + .expect("original node remains") + .content, + before_content, + "a conflicting replay must preserve the original definition" + ); + drop(plans); + let events: Vec<_> = std::iter::from_fn(|| fx.client_rx.try_recv().ok()).collect(); + assert!(events.iter().any(|event| matches!( + event, + ServerEvent::Error { message, .. } if message.contains("duplicate node id 'shared'") + ))); +} + +#[tokio::test] +async fn e2e_seed_rejects_cycle_without_mutating_plan() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + fx.seed( + "light", + vec![ + node_spec("a", "explore", &["b"]), + node_spec("b", "explore", &["a"]), + ], + ) + .await; + + // Plan stays empty and an error is surfaced. + let plans = fx.swarm_plans.read().await; + assert!(plans[&fx.swarm_id].items.is_empty()); + drop(plans); + let mut saw_error = false; + while let Ok(ev) = fx.client_rx.try_recv() { + if let ServerEvent::Error { message, .. } = ev { + assert!(message.contains("rejected") || message.contains("cycle")); + saw_error = true; + } + } + assert!(saw_error, "cycle seed should surface an error"); +} + +#[tokio::test] +async fn e2e_deep_expand_inserts_gate_in_live_plan() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + fx.seed("deep", vec![node_spec("root", "explore", &[])]) + .await; + + // Assign + dispatch root to the worker so it owns the node, then expand. + handle_comm_assign_task( + 2, + fx.coord.clone(), + Some(fx.worker.clone()), + Some("root".to_string()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + + // Mark root running (assignment leaves it queued); the engine requires a + // running owner to expand. Simulate the worker starting by setting status. + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let root = plan.items.iter_mut().find(|i| i.id == "root").unwrap(); + root.status = "running".to_string(); + root.assigned_to = Some(fx.worker.clone()); + } + + handle_comm_expand_node( + 3, + fx.worker.clone(), + "root".to_string(), + vec![ + node_spec("root.1", "explore", &[]), + node_spec("root.2", "explore", &[]), + ], + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + // Gate inserted, root marked composite/expanded. + let gate = plan + .items + .iter() + .find(|i| { + plan.node_meta + .get(&i.id) + .map(|m| m.is_gate) + .unwrap_or(false) + }) + .expect("a gate node should be present after deep expand"); + assert_eq!(plan.node_meta[&gate.id].kind.as_deref(), Some("critique")); + assert!(plan.node_meta["root"].expanded); +} + +/// The budget-utilization mechanism: a deep-mode assignment must carry the +/// deep-node execution contract (expand_node for parallel fan-out, or +/// complete_node with a typed artifact) all the way to the worker, and a gate +/// assignment must carry the inject_gap contract. Without this the fan-out +/// budget goes unused because spawned workers never learn the deep workflow. +#[tokio::test] +async fn e2e_deep_assignment_carries_fanout_and_artifact_contract() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-deep-directive", "coord-dd", "worker-dd").await; + fx.seed( + "deep", + vec![ + node_spec("explore.a", "explore", &[]), + node_spec("explore.b", "explore", &[]), + ], + ) + .await; + + // Assign a node to the worker via the live path. + handle_comm_assign_task( + 2, + fx.coord.clone(), + Some(fx.worker.clone()), + Some("explore.a".to_string()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + + // The worker has no live client, so the assignment ran through + // spawn_assigned_task_run against the test agent. The durable record of + // what the worker was told is the assignment summary; assert on the + // soft-interrupt prompt queued for the worker instead, which carries the + // full assignment text. + let queued = { + let queues = fx.soft_interrupt_queues.read().await; + queues.get(&fx.worker).and_then(|queue| { + queue + .lock() + .ok() + .and_then(|pending| pending.first().map(|msg| msg.content.clone())) + }) + }; + let prompt = queued.expect("deep assignment should queue a task prompt for the worker"); + assert!( + prompt.contains(jcode_swarm_core::SWARM_DEEP_NODE_MARKER), + "deep assignment prompt must carry the deep-node contract, got: {prompt}" + ); + assert!(prompt.contains("action=\"expand_node\", node_id=\"explore.a\"")); + assert!(prompt.contains("action=\"complete_node\", node_id=\"explore.a\"")); + + // Light plans must NOT get the directive. Use a fresh fixture: re-seeding + // the existing non-empty deep plan as light is now rejected (silent rigor + // downgrade guard), so the light case needs its own swarm. + let mut lfx = graph_fixture_named("swarm-light-directive", "coord-ld", "worker-ld").await; + lfx.seed("light", vec![node_spec("light.a", "explore", &[])]) + .await; + handle_comm_assign_task( + 3, + lfx.coord.clone(), + Some(lfx.worker.clone()), + Some("light.a".to_string()), + None, + &lfx.client_tx, + &lfx.sessions, + &lfx.soft_interrupt_queues, + &lfx.client_connections, + &lfx.swarm_members, + &lfx.swarms_by_id, + &lfx.swarm_plans, + &lfx.swarm_coordinators, + &lfx.event_history, + &lfx.event_counter, + &lfx.swarm_event_tx, + &lfx.mutation_runtime, + ) + .await; + let light_prompt = { + let queues = lfx.soft_interrupt_queues.read().await; + queues.get(&lfx.worker).and_then(|queue| { + queue + .lock() + .ok() + .and_then(|pending| pending.last().map(|msg| msg.content.clone())) + }) + } + .expect("light assignment should also queue a task prompt"); + assert!( + !light_prompt.contains(jcode_swarm_core::SWARM_DEEP_NODE_MARKER), + "light assignments must not carry the deep contract" + ); +} + +/// Gate dispatch in a deep plan carries the inject_gap contract. +#[tokio::test] +async fn e2e_deep_gate_assignment_carries_inject_gap_contract() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-deep-gate", "coord-dg", "worker-dg").await; + fx.seed("deep", vec![node_spec("root", "explore", &[])]) + .await; + + // Worker owns root (running), expands it so the engine inserts a gate. + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let root = plan.items.iter_mut().find(|i| i.id == "root").unwrap(); + root.status = "running".to_string(); + root.assigned_to = Some(fx.worker.clone()); + } + handle_comm_expand_node( + 2, + fx.worker.clone(), + "root".to_string(), + vec![node_spec("root.1", "explore", &[])], + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + // Complete the child so the gate becomes ready. + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let child = plan.items.iter_mut().find(|i| i.id == "root.1").unwrap(); + child.status = "running".to_string(); + child.assigned_to = Some(fx.worker.clone()); + } + handle_comm_complete_node( + 3, + fx.worker.clone(), + "root.1".to_string(), + serde_json::json!({ + "findings": "explored", + "confidence": "low", + "what_i_did_not_check": ["error paths"], + }) + .to_string(), + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + let gate_id = { + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + plan.items + .iter() + .find(|i| { + plan.node_meta + .get(&i.id) + // The composite's own gate, not the plan-wide root gate. + .map(|m| m.is_gate && m.parent.as_deref() == Some("root")) + .unwrap_or(false) + }) + .map(|i| i.id.clone()) + .expect("deep expand should have inserted a gate") + }; + + // Assign the gate to the worker; its prompt must carry the gate contract. + handle_comm_assign_task( + 4, + fx.coord.clone(), + Some(fx.worker.clone()), + Some(gate_id.clone()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + + let prompt = { + let queues = fx.soft_interrupt_queues.read().await; + queues.get(&fx.worker).and_then(|queue| { + queue + .lock() + .ok() + .and_then(|pending| pending.last().map(|msg| msg.content.clone())) + }) + } + .expect("gate assignment should queue a task prompt for the worker"); + assert!(prompt.contains(jcode_swarm_core::SWARM_DEEP_NODE_MARKER)); + assert!( + prompt.contains(&format!("action=\"inject_gap\", gate_id=\"{gate_id}\"")), + "gate prompt must carry the inject_gap contract, got: {prompt}" + ); + // The gate also sees the child's artifact (forward dataflow) including the + // unexplored surface it is supposed to mine. + assert!(prompt.contains("error paths")); + // The child completed with LOW confidence, so the gate directive must name + // it as a priority probe target (the engine rejects a pass over it). + assert!( + prompt.contains("PRIORITY") && prompt.contains("root.1"), + "gate prompt must call out the low-confidence sibling, got: {prompt}" + ); +} + +#[tokio::test] +async fn e2e_complete_flows_artifact_to_downstream_assignment() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + fx.seed( + "light", + vec![ + node_spec("api", "implement", &[]), + node_spec("ui", "implement", &["api"]), + ], + ) + .await; + + // Assign "api" to the worker, mark running, then complete with an artifact. + handle_comm_assign_task( + 2, + fx.coord.clone(), + Some(fx.worker.clone()), + Some("api".to_string()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let api = plan.items.iter_mut().find(|i| i.id == "api").unwrap(); + api.status = "running".to_string(); + api.assigned_to = Some(fx.worker.clone()); + } + + let artifact = serde_json::json!({ + "findings": "API built in crates/foo/api.rs with types Req/Resp", + "evidence": ["crates/foo/api.rs:1"], + }) + .to_string(); + handle_comm_complete_node( + 4, + fx.worker.clone(), + "api".to_string(), + artifact, + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + // api is now completed; ui should be runnable. + { + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + let api = plan.items.iter().find(|i| i.id == "api").unwrap(); + assert_eq!(api.status, "completed"); + assert!(plan.node_meta["api"].artifact_json.is_some()); + let ready = jcode_plan::next_runnable_item_ids(&plan.items, None); + assert!( + ready.contains(&"ui".to_string()), + "ui should be ready: {ready:?}" + ); + } + + // Assign "ui": its prompt must be hydrated with api's artifact. + handle_comm_assign_task( + 5, + fx.coord.clone(), + Some(fx.worker.clone()), + Some("ui".to_string()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + + // The assignment summary stored in task_progress should reflect hydration. + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + let ui = plan.items.iter().find(|i| i.id == "ui").unwrap(); + assert_eq!(ui.assigned_to.as_deref(), Some(fx.worker.as_str())); +} + +#[tokio::test] +async fn e2e_composite_rewake_prefers_planner_via_assign_next() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + // Two workers so auto-assignment has a choice; the planner should still win + // the composite synthesis re-wake. + let planner = "planner".to_string(); + let other = "other".to_string(); + { + let mut members = fx.swarm_members.write().await; + members.insert( + planner.clone(), + owned_member(&planner, &fx.swarm_id, "ready", &fx.coord), + ); + members.insert( + other.clone(), + owned_member(&other, &fx.swarm_id, "ready", &fx.coord), + ); + let mut by_id = fx.swarms_by_id.write().await; + by_id + .get_mut(&fx.swarm_id) + .unwrap() + .extend([planner.clone(), other.clone()]); + let mut sessions = fx.sessions.write().await; + sessions.insert(planner.clone(), test_agent().await); + sessions.insert(other.clone(), test_agent().await); + } + + fx.seed("light", vec![node_spec("root", "explore", &[])]) + .await; + + // planner owns root and decomposes it into one child. + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let root = plan.items.iter_mut().find(|i| i.id == "root").unwrap(); + root.status = "running".to_string(); + root.assigned_to = Some(planner.clone()); + } + handle_comm_expand_node( + 3, + planner.clone(), + "root".to_string(), + vec![node_spec("root.1", "explore", &[])], + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + // Planner recorded; root owner freed. + { + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!( + plan.node_meta["root"].planner.as_deref(), + Some(planner.as_str()) + ); + let root = plan.items.iter().find(|i| i.id == "root").unwrap(); + assert!(root.assigned_to.is_none()); + } + + // Complete the child so the composite root becomes runnable again. + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let child = plan.items.iter_mut().find(|i| i.id == "root.1").unwrap(); + child.status = "running".to_string(); + child.assigned_to = Some(other.clone()); + } + handle_comm_complete_node( + 4, + other.clone(), + "root.1".to_string(), + serde_json::json!({"findings": "child done"}).to_string(), + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + // assign_next should route the composite synthesis back to the planner. + let resolved = crate::server::comm_control::resolve_assignment_target_for_task_test_hook( + &fx.coord, + &fx.swarm_id, + "root", + None, + &fx.swarm_members, + &fx.swarm_plans, + ) + .await; + assert_eq!(resolved.as_deref(), Ok(planner.as_str())); +} + +/// A solo deep-mode agent (no coordinator registered) seeds a graph. It must be +/// elected coordinator so it can then drive the coordinator-gated assign path it +/// just created work for. +#[tokio::test] +async fn e2e_solo_seeder_is_elected_coordinator_and_can_assign() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-solo".to_string(); + let seeder = "seeder".to_string(); + let worker = "worker".to_string(); + let (client_tx, _client_rx) = mpsc::unbounded_channel(); + let sessions: crate::server::SessionAgents = Arc::new(RwLock::new(HashMap::from([ + (seeder.clone(), test_agent().await), + (worker.clone(), test_agent().await), + ]))); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (seeder.clone(), member(&seeder, &swarm_id, "ready")), + (worker.clone(), member(&worker, &swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([seeder.clone(), worker.clone()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + VersionedPlan::new(), + )]))); + // No coordinator registered: this is the deep-mode solo-agent starting state. + let swarm_coordinators: Arc<RwLock<HashMap<String, String>>> = + Arc::new(RwLock::new(HashMap::new())); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let swarm_event_tx = broadcast::channel(64).0; + let mutation_runtime = SwarmMutationRuntime::default(); + let soft_interrupt_queues: crate::server::SessionInterruptQueues = + Arc::new(RwLock::new(HashMap::new())); + let client_connections: Arc<RwLock<HashMap<String, crate::server::ClientConnectionInfo>>> = + Arc::new(RwLock::new(HashMap::new())); + + handle_comm_seed_graph( + 1, + seeder.clone(), + Some("deep".to_string()), + vec![ + node_spec("explore", "explore", &[]), + node_spec("synth", "synthesize", &["explore"]), + ], + &client_tx, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + + // The seeder is now the coordinator of its swarm. + assert_eq!( + swarm_coordinators.read().await.get(&swarm_id).cloned(), + Some(seeder.clone()), + "solo seeder should be elected coordinator" + ); + assert_eq!( + swarm_members.read().await.get(&seeder).unwrap().role, + "coordinator" + ); + + // And it can now drive the graph: assign the ready node to the worker. + handle_comm_assign_task( + 2, + seeder.clone(), + Some(worker.clone()), + Some("explore".to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + let plans = swarm_plans.read().await; + let explore = plans[&swarm_id] + .items + .iter() + .find(|i| i.id == "explore") + .unwrap(); + assert_eq!( + explore.assigned_to.as_deref(), + Some(worker.as_str()), + "elected coordinator should be able to assign the seeded task" + ); +} + +/// A live, non-headless coordinator must not be displaced by a different member +/// that happens to seed a graph. +#[tokio::test] +async fn e2e_seed_does_not_displace_live_coordinator() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-live-coord".to_string(); + let coord = "coord".to_string(); + let worker = "worker".to_string(); + let (client_tx, _client_rx) = mpsc::unbounded_channel(); + + // Build the coordinator with a *retained* receiver so its event channel is + // genuinely open (the shared `member()` helper drops the receiver, which would + // make the channel look closed and the coordinator look dead). + let (coord_tx, _coord_rx) = mpsc::unbounded_channel(); + let mut coord_member = member(&coord, &swarm_id, "ready"); + coord_member.event_tx = coord_tx; + coord_member.role = "coordinator".to_string(); + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (coord.clone(), coord_member), + (worker.clone(), member(&worker, &swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([coord.clone(), worker.clone()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + VersionedPlan::new(), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + coord.clone(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let swarm_event_tx = broadcast::channel(64).0; + + // The non-coordinator worker seeds the graph. + handle_comm_seed_graph( + 1, + worker.clone(), + Some("deep".to_string()), + vec![node_spec("root", "explore", &[])], + &client_tx, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + + assert_eq!( + swarm_coordinators.read().await.get(&swarm_id).cloned(), + Some(coord.clone()), + "a live coordinator must not be displaced by a seeding worker" + ); + assert_eq!( + swarm_members.read().await.get(&worker).unwrap().role, + "agent", + "the seeding worker should remain an agent" + ); +} + +/// Regression for the deep-swarm drive gap: a deep-mode plan participant that is +/// **not** the swarm coordinator must still be able to dispatch the graph it owns. +/// Before this, `assign_task` was hard-gated to the coordinator, so a deep agent +/// joining a shared swarm (where another session already coordinates) could seed a +/// graph but never spawn/assign any of it, and nothing ran. +#[tokio::test] +async fn e2e_deep_participant_can_assign_without_being_coordinator() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + // `coord` is the swarm coordinator; `worker` is a plain agent. Seed a deep + // graph *as the worker* and register it as a participant, mirroring a deep + // agent that joined a swarm someone else coordinates. + fx.seed("deep", vec![node_spec("explore", "explore", &[])]) + .await; + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + plan.participants.insert(fx.worker.clone()); + } + + // The worker (a non-coordinator deep participant) assigns the ready node to a + // distinct swarm member (`coord` here stands in for any other worker). + handle_comm_assign_task( + 2, + fx.worker.clone(), + Some(fx.coord.clone()), + Some("explore".to_string()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + + let plans = fx.swarm_plans.read().await; + let explore = plans[&fx.swarm_id] + .items + .iter() + .find(|i| i.id == "explore") + .unwrap(); + assert_eq!( + explore.assigned_to.as_deref(), + Some(fx.coord.as_str()), + "a deep-mode plan participant should be able to assign even without the coordinator slot" + ); +} + +/// The deep-participant escape hatch is mode-scoped: in **light** mode the +/// single-coordinator rule still holds, so a non-coordinator participant is +/// rejected and the task stays unassigned. +#[tokio::test] +async fn e2e_light_non_coordinator_participant_cannot_assign() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture().await; + fx.seed("light", vec![node_spec("task", "implement", &[])]) + .await; + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + plan.participants.insert(fx.worker.clone()); + } + + handle_comm_assign_task( + 2, + fx.worker.clone(), + Some(fx.coord.clone()), + Some("task".to_string()), + None, + &fx.client_tx, + &fx.sessions, + &fx.soft_interrupt_queues, + &fx.client_connections, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + &fx.mutation_runtime, + ) + .await; + + let plans = fx.swarm_plans.read().await; + let task = plans[&fx.swarm_id] + .items + .iter() + .find(|i| i.id == "task") + .unwrap(); + assert!( + task.assigned_to.is_none(), + "light mode must keep the coordinator-only assignment rule" + ); + drop(plans); + let mut saw_permission_error = false; + while let Ok(ev) = fx.client_rx.try_recv() { + if let ServerEvent::Error { message, .. } = ev + && message.contains("Only the coordinator can assign tasks") + { + saw_permission_error = true; + } + } + assert!( + saw_permission_error, + "light-mode non-coordinator assign should be rejected with the coordinator error" + ); +} + +/// Regression: a solo deep-mode seeder must be able to complete (and expand) a +/// node it seeded. Seeded nodes are unowned and the assign path refuses +/// self-assignment, so without the handler-level self-claim the seeder's +/// `complete_node` bounced with "does not own node" (observed live 2026-06-30, +/// session_shrimp completing node 'probe'). +#[tokio::test] +async fn e2e_solo_seeder_can_complete_its_own_seeded_node() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-self-claim", "coord-sc", "worker-sc").await; + fx.seed("deep", vec![node_spec("probe", "explore", &[])]) + .await; + + // The seeder completes its own seeded node directly: the handler must + // auto-claim the unowned queued node instead of rejecting with NotOwner. + handle_comm_complete_node( + 2, + fx.coord.clone(), + "probe".to_string(), + serde_json::json!({ + "findings": "probe complete", + "confidence": "high", + "what_i_did_not_check": ["nothing; probe only"], + }) + .to_string(), + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + let probe = plan.items.iter().find(|i| i.id == "probe").unwrap(); + assert_eq!( + probe.status, "completed", + "solo seeder must be able to complete its own seeded node" + ); + assert!( + plan.node_meta["probe"].artifact_json.is_some(), + "artifact must be recorded" + ); +} + +/// Regression: the self-claim must not let an actor steal a node that is +/// assigned to someone else. The engine's NotOwner check still applies. +#[tokio::test] +async fn e2e_self_claim_does_not_steal_foreign_assignment() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-no-steal", "coord-ns", "worker-ns").await; + fx.seed("deep", vec![node_spec("task", "explore", &[])]) + .await; + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let item = plan.items.iter_mut().find(|i| i.id == "task").unwrap(); + item.assigned_to = Some(fx.worker.clone()); + item.status = "queued".to_string(); + } + + // The coordinator (not the assignee) tries to complete it -> rejected. + handle_comm_complete_node( + 2, + fx.coord.clone(), + "task".to_string(), + serde_json::json!({ + "findings": "hijack", + "confidence": "high", + "what_i_did_not_check": ["everything"], + }) + .to_string(), + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + let task = plan.items.iter().find(|i| i.id == "task").unwrap(); + assert_eq!( + task.status, "queued", + "a foreign actor must not complete someone else's assignment" + ); + assert_eq!(task.assigned_to.as_deref(), Some(fx.worker.as_str())); +} + +/// Regression: an assignee whose node was left `queued` (client-attached worker +/// path skips the server-side flip to running) must still be able to +/// complete/expand its own assignment. +#[tokio::test] +async fn e2e_assignee_can_complete_queued_assignment() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-queued-own", "coord-qo", "worker-qo").await; + fx.seed("deep", vec![node_spec("mine", "explore", &[])]) + .await; + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + let item = plan.items.iter_mut().find(|i| i.id == "mine").unwrap(); + // Assigned but never flipped to running (live-client path). + item.assigned_to = Some(fx.worker.clone()); + item.status = "queued".to_string(); + } + + handle_comm_complete_node( + 2, + fx.worker.clone(), + "mine".to_string(), + serde_json::json!({ + "findings": "did the work", + "confidence": "high", + "what_i_did_not_check": ["nothing"], + }) + .to_string(), + &fx.client_tx, + &fx.swarm_members, + &fx.swarms_by_id, + &fx.swarm_plans, + &fx.swarm_coordinators, + &fx.event_history, + &fx.event_counter, + &fx.swarm_event_tx, + ) + .await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + let mine = plan.items.iter().find(|i| i.id == "mine").unwrap(); + assert_eq!( + mine.status, "completed", + "the assignee must be able to complete its queued assignment" + ); +} + +/// Regression: re-seeding a non-empty deep plan as light is a silent rigor +/// downgrade (drops gates + artifact validation) and must be rejected. +#[tokio::test] +async fn e2e_seed_rejects_light_downgrade_of_nonempty_deep_plan() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = graph_fixture_named("swarm-no-downgrade", "coord-nd", "worker-nd").await; + fx.seed("deep", vec![node_spec("a", "explore", &[])]).await; + + // Attempt the downgrade. + fx.seed("light", vec![node_spec("b", "explore", &[])]).await; + + let plans = fx.swarm_plans.read().await; + let plan = &plans[&fx.swarm_id]; + assert_eq!(plan.mode, "deep", "deep plan must not be downgraded to light"); + assert!( + plan.items.iter().all(|i| i.id != "b"), + "the downgrade seed must be rejected wholesale" + ); + drop(plans); + let mut saw_downgrade_error = false; + while let Ok(ev) = fx.client_rx.try_recv() { + if let ServerEvent::Error { message, .. } = ev + && message.contains("deep-mode plan") + { + saw_downgrade_error = true; + } + } + assert!(saw_downgrade_error, "downgrade must surface a clear error"); +} diff --git a/crates/jcode-app-core/src/server/comm_control_tests/task_control.rs b/crates/jcode-app-core/src/server/comm_control_tests/task_control.rs new file mode 100644 index 0000000..746c619 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_control_tests/task_control.rs @@ -0,0 +1,619 @@ +#[tokio::test] +async fn task_control_wake_returns_structured_response_with_plan_summary() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-task-control"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let worker_agent = test_agent().await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + worker.to_string(), + worker_agent, + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let mut assigned = plan_item("active-task", "queued", "high", &[]); + assigned.assigned_to = Some(worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![assigned, plan_item("next", "queued", "high", &[])], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_task_control( + 101, + requester.to_string(), + "wake".to_string(), + "active-task".to_string(), + Some(worker.to_string()), + Some("continue".to_string()), + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommTaskControlResponse { + id, + action, + task_id, + target_session, + status, + summary, + } => { + assert_eq!(id, 101); + assert_eq!(action, "wake"); + assert_eq!(task_id, "active-task"); + assert_eq!(target_session.as_deref(), Some(worker)); + assert_eq!(status, "running"); + assert_eq!(summary.item_count, 2); + assert!(summary.ready_ids.contains(&"next".to_string())); + } + other => panic!("expected CommTaskControlResponse, got {other:?}"), + } +} + +#[tokio::test] +async fn task_control_resume_without_task_id_uses_unique_target_assignment() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-task-control-target"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let worker_agent = test_agent().await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + worker.to_string(), + worker_agent, + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "stopped")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let mut assigned = plan_item("resume-me", "queued", "high", &[]); + assigned.assigned_to = Some(worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![assigned], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_task_control( + 102, + requester.to_string(), + "resume".to_string(), + String::new(), + Some(worker.to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::CommTaskControlResponse { + id, + action, + task_id, + target_session, + status, + .. + } => { + assert_eq!(id, 102); + assert_eq!(action, "resume"); + assert_eq!(task_id, "resume-me"); + assert_eq!(target_session.as_deref(), Some(worker)); + assert_eq!(status, "running"); + } + other => panic!("expected CommTaskControlResponse, got {other:?}"), + } +} + +#[tokio::test] +async fn task_control_without_task_id_rejects_ambiguous_target_assignments() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-task-control-ambiguous"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "stopped")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let mut first = plan_item("first", "queued", "high", &[]); + first.assigned_to = Some(worker.to_string()); + let mut second = plan_item("second", "queued", "high", &[]); + second.assigned_to = Some(worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![first, second], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + handle_comm_task_control( + 103, + requester.to_string(), + "resume".to_string(), + String::new(), + Some(worker.to_string()), + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::Error { id, message, .. } => { + assert_eq!(id, 103); + assert!(message.contains("Multiple tasks assigned"), "{message}"); + assert!(message.contains("first"), "{message}"); + assert!(message.contains("second"), "{message}"); + } + other => panic!("expected Error, got {other:?}"), + } +} + +/// Regression: resuming a plain-'running' task whose agent is busy must be +/// rejected BEFORE any plan mutation. The old ordering requeued the live task +/// first (flipping it to 'queued' and rewriting its progress record) and only +/// then discovered the agent was busy, so a rejected resume still mangled the +/// running task's state and run history. +#[tokio::test] +async fn task_control_resume_busy_agent_rejects_without_mutating_plan() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-task-control-busy"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let worker_agent = test_agent().await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + worker.to_string(), + Arc::clone(&worker_agent), + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "running")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let mut assigned = plan_item("busy-task", "running", "high", &[]); + assigned.assigned_to = Some(worker.to_string()); + let prior_progress = crate::server::SwarmTaskProgress { + assigned_session_id: Some(worker.to_string()), + assignment_summary: Some("original assignment".to_string()), + assigned_at_unix_ms: Some(1_000), + started_at_unix_ms: Some(2_000), + last_heartbeat_unix_ms: Some(3_000), + heartbeat_count: Some(7), + checkpoint_count: Some(2), + checkpoint_summary: Some("halfway".to_string()), + ..crate::server::SwarmTaskProgress::default() + }; + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![assigned], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::from([("busy-task".to_string(), prior_progress.clone())]), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + // Hold the agent lock so the resume path sees the worker as busy. + let _busy_guard = worker_agent.lock().await; + + handle_comm_task_control( + 104, + requester.to_string(), + "resume".to_string(), + "busy-task".to_string(), + None, + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + + match client_rx.recv().await.expect("response") { + ServerEvent::Error { id, message, .. } => { + assert_eq!(id, 104); + assert!(message.contains("currently busy"), "{message}"); + } + other => panic!("expected busy Error, got {other:?}"), + } + + let plans = swarm_plans.read().await; + let plan = plans.get(swarm_id).expect("plan exists"); + let item = plan + .items + .iter() + .find(|item| item.id == "busy-task") + .expect("task exists"); + assert_eq!( + item.status, "running", + "rejected resume must not flip a live task back to queued" + ); + assert_eq!(plan.version, 1, "rejected resume must not bump the plan"); + assert_eq!( + plan.task_progress.get("busy-task"), + Some(&prior_progress), + "rejected resume must not touch the task's progress record" + ); +} + +/// Regression: requeueing an existing assignment (resume of a running/stale +/// task) must preserve the prior run's history instead of replacing the +/// progress record. Wiping started_at/heartbeats/checkpoints blinded +/// staleness monitors and salvage flows to everything the previous run did. +#[tokio::test] +async fn requeue_existing_assignment_preserves_prior_progress_history() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-requeue-preserve"; + let requester = "coord"; + let worker = "worker"; + let mut assigned = plan_item("requeue-me", "running_stale", "high", &[]); + assigned.assigned_to = Some(worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![assigned], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::from([( + "requeue-me".to_string(), + crate::server::SwarmTaskProgress { + assigned_session_id: Some(worker.to_string()), + assignment_summary: Some("original assignment".to_string()), + assigned_at_unix_ms: Some(1_000), + started_at_unix_ms: Some(2_000), + last_heartbeat_unix_ms: Some(3_000), + last_detail: Some("was working".to_string()), + last_checkpoint_unix_ms: Some(3_500), + checkpoint_summary: Some("halfway".to_string()), + heartbeat_count: Some(7), + checkpoint_count: Some(2), + stale_since_unix_ms: Some(4_000), + completed_at_unix_ms: None, + no_artifact_requeues: None, + dead_assignee_reclaims: None, + }, + )]), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + + let result = super::requeue_existing_assignment( + swarm_id, + requester, + worker, + "requeue-me", + "resume this work".to_string(), + &swarm_plans, + ) + .await; + assert!(result.is_some(), "requeue should succeed"); + + let plans = swarm_plans.read().await; + let plan = plans.get(swarm_id).expect("plan exists"); + let item = plan + .items + .iter() + .find(|item| item.id == "requeue-me") + .expect("task exists"); + assert_eq!(item.status, "queued"); + let progress = plan + .task_progress + .get("requeue-me") + .expect("progress exists"); + assert_eq!( + progress.started_at_unix_ms, + Some(2_000), + "prior run's start time must survive the requeue" + ); + assert_eq!(progress.last_heartbeat_unix_ms, Some(3_000)); + assert_eq!(progress.heartbeat_count, Some(7)); + assert_eq!(progress.checkpoint_count, Some(2)); + assert_eq!(progress.checkpoint_summary.as_deref(), Some("halfway")); + assert_eq!(progress.last_detail.as_deref(), Some("was working")); + assert_eq!( + progress.stale_since_unix_ms, None, + "requeued task is no longer stale" + ); + assert_eq!( + progress.assignment_summary.as_deref(), + Some("resume this work"), + "assignment-scoped fields refresh for the new attempt" + ); + assert!( + progress.assigned_at_unix_ms.unwrap_or(0) > 1_000, + "assigned_at refreshes for the new attempt" + ); +} + +/// Regression: an identical coordinator retry issued shortly after a +/// previous retry succeeded must actually re-dispatch the task. The durable +/// mutation layer used to replay the persisted assign_task success for any +/// identical request within the final-state TTL (30s), so a worker that +/// failed quickly made the second retry a silent no-op: the coordinator got +/// a success response while the task stayed failed. +#[tokio::test] +async fn task_control_retry_re_dispatches_after_recent_identical_retry() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let swarm_id = "swarm-retry-replay"; + let requester = "coord"; + let worker = "worker"; + let (client_tx, mut client_rx) = mpsc::unbounded_channel(); + let worker_agent = test_agent().await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + worker.to_string(), + worker_agent, + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (requester.to_string(), { + let mut member = member(requester, swarm_id, "ready"); + member.role = "coordinator".to_string(); + member + }), + (worker.to_string(), member(worker, swarm_id, "ready")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + HashSet::from([requester.to_string(), worker.to_string()]), + )]))); + let mut assigned = plan_item("flaky-task", "failed", "high", &[]); + assigned.assigned_to = Some(worker.to_string()); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + VersionedPlan { + items: vec![assigned], + version: 1, + participants: HashSet::from([requester.to_string(), worker.to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.to_string(), + requester.to_string(), + )]))); + let event_history = Arc::new(RwLock::new(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(1)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32); + let mutation_runtime = SwarmMutationRuntime::default(); + + let run_retry = |id: u64| { + let requester = requester.to_string(); + let client_tx = client_tx.clone(); + let sessions = Arc::clone(&sessions); + let soft_interrupt_queues = Arc::clone(&soft_interrupt_queues); + let client_connections = Arc::clone(&client_connections); + let swarm_members = Arc::clone(&swarm_members); + let swarms_by_id = Arc::clone(&swarms_by_id); + let swarm_plans = Arc::clone(&swarm_plans); + let swarm_coordinators = Arc::clone(&swarm_coordinators); + let event_history = Arc::clone(&event_history); + let event_counter = Arc::clone(&event_counter); + let swarm_event_tx = swarm_event_tx.clone(); + let mutation_runtime = mutation_runtime.clone(); + async move { + handle_comm_task_control( + id, + requester, + "retry".to_string(), + "flaky-task".to_string(), + None, + None, + &client_tx, + &sessions, + &soft_interrupt_queues, + &client_connections, + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + &event_history, + &event_counter, + &swarm_event_tx, + &mutation_runtime, + ) + .await; + } + }; + + let wait_for_status_leaving_failed = || { + let swarm_plans = Arc::clone(&swarm_plans); + async move { + for _ in 0..200 { + { + let plans = swarm_plans.read().await; + let status = plans + .get(swarm_id) + .and_then(|plan| plan.items.iter().find(|item| item.id == "flaky-task")) + .map(|item| item.status.clone()) + .unwrap_or_default(); + if status != "failed" { + return status; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + "failed".to_string() + } + }; + + // First retry dispatches the task. + run_retry(201).await; + match client_rx.recv().await.expect("first retry response") { + ServerEvent::CommAssignTaskResponse { id, task_id, .. } => { + assert_eq!(id, 201); + assert_eq!(task_id, "flaky-task"); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } + let status = wait_for_status_leaving_failed().await; + assert_ne!(status, "failed", "first retry should dispatch the task"); + + // Simulate the worker failing quickly, well within the final-state TTL. + { + let mut plans = swarm_plans.write().await; + let plan = plans.get_mut(swarm_id).expect("plan exists"); + let item = plan + .items + .iter_mut() + .find(|item| item.id == "flaky-task") + .expect("task exists"); + item.status = "failed".to_string(); + } + + // Identical second retry must re-dispatch instead of replaying the + // persisted success from the first retry. + run_retry(202).await; + match client_rx.recv().await.expect("second retry response") { + ServerEvent::CommAssignTaskResponse { id, task_id, .. } => { + assert_eq!(id, 202); + assert_eq!(task_id, "flaky-task"); + } + other => panic!("expected CommAssignTaskResponse, got {other:?}"), + } + let status = wait_for_status_leaving_failed().await; + assert_ne!( + status, "failed", + "second identical retry must actually re-dispatch the task" + ); +} diff --git a/crates/jcode-app-core/src/server/comm_graph.rs b/crates/jcode-app-core/src/server/comm_graph.rs new file mode 100644 index 0000000..24516a6 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_graph.rs @@ -0,0 +1,525 @@ +//! Server handlers for the task-DAG mutation ops (seed/expand/complete/inject). +//! +//! These are the live counterparts of the validated engine ops in +//! `jcode_plan::dag`. Each handler lifts the swarm's current `VersionedPlan` into +//! a `TaskGraph` (via `jcode_plan::bridge`), applies the engine op (which enforces +//! acyclicity, ownership, gate insertion, and artifact validation), lowers the +//! result back into the plan, then persists and broadcasts using the existing +//! swarm machinery. This keeps a single source of truth and reuses the scheduler, +//! persistence, and TUI broadcast paths. + +use super::{ + SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan, broadcast_swarm_plan, + persist_swarm_state_for, record_swarm_event, +}; +use crate::protocol::ServerEvent; +use crate::protocol::TaskGraphNodeSpec; +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}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::{RwLock, broadcast}; + +fn spec_from_wire(spec: TaskGraphNodeSpec) -> NodeSpec { + NodeSpec { + id: Some(spec.id), + content: spec.content, + kind: parse_kind(spec.kind.as_deref()), + depends_on: spec.depends_on, + priority: spec.priority, + } +} + +async fn swarm_id_for( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + swarm_members + .read() + .await + .get(session_id) + .and_then(|member| member.swarm_id.clone()) +} + +/// Ensure the seeding session can actually drive the graph it just created. +/// +/// Deep-mode sessions are frequently solo `agent`s with no coordinator elected, +/// yet `assign_task` / `assign_next` / `run_plan` are coordinator-gated. Without +/// this, a fresh deep-mode agent can seed a task graph but then cannot dispatch +/// any of it. We elect the seeder as coordinator when the swarm has no *live* +/// coordinator, mirroring the self-promote rule used by `assign_role`. A live, +/// non-headless coordinator is left untouched so a real coordinator is never +/// displaced by a worker that happens to seed. +/// +/// Returns true when the seeder was (or already is) the coordinator afterwards. +async fn ensure_seeder_can_coordinate( + swarm_id: &str, + seeder_session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) -> bool { + // 1. Read the current coordinator id without holding the lock across the + // liveness check (matches the non-nested lock pattern used elsewhere). + let current = swarm_coordinators.read().await.get(swarm_id).cloned(); + match ¤t { + Some(coord) if coord == seeder_session_id => return true, + _ => {} + } + + // 2. Decide whether the existing coordinator is still a live driver. + let coordinator_is_live = match ¤t { + Some(coord) => { + let members = swarm_members.read().await; + members + .get(coord) + .map(|member| !member.event_tx.is_closed() && !member.is_headless) + .unwrap_or(false) + } + None => false, + }; + if coordinator_is_live { + return false; + } + + // 3. Promote the seeder; demote any prior (stale) coordinator member. Re-check + // under the write lock that the coordinator is still the one we inspected + // (compare-and-swap): two concurrent seeders race here, and the loser must + // not silently displace the winner it never liveness-checked. + let prior = { + let mut coordinators = swarm_coordinators.write().await; + if coordinators.get(swarm_id) != current.as_ref() { + // Someone else changed the coordinator between our read and write. + return coordinators.get(swarm_id).map(String::as_str) == Some(seeder_session_id); + } + coordinators.insert(swarm_id.to_string(), seeder_session_id.to_string()) + }; + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(seeder_session_id) { + member.role = "coordinator".to_string(); + } + if let Some(prior) = prior + && prior != seeder_session_id + && let Some(member) = members.get_mut(&prior) + { + member.role = "agent".to_string(); + } + } + true +} + +/// Auto-claim a queued node for the participant that is trying to mutate it. +/// +/// Seeded nodes are unowned until dispatch, but the deep-mode contract tells the +/// seeding agent to `expand_node`/`complete_node` its own nodes, and the assign +/// path refuses self-assignment — so without this a solo deep seeder could never +/// legally touch any node it seeded (observed live as "Complete rejected: actor +/// does not own node"). Similarly, assignment to a client-attached worker leaves +/// the item `queued` (the server-run flip to `running` is skipped when a live +/// client owns the turn), so the assignee's own complete/expand would bounce with +/// "invalid state Queued". +/// +/// Claiming is safe only when the node is genuinely available to this actor: +/// queued, with every dependency done (enforced by `dispatch`), and either +/// unowned or already assigned to this same actor. A node owned by someone else +/// is never touched — the engine's `NotOwner` check still applies. +fn claim_queued_node_for_actor(graph: &mut TaskGraph, node_id: &str, actor: &str) { + let claimable = graph.get(node_id).is_some_and(|node| { + node.status == NodeStatus::Queued + && node.owner.as_deref().is_none_or(|owner| owner == actor) + }); + if claimable { + // `dispatch` re-validates queued status and dependency satisfaction; if + // deps are not done the claim is skipped and the engine op reports the + // real error. + let _ = dag::dispatch(graph, node_id, actor); + } +} + +fn err(client_event_tx: &mpsc::UnboundedSender<ServerEvent>, id: u64, message: String) { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); +} + +/// Shared finalize: persist, broadcast, record a plan-update event, and ack. +#[expect( + clippy::too_many_arguments, + reason = "finalize threads through swarm persistence, broadcast, and event-history handles" +)] +async fn finalize( + id: u64, + swarm_id: &str, + req_session_id: &str, + reason: &str, + item_count: usize, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let from_name = swarm_members + .read() + .await + .get(req_session_id) + .and_then(|member| member.friendly_name.clone()); + + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + broadcast_swarm_plan( + swarm_id, + Some(reason.to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.to_string(), + from_name, + Some(swarm_id.to_string()), + SwarmEventType::PlanUpdate { + swarm_id: swarm_id.to_string(), + item_count, + }, + ) + .await; + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +/// Seed (or re-seed) the swarm task DAG from a batch of node specs. +#[expect( + clippy::too_many_arguments, + reason = "swarm op threads runtime handles" +)] +pub(super) async fn handle_comm_seed_graph( + id: u64, + req_session_id: String, + mode: Option<String>, + nodes: Vec<TaskGraphNodeSpec>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let Some(swarm_id) = swarm_id_for(&req_session_id, swarm_members).await else { + err(client_event_tx, id, "Not in a swarm.".to_string()); + return; + }; + + // A deep-mode seeder is usually a solo agent. Elect it coordinator (when no + // live coordinator exists) so it can actually dispatch the graph it seeds via + // the coordinator-gated assign/run_plan paths. + ensure_seeder_can_coordinate( + &swarm_id, + &req_session_id, + swarm_members, + swarm_coordinators, + ) + .await; + + let specs: Vec<NodeSpec> = nodes.into_iter().map(spec_from_wire).collect(); + let count = specs.len(); + + // Resolve the plan mode. The model is *asked* to pass `mode:"deep"` when it is + // running at `swarm-deep` effort, but it frequently forgets. Rather than + // silently downgrading a deep-effort session to light (which disables the + // gates + artifact validation that define deep mode), default the mode from + // the seeder's recorded reasoning effort when the caller did not specify one. + // An explicit `mode` always wins so a caller can still opt into light. + let resolved_mode = mode.or_else(|| { + crate::session_effort::session_effort(&req_session_id) + .filter(|effort| crate::prompt::is_deep_swarm_effort(effort)) + .map(|_| "deep".to_string()) + }); + + let result = { + let mut plans = swarm_plans.write().await; + let plan = plans + .entry(swarm_id.clone()) + .or_insert_with(VersionedPlan::new); + if let Some(mode) = resolved_mode { + // Guard against silent rigor downgrades: re-seeding an existing deep + // plan as light would strip the gates + artifact validation from all + // nodes already in flight. Deepening (light -> deep) or re-stating + // the same mode is fine; only the downgrade of a non-empty deep plan + // is rejected. + let downgrades_deep = plan.mode.eq_ignore_ascii_case("deep") + && !mode.eq_ignore_ascii_case("deep") + && !plan.items.is_empty(); + if downgrades_deep { + err( + client_event_tx, + id, + "Seed rejected: this swarm already has a non-empty deep-mode plan; \ + seeding with mode=light would silently strip its gates and artifact \ + validation. Omit `mode` to keep deep, or finish/clear the current plan first." + .to_string(), + ); + return; + } + plan.mode = mode; + } + plan.participants.insert(req_session_id.clone()); + 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(()) + } + Err(e) => Err(e), + } + }; + + match result { + Ok(()) => { + finalize( + id, + &swarm_id, + &req_session_id, + "task_graph_seed", + count, + client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Err(e) => err(client_event_tx, id, format!("Seed rejected: {e}")), + } +} + +/// Decompose a node the caller owns into a child sub-DAG. +#[expect( + clippy::too_many_arguments, + reason = "swarm op threads runtime handles" +)] +pub(super) async fn handle_comm_expand_node( + id: u64, + req_session_id: String, + node_id: String, + children: Vec<TaskGraphNodeSpec>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let Some(swarm_id) = swarm_id_for(&req_session_id, swarm_members).await else { + err(client_event_tx, id, "Not in a swarm.".to_string()); + return; + }; + let specs: Vec<NodeSpec> = children.into_iter().map(spec_from_wire).collect(); + let count = specs.len(); + + let result = { + let mut plans = swarm_plans.write().await; + let Some(plan) = plans.get_mut(&swarm_id) else { + err(client_event_tx, id, "No plan for this swarm.".to_string()); + return; + }; + 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(()) + } + Err(e) => Err(e.to_string()), + } + }; + + match result { + Ok(()) => { + finalize( + id, + &swarm_id, + &req_session_id, + "task_graph_expand", + count, + client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Err(e) => err(client_event_tx, id, format!("Expand rejected: {e}")), + } +} + +/// Complete a node the caller owns with a typed handoff artifact. +#[expect( + clippy::too_many_arguments, + reason = "swarm op threads runtime handles" +)] +pub(super) async fn handle_comm_complete_node( + id: u64, + req_session_id: String, + node_id: String, + artifact_json: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let Some(swarm_id) = swarm_id_for(&req_session_id, swarm_members).await else { + err(client_event_tx, id, "Not in a swarm.".to_string()); + return; + }; + + let artifact: HandoffArtifact = match serde_json::from_str(&artifact_json) { + Ok(artifact) => artifact, + Err(e) => { + err(client_event_tx, id, format!("Invalid artifact JSON: {e}")); + return; + } + }; + + let result = { + let mut plans = swarm_plans.write().await; + let Some(plan) = plans.get_mut(&swarm_id) else { + err(client_event_tx, id, "No plan for this swarm.".to_string()); + return; + }; + let mut graph = to_task_graph(plan); + claim_queued_node_for_actor(&mut graph, &node_id, &req_session_id); + match dag::complete_node(&mut graph, &node_id, &req_session_id, artifact) { + Ok(()) => { + apply_task_graph(plan, &graph); + plan.version += 1; + Ok(()) + } + Err(e) => Err(e.to_string()), + } + }; + + match result { + Ok(()) => { + finalize( + id, + &swarm_id, + &req_session_id, + "task_graph_complete", + 1, + client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Err(e) => err(client_event_tx, id, format!("Complete rejected: {e}")), + } +} + +/// Inject gap/fix nodes from a gate the caller owns. +#[expect( + clippy::too_many_arguments, + reason = "swarm op threads runtime handles" +)] +pub(super) async fn handle_comm_inject_gap( + id: u64, + req_session_id: String, + gate_id: String, + nodes: Vec<TaskGraphNodeSpec>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let Some(swarm_id) = swarm_id_for(&req_session_id, swarm_members).await else { + err(client_event_tx, id, "Not in a swarm.".to_string()); + return; + }; + let specs: Vec<NodeSpec> = nodes.into_iter().map(spec_from_wire).collect(); + let count = specs.len(); + + let result = { + let mut plans = swarm_plans.write().await; + let Some(plan) = plans.get_mut(&swarm_id) else { + err(client_event_tx, id, "No plan for this swarm.".to_string()); + return; + }; + 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(()) + } + Err(e) => Err(e.to_string()), + } + }; + + match result { + Ok(()) => { + finalize( + id, + &swarm_id, + &req_session_id, + "task_graph_inject_gap", + count, + client_event_tx, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + Err(e) => err(client_event_tx, id, format!("Inject rejected: {e}")), + } +} diff --git a/crates/jcode-app-core/src/server/comm_plan.rs b/crates/jcode-app-core/src/server/comm_plan.rs new file mode 100644 index 0000000..0eada08 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_plan.rs @@ -0,0 +1,710 @@ +use super::swarm_mutation_state::{ + PersistedSwarmMutationResponse, SwarmMutationRuntime, begin_or_replay, finish_request, + request_key, +}; +use super::{ + SessionInterruptQueues, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, + VersionedPlan, broadcast_swarm_plan, persist_swarm_state_for, queue_soft_interrupt_for_session, + record_swarm_event, summarize_plan_items, +}; +use crate::agent::Agent; +use crate::plan::PlanItem; +use crate::protocol::{NotificationType, ServerEvent}; +use jcode_agent_runtime::SoftInterruptSource; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +/// Reject plans whose dependency graph contains a cycle. Cyclic items can never +/// become runnable (`summarize_plan_graph` parks them in `blocked_ids` forever), +/// so letting one into a live plan silently wedges all dependent work. This +/// mirrors the acyclicity validation `jcode_plan::dag::seed`/`expand` already +/// enforce on the task-graph paths. Returns a user-facing error naming the +/// cyclic item ids, or `None` when the graph is a valid DAG. +fn plan_cycle_error(items: &[PlanItem]) -> Option<String> { + let cycle_ids = crate::plan::cycle_item_ids(items); + if cycle_ids.is_empty() { + return None; + } + Some(format!( + "Plan rejected: dependency cycle among item(s): {}. Fix the blocked_by \ + edges so the plan forms a DAG, then propose again.", + cycle_ids.join(", ") + )) +} + +#[expect( + clippy::too_many_arguments, + reason = "plan proposal updates sessions, swarm coordination, shared context, interrupts, and event history" +)] +pub(super) async fn handle_comm_propose_plan( + id: u64, + req_session_id: String, + items: Vec<PlanItem>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + _swarm_mutation_runtime: &SwarmMutationRuntime, +) { + let swarm_id = { + let members = swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()) + }; + + let swarm_id = match swarm_id.as_ref() { + Some(swarm_id) => swarm_id.clone(), + None => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + return; + } + }; + + let (from_name, coordinator_id) = { + let members = swarm_members.read().await; + let from_name = members + .get(&req_session_id) + .and_then(|member| member.friendly_name.clone()); + let coordinators = swarm_coordinators.read().await; + let coordinator_id = coordinators.get(&swarm_id).cloned(); + (from_name, coordinator_id) + }; + let from_label = from_name + .clone() + .unwrap_or_else(|| req_session_id.chars().take(8).collect()); + + let Some(coordinator_id) = coordinator_id else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "No coordinator for this swarm.".to_string(), + retry_after_secs: None, + }); + return; + }; + + if coordinator_id == req_session_id { + // Close the last entry path for dependency cycles into live plans: the + // dag seed/expand ops validate acyclicity, but this direct-update path + // used to write `plan.items` verbatim. A cyclic task can never become + // runnable (it lands in blocked_ids forever), silently wedging all + // dependent work, so reject for light plans too, matching `dag::seed` + // which rejects cycles in both modes. + if let Some(message) = plan_cycle_error(&items) { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + return; + } + let (version, participant_ids) = { + let mut plans = swarm_plans.write().await; + let plan = plans + .entry(swarm_id.clone()) + .or_insert_with(VersionedPlan::new); + plan.participants.insert(req_session_id.clone()); + for item in &items { + if let Some(owner) = &item.assigned_to { + plan.participants.insert(owner.clone()); + } + } + plan.items = items.clone(); + plan.version += 1; + (plan.version, plan.participants.clone()) + }; + + let members = swarm_members.read().await; + let notification_msg = format!( + "Plan updated by {} ({} items, v{})", + from_label, + items.len(), + version + ); + for sid in participant_ids { + if sid == req_session_id { + continue; + } + if let Some(member) = members.get(&sid) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: from_name.clone(), + notification_type: NotificationType::Message { + scope: Some("plan".to_string()), + channel: None, + tldr: None, + }, + message: notification_msg.clone(), + }); + } + let _ = queue_soft_interrupt_for_session( + &sid, + notification_msg.clone(), + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + } + + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + + broadcast_swarm_plan( + &swarm_id, + Some("coordinator_direct_update".to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + from_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::PlanUpdate { + swarm_id: swarm_id.clone(), + item_count: items.len(), + }, + ) + .await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); + return; + } + + // Non-coordinator proposal path. A cycle among the proposed items alone is + // a cycle in any merged graph, so reject it here for immediate proposer + // feedback instead of storing a proposal that can only bounce at approval. + // (Cycles that only form against the *existing* plan are caught at + // approve-time, where the merge is authoritative.) + if let Some(message) = plan_cycle_error(&items) { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + return; + } + + let proposal_key = format!("plan_proposal:{req_session_id}"); + let proposal_value = serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string()); + { + let mut context = shared_context.write().await; + let swarm_context = context.entry(swarm_id.clone()).or_insert_with(HashMap::new); + let now = Instant::now(); + swarm_context.insert( + proposal_key.clone(), + SharedContext { + key: proposal_key.clone(), + value: proposal_value, + from_session: req_session_id.clone(), + from_name: from_name.clone(), + created_at: now, + updated_at: now, + }, + ); + } + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + from_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::PlanProposal { + swarm_id: swarm_id.clone(), + proposer_session: req_session_id.clone(), + item_count: items.len(), + }, + ) + .await; + + let summary = summarize_plan_items(&items, 3); + let notification_msg = format!( + "Plan proposal from {} ({} items). Summary: {}. Review with communicate read key '{}'.", + from_label, + items.len(), + summary, + proposal_key + ); + + let members = swarm_members.read().await; + if let Some(member) = members.get(&coordinator_id) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: from_name.clone(), + notification_type: NotificationType::Message { + scope: Some("plan_proposal".to_string()), + channel: None, + tldr: None, + }, + message: notification_msg.clone(), + }); + let _ = member.event_tx.send(ServerEvent::SwarmPlanProposal { + swarm_id: swarm_id.clone(), + proposer_session: req_session_id.clone(), + proposer_name: from_name.clone(), + items: items.clone(), + summary: summary.clone(), + proposal_key: proposal_key.clone(), + }); + } + let _ = queue_soft_interrupt_for_session( + &coordinator_id, + notification_msg.clone(), + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + + let proposer_confirmation = "Plan proposal sent to coordinator (not yet applied).".to_string(); + if let Some(member) = members.get(&req_session_id) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: from_name.clone(), + notification_type: NotificationType::Message { + scope: Some("plan_proposal".to_string()), + channel: None, + tldr: None, + }, + message: proposer_confirmation.clone(), + }); + } + let _ = queue_soft_interrupt_for_session( + &req_session_id, + proposer_confirmation, + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +#[expect( + clippy::too_many_arguments, + reason = "plan approval updates sessions, swarm coordination, interrupts, and event history" +)] +pub(super) async fn handle_comm_approve_plan( + id: u64, + req_session_id: String, + proposer_session: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + let swarm_id = match require_coordinator_swarm( + id, + &req_session_id, + "Only the coordinator can approve plan proposals.", + client_event_tx, + swarm_members, + swarm_coordinators, + ) + .await + { + Some(swarm_id) => swarm_id, + None => return, + }; + + let mutation_key = request_key( + &req_session_id, + "approve_plan", + &[swarm_id.clone(), proposer_session.clone()], + ); + let Some(mutation_state) = begin_or_replay( + swarm_mutation_runtime, + &mutation_key, + "approve_plan", + &req_session_id, + id, + client_event_tx, + ) + .await + else { + return; + }; + + let proposal_key = format!("plan_proposal:{proposer_session}"); + let proposal_value = { + let context = shared_context.read().await; + context + .get(&swarm_id) + .and_then(|swarm_context| swarm_context.get(&proposal_key)) + .map(|context| context.value.clone()) + }; + + let proposal = match proposal_value { + Some(proposal) => proposal, + None => { + finish_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message: format!("No pending plan proposal from session '{proposer_session}'"), + retry_after_secs: None, + }, + ) + .await; + return; + } + }; + + if let Ok(items) = serde_json::from_str::<Vec<PlanItem>>(&proposal) { + // 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 + // proposal pending for the proposer to fix and re-propose. + let merged_cycle_error = { + let plans = swarm_plans.read().await; + let mut merged: Vec<PlanItem> = plans + .get(&swarm_id) + .map(|plan| plan.items.clone()) + .unwrap_or_default(); + merged.extend(items.iter().cloned()); + plan_cycle_error(&merged) + }; + if let Some(message) = merged_cycle_error { + finish_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message, + retry_after_secs: None, + }, + ) + .await; + return; + } + + let participant_ids = { + let mut plans = swarm_plans.write().await; + let plan = plans + .entry(swarm_id.clone()) + .or_insert_with(VersionedPlan::new); + plan.items.extend(items.clone()); + plan.version += 1; + plan.participants.insert(req_session_id.clone()); + plan.participants.insert(proposer_session.clone()); + for item in &items { + if let Some(owner) = &item.assigned_to { + plan.participants.insert(owner.clone()); + } + } + plan.participants.clone() + }; + + { + let mut context = shared_context.write().await; + if let Some(swarm_context) = context.get_mut(&swarm_id) { + swarm_context.remove(&proposal_key); + } + } + + broadcast_swarm_plan( + &swarm_id, + Some("proposal_approved".to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + None, + Some(swarm_id.clone()), + SwarmEventType::PlanUpdate { + swarm_id: swarm_id.clone(), + item_count: items.len(), + }, + ) + .await; + + let coordinator_name = { + let members = swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.friendly_name.clone()) + }; + + let members = swarm_members.read().await; + for sid in participant_ids { + if let Some(member) = members.get(&sid) { + let message = format!( + "Plan approved by coordinator: {} items added from {}", + items.len(), + proposer_session + ); + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: coordinator_name.clone(), + notification_type: NotificationType::Message { + scope: Some("plan".to_string()), + channel: None, + tldr: None, + }, + message: message.clone(), + }); + + let _ = queue_soft_interrupt_for_session( + &sid, + message.clone(), + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + } + } + + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + } + + finish_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Done, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "plan rejection updates sessions, swarm coordination, interrupts, and event history" +)] +pub(super) async fn handle_comm_reject_plan( + id: u64, + req_session_id: String, + proposer_session: String, + reason: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + let swarm_id = match require_coordinator_swarm( + id, + &req_session_id, + "Only the coordinator can reject plan proposals.", + client_event_tx, + swarm_members, + swarm_coordinators, + ) + .await + { + Some(swarm_id) => swarm_id, + None => return, + }; + + let mutation_key = request_key( + &req_session_id, + "reject_plan", + &[ + swarm_id.clone(), + proposer_session.clone(), + reason.clone().unwrap_or_default(), + ], + ); + let Some(mutation_state) = begin_or_replay( + swarm_mutation_runtime, + &mutation_key, + "reject_plan", + &req_session_id, + id, + client_event_tx, + ) + .await + else { + return; + }; + + let proposal_key = format!("plan_proposal:{proposer_session}"); + let proposal_exists = { + let context = shared_context.read().await; + context + .get(&swarm_id) + .and_then(|swarm_context| swarm_context.get(&proposal_key)) + .is_some() + }; + + if !proposal_exists { + finish_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Error { + message: format!("No pending plan proposal from session '{proposer_session}'"), + retry_after_secs: None, + }, + ) + .await; + return; + } + + { + let mut context = shared_context.write().await; + if let Some(swarm_context) = context.get_mut(&swarm_id) { + swarm_context.remove(&proposal_key); + } + } + + let coordinator_name = { + let members = swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.friendly_name.clone()) + }; + + let members = swarm_members.read().await; + if let Some(member) = members.get(&proposer_session) { + let reason_msg = reason + .as_ref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default(); + let message = format!("Your plan proposal was rejected by the coordinator{reason_msg}"); + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: coordinator_name.clone(), + notification_type: NotificationType::Message { + scope: Some("dm".to_string()), + channel: None, + tldr: None, + }, + message: message.clone(), + }); + + let _ = queue_soft_interrupt_for_session( + &proposer_session, + message, + false, + SoftInterruptSource::System, + soft_interrupt_queues, + sessions, + ) + .await; + } + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + req_session_id.clone(), + coordinator_name, + Some(swarm_id.clone()), + SwarmEventType::Notification { + notification_type: "plan_rejected".to_string(), + message: proposer_session.clone(), + }, + ) + .await; + + finish_request( + swarm_mutation_runtime, + &mutation_state, + PersistedSwarmMutationResponse::Done, + ) + .await; +} + +#[cfg(test)] +#[path = "comm_plan_tests.rs"] +mod tests; + +async fn require_coordinator_swarm( + id: u64, + req_session_id: &str, + permission_error: &str, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) -> Option<String> { + let (swarm_id, is_coordinator) = { + let members = swarm_members.read().await; + let swarm_id = members + .get(req_session_id) + .and_then(|member| member.swarm_id.clone()); + let is_coordinator = if let Some(ref swarm_id) = swarm_id { + let coordinators = swarm_coordinators.read().await; + coordinators + .get(swarm_id) + .map(|coordinator| coordinator == req_session_id) + .unwrap_or(false) + } else { + false + }; + (swarm_id, is_coordinator) + }; + + if !is_coordinator { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: permission_error.to_string(), + retry_after_secs: None, + }); + return None; + } + + match swarm_id { + Some(swarm_id) => Some(swarm_id), + None => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + None + } + } +} diff --git a/crates/jcode-app-core/src/server/comm_plan_tests.rs b/crates/jcode-app-core/src/server/comm_plan_tests.rs new file mode 100644 index 0000000..0a854c8 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_plan_tests.rs @@ -0,0 +1,661 @@ +//! Tests for plan proposal/approval cycle validation. +//! +//! `dag::seed`/`expand` already reject cyclic task graphs, but `propose_plan` +//! (coordinator direct update) and `approve_plan` used to write `plan.items` +//! verbatim. A cycle entering there parks its nodes in `blocked_ids` forever +//! and silently wedges dependent work, so these handlers must validate +//! acyclicity too. + +use super::{handle_comm_approve_plan, handle_comm_propose_plan, plan_cycle_error}; +use crate::plan::PlanItem; +use crate::protocol::ServerEvent; +use crate::server::{SharedContext, SwarmEvent, SwarmMember, SwarmMutationRuntime, VersionedPlan}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::time::Instant; +use tokio::sync::{RwLock, broadcast, mpsc}; + +struct RuntimeEnvGuard { + _guard: std::sync::MutexGuard<'static, ()>, + prev_runtime: Option<std::ffi::OsString>, +} + +impl RuntimeEnvGuard { + fn new() -> (Self, tempfile::TempDir) { + let guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create runtime dir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + ( + Self { + _guard: guard, + prev_runtime, + }, + temp, + ) + } +} + +impl Drop for RuntimeEnvGuard { + fn drop(&mut self) { + if let Some(prev_runtime) = self.prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +fn member(session_id: &str, swarm_id: &str, role: &str) -> SwarmMember { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some(session_id.to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: role.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, + } +} + +fn plan_item(id: &str, blocked_by: &[&str]) -> PlanItem { + PlanItem { + content: format!("task {id}"), + status: "pending".to_string(), + priority: "medium".to_string(), + id: id.to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: blocked_by.iter().map(|value| value.to_string()).collect(), + assigned_to: None, + } +} + +/// Fixture: coordinator + worker swarm with all the runtime handles the plan +/// handlers thread through. +struct PlanFixture { + swarm_id: String, + coord: String, + worker: String, + client_tx: mpsc::UnboundedSender<ServerEvent>, + client_rx: mpsc::UnboundedReceiver<ServerEvent>, + sessions: crate::server::SessionAgents, + soft_interrupt_queues: crate::server::SessionInterruptQueues, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + event_history: Arc<RwLock<VecDeque<SwarmEvent>>>, + event_counter: Arc<AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + mutation_runtime: SwarmMutationRuntime, +} + +fn plan_fixture(swarm_id: &str, coord: &str, worker: &str) -> PlanFixture { + let swarm_id = swarm_id.to_string(); + let coord = coord.to_string(); + let worker = worker.to_string(); + let (client_tx, client_rx) = mpsc::unbounded_channel(); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (coord.clone(), member(&coord, &swarm_id, "coordinator")), + (worker.clone(), member(&worker, &swarm_id, "agent")), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + HashSet::from([coord.clone(), worker.clone()]), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + VersionedPlan::new(), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + swarm_id.clone(), + coord.clone(), + )]))); + PlanFixture { + swarm_id, + coord, + worker, + client_tx, + client_rx, + sessions: Arc::new(RwLock::new(HashMap::new())), + soft_interrupt_queues: Arc::new(RwLock::new(HashMap::new())), + swarm_members, + swarms_by_id, + shared_context: Arc::new(RwLock::new(HashMap::new())), + swarm_plans, + swarm_coordinators, + event_history: Arc::new(RwLock::new(VecDeque::new())), + event_counter: Arc::new(AtomicU64::new(1)), + swarm_event_tx: broadcast::channel(64).0, + mutation_runtime: SwarmMutationRuntime::default(), + } +} + +impl PlanFixture { + async fn propose(&self, from: &str, items: Vec<PlanItem>) { + handle_comm_propose_plan( + 1, + from.to_string(), + items, + &self.client_tx, + &self.swarm_members, + &self.swarms_by_id, + &self.shared_context, + &self.swarm_plans, + &self.swarm_coordinators, + &self.sessions, + &self.soft_interrupt_queues, + &self.event_history, + &self.event_counter, + &self.swarm_event_tx, + &self.mutation_runtime, + ) + .await; + } + + async fn approve(&self, proposer: &str) { + handle_comm_approve_plan( + 2, + self.coord.clone(), + proposer.to_string(), + &self.client_tx, + &self.swarm_members, + &self.swarms_by_id, + &self.shared_context, + &self.swarm_plans, + &self.swarm_coordinators, + &self.sessions, + &self.soft_interrupt_queues, + &self.event_history, + &self.event_counter, + &self.swarm_event_tx, + &self.mutation_runtime, + ) + .await; + } + + async fn plan_item_ids(&self) -> Vec<String> { + let plans = self.swarm_plans.read().await; + plans[&self.swarm_id] + .items + .iter() + .map(|item| item.id.clone()) + .collect() + } + + fn drain_events(&mut self) -> Vec<ServerEvent> { + let mut events = Vec::new(); + while let Ok(event) = self.client_rx.try_recv() { + events.push(event); + } + events + } +} + +fn error_messages(events: &[ServerEvent]) -> Vec<String> { + events + .iter() + .filter_map(|event| match event { + ServerEvent::Error { message, .. } => Some(message.clone()), + _ => None, + }) + .collect() +} + +fn saw_done(events: &[ServerEvent]) -> bool { + events + .iter() + .any(|event| matches!(event, ServerEvent::Done { .. })) +} + +#[test] +fn plan_cycle_error_names_cyclic_ids_and_accepts_dags() { + // b <-> c cycle: both ids named, deterministic order. + let cyclic = vec![ + plan_item("a", &[]), + plan_item("b", &["c"]), + plan_item("c", &["b"]), + ]; + let message = plan_cycle_error(&cyclic).expect("cycle must be rejected"); + assert!(message.contains("b, c"), "message: {message}"); + + // Self-dependency is a one-node cycle. + let self_dep = vec![plan_item("solo", &["solo"])]; + let message = plan_cycle_error(&self_dep).expect("self-dependency must be rejected"); + assert!(message.contains("solo"), "message: {message}"); + + // A valid DAG (including deps on unknown/external ids) passes. + let dag = vec![ + plan_item("a", &[]), + plan_item("b", &["a"]), + plan_item("c", &["a", "b", "external-id"]), + ]; + assert_eq!(plan_cycle_error(&dag), None); +} + +#[tokio::test] +async fn coordinator_direct_update_rejects_cyclic_plan_without_mutating_it() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-plan-cycle", "coord-pc", "worker-pc"); + + // Pre-existing valid plan content must survive the rejected update. + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + plan.items = vec![plan_item("keep", &[])]; + plan.version = 3; + } + + let coord = fx.coord.clone(); + fx.propose( + &coord, + vec![ + plan_item("a", &[]), + plan_item("b", &["c"]), + plan_item("c", &["b"]), + ], + ) + .await; + + assert_eq!(fx.plan_item_ids().await, vec!["keep".to_string()]); + let plans = fx.swarm_plans.read().await; + assert_eq!(plans[&fx.swarm_id].version, 3, "version must not bump"); + drop(plans); + + let events = fx.drain_events(); + let errors = error_messages(&events); + assert!( + errors + .iter() + .any(|m| m.contains("cycle") && m.contains("b, c")), + "expected cycle error naming b and c, got: {errors:?}" + ); + assert!(!saw_done(&events), "rejected update must not ack Done"); +} + +#[tokio::test] +async fn coordinator_direct_update_rejects_self_dependency() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-plan-selfdep", "coord-sd", "worker-sd"); + + let coord = fx.coord.clone(); + fx.propose(&coord, vec![plan_item("loop", &["loop"])]).await; + + assert!(fx.plan_item_ids().await.is_empty()); + let events = fx.drain_events(); + let errors = error_messages(&events); + assert!( + errors + .iter() + .any(|m| m.contains("cycle") && m.contains("loop")), + "expected self-dependency rejection, got: {errors:?}" + ); +} + +#[tokio::test] +async fn coordinator_direct_update_accepts_valid_dag() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-plan-dag", "coord-dag", "worker-dag"); + + let coord = fx.coord.clone(); + fx.propose( + &coord, + vec![ + plan_item("a", &[]), + plan_item("b", &["a"]), + plan_item("c", &["a", "b"]), + ], + ) + .await; + + assert_eq!( + fx.plan_item_ids().await, + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + let plans = fx.swarm_plans.read().await; + assert_eq!(plans[&fx.swarm_id].version, 1); + drop(plans); + + let events = fx.drain_events(); + assert!(saw_done(&events), "valid DAG update must ack Done"); + assert!(error_messages(&events).is_empty()); +} + +#[tokio::test] +async fn worker_proposal_with_internal_cycle_is_rejected_before_storage() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-prop-cycle", "coord-wp", "worker-wp"); + + let worker = fx.worker.clone(); + fx.propose( + &worker, + vec![plan_item("b", &["c"]), plan_item("c", &["b"])], + ) + .await; + + // No proposal stored for the coordinator to approve. + let context = fx.shared_context.read().await; + let stored = context + .get(&fx.swarm_id) + .and_then(|swarm_context| swarm_context.get(&format!("plan_proposal:{worker}"))); + assert!(stored.is_none(), "cyclic proposal must not be stored"); + drop(context); + + let events = fx.drain_events(); + let errors = error_messages(&events); + assert!( + errors.iter().any(|m| m.contains("cycle")), + "expected proposer-side cycle rejection, got: {errors:?}" + ); +} + +#[tokio::test] +async fn approve_plan_rejects_proposal_that_forms_cycle_with_existing_plan() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-approve-cycle", "coord-ac", "worker-ac"); + + // Existing plan: a depends on b (b not yet defined, so no cycle yet). + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + plan.items = vec![plan_item("a", &["b"])]; + plan.version = 1; + } + + // Proposal alone is acyclic (its dep 'a' is external to the proposal), so it + // passes the proposer-side check and is stored for approval. + let worker = fx.worker.clone(); + fx.propose(&worker, vec![plan_item("b", &["a"])]).await; + { + let context = fx.shared_context.read().await; + assert!( + context + .get(&fx.swarm_id) + .and_then(|c| c.get(&format!("plan_proposal:{worker}"))) + .is_some(), + "acyclic-in-isolation proposal should be stored" + ); + } + + // Approval must reject: merged graph has a <-> b. + fx.approve(&worker).await; + + assert_eq!(fx.plan_item_ids().await, vec!["a".to_string()]); + let plans = fx.swarm_plans.read().await; + assert_eq!(plans[&fx.swarm_id].version, 1, "version must not bump"); + drop(plans); + + // The proposal stays pending so the proposer can fix and re-propose. + let context = fx.shared_context.read().await; + assert!( + context + .get(&fx.swarm_id) + .and_then(|c| c.get(&format!("plan_proposal:{worker}"))) + .is_some(), + "rejected proposal should remain pending" + ); + drop(context); + + let events = fx.drain_events(); + let errors = error_messages(&events); + assert!( + errors + .iter() + .any(|m| m.contains("cycle") && m.contains('a') && m.contains('b')), + "expected merged-cycle rejection naming a and b, got: {errors:?}" + ); +} + +/// Deterministic demonstration of SwarmPlanProposal delivery loss +/// (wiring-audit.status-proposal-ordering, bug 2). +/// +/// The non-coordinator proposal path in `handle_comm_propose_plan` sends the +/// coordinator's `SwarmPlanProposal` (and its companion Notification) via the +/// raw cached `member.event_tx` with the send result discarded, instead of +/// `fanout_session_event`. The cached primary channel goes stale whenever the +/// connection that registered it drops (e.g. TUI reconnect): the member entry +/// keeps a live attachment in `event_txs`, but `event_tx` still points at the +/// closed channel. `fanout_session_event` handles exactly this by retaining +/// live `event_txs` and re-pointing `event_tx`, so a coordinator with a live +/// attachment silently loses the structured proposal event only because this +/// call site bypasses it. +/// +/// The soft-interrupt fallback (queue_soft_interrupt_for_session) still fires, +/// so the coordinator gets a textual hint, but any UI driven by the +/// SwarmPlanProposal event never sees the proposal. +/// +/// If this test starts failing because `live_rx` received the proposal, the +/// call site was switched to fanout delivery: update the wiring audit. +#[tokio::test] +async fn worker_proposal_is_lost_when_coordinator_cached_channel_is_closed() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-prop-lost", "coord-pl", "worker-pl"); + let coord = fx.coord.clone(); + let worker = fx.worker.clone(); + + // Stale primary channel: receiver dropped before the proposal arrives. + let (closed_tx, closed_rx) = mpsc::unbounded_channel::<ServerEvent>(); + drop(closed_rx); + // Live attachment, as fanout_session_event would use after a reconnect. + let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ServerEvent>(); + { + let mut members = fx.swarm_members.write().await; + let member = members.get_mut(&coord).expect("coordinator member"); + member.event_tx = closed_tx; + member.event_txs.insert("conn-live".to_string(), live_tx); + } + + // Register a live soft-interrupt queue for the coordinator so the + // fallback path is observable. + let coord_queue: jcode_agent_runtime::SoftInterruptQueue = + Arc::new(std::sync::Mutex::new(Vec::new())); + fx.soft_interrupt_queues + .write() + .await + .insert(coord.clone(), coord_queue.clone()); + + fx.propose(&worker, vec![plan_item("new-task", &[])]).await; + + // The proposal is stored and the proposer is acked, so nothing upstream + // signals a failure... + { + let context = fx.shared_context.read().await; + assert!( + context + .get(&fx.swarm_id) + .and_then(|c| c.get(&format!("plan_proposal:{worker}"))) + .is_some(), + "proposal should be stored for approval" + ); + } + let events = fx.drain_events(); + assert!(saw_done(&events), "proposer must be acked with Done"); + assert!(error_messages(&events).is_empty(), "no error surfaced"); + + // ...but the coordinator's live attachment never receives the structured + // proposal event (or its notification): both went to the closed cached + // channel and the Err was discarded. + let mut delivered = Vec::new(); + while let Ok(event) = live_rx.try_recv() { + delivered.push(event); + } + assert!( + !delivered + .iter() + .any(|event| matches!(event, ServerEvent::SwarmPlanProposal { .. })), + "expected SwarmPlanProposal to be silently lost on the closed cached \ + channel; receiving it here means the call site now uses fanout \ + delivery (update the wiring audit): {delivered:?}" + ); + assert!( + delivered.is_empty(), + "no event at all should reach the live attachment via this path: {delivered:?}" + ); + + // The soft-interrupt fallback still fires, so the coordinator gets a + // textual hint about the proposal even though the event was lost. + let pending = coord_queue.lock().expect("coordinator interrupt queue"); + assert_eq!( + pending.len(), + 1, + "soft-interrupt fallback should queue once" + ); + assert!( + pending[0].content.contains("Plan proposal from") + && pending[0] + .content + .contains(&format!("plan_proposal:{worker}")), + "fallback text should reference the stored proposal key: {}", + pending[0].content + ); +} + +#[tokio::test] +async fn approve_plan_accepts_valid_dag_proposal() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-approve-dag", "coord-ad", "worker-ad"); + + { + let mut plans = fx.swarm_plans.write().await; + let plan = plans.get_mut(&fx.swarm_id).unwrap(); + plan.items = vec![plan_item("a", &[])]; + plan.version = 1; + } + + let worker = fx.worker.clone(); + fx.propose(&worker, vec![plan_item("b", &["a"])]).await; + fx.approve(&worker).await; + + assert_eq!( + fx.plan_item_ids().await, + vec!["a".to_string(), "b".to_string()] + ); + let plans = fx.swarm_plans.read().await; + assert_eq!(plans[&fx.swarm_id].version, 2); + drop(plans); + + // The consumed proposal is removed. + let context = fx.shared_context.read().await; + assert!( + context + .get(&fx.swarm_id) + .and_then(|c| c.get(&format!("plan_proposal:{worker}"))) + .is_none(), + "approved proposal should be consumed" + ); + drop(context); + + let events = fx.drain_events(); + assert!(saw_done(&events), "approval must ack"); +} + +/// Pins the delivery gap for the coordinator direct-update path +/// (wiring-audit.raw-event-tx-delivery-audit), the representative lossy site +/// OUTSIDE the proposal path: `handle_comm_propose_plan`'s +/// coordinator-direct-update branch sends the "Plan updated by ..." +/// Notification to each participant via the raw cached `member.event_tx` +/// (comm_plan.rs ~144), and `broadcast_swarm_plan` sends the SwarmPlan event +/// the same way (swarm.rs ~781). Neither routes through +/// `fanout_session_event` (state.rs), which retains live `event_txs` and +/// re-points the stale cached channel, so after a reattach rotates channels +/// both structured events are silently dropped even though a live attachment +/// exists. Only the soft-interrupt text fallback survives. +/// +/// If a production fix routes these sites through fanout delivery, the +/// "lost" assertions below start failing: flip them to assert delivery. +#[tokio::test] +async fn direct_update_notification_is_lost_on_stale_cached_event_tx() { + let (_env, _runtime) = RuntimeEnvGuard::new(); + let mut fx = plan_fixture("swarm-plan-stale-tx", "coord-st", "worker-st"); + let coord = fx.coord.clone(); + let worker = fx.worker.clone(); + + // Rig the worker like a member whose cached channel went stale while a + // live attachment exists: `event_tx` closed (receiver dropped), while + // `event_txs` holds one live connection. + let (live_tx, mut live_rx) = mpsc::unbounded_channel::<ServerEvent>(); + { + let mut members = fx.swarm_members.write().await; + let member = members.get_mut(&worker).expect("worker member"); + let (stale_tx, stale_rx) = mpsc::unbounded_channel::<ServerEvent>(); + drop(stale_rx); + member.event_tx = stale_tx; + member.event_txs.insert("conn-live".to_string(), live_tx); + } + + // Register a soft-interrupt queue for the worker so the fallback this + // site does have is observable. + let interrupt_queue: jcode_agent_runtime::SoftInterruptQueue = + Arc::new(std::sync::Mutex::new(Vec::new())); + fx.soft_interrupt_queues + .write() + .await + .insert(worker.clone(), interrupt_queue.clone()); + + // A coordinator direct update assigning an item to the worker makes the + // worker a plan participant, so the notify loop targets it. + let mut item = plan_item("a", &[]); + item.assigned_to = Some(worker.clone()); + fx.propose(&coord, vec![item]).await; + + let events = fx.drain_events(); + assert!(saw_done(&events), "direct update should ack: {events:?}"); + + // Lossy pin: neither the "Plan updated" Notification (comm_plan.rs raw + // send) nor the SwarmPlan broadcast (swarm.rs raw send) reached the live + // attachment. Both went to the closed cached channel. + let mut delivered = Vec::new(); + while let Ok(event) = live_rx.try_recv() { + delivered.push(event); + } + assert!( + delivered.is_empty(), + "raw event_tx sends currently bypass live event_txs attachments; if \ + events now arrive here, the delivery gap was fixed - update this \ + test and the wiring audit: {delivered:?}" + ); + + // The fallback that does exist for this site: the plan-update text was + // queued as a soft interrupt, so a live agent still learns of the change + // even though the structured UI events were dropped. + { + let pending = interrupt_queue.lock().expect("queue lock"); + assert_eq!(pending.len(), 1, "soft-interrupt fallback should fire"); + assert!( + pending[0].content.contains("Plan updated"), + "unexpected fallback content: {}", + pending[0].content + ); + } + + // Contrast: identical member state routed through fanout_session_event + // recovers from the stale cached channel and reaches the live attachment. + let delivered = crate::server::fanout_session_event( + &fx.swarm_members, + &worker, + ServerEvent::Done { id: 99 }, + ) + .await; + assert_eq!(delivered, 1, "fanout must rotate onto the live attachment"); + assert!( + matches!(live_rx.try_recv(), Ok(ServerEvent::Done { id: 99 })), + "fanout-delivered event should arrive on the live attachment" + ); +} diff --git a/crates/jcode-app-core/src/server/comm_session.rs b/crates/jcode-app-core/src/server/comm_session.rs new file mode 100644 index 0000000..6b8cea9 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_session.rs @@ -0,0 +1,1345 @@ +use super::ClientConnectionInfo; +use super::client_lifecycle::process_message_streaming_mpsc; +use super::swarm_mutation_state::{ + PersistedSwarmMutationResponse, SwarmMutationRuntime, begin_or_replay, finish_request, + request_key, +}; +use super::{ + SessionInterruptQueues, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan, + append_swarm_completion_report_instructions, broadcast_swarm_plan, broadcast_swarm_status, + create_headless_session, fanout_session_event, persist_swarm_state_for, record_swarm_event, + record_swarm_event_for_session, remove_background_tool_signal, + remove_session_channel_subscriptions, remove_session_from_swarm, + remove_session_interrupt_queue, set_member_task_label, truncate_detail, update_member_status, + update_member_status_with_report, +}; +use crate::agent::Agent; +use crate::config::SwarmSpawnMode; +use crate::protocol::{NotificationType, ServerEvent}; +use crate::provider::Provider; +use crate::session::Session; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; +type ClientConnections = Arc<RwLock<HashMap<String, ClientConnectionInfo>>>; + +/// 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 +/// recently seen connection when a session has more than one client attached. +async fn client_terminal_env_for_session( + session_id: &str, + client_connections: &ClientConnections, +) -> Vec<(String, String)> { + let connections = client_connections.read().await; + connections + .values() + .filter(|info| info.session_id == session_id && !info.terminal_env.is_empty()) + .max_by_key(|info| info.last_seen) + .map(|info| info.terminal_env.clone()) + .unwrap_or_default() +} + +fn create_visible_spawn_session( + working_dir: Option<&str>, + model_override: Option<&str>, + provider_key_override: Option<&str>, + route_api_method_override: Option<&str>, + effort_override: Option<&str>, + selfdev_requested: bool, +) -> anyhow::Result<(String, PathBuf)> { + let cwd = working_dir + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + + let mut session = Session::create(None, None); + session.working_dir = Some(cwd.display().to_string()); + if let Some(model) = model_override { + session.model = Some(model.to_string()); + } + if let Some(provider_key) = provider_key_override { + session.provider_key = Some(provider_key.to_string()); + } + if let Some(route_api_method) = route_api_method_override + .map(str::trim) + .filter(|route| !route.is_empty()) + { + session.route_api_method = Some(route_api_method.to_string()); + } + if let Some(effort) = effort_override.map(str::trim).filter(|e| !e.is_empty()) { + // Persisted effort is restored (and validated against the resolved + // provider/model) by `restore_reasoning_effort_from_session` when the + // headed client attaches to this session. + session.reasoning_effort = Some(effort.to_string()); + } + if selfdev_requested { + session.set_canary("self-dev"); + } + session.save()?; + + Ok((session.id.clone(), cwd)) +} + +async fn resolve_spawn_working_dir( + requested_working_dir: Option<String>, + req_session_id: &str, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<String> { + if requested_working_dir + .as_deref() + .is_some_and(|dir| !dir.trim().is_empty()) + { + return requested_working_dir; + } + + if let Some(agent_dir) = { + let agent_sessions = sessions.read().await; + agent_sessions.get(req_session_id).and_then(|agent| { + agent + .try_lock() + .ok() + .and_then(|agent_guard| agent_guard.working_dir().map(str::to_string)) + }) + } && !agent_dir.trim().is_empty() + { + return Some(agent_dir); + } + + swarm_members + .read() + .await + .get(req_session_id) + .and_then(|member| member.working_dir.as_ref()) + .map(|dir| dir.display().to_string()) + .filter(|dir| !dir.trim().is_empty()) +} + +/// Launch a headed window for `session_id`, exporting the given spawn context +/// (`JCODE_SPAWN_KIND`, swarm/coordinator ids, ...) to spawn hooks and +/// spawned terminals so external programs can reroute the window. +fn spawn_visible_session_window_with_context( + session_id: &str, + cwd: &std::path::Path, + selfdev_requested: bool, + provider_key: Option<&str>, + context: &crate::session_launch::SessionSpawnContext, +) -> anyhow::Result<bool> { + let exe = crate::build::client_update_candidate(selfdev_requested) + .map(|(path, _label)| path) + .or_else(|| std::env::current_exe().ok()) + .unwrap_or_else(|| PathBuf::from("jcode")); + if selfdev_requested { + crate::session_launch::spawn_selfdev_in_new_terminal_with_context( + &exe, + session_id, + cwd, + provider_key, + context, + ) + } else { + crate::session_launch::spawn_resume_in_new_terminal_with_context( + &exe, + session_id, + cwd, + provider_key, + context, + ) + } +} + +fn provider_key_for_spawn_model( + model: Option<&str>, + provider_key_override: Option<&str>, +) -> Option<String> { + if let Some(provider_key) = provider_key_override + .map(str::trim) + .filter(|provider_key| !provider_key.is_empty()) + { + return Some(provider_key.to_string()); + } + + let model = model?.trim(); + if model.is_empty() { + return None; + } + + if let Some((prefix, _rest)) = model.split_once(':') { + let prefix = prefix.trim(); + if crate::provider::provider_from_model_key(prefix).is_some() + || crate::provider_catalog::resolve_openai_compatible_profile_selection(prefix) + .is_some() + || crate::config::config().providers.contains_key(prefix) + { + return Some(prefix.to_string()); + } + } + + crate::provider::provider_for_model(model).map(str::to_string) +} + +/// The model/auth identity a spawned swarm agent should inherit from its +/// coordinator. Resolved with a persisted-session fallback so it stays correct +/// even when the coordinator agent is mid-turn (its mutex held), which is the +/// common case because spawns are issued from inside the coordinator's turn. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct CoordinatorSpawnIdentity { + pub model: Option<String>, + pub provider_key: Option<String>, + pub route_api_method: Option<String>, + pub is_canary: bool, +} + +/// The resolved model + auth route a spawned swarm agent should be created +/// with, after reconciling `agents.swarm_model` config against the +/// coordinator's identity. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct SwarmSpawnSelection { + pub model: Option<String>, + pub provider_key: Option<String>, + pub route_api_method: Option<String>, +} + +/// Resolve the coordinator's model/auth identity without blocking on its agent +/// mutex. During an active coordinator turn the agent lock is held for the +/// whole turn, so `try_lock` fails exactly when a spawn is issued. We fall back +/// to the persisted session snapshot so spawned agents still inherit the +/// coordinator's model, provider key, and auth route instead of silently +/// dropping to the config default (e.g. Claude OAuth instead of the API route). +async fn resolve_coordinator_spawn_identity( + req_session_id: &str, + sessions: &SessionAgents, +) -> CoordinatorSpawnIdentity { + if let Some(agent) = { + let agent_sessions = sessions.read().await; + agent_sessions.get(req_session_id).cloned() + } && let Ok(agent_guard) = agent.try_lock() + { + return CoordinatorSpawnIdentity { + model: Some(agent_guard.provider_model()), + provider_key: agent_guard.session_provider_key(), + route_api_method: agent_guard.session_route_api_method(), + is_canary: agent_guard.is_canary(), + }; + } + + // Agent busy (mid-turn) or not resident: read the authoritative persisted + // session snapshot instead of falling back to config defaults. + match Session::load_startup_stub(req_session_id) { + Ok(session) => { + let identity = CoordinatorSpawnIdentity { + model: session.model.clone(), + provider_key: session.provider_key.clone(), + route_api_method: session.route_api_method.clone(), + is_canary: session.is_canary, + }; + crate::logging::info(&format!( + "Swarm spawn: coordinator {} agent busy/unavailable, inheriting identity from persisted session (model={:?} provider_key={:?} route={:?} canary={})", + req_session_id, + identity.model, + identity.provider_key, + identity.route_api_method, + identity.is_canary, + )); + identity + } + Err(error) => { + crate::logging::warn(&format!( + "Swarm spawn: failed to load persisted coordinator session {} for model inheritance: {} (spawned agent will use server defaults)", + req_session_id, error + )); + CoordinatorSpawnIdentity::default() + } + } +} + +/// Split a configured swarm model that carries an explicit auth-route prefix +/// (`openai-api:`, `openai-oauth:`, `claude-api:`, `claude-oauth:`) into a +/// structured selection so spawned sessions pin the exact provider + auth +/// method instead of guessing from the bare model name. +/// +/// Example: `agents.swarm_model = "openai-api:gpt-5.5"` resolves to +/// `model = gpt-5.5`, `provider_key = openai-api-key`, +/// `route_api_method = openai-api-key`, which makes every spawned agent use +/// GPT-5.5 on the OpenAI API key route regardless of the coordinator's model. +/// +/// Returns `None` for models without such a prefix, or for prefixes that carry +/// no API-vs-OAuth decision (bare provider aliases, OpenRouter, Copilot, ...). +/// Those keep their prefixed model and route correctly via the existing +/// session-restore path. +fn explicit_route_for_configured_model(model: &str) -> Option<SwarmSpawnSelection> { + let (_, prefix, bare) = crate::provider::explicit_model_provider_prefix(model)?; + let bare = bare.trim(); + if bare.is_empty() { + return None; + } + // Only the dual-auth (Anthropic/OpenAI OAuth-vs-API) prefixes carry an + // explicit credential decision worth pinning. The canonical parser maps the + // prefix to its stable route id, which `ModelRouteApiMethod::parse` round- + // trips back to the exact auth method when the spawned session is restored. + let route_id = jcode_provider_core::AuthRoute::parse_explicit_credential_prefix(prefix)? + .route_api_method(); + Some(SwarmSpawnSelection { + model: Some(bare.to_string()), + provider_key: Some(route_id.to_string()), + route_api_method: Some(route_id.to_string()), + }) +} + +/// True when a model string is one of the "inherit the coordinator" sentinels. +fn is_inherit_sentinel(model: &str) -> bool { + let trimmed = model.trim(); + trimmed.eq_ignore_ascii_case("inherit") || trimmed.eq_ignore_ascii_case("coordinator") +} + +/// Selection that inherits the coordinator's model, provider key, and route. +fn inherit_coordinator_selection(coordinator: &CoordinatorSpawnIdentity) -> SwarmSpawnSelection { + SwarmSpawnSelection { + model: coordinator.model.clone(), + provider_key: coordinator + .provider_key + .clone() + .or_else(|| provider_key_for_spawn_model(coordinator.model.as_deref(), None)), + route_api_method: coordinator.route_api_method.clone(), + } +} + +/// Selection for a concrete model string (optionally route-prefixed like +/// `openai-api:gpt-5.5`), reconciled against the coordinator's identity. +fn selection_for_concrete_model( + model: String, + coordinator: &CoordinatorSpawnIdentity, +) -> SwarmSpawnSelection { + // A model may pin an explicit provider + auth route via a prefix + // (e.g. "openai-api:gpt-5.5"). Honor it directly so spawned agents do + // NOT inherit the coordinator's model/auth and instead use the + // requested model on the requested API route. + if let Some(selection) = explicit_route_for_configured_model(&model) { + return selection; + } + + // A concrete model only inherits the coordinator's provider_key/route + // when it targets the same model; otherwise the route would point at + // the wrong provider/auth mode. + if coordinator.model.as_deref() == Some(model.as_str()) { + SwarmSpawnSelection { + model: Some(model.clone()), + provider_key: coordinator + .provider_key + .clone() + .or_else(|| provider_key_for_spawn_model(Some(&model), None)), + route_api_method: coordinator.route_api_method.clone(), + } + } else { + SwarmSpawnSelection { + provider_key: provider_key_for_spawn_model(Some(&model), None), + model: Some(model), + route_api_method: None, + } + } +} + +fn resolve_swarm_spawn_selection( + requested_model: Option<String>, + configured_swarm_model: Option<String>, + 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 + .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); + } + + // 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 + .filter(|model| !model.trim().is_empty() && !is_inherit_sentinel(model)); + + match configured_swarm_model { + Some(model) => selection_for_concrete_model(model, coordinator), + None => inherit_coordinator_selection(coordinator), + } +} + +fn persist_headed_startup_message(session_id: &str, message: &str) { + crate::logging::info(&format!( + "Headed spawn: persisting startup submission for {session_id} (chars={}) to client-input handoff file", + message.chars().count(), + )); + crate::client_input::save_startup_submission_for_session( + session_id, + message.to_string(), + Vec::new(), + ); +} + +fn clear_headed_startup_message(session_id: &str) { + if let Ok(jcode_dir) = crate::storage::jcode_dir() { + let path = jcode_dir.join(format!("client-input-{}", session_id)); + let _ = std::fs::remove_file(path); + } +} + +fn cleanup_prepared_visible_spawn_session(session_id: &str) { + clear_headed_startup_message(session_id); + if let Ok(path) = crate::session::session_path(session_id) { + let _ = std::fs::remove_file(path); + } + if let Ok(path) = crate::session::session_journal_path(session_id) { + let _ = std::fs::remove_file(path); + } +} + +#[allow(clippy::too_many_arguments)] +fn prepare_visible_spawn_session<F>( + working_dir: Option<&str>, + model_override: Option<&str>, + provider_key_override: Option<&str>, + route_api_method_override: Option<&str>, + effort_override: Option<&str>, + selfdev_requested: bool, + startup_message: Option<&str>, + launch_visible: F, +) -> anyhow::Result<(String, bool)> +where + F: FnOnce(&str, &std::path::Path, bool, Option<&str>) -> anyhow::Result<bool>, +{ + let provider_key = provider_key_for_spawn_model(model_override, provider_key_override); + let (new_session_id, cwd) = create_visible_spawn_session( + working_dir, + model_override, + provider_key.as_deref(), + route_api_method_override, + effort_override, + selfdev_requested, + )?; + + if let Some(message) = startup_message { + persist_headed_startup_message(&new_session_id, message); + } + + match launch_visible( + &new_session_id, + &cwd, + selfdev_requested, + provider_key.as_deref(), + ) { + Ok(launched) => { + if !launched { + cleanup_prepared_visible_spawn_session(&new_session_id); + } + Ok((new_session_id, launched)) + } + Err(error) => { + cleanup_prepared_visible_spawn_session(&new_session_id); + Err(error) + } + } +} + +#[expect( + clippy::too_many_arguments, + reason = "visible spawn registration updates swarm state, event history, and UI delivery metadata together" +)] +async fn register_visible_spawned_member( + session_id: &str, + swarm_id: &str, + working_dir: Option<&str>, + has_startup_message: bool, + report_back_to_session_id: Option<&str>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let now = Instant::now(); + let friendly_name = crate::id::extract_session_name(session_id) + .map(|name| name.to_string()) + .unwrap_or_else(|| session_id.to_string()); + let (status, detail) = if has_startup_message { + ("running".to_string(), Some("startup queued".to_string())) + } else { + ("spawned".to_string(), Some("launching client".to_string())) + }; + + { + let mut members = swarm_members.write().await; + members.insert( + session_id.to_string(), + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: working_dir.map(PathBuf::from), + swarm_id: Some(swarm_id.to_string()), + swarm_enabled: true, + status, + detail, + task_label: None, + friendly_name: Some(friendly_name), + report_back_to_session_id: report_back_to_session_id.map(str::to_string), + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + ); + } + + { + let mut swarms = swarms_by_id.write().await; + swarms + .entry(swarm_id.to_string()) + .or_insert_with(HashSet::new) + .insert(session_id.to_string()); + } + + record_swarm_event_for_session( + session_id, + SwarmEventType::MemberChange { + action: "joined".to_string(), + }, + swarm_members, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + broadcast_swarm_status(swarm_id, swarm_members, swarms_by_id).await; +} + +#[expect( + clippy::too_many_arguments, + reason = "server-side swarm spawning needs session, swarm state, provider, and event sinks together" +)] +pub(super) async fn spawn_swarm_agent( + req_session_id: &str, + swarm_id: &str, + working_dir: Option<String>, + initial_message: Option<String>, + spawn_mode: Option<SwarmSpawnMode>, + requested_model: Option<String>, + requested_effort: Option<String>, + label: Option<String>, + sessions: &SessionAgents, + global_session_id: &Arc<RwLock<String>>, + provider_template: &Arc<dyn Provider>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + mcp_pool: &Arc<crate::mcp::SharedMcpPool>, + soft_interrupt_queues: &SessionInterruptQueues, + client_connections: &ClientConnections, +) -> anyhow::Result<String> { + let resolved_working_dir = + resolve_spawn_working_dir(working_dir, req_session_id, sessions, swarm_members).await; + let coordinator = resolve_coordinator_spawn_identity(req_session_id, sessions).await; + let coordinator_is_canary = coordinator.is_canary; + // Capture the requesting client's terminal env so spawn hooks place the new + // window in the terminal the user is attached to, not the server's stale + // startup env (#405). + let client_terminal_env = + client_terminal_env_for_session(req_session_id, client_connections).await; + let agents_config = &crate::config::config().agents; + let configured_swarm_model = agents_config.swarm_model.clone(); + let resolved_spawn_mode = spawn_mode.unwrap_or(agents_config.swarm_spawn_mode); + let selection = resolve_swarm_spawn_selection( + requested_model.clone(), + configured_swarm_model.clone(), + &coordinator, + ); + 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); + 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={:?}", + requested_model, + spawn_effort, + configured_swarm_model, + coordinator.model, + coordinator.provider_key, + coordinator.route_api_method, + spawn_model, + spawn_provider_key, + spawn_route_api_method, + )); + + let startup_message = initial_message + .as_deref() + .map(append_swarm_completion_report_instructions); + + let visible_spawn = match resolved_spawn_mode { + // Inline workers run in-process like headless ones; the difference is + // purely how the coordinator renders them (a live inline gallery). + SwarmSpawnMode::Headless | SwarmSpawnMode::Inline => { + Err(anyhow::anyhow!("headless spawn requested")) + } + SwarmSpawnMode::Visible | SwarmSpawnMode::Auto => prepare_visible_spawn_session( + resolved_working_dir.as_deref(), + spawn_model.as_deref(), + spawn_provider_key.as_deref(), + spawn_route_api_method.as_deref(), + spawn_effort.as_deref(), + coordinator_is_canary, + startup_message.as_deref(), + |session_id, cwd, selfdev_requested, provider_key| { + // Tag the headed window as a swarm-agent spawn so spawn hooks + // and terminals can identify and reroute it (JCODE_SPAWN_*). + let context = crate::session_launch::SessionSpawnContext::kind("swarm-agent") + .env("JCODE_SPAWN_SWARM_ID", swarm_id) + .env("JCODE_SPAWN_COORDINATOR_SESSION_ID", req_session_id) + .with_client_terminal_env(client_terminal_env.clone()); + spawn_visible_session_window_with_context( + session_id, + cwd, + selfdev_requested, + provider_key, + &context, + ) + }, + ), + }; + + let (new_session_id, is_headless_fallback) = match visible_spawn { + Ok((new_session_id, true)) => Ok((new_session_id, false)), + Ok((_, false)) | Err(_) => { + let cmd = if let Some(ref dir) = resolved_working_dir { + format!("create_session:{dir}") + } else { + "create_session".to_string() + }; + create_headless_session( + sessions, + global_session_id, + provider_template, + &cmd, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + soft_interrupt_queues, + coordinator_is_canary, + spawn_model.clone(), + spawn_provider_key.clone(), + spawn_route_api_method.clone(), + spawn_effort.clone(), + Some(Arc::clone(mcp_pool)), + Some(req_session_id.to_string()), + ) + .await + .and_then(|result_json| { + serde_json::from_str::<serde_json::Value>(&result_json) + .ok() + .and_then(|value| { + value + .get("session_id") + .and_then(|session_id| session_id.as_str()) + .map(|session_id| session_id.to_string()) + }) + .map(|session_id| (session_id, true)) + .ok_or_else(|| anyhow::anyhow!("Failed to parse spawned session id")) + }) + } + }?; + + let startup_message = startup_message.clone(); + { + let mut plans = swarm_plans.write().await; + if let Some(plan) = plans.get_mut(swarm_id) + && (!plan.items.is_empty() || !plan.participants.is_empty()) + { + plan.participants.insert(req_session_id.to_string()); + plan.participants.insert(new_session_id.clone()); + } + } + + broadcast_swarm_plan( + swarm_id, + Some("participant_spawned".to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + if !is_headless_fallback { + register_visible_spawned_member( + &new_session_id, + swarm_id, + resolved_working_dir.as_deref(), + startup_message.is_some(), + Some(req_session_id), + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + } + // Label the worker with what it was spawned for so the swarm strip and + // member lists can show the task, not just the animal name. An explicit + // spawn `label` wins; otherwise the label is derived from the raw prompt + // (before completion-report boilerplate is appended). + if let Some(label_text) = label.as_deref().or(initial_message.as_deref()) { + set_member_task_label(&new_session_id, label_text, swarm_members).await; + } + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + + if let Some(initial_msg) = startup_message + && is_headless_fallback + { + record_swarm_event_for_session( + &new_session_id, + SwarmEventType::MemberChange { + action: "joined".to_string(), + }, + swarm_members, + event_history, + event_counter, + swarm_event_tx, + ) + .await; + + let agent_arc = { + let agent_sessions = sessions.read().await; + agent_sessions.get(&new_session_id).cloned() + }; + if let Some(agent_arc) = agent_arc { + let sid_clone = new_session_id.clone(); + let swarm_members2 = Arc::clone(swarm_members); + let swarms_by_id2 = Arc::clone(swarms_by_id); + let event_history2 = Arc::clone(event_history); + let event_counter2 = Arc::clone(event_counter); + let swarm_event_tx2 = swarm_event_tx.clone(); + tokio::spawn(async move { + update_member_status( + &sid_clone, + "running", + Some(truncate_detail(&initial_msg, 120)), + &swarm_members2, + &swarms_by_id2, + Some(&event_history2), + Some(&event_counter2), + Some(&swarm_event_tx2), + ) + .await; + let event_tx = super::session_event_fanout_sender( + sid_clone.clone(), + Arc::clone(&swarm_members2), + ); + let start_message_index = { + let agent = agent_arc.lock().await; + agent.message_count() + }; + let result = process_message_streaming_mpsc( + Arc::clone(&agent_arc), + &initial_msg, + vec![], + None, + event_tx, + ) + .await; + let completion_report = if result.is_ok() { + let agent = agent_arc.lock().await; + agent.latest_assistant_text_after(start_message_index) + } else { + None + }; + let (new_status, new_detail) = match result { + Ok(()) => ("ready", None), + Err(ref error) => ("failed", Some(truncate_detail(&error.to_string(), 120))), + }; + update_member_status_with_report( + &sid_clone, + new_status, + new_detail, + completion_report, + &swarm_members2, + &swarms_by_id2, + Some(&event_history2), + Some(&event_counter2), + Some(&swarm_event_tx2), + ) + .await; + }); + } + } + + Ok(new_session_id) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_comm_spawn( + id: u64, + req_session_id: String, + working_dir: Option<String>, + initial_message: Option<String>, + request_nonce: Option<String>, + spawn_mode: Option<SwarmSpawnMode>, + model: Option<String>, + effort: Option<String>, + label: Option<String>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + global_session_id: &Arc<RwLock<String>>, + provider_template: &Arc<dyn Provider>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + _channel_subscriptions: &ChannelSubscriptions, + _channel_subscriptions_by_session: &ChannelSubscriptions, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + mcp_pool: &Arc<crate::mcp::SharedMcpPool>, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_mutation_runtime: &SwarmMutationRuntime, + client_connections: &ClientConnections, +) { + let swarm_id = match ensure_spawn_coordinator_swarm( + id, + &req_session_id, + client_event_tx, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + ) + .await + { + Some(swarm_id) => swarm_id, + None => return, + }; + + let mutation_key = request_key( + &req_session_id, + "spawn", + &[ + swarm_id.clone(), + working_dir.clone().unwrap_or_default(), + initial_message.clone().unwrap_or_default(), + request_nonce.clone().unwrap_or_default(), + spawn_mode + .map(|mode| format!("{mode:?}")) + .unwrap_or_default(), + model.clone().unwrap_or_default(), + effort.clone().unwrap_or_default(), + label.clone().unwrap_or_default(), + ], + ); + let Some(mutation_state) = begin_or_replay( + swarm_mutation_runtime, + &mutation_key, + "spawn", + &req_session_id, + id, + client_event_tx, + ) + .await + else { + return; + }; + + let response = match spawn_swarm_agent( + &req_session_id, + &swarm_id, + working_dir, + initial_message, + spawn_mode, + model, + effort, + label, + sessions, + global_session_id, + provider_template, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + event_history, + event_counter, + swarm_event_tx, + mcp_pool, + soft_interrupt_queues, + client_connections, + ) + .await + { + Ok(new_session_id) => PersistedSwarmMutationResponse::Spawn { new_session_id }, + Err(error) => PersistedSwarmMutationResponse::Error { + message: format!("Failed to spawn agent: {error}"), + retry_after_secs: None, + }, + }; + + finish_request(swarm_mutation_runtime, &mutation_state, response).await; +} + +/// Handle `comm_list_models`: report the model routes available for spawning +/// swarm agents, plus the requester's current model (the spawn default) and +/// any `agents.swarm_model` config pin. Read-only, so it needs no coordinator +/// check or mutation dedup. Uses the requester's live agent catalog when its +/// lock is free, otherwise falls back to the provider template's catalog. +pub(super) async fn handle_comm_list_models( + id: u64, + req_session_id: &str, + sessions: &SessionAgents, + provider_template: &Arc<dyn Provider>, + send_event: impl FnOnce(ServerEvent), +) { + let coordinator = resolve_coordinator_spawn_identity(req_session_id, sessions).await; + + let agent = { + let agent_sessions = sessions.read().await; + agent_sessions.get(req_session_id).cloned() + }; + let model_routes = match agent.as_ref().and_then(|agent| agent.try_lock().ok()) { + Some(agent_guard) => agent_guard.model_routes(), + // Agent busy (mid-turn, the common case for tool calls) or not + // resident: the provider template exposes the same route catalog. + None => provider_template.model_routes(), + }; + + send_event(ServerEvent::CommListModelsResponse { + id, + current_model: coordinator.model, + configured_swarm_model: crate::config::config().agents.swarm_model.clone(), + model_routes, + }); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_comm_stop( + id: u64, + req_session_id: String, + target_session: String, + force: bool, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_mutation_runtime: &SwarmMutationRuntime, +) { + // Stopping is authorized per-target by ownership (the requester is the + // target's spawner or a transitive ancestor) rather than by the swarm-level + // coordinator slot, so that any parent can stop agents in its own subtree. + // We only require the requester to be a member of a swarm here; the concrete + // permission check happens below via `stop_allowed`. + let swarm_id = { + let members = swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()) + }; + let Some(swarm_id) = swarm_id else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + return; + }; + + let target_session = + match resolve_stop_target_session(&swarm_id, &target_session, swarm_members).await { + Ok(target_session) => target_session, + Err(message) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message, + retry_after_secs: None, + }); + return; + } + }; + + let stop_allowed = { + let members = swarm_members.read().await; + members + .get(&target_session) + .map(|member| { + swarm_stop_allowed_by_owner(&req_session_id, member, force) + || (!force + && super::swarm_is_self_or_ancestor( + &members, + &req_session_id, + &target_session, + )) + }) + .unwrap_or(false) + }; + if !stop_allowed { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Refusing to stop session '{target_session}' because it was not spawned by this coordinator. Pass force=true to stop a non-owned/user-created swarm session explicitly." + ), + retry_after_secs: None, + }); + return; + } + + let _ = fanout_session_event( + swarm_members, + &target_session, + ServerEvent::SessionCloseRequested { + reason: format!("Stopped by coordinator {req_session_id}"), + }, + ) + .await; + + let mutation_key = request_key(&req_session_id, "stop", &[swarm_id, target_session.clone()]); + let Some(mutation_state) = begin_or_replay( + swarm_mutation_runtime, + &mutation_key, + "stop", + &req_session_id, + id, + client_event_tx, + ) + .await + else { + return; + }; + + let mut sessions_guard = sessions.write().await; + let removed_agent = sessions_guard.remove(&target_session); + 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() { + let memory_enabled = agent.memory_enabled(); + let transcript = if memory_enabled { + Some(agent.build_transcript_for_extraction()) + } else { + None + }; + let sid = target_session.clone(); + let working_dir = agent.working_dir().map(|dir| dir.to_string()); + drop(agent); + if let Some(transcript) = transcript { + crate::memory_agent::trigger_final_extraction_with_dir( + transcript, + sid, + working_dir, + ); + } + } + } + + let (removed_swarm_id, removed_name) = { + let mut members = swarm_members.write().await; + if let Some(member) = members.remove(&target_session) { + (member.swarm_id, member.friendly_name) + } else { + (None, None) + } + }; + if let Some(ref swarm_id) = removed_swarm_id { + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + target_session.clone(), + removed_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::MemberChange { + action: "left".to_string(), + }, + ) + .await; + remove_session_from_swarm( + &target_session, + swarm_id, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + ) + .await; + } + remove_session_channel_subscriptions( + &target_session, + channel_subscriptions, + channel_subscriptions_by_session, + ) + .await; + + let response = if removed_live_agent || removed_swarm_id.is_some() { + PersistedSwarmMutationResponse::Done + } else { + PersistedSwarmMutationResponse::Error { + message: format!("Unknown session '{target_session}'"), + retry_after_secs: None, + } + }; + finish_request(swarm_mutation_runtime, &mutation_state, response).await; +} + +fn swarm_stop_allowed_by_owner( + req_session_id: &str, + target_member: &SwarmMember, + force: bool, +) -> bool { + force || target_member.report_back_to_session_id.as_deref() == Some(req_session_id) +} + +async fn resolve_stop_target_session( + swarm_id: &str, + target: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> std::result::Result<String, String> { + let target = target.trim(); + if target.is_empty() { + return Err("target_session is required.".to_string()); + } + + let members = swarm_members.read().await; + if members + .get(target) + .is_some_and(|member| member.swarm_id.as_deref() == Some(swarm_id)) + { + return Ok(target.to_string()); + } + + let mut matches = members + .iter() + .filter(|(_, member)| member.swarm_id.as_deref() == Some(swarm_id)) + .filter(|(session_id, member)| { + member.friendly_name.as_deref() == Some(target) + || session_id.starts_with(target) + || session_id.ends_with(target) + }) + .map(|(session_id, member)| { + ( + session_id.clone(), + member + .friendly_name + .as_deref() + .unwrap_or(session_id) + .to_string(), + ) + }) + .collect::<Vec<_>>(); + matches.sort_by(|a, b| a.0.cmp(&b.0)); + + match matches.len() { + 0 => Err(format!( + "Unknown swarm session '{target}'. Use an exact session ID, unique friendly name, or unique session ID prefix/suffix." + )), + 1 => Ok(matches.remove(0).0), + _ => Err(format!( + "Ambiguous swarm session '{target}' matched: {}. Use an exact session ID.", + matches + .iter() + .map(|(session_id, friendly)| format!("{friendly} [{session_id}]")) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +fn swarm_member_status_is_stale_for_coordination(status: &str) -> bool { + matches!( + status, + "crashed" | "failed" | "stopped" | "closed" | "disconnected" + ) +} + +async fn ensure_spawn_coordinator_swarm( + id: u64, + req_session_id: &str, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) -> Option<String> { + let (swarm_id, from_name, is_root, coordinator_id, coordinator_is_stale, swarm_size) = { + let members = swarm_members.read().await; + let swarm_id = members + .get(req_session_id) + .and_then(|member| member.swarm_id.clone()); + let from_name = members + .get(req_session_id) + .and_then(|member| member.friendly_name.clone()); + // A session is a "root" when it has no spawner/owner above it. + let is_root = members + .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 + .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() + }) + .unwrap_or(0); + let coordinator_id = if let Some(ref swarm_id) = swarm_id { + let coordinators = swarm_coordinators.read().await; + coordinators.get(swarm_id).cloned() + } else { + None + }; + let coordinator_is_stale = coordinator_id.as_ref().is_some_and(|coordinator| { + !members.get(coordinator).is_some_and(|member| { + // A coordinator is stale for slot-reclaim purposes when it left + // the swarm, reached a terminal status, or can no longer be + // reached at all (every event channel closed). The last case + // catches a wedged coordinator whose client died without a + // clean status transition; without it the slot stays blocked + // until the status sweep happens to notice. + let unreachable = member.event_tx.is_closed() + && member.event_txs.values().all(|tx| tx.is_closed()); + member.swarm_id.as_deref() == swarm_id.as_deref() + && !swarm_member_status_is_stale_for_coordination(&member.status) + && !unreachable + }) + }); + ( + swarm_id, + from_name, + is_root, + coordinator_id, + coordinator_is_stale, + swarm_size, + ) + }; + + let Some(swarm_id) = swarm_id else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + 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 { + 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.", + super::MAX_SWARM_MEMBERS + ), + 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. + if is_root && coordinator_id.as_deref() != Some(req_session_id) { + let should_claim = coordinator_id.is_none() || coordinator_is_stale; + if should_claim { + let promoted = { + let mut coordinators = swarm_coordinators.write().await; + match coordinators.get(&swarm_id) { + Some(existing) if existing == req_session_id => false, + Some(_) if !coordinator_is_stale => false, + _ => { + coordinators.insert(swarm_id.clone(), req_session_id.to_string()); + true + } + } + }; + + if promoted { + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(req_session_id) { + member.role = "coordinator".to_string(); + } + } + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await; + let _ = client_event_tx.send(ServerEvent::Notification { + from_session: req_session_id.to_string(), + from_name, + notification_type: NotificationType::Message { + scope: Some("swarm".to_string()), + channel: None, + tldr: None, + }, + message: "You are the coordinator for this swarm.".to_string(), + }); + } + } + } + + Some(swarm_id) +} + +#[cfg(test)] +#[path = "comm_session_tests.rs"] +mod comm_session_tests; diff --git a/crates/jcode-app-core/src/server/comm_session_tests.rs b/crates/jcode-app-core/src/server/comm_session_tests.rs new file mode 100644 index 0000000..a87c52d --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_session_tests.rs @@ -0,0 +1,1006 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +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, +}; +use crate::agent::Agent; +use crate::message::{Message, ToolDefinition}; +use crate::protocol::{NotificationType, ServerEvent}; +use crate::provider::{EventStream, Provider}; +use crate::server::{SwarmEventType, SwarmMember, VersionedPlan}; +use crate::tool::Registry; +use anyhow::Result; +use async_trait::async_trait; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +struct MockProvider; + +#[async_trait] +impl Provider for MockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!("mock provider should not be called")) + } + + fn name(&self) -> &str { + "mock" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(MockProvider) + } +} + +fn member( + session_id: &str, + swarm_id: Option<&str>, + role: &str, +) -> (SwarmMember, mpsc::UnboundedReceiver<ServerEvent>) { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + ( + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: swarm_id.map(|id| id.to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some(session_id.to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: role.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, + }, + event_rx, + ) +} + +async fn test_agent_with_working_dir(session_id: &str, working_dir: &str) -> Arc<Mutex<Agent>> { + let provider: Arc<dyn Provider> = Arc::new(MockProvider); + let registry = Registry::new(provider.clone()).await; + let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None); + session.model = Some("mock".to_string()); + session.working_dir = Some(working_dir.to_string()); + let mut agent = Agent::new_with_session(provider, registry, session, None); + agent.set_working_dir(working_dir); + Arc::new(Mutex::new(agent)) +} + +#[tokio::test] +async fn resolve_spawn_working_dir_prefers_explicit_then_spawner_agent_dir() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + sessions.write().await.insert( + "req".to_string(), + test_agent_with_working_dir("req", "/tmp/spawner-agent").await, + ); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + + assert_eq!( + resolve_spawn_working_dir( + Some("/tmp/explicit".to_string()), + "req", + &sessions, + &swarm_members, + ) + .await + .as_deref(), + Some("/tmp/explicit") + ); + assert_eq!( + resolve_spawn_working_dir(None, "req", &sessions, &swarm_members) + .await + .as_deref(), + Some("/tmp/spawner-agent") + ); +} + +#[tokio::test] +async fn resolve_spawn_working_dir_falls_back_to_member_dir() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let (mut req_member, _rx) = member("req", Some("swarm-1"), "coordinator"); + req_member.working_dir = Some(std::path::PathBuf::from("/tmp/member-dir")); + swarm_members + .write() + .await + .insert("req".to_string(), req_member); + + assert_eq!( + resolve_spawn_working_dir(None, "req", &sessions, &swarm_members) + .await + .as_deref(), + Some("/tmp/member-dir") + ); +} + +#[test] +fn stop_permission_defaults_to_sessions_spawned_by_requesting_coordinator() { + let (mut owned, _owned_rx) = member("worker-owned", Some("swarm-1"), "agent"); + owned.report_back_to_session_id = Some("coord".to_string()); + let (mut user_created, _user_rx) = member("worker-user", Some("swarm-1"), "agent"); + user_created.report_back_to_session_id = None; + let (mut other_owned, _other_rx) = member("worker-other", Some("swarm-1"), "agent"); + other_owned.report_back_to_session_id = Some("other-coord".to_string()); + + assert!(swarm_stop_allowed_by_owner("coord", &owned, false)); + assert!(!swarm_stop_allowed_by_owner("coord", &user_created, false)); + assert!(!swarm_stop_allowed_by_owner("coord", &other_owned, false)); + assert!(swarm_stop_allowed_by_owner("coord", &user_created, true)); +} + +#[tokio::test] +async fn stop_target_resolves_unique_friendly_name_and_suffix() { + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let (mut worker, _worker_rx) = member("session_jellyfish_1234_abcd", Some("swarm-1"), "agent"); + worker.friendly_name = Some("jellyfish".to_string()); + swarm_members + .write() + .await + .insert(worker.session_id.clone(), worker); + + assert_eq!( + resolve_stop_target_session("swarm-1", "jellyfish", &swarm_members) + .await + .as_deref(), + Ok("session_jellyfish_1234_abcd") + ); + assert_eq!( + resolve_stop_target_session("swarm-1", "abcd", &swarm_members) + .await + .as_deref(), + Ok("session_jellyfish_1234_abcd") + ); +} + +#[tokio::test] +async fn stop_target_rejects_ambiguous_friendly_name() { + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let (mut first, _first_rx) = member("session_bear_1", Some("swarm-1"), "agent"); + first.friendly_name = Some("bear".to_string()); + let (mut second, _second_rx) = member("session_bear_2", Some("swarm-1"), "agent"); + second.friendly_name = Some("bear".to_string()); + let mut members = swarm_members.write().await; + members.insert(first.session_id.clone(), first); + members.insert(second.session_id.clone(), second); + drop(members); + + let err = resolve_stop_target_session("swarm-1", "bear", &swarm_members) + .await + .expect_err("ambiguous friendly names should be rejected"); + assert!(err.contains("Ambiguous swarm session 'bear'")); +} + +#[tokio::test] +async fn register_visible_spawned_member_marks_startup_as_running() { + 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(VecDeque::new())); + let event_counter = Arc::new(AtomicU64::new(0)); + let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(8); + + register_visible_spawned_member( + "child-1", + "swarm-1", + Some("/tmp/worktree"), + true, + Some("owner"), + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ) + .await; + + let members = swarm_members.read().await; + let member = members.get("child-1").expect("spawned member should exist"); + assert_eq!(member.status, "running"); + assert_eq!(member.detail.as_deref(), Some("startup queued")); + assert_eq!(member.swarm_id.as_deref(), Some("swarm-1")); + assert_eq!( + member.working_dir.as_deref(), + Some(std::path::Path::new("/tmp/worktree")) + ); + drop(members); + + assert!( + swarms_by_id + .read() + .await + .get("swarm-1") + .is_some_and(|members| members.contains("child-1")) + ); + + let history = event_history.read().await; + assert!(history.iter().any(|event| { + event.session_id == "child-1" + && matches!(event.event, SwarmEventType::MemberChange { ref action } if action == "joined") + })); +} + +#[test] +fn prepare_visible_spawn_session_persists_startup_before_launch() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let worktree = tempfile::TempDir::new().expect("temp worktree"); + let startup = "Please start by auditing prompt delivery."; + + let (session_id, launched) = prepare_visible_spawn_session( + Some(worktree.path().to_str().expect("utf8 worktree path")), + None, + None, + None, + None, + false, + Some(startup), + |session_id, _cwd: &std::path::Path, _selfdev, provider_key| { + assert_eq!(provider_key, None); + let path = crate::storage::jcode_dir() + .expect("jcode dir") + .join(format!("client-input-{}", session_id)); + let data = std::fs::read_to_string(&path).expect("startup file should exist"); + assert!( + data.contains(startup), + "startup payload should be written before launch" + ); + assert!( + data.contains(r#""submit_on_restore":true"#), + "startup payload should auto-submit on restore" + ); + Ok(true) + }, + ) + .expect("visible spawn preparation should succeed"); + + assert!(launched); + let path = crate::storage::jcode_dir() + .expect("jcode dir") + .join(format!("client-input-{}", session_id)); + assert!( + path.exists(), + "startup file should remain for launched visible session" + ); + + crate::env::remove_var("JCODE_HOME"); +} + +#[test] +fn prepare_visible_spawn_session_cleans_startup_when_launch_not_started() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let worktree = tempfile::TempDir::new().expect("temp worktree"); + + let (session_id, launched) = prepare_visible_spawn_session( + Some(worktree.path().to_str().expect("utf8 worktree path")), + None, + None, + None, + None, + false, + Some("Do the thing."), + |_session_id, _cwd: &std::path::Path, _selfdev, _provider_key| Ok(false), + ) + .expect("visible spawn preparation should succeed even when launch is skipped"); + + assert!(!launched); + let path = crate::storage::jcode_dir() + .expect("jcode dir") + .join(format!("client-input-{}", session_id)); + assert!( + !path.exists(), + "startup file should be removed when visible launch does not start" + ); + assert!( + !crate::session::session_exists(&session_id), + "prepared session should be cleaned up when visible launch does not start" + ); + + crate::env::remove_var("JCODE_HOME"); +} + +#[test] +fn prepare_visible_spawn_session_cleans_session_when_launch_errors() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let worktree = tempfile::TempDir::new().expect("temp worktree"); + + let error = prepare_visible_spawn_session( + Some(worktree.path().to_str().expect("utf8 worktree path")), + None, + None, + None, + None, + false, + Some("Do the thing."), + |_session_id, _cwd: &std::path::Path, _selfdev, _provider_key| { + Err(anyhow::anyhow!("launch failed")) + }, + ) + .expect_err("visible spawn preparation should surface launch error"); + + assert!(error.to_string().contains("launch failed")); + let sessions_dir = crate::storage::jcode_dir() + .expect("jcode dir") + .join("sessions"); + let remaining_sessions = std::fs::read_dir(&sessions_dir) + .map(|entries| entries.count()) + .unwrap_or(0); + assert_eq!( + remaining_sessions, 0, + "failed visible launch should not leave orphan prepared sessions" + ); + + crate::env::remove_var("JCODE_HOME"); +} + +#[test] +fn prepare_visible_spawn_session_persists_and_launches_provider_key_for_openrouter_model() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let worktree = tempfile::TempDir::new().expect("temp worktree"); + let (session_id, launched) = prepare_visible_spawn_session( + Some(worktree.path().to_str().expect("utf8 worktree path")), + Some("openai/gpt-5.4@OpenAI"), + None, + None, + None, + false, + None, + |_session_id, _cwd: &std::path::Path, _selfdev, provider_key| { + assert_eq!(provider_key, Some("openrouter")); + Ok(true) + }, + ) + .expect("visible spawn preparation should succeed"); + + assert!(launched); + let session = crate::session::Session::load(&session_id).expect("prepared session should save"); + assert_eq!(session.model.as_deref(), Some("openai/gpt-5.4@OpenAI")); + assert_eq!(session.provider_key.as_deref(), Some("openrouter")); + + crate::env::remove_var("JCODE_HOME"); +} + +#[test] +fn prepare_visible_spawn_session_persists_requested_effort() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let worktree = tempfile::TempDir::new().expect("temp worktree"); + let (session_id, launched) = prepare_visible_spawn_session( + Some(worktree.path().to_str().expect("utf8 worktree path")), + Some("gpt-5.5"), + None, + None, + Some("low"), + false, + None, + |_session_id, _cwd: &std::path::Path, _selfdev, _provider_key| Ok(true), + ) + .expect("visible spawn preparation should succeed"); + + assert!(launched); + let session = crate::session::Session::load(&session_id).expect("prepared session should save"); + assert_eq!(session.model.as_deref(), Some("gpt-5.5")); + assert_eq!( + session.reasoning_effort.as_deref(), + Some("low"), + "requested effort should persist so the headed client restores it" + ); + + crate::env::remove_var("JCODE_HOME"); +} + +#[test] +fn prepare_visible_spawn_session_prefers_parent_provider_key_over_model_guess() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let worktree = tempfile::TempDir::new().expect("temp worktree"); + let (session_id, launched) = prepare_visible_spawn_session( + Some(worktree.path().to_str().expect("utf8 worktree path")), + Some("gpt-5.4"), + Some("ollama"), + None, + None, + false, + None, + |_session_id, _cwd: &std::path::Path, _selfdev, provider_key| { + assert_eq!(provider_key, Some("ollama")); + Ok(true) + }, + ) + .expect("visible spawn preparation should succeed"); + + assert!(launched); + let session = crate::session::Session::load(&session_id).expect("prepared session should save"); + assert_eq!(session.model.as_deref(), Some("gpt-5.4")); + assert_eq!(session.provider_key.as_deref(), Some("ollama")); + + crate::env::remove_var("JCODE_HOME"); +} + +fn coordinator_identity( + model: Option<&str>, + provider_key: Option<&str>, + route_api_method: Option<&str>, +) -> CoordinatorSpawnIdentity { + CoordinatorSpawnIdentity { + model: model.map(str::to_string), + provider_key: provider_key.map(str::to_string), + route_api_method: route_api_method.map(str::to_string), + is_canary: false, + } +} + +#[test] +fn resolve_swarm_spawn_model_prefers_configured_model_over_coordinator_model() { + let selection = resolve_swarm_spawn_selection( + None, + Some("openai/gpt-5.4@OpenAI".to_string()), + &coordinator_identity( + Some("nvidia/llama-3.3-nemotron-super-49b-v1"), + Some("nvidia"), + Some("openai-compatible:nvidia-nim"), + ), + ); + + assert_eq!(selection.model.as_deref(), Some("openai/gpt-5.4@OpenAI")); + assert_eq!(selection.provider_key.as_deref(), Some("openrouter")); + // A different configured model must not inherit the coordinator's route. + assert_eq!(selection.route_api_method, None); +} + +#[test] +fn resolve_swarm_spawn_model_inherits_coordinator_when_unconfigured() { + let selection = resolve_swarm_spawn_selection( + None, + None, + &coordinator_identity( + Some("nvidia/llama-3.3-nemotron-super-49b-v1"), + Some("nvidia"), + Some("openai-compatible:nvidia-nim"), + ), + ); + + assert_eq!( + selection.model.as_deref(), + Some("nvidia/llama-3.3-nemotron-super-49b-v1") + ); + assert_eq!(selection.provider_key.as_deref(), Some("nvidia")); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-compatible:nvidia-nim") + ); +} + +#[test] +fn resolve_swarm_spawn_model_inherits_coordinator_auth_route_for_oauth_vs_api() { + // Regression: a coordinator on the Claude API route must spawn agents on + // the same API route, not Claude OAuth (the config default). + let selection = resolve_swarm_spawn_selection( + None, + None, + &coordinator_identity( + Some("claude-opus-4-6"), + Some("claude-api"), + Some("claude-api"), + ), + ); + + assert_eq!(selection.model.as_deref(), Some("claude-opus-4-6")); + 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_keeps_provider_key_when_config_matches_coordinator() { + let selection = resolve_swarm_spawn_selection( + None, + Some("custom-model".to_string()), + &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")); +} + +#[test] +fn resolve_swarm_spawn_model_openai_api_prefix_pins_api_route_over_coordinator() { + // `agents.swarm_model = "openai-api:gpt-5.5"` must spawn agents on GPT-5.5 + // via the OpenAI API key route, regardless of the coordinator's model/auth. + let selection = resolve_swarm_spawn_selection( + None, + Some("openai-api:gpt-5.5".to_string()), + &coordinator_identity( + Some("claude-opus-4-8"), + 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_auth_route_prefixes_pin_expected_routes() { + for (configured, expected_model, expected_key) in [ + ("openai-api:gpt-5.5", "gpt-5.5", "openai-api-key"), + ("openai-oauth:gpt-5.5", "gpt-5.5", "openai-oauth"), + ( + "claude-api:claude-opus-4-8", + "claude-opus-4-8", + "anthropic-api-key", + ), + ( + "claude-oauth:claude-opus-4-8", + "claude-opus-4-8", + "claude-oauth", + ), + ] { + let selection = resolve_swarm_spawn_selection( + None, + Some(configured.to_string()), + &coordinator_identity( + Some("some-other-model"), + Some("some-key"), + Some("some-route"), + ), + ); + assert_eq!( + selection.model.as_deref(), + Some(expected_model), + "configured {configured:?} model", + ); + assert_eq!( + selection.provider_key.as_deref(), + Some(expected_key), + "configured {configured:?} provider_key", + ); + assert_eq!( + selection.route_api_method.as_deref(), + Some(expected_key), + "configured {configured:?} route_api_method", + ); + } +} + +#[test] +fn resolve_swarm_spawn_model_inherit_sentinel_uses_coordinator_model() { + for sentinel in ["inherit", "INHERIT", "coordinator", " inherit ", ""] { + let selection = resolve_swarm_spawn_selection( + None, + Some(sentinel.to_string()), + &coordinator_identity( + Some("nvidia/llama-3.3-nemotron-super-49b-v1"), + Some("nvidia"), + Some("openai-compatible:nvidia-nim"), + ), + ); + + assert_eq!( + selection.model.as_deref(), + Some("nvidia/llama-3.3-nemotron-super-49b-v1"), + "sentinel {sentinel:?} should inherit coordinator model", + ); + assert_eq!( + selection.provider_key.as_deref(), + Some("nvidia"), + "sentinel {sentinel:?} should inherit coordinator provider key", + ); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-compatible:nvidia-nim"), + "sentinel {sentinel:?} should inherit coordinator auth route", + ); + } +} + +#[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"), + ), + ); + + 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"), + ), + ); + + 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, + &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")); +} + +#[test] +fn resolve_swarm_spawn_model_blank_requested_model_falls_back_to_config() { + // A whitespace-only requested model is treated as "not provided". + let selection = resolve_swarm_spawn_selection( + Some(" ".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")); +} + +#[tokio::test] +async fn coordinator_identity_uses_live_agent_when_lock_is_available() { + let agent = test_agent_with_working_dir("coord", "/tmp/coord").await; + let live_model = agent.lock().await.provider_model(); + let sessions = Arc::new(RwLock::new(HashMap::new())); + sessions + .write() + .await + .insert("coord".to_string(), Arc::clone(&agent)); + + let identity = resolve_coordinator_spawn_identity("coord", &sessions).await; + assert_eq!(identity.model.as_deref(), Some(live_model.as_str())); +} + +#[tokio::test] +async fn coordinator_identity_falls_back_to_persisted_session_when_agent_busy() { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new().expect("temp home"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let agent = test_agent_with_working_dir("coord_busy", "/tmp/coord").await; + + // Persist a coordinator session that records a concrete model + auth route. + // Persist after the agent is built so it reflects the authoritative on-disk + // snapshot the spawn path will read when the agent lock is unavailable. + let mut session = crate::session::Session::create_with_id("coord_busy".to_string(), None, None); + session.model = Some("claude-opus-4-6".to_string()); + session.provider_key = Some("claude-api".to_string()); + session.route_api_method = Some("claude-api".to_string()); + session.save().expect("persist coordinator session"); + + // Hold the agent lock to simulate a coordinator mid-turn: the spawn path + // must not block and must read the persisted identity instead of defaults. + let _held = agent.lock().await; + let sessions = Arc::new(RwLock::new(HashMap::new())); + sessions + .write() + .await + .insert("coord_busy".to_string(), Arc::clone(&agent)); + + let identity = resolve_coordinator_spawn_identity("coord_busy", &sessions).await; + assert_eq!(identity.model.as_deref(), Some("claude-opus-4-6")); + assert_eq!(identity.provider_key.as_deref(), Some("claude-api")); + assert_eq!(identity.route_api_method.as_deref(), Some("claude-api")); + + crate::env::remove_var("JCODE_HOME"); +} + +#[tokio::test] +async fn spawn_bootstraps_coordinator_when_swarm_has_none() { + 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(["req".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new())); + let (req_member, _req_rx) = member("req", Some("swarm-1"), "agent"); + swarm_members + .write() + .await + .insert("req".to_string(), req_member); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_id = ensure_spawn_coordinator_swarm( + 1, + "req", + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + + assert_eq!(swarm_id.as_deref(), Some("swarm-1")); + assert_eq!( + swarm_coordinators + .read() + .await + .get("swarm-1") + .map(String::as_str), + Some("req") + ); + assert_eq!( + swarm_members + .read() + .await + .get("req") + .map(|member| member.role.as_str()), + Some("coordinator") + ); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + }) if message == "You are the coordinator for this swarm." + )); +} + +#[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. + 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()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "coord".to_string(), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::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 members = swarm_members.write().await; + members.insert("child".to_string(), child_member); + members.insert("coord".to_string(), coord_member); + drop(members); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let swarm_id = ensure_spawn_coordinator_swarm( + 2, + "child", + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .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") + ); + 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. + 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::<String, VersionedPlan>::new())); + { + let mut members = swarm_members.write().await; + let (root, _rx) = member("root", Some("swarm-1"), "coordinator"); + members.insert("root".to_string(), root); + let chain = [ + ("a", "root"), + ("b", "a"), + ("c", "b"), + ("d", "c"), + ("e", "d"), + ("f", "e"), + ]; + for (id, parent) in chain { + let (mut m, _rx) = member(id, Some("swarm-1"), "agent"); + m.report_back_to_session_id = Some(parent.to_string()); + members.insert(id.to_string(), m); + } + } + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel(); + + // `f` is deeply nested but the swarm is far below the member cap, so spawning + // is allowed. + let allowed = ensure_spawn_coordinator_swarm( + 7, + "f", + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + assert_eq!(allowed.as_deref(), Some("swarm-1")); +} + +#[tokio::test] +async fn spawn_rejected_when_member_limit_reached() { + use crate::server::swarm::MAX_SWARM_MEMBERS; + + // Fill the swarm to the member cap; the next spawn must be refused. + 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::<String, VersionedPlan>::new())); + { + let mut members = swarm_members.write().await; + let (root, _rx) = member("root", Some("swarm-1"), "coordinator"); + members.insert("root".to_string(), root); + // Add filler members so the swarm holds exactly MAX_SWARM_MEMBERS total. + for idx in 1..MAX_SWARM_MEMBERS { + let id = format!("agent-{idx}"); + let (mut m, _rx) = member(&id, Some("swarm-1"), "agent"); + m.report_back_to_session_id = Some("root".to_string()); + members.insert(id, m); + } + } + 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, + ) + .await; + assert!(refused.is_none()); + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Error { message, .. }) + if message.contains("Swarm member limit reached") + )); +} + +#[tokio::test] +async fn terminal_members_do_not_consume_spawn_capacity() { + use crate::server::swarm::MAX_SWARM_MEMBERS; + + 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::<String, VersionedPlan>::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..MAX_SWARM_MEMBERS { + let id = format!("historical-{idx}"); + let (mut historical, _rx) = member(&id, Some("swarm-1"), "agent"); + historical.status = if idx % 2 == 0 { + "completed".to_string() + } else { + "stopped".to_string() + }; + historical.latest_completion_report = Some(format!("report {idx}")); + historical.report_back_to_session_id = Some("root".to_string()); + members.insert(id, historical); + } + } + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel(); + + let allowed = ensure_spawn_coordinator_swarm( + 7, + "root", + &client_event_tx, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + + assert_eq!(allowed.as_deref(), Some("swarm-1")); +} diff --git a/crates/jcode-app-core/src/server/comm_sync.rs b/crates/jcode-app-core/src/server/comm_sync.rs new file mode 100644 index 0000000..94681f3 --- /dev/null +++ b/crates/jcode-app-core/src/server/comm_sync.rs @@ -0,0 +1,499 @@ +use super::{ + ClientConnectionInfo, FileTouchService, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, + VersionedPlan, broadcast_swarm_plan, persist_swarm_state_for, record_swarm_event, +}; +use crate::agent::Agent; +use crate::protocol::{ + AgentStatusSnapshot, NotificationType, PlanGraphStatus, ServerEvent, SessionActivitySnapshot, +}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +pub(super) struct CommResyncPlanContext<'a> { + pub(super) client_event_tx: &'a mpsc::UnboundedSender<ServerEvent>, + pub(super) swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + pub(super) swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub(super) swarm_plans: &'a Arc<RwLock<HashMap<String, VersionedPlan>>>, + pub(super) swarm_coordinators: &'a Arc<RwLock<HashMap<String, String>>>, + pub(super) event_history: &'a Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + pub(super) event_counter: &'a Arc<std::sync::atomic::AtomicU64>, + pub(super) swarm_event_tx: &'a broadcast::Sender<SwarmEvent>, +} + +fn live_activity_snapshot( + connections: &HashMap<String, ClientConnectionInfo>, + session_id: &str, + fallback_processing: bool, +) -> Option<SessionActivitySnapshot> { + let mut processing_without_tool = false; + let mut tool_name = None; + for info in connections.values() { + if info.session_id != session_id || !info.is_processing { + continue; + } + if let Some(current_tool_name) = info.current_tool_name.clone() { + tool_name = Some(current_tool_name); + break; + } + processing_without_tool = true; + } + + tool_name + .map(|current_tool_name| SessionActivitySnapshot { + is_processing: true, + current_tool_name: Some(current_tool_name), + }) + .or_else(|| { + processing_without_tool.then_some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: None, + }) + }) + .or_else(|| { + fallback_processing.then_some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: None, + }) + }) +} + +/// Recent-token lookback window used when reporting per-agent churn in +/// `swarm list`. Short enough to reflect "what is this agent doing right now". +pub(super) const SWARM_LIST_TOKEN_WINDOW_SECS: u64 = 10; + +/// Runtime extras for a swarm member, gathered without holding the agent lock +/// for long. Used to enrich the `swarm list` roster with live activity, +/// provider/model, token churn, turn count, and todo progress. +#[derive(Default)] +pub(super) struct MemberRuntimeExtras { + pub(super) activity: Option<SessionActivitySnapshot>, + pub(super) provider_name: Option<String>, + pub(super) provider_model: Option<String>, + pub(super) turn_count: Option<u64>, + pub(super) recent_total_tokens: Option<u64>, + pub(super) recent_output_tokens: Option<u64>, + pub(super) recent_window_secs: Option<u64>, + pub(super) cumulative_total_tokens: Option<u64>, + pub(super) last_activity_age_secs: Option<u64>, + pub(super) todos_completed: Option<usize>, + pub(super) todos_total: Option<usize>, +} + +/// Gather live runtime extras for a single member session. +/// +/// `member_is_running` is used as a fallback "processing" hint when no live +/// client connection is reporting activity (e.g. headless sessions). +pub(super) async fn member_runtime_extras( + session_id: &str, + member_is_running: bool, + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, +) -> MemberRuntimeExtras { + let activity = { + let connections = client_connections.read().await; + live_activity_snapshot(&connections, session_id, member_is_running) + }; + + let (provider_name, provider_model) = { + 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())) + } else { + (None, None) + } + } else { + (None, None) + } + }; + + let metrics = crate::session_metrics::snapshot( + session_id, + std::time::Duration::from_secs(SWARM_LIST_TOKEN_WINDOW_SECS), + ); + + let (todos_completed, todos_total) = match crate::todo::load_todos(session_id) { + Ok(todos) if !todos.is_empty() => { + let completed = todos.iter().filter(|t| t.status == "completed").count(); + (Some(completed), Some(todos.len())) + } + _ => (None, None), + }; + + MemberRuntimeExtras { + activity, + provider_name, + provider_model, + 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), + recent_window_secs: metrics.map(|_| SWARM_LIST_TOKEN_WINDOW_SECS), + cumulative_total_tokens: metrics.map(|m| m.cumulative_total_tokens), + last_activity_age_secs: metrics.and_then(|m| m.last_activity_age_secs), + todos_completed, + todos_total, + } +} + +async fn ensure_same_swarm_access( + id: u64, + req_session_id: &str, + target_session: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) -> bool { + let (req_swarm, target_swarm) = { + let members = swarm_members.read().await; + ( + members + .get(req_session_id) + .and_then(|member| member.swarm_id.clone()), + members + .get(target_session) + .and_then(|member| member.swarm_id.clone()), + ) + }; + + if req_swarm.is_some() && req_swarm == target_swarm { + true + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Session '{}' is not in the same swarm as requester '{}'", + target_session, req_session_id + ), + retry_after_secs: None, + }); + false + } +} + +async fn can_read_full_context( + req_session_id: &str, + target_session: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> bool { + if req_session_id == target_session { + return true; + } + + let members = swarm_members.read().await; + members + .get(req_session_id) + .map(|member| member.role == "coordinator") + .unwrap_or(false) +} + +pub(super) async fn handle_comm_summary( + id: u64, + req_session_id: String, + target_session: String, + limit: Option<usize>, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if !ensure_same_swarm_access( + id, + &req_session_id, + &target_session, + swarm_members, + client_event_tx, + ) + .await + { + return; + } + + let limit = limit.unwrap_or(10); + let agent_sessions = sessions.read().await; + if let Some(agent) = agent_sessions.get(&target_session) { + let tool_calls = if let Ok(agent) = agent.try_lock() { + agent.get_tool_call_summaries(limit) + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Session '{}' is busy; try summary again shortly", + target_session + ), + retry_after_secs: Some(1), + }); + return; + }; + let _ = client_event_tx.send(ServerEvent::CommSummaryResponse { + id, + session_id: target_session, + tool_calls, + }); + } else { + let _ = client_event_tx.send(ServerEvent::CommSummaryResponse { + id, + session_id: target_session, + tool_calls: Vec::new(), + }); + } +} + +#[expect( + clippy::too_many_arguments, + reason = "status snapshots combine live connection state, session metadata, files touched, and optional provider/model hints" +)] +pub(super) async fn handle_comm_status( + id: u64, + req_session_id: String, + target_session: String, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + file_touch: &FileTouchService, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if !ensure_same_swarm_access( + id, + &req_session_id, + &target_session, + swarm_members, + client_event_tx, + ) + .await + { + return; + } + + let snapshot = { + let members = swarm_members.read().await; + let Some(member) = members.get(&target_session) else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Unknown session '{target_session}'"), + retry_after_secs: None, + }); + return; + }; + + let files_touched = file_touch + .sorted_file_strings_for_session(&target_session) + .await; + + let activity = { + let connections = client_connections.read().await; + live_activity_snapshot(&connections, &target_session, member.status == "running") + }; + + let (provider_name, provider_model) = { + let agent_sessions = sessions.read().await; + if let Some(agent) = agent_sessions.get(&target_session) { + if let Ok(agent) = agent.try_lock() { + (Some(agent.provider_name()), Some(agent.provider_model())) + } else { + (None, None) + } + } else { + (None, None) + } + }; + + AgentStatusSnapshot { + session_id: member.session_id.clone(), + friendly_name: member.friendly_name.clone(), + swarm_id: member.swarm_id.clone(), + status: Some(member.status.clone()), + detail: member.detail.clone(), + role: Some(member.role.clone()), + is_headless: Some(member.is_headless), + live_attachments: Some(member.event_txs.len()), + status_age_secs: Some(member.last_status_change.elapsed().as_secs()), + last_activity_age_secs: crate::session_metrics::last_activity_age_secs(&target_session), + joined_age_secs: Some(member.joined_at.elapsed().as_secs()), + files_touched, + activity, + provider_name, + provider_model, + } + }; + + let _ = client_event_tx.send(ServerEvent::CommStatusResponse { id, snapshot }); +} + +pub(super) async fn handle_comm_read_context( + id: u64, + req_session_id: String, + target_session: String, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if !ensure_same_swarm_access( + id, + &req_session_id, + &target_session, + swarm_members, + client_event_tx, + ) + .await + { + return; + } + + if !can_read_full_context(&req_session_id, &target_session, swarm_members).await { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Only the coordinator, worktree manager, or the target session may read full context. Use summary for lightweight access.".to_string(), + retry_after_secs: None, + }); + return; + } + + let agent_sessions = sessions.read().await; + if let Some(agent) = agent_sessions.get(&target_session) { + let messages = if let Ok(agent) = agent.try_lock() { + agent.get_history() + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!( + "Session '{}' is busy; try read_context again shortly", + target_session + ), + retry_after_secs: Some(1), + }); + return; + }; + let _ = client_event_tx.send(ServerEvent::CommContextHistory { + id, + session_id: target_session, + messages, + }); + } else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Unknown session '{target_session}'"), + retry_after_secs: None, + }); + } +} + +pub(super) async fn handle_comm_plan_status( + id: u64, + req_session_id: String, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let swarm_id = { + let members = swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()) + }; + + let Some(swarm_id) = swarm_id else { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + return; + }; + + let summary = { + let plans = swarm_plans.read().await; + let plan = plans.get(&swarm_id); + if let Some(plan) = plan { + PlanGraphStatus::from_versioned_plan(swarm_id.clone(), plan, Some(8), Vec::new()) + } else { + PlanGraphStatus::empty_for_swarm(swarm_id.clone()) + } + }; + + let _ = client_event_tx.send(ServerEvent::CommPlanStatusResponse { id, summary }); +} + +pub(super) async fn handle_comm_resync_plan( + id: u64, + req_session_id: String, + ctx: &CommResyncPlanContext<'_>, +) { + let swarm_id = { + let members = ctx.swarm_members.read().await; + members + .get(&req_session_id) + .and_then(|member| member.swarm_id.clone()) + }; + + if let Some(swarm_id) = swarm_id { + let plan_state = { + let mut plans = ctx.swarm_plans.write().await; + plans.get_mut(&swarm_id).map(|plan| { + plan.participants.insert(req_session_id.clone()); + (plan.version, plan.items.len()) + }) + }; + if let Some((version, item_count)) = plan_state { + let swarm_state = SwarmState { + members: Arc::clone(ctx.swarm_members), + swarms_by_id: Arc::clone(ctx.swarms_by_id), + plans: Arc::clone(ctx.swarm_plans), + coordinators: Arc::clone(ctx.swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + if let Some(member) = ctx.swarm_members.read().await.get(&req_session_id) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: req_session_id.clone(), + from_name: member.friendly_name.clone(), + notification_type: NotificationType::Message { + scope: Some("plan".to_string()), + channel: None, + tldr: None, + }, + message: format!( + "Plan attached to this session (v{}, {} items).", + version, item_count + ), + }); + } + broadcast_swarm_plan( + &swarm_id, + Some("resync".to_string()), + ctx.swarm_plans, + ctx.swarm_members, + ctx.swarms_by_id, + ) + .await; + record_swarm_event( + ctx.event_history, + ctx.event_counter, + ctx.swarm_event_tx, + req_session_id.clone(), + None, + Some(swarm_id.clone()), + SwarmEventType::PlanUpdate { + swarm_id: swarm_id.clone(), + item_count, + }, + ) + .await; + let _ = ctx.client_event_tx.send(ServerEvent::Done { id }); + } else { + let _ = ctx.client_event_tx.send(ServerEvent::Error { + id, + message: "No swarm plan exists for this swarm.".to_string(), + retry_after_secs: None, + }); + } + } else { + let _ = ctx.client_event_tx.send(ServerEvent::Error { + id, + message: "Not in a swarm.".to_string(), + retry_after_secs: None, + }); + } +} diff --git a/crates/jcode-app-core/src/server/debug.rs b/crates/jcode-app-core/src/server/debug.rs new file mode 100644 index 0000000..559b046 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug.rs @@ -0,0 +1,576 @@ +#![cfg_attr( + test, + allow(clippy::await_holding_lock, clippy::items_after_test_module) +)] + +use super::debug_ambient::maybe_handle_ambient_command; +use super::debug_command_exec::{ + DebugInterruptContext, execute_debug_command, resolve_debug_session, +}; +use super::debug_events::{ + maybe_handle_event_query_command, maybe_handle_event_subscription_command, +}; +use super::debug_help::{debug_help_text, parse_namespaced_command, swarm_debug_help_text}; +use super::debug_jobs::{DebugJob, maybe_handle_job_command}; +use super::debug_server_state::maybe_handle_server_state_command; +use super::debug_session_admin::maybe_handle_session_admin_command; +use super::debug_swarm_read::maybe_handle_swarm_read_command; +use super::debug_swarm_write::{DebugSwarmWriteContext, maybe_handle_swarm_write_command}; +use super::debug_testers::execute_tester_command; +use super::{ + FileTouchService, ServerIdentity, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, + debug_control_allowed, fanout_session_event, +}; +use crate::agent::Agent; +use crate::ambient_runner::AmbientRunnerHandle; +use crate::protocol::{Request, ServerEvent, TranscriptMode, decode_request, encode_event}; +use crate::provider::Provider; +use crate::transport::Stream; +use anyhow::Result; +use jcode_agent_runtime::InterruptSignal; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; + +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +#[derive(Default)] +pub(super) struct ClientDebugState { + pub(super) active_id: Option<String>, + pub(super) clients: HashMap<String, mpsc::UnboundedSender<(u64, String)>>, +} + +#[derive(Clone, Debug)] +pub(super) struct ClientConnectionInfo { + pub(super) client_id: String, + pub(super) session_id: String, + pub(super) client_instance_id: Option<String>, + pub(super) debug_client_id: Option<String>, + pub(super) connected_at: Instant, + pub(super) last_seen: Instant, + pub(super) is_processing: bool, + pub(super) current_tool_name: Option<String>, + /// Terminal-identifying env vars captured from this client (tmux/zellij/ + /// kitty/DISPLAY/...). Used to route spawn/focus hooks to the client's + /// terminal instead of the long-lived server's stale startup env (#405). + pub(super) terminal_env: Vec<(String, String)>, + pub(super) disconnect_tx: mpsc::UnboundedSender<()>, +} + +impl ClientDebugState { + pub(super) fn register(&mut self, client_id: String, tx: mpsc::UnboundedSender<(u64, String)>) { + self.active_id = Some(client_id.clone()); + self.clients.insert(client_id, tx); + } + + pub(super) fn unregister(&mut self, client_id: &str) { + self.clients.remove(client_id); + if self.active_id.as_deref() == Some(client_id) { + self.active_id = self.clients.keys().next().cloned(); + } + } + + pub(super) fn active_sender( + &mut self, + ) -> Option<(String, mpsc::UnboundedSender<(u64, String)>)> { + if let Some(active_id) = self.active_id.clone() + && let Some(tx) = self.clients.get(&active_id) + { + return Some((active_id, tx.clone())); + } + if let Some((id, tx)) = self.clients.iter().next() { + let id = id.clone(); + self.active_id = Some(id.clone()); + return Some((id, tx.clone())); + } + None + } + + pub(super) fn sender_for_id( + &self, + client_id: &str, + ) -> Option<mpsc::UnboundedSender<(u64, String)>> { + self.clients.get(client_id).cloned() + } +} + +async fn resolve_client_debug_sender( + requested_session: Option<&str>, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + client_debug_state: &Arc<RwLock<ClientDebugState>>, +) -> Result<(String, mpsc::UnboundedSender<(u64, String)>)> { + if let Some(session_id) = requested_session.filter(|value| !value.trim().is_empty()) { + let active_debug_id = client_debug_state.read().await.active_id.clone(); + let target_debug_id = { + let connections = client_connections.read().await; + connections + .values() + .filter(|info| info.session_id == session_id) + .filter_map(|info| { + info.debug_client_id.as_ref().map(|debug_client_id| { + let is_active = + active_debug_id.as_deref() == Some(debug_client_id.as_str()); + (debug_client_id.clone(), info.last_seen, is_active) + }) + }) + .max_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2))) + .map(|(debug_client_id, _, _)| debug_client_id) + }; + + let Some(debug_client_id) = target_debug_id else { + anyhow::bail!( + "Session '{}' does not have a connected TUI client for client: debug commands", + session_id + ); + }; + + let sender = client_debug_state + .read() + .await + .sender_for_id(&debug_client_id) + .ok_or_else(|| { + anyhow::anyhow!( + "Session '{}' debug client '{}' is not currently available", + session_id, + debug_client_id + ) + })?; + + return Ok((debug_client_id, sender)); + } + + let (client_id, sender) = { + let mut debug_state = client_debug_state.write().await; + debug_state + .active_sender() + .ok_or_else(|| anyhow::anyhow!("No TUI client connected"))? + }; + Ok((client_id, sender)) +} + +async fn resolve_transcript_target_session( + requested_session: Option<String>, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + client_debug_state: &Arc<RwLock<ClientDebugState>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Result<String> { + let live_sessions: std::collections::HashSet<String> = swarm_members + .read() + .await + .iter() + .filter(|(_, member)| !member.is_headless && !member.event_txs.is_empty()) + .map(|(session_id, _)| session_id.clone()) + .collect(); + + if let Some(session_id) = requested_session.filter(|value| !value.trim().is_empty()) { + if !live_sessions.contains(&session_id) { + anyhow::bail!( + "Session '{}' does not have a connected TUI client for transcript injection", + session_id + ); + } + return Ok(session_id); + } + + if let Ok(Some(session_id)) = crate::dictation::focused_jcode_session() + && live_sessions.contains(&session_id) + { + return Ok(session_id); + } + + if let Ok(Some(session_id)) = crate::dictation::last_focused_session() + && live_sessions.contains(&session_id) + { + return Ok(session_id); + } + + let active_debug_id = client_debug_state.read().await.active_id.clone(); + let connections = client_connections.read().await; + + connections + .values() + .filter(|info| live_sessions.contains(&info.session_id)) + .max_by(|left, right| { + left.last_seen + .cmp(&right.last_seen) + .then_with(|| { + let left_is_active = + active_debug_id.as_deref() == left.debug_client_id.as_deref(); + let right_is_active = + active_debug_id.as_deref() == right.debug_client_id.as_deref(); + left_is_active.cmp(&right_is_active) + }) + }) + .map(|info| info.session_id.clone()) + .ok_or_else(|| { + anyhow::anyhow!( + "Transcript target could not be resolved from focused window, last-focused session, or any live TUI client" + ) + }) +} + +pub(super) async fn inject_transcript( + id: u64, + text: String, + mode: TranscriptMode, + requested_session: Option<String>, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + client_debug_state: &Arc<RwLock<ClientDebugState>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Result<ServerEvent> { + let session_id = resolve_transcript_target_session( + requested_session, + client_connections, + client_debug_state, + swarm_members, + ) + .await?; + + let delivered = fanout_session_event( + swarm_members, + &session_id, + ServerEvent::Transcript { text, mode }, + ) + .await + > 0; + + if !delivered { + anyhow::bail!("Failed to deliver transcript to session '{}'", session_id); + } + + Ok(ServerEvent::Done { id }) +} + +#[expect( + clippy::too_many_arguments, + reason = "debug client wiring fans out across sessions, swarms, files, channels, jobs, and transport state" +)] +pub(super) async fn handle_debug_client( + stream: Stream, + sessions: Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>, + is_processing: Arc<RwLock<bool>>, + session_id: Arc<RwLock<String>>, + provider: Arc<dyn Provider>, + client_connections: Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: Arc<RwLock<HashMap<String, String>>>, + file_touch: FileTouchService, + channel_subscriptions: ChannelSubscriptions, + channel_subscriptions_by_session: ChannelSubscriptions, + client_debug_state: Arc<RwLock<ClientDebugState>>, + client_debug_response_tx: broadcast::Sender<(u64, String)>, + debug_jobs: Arc<RwLock<HashMap<String, DebugJob>>>, + event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + server_identity: ServerIdentity, + server_start_time: std::time::Instant, + ambient_runner: Option<AmbientRunnerHandle>, + mcp_pool: Option<Arc<crate::mcp::SharedMcpPool>>, + shutdown_signals: Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: super::SessionInterruptQueues, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + break; + } + + let request = match decode_request(&line) { + Ok(r) => r, + Err(e) => { + let event = ServerEvent::Error { + id: 0, + message: format!("Invalid request: {}", e), + retry_after_secs: None, + }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + continue; + } + }; + + match request { + Request::Ping { id } => { + let event = ServerEvent::Pong { id }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + } + + Request::GetState { id } => { + let current_session_id = session_id.read().await.clone(); + let sessions = sessions.read().await; + let message_count = sessions.len(); + + let event = ServerEvent::State { + id, + session_id: current_session_id, + message_count, + is_processing: *is_processing.read().await, + }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + } + + Request::Transcript { + id, + text, + mode, + session_id: requested_session, + } => { + let event = match inject_transcript( + id, + text, + mode, + requested_session, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + { + Ok(event) => event, + Err(err) => ServerEvent::Error { + id, + message: err.to_string(), + retry_after_secs: None, + }, + }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + } + + Request::DebugCommand { + id, + command, + session_id: requested_session, + } => { + if !debug_control_allowed() { + let event = ServerEvent::Error { + id, + message: "Debug control is disabled. Set JCODE_DEBUG_CONTROL=1, enable display.debug_socket, or start the shared server from a self-dev session.".to_string(), + retry_after_secs: None, + }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + continue; + } + + // Parse namespaced command + let (namespace, cmd) = parse_namespaced_command(&command); + + let result = match namespace { + "client" => { + // Forward to TUI client + let mut response_rx = client_debug_response_tx.subscribe(); + let mut attempts = 0usize; + + loop { + let (client_id, tx) = match resolve_client_debug_sender( + requested_session.as_deref(), + &client_connections, + &client_debug_state, + ) + .await + { + Ok(target) => target, + Err(err) => break Err(err), + }; + + if tx.send((id, cmd.to_string())).is_ok() { + // Wait for response with timeout + let timeout = tokio::time::Duration::from_secs(30); + match tokio::time::timeout(timeout, async { + loop { + if let Ok((resp_id, output)) = response_rx.recv().await + && resp_id == id + { + return Ok(output); + } + } + }) + .await + { + Ok(result) => break result, + Err(_) => { + break Err(anyhow::anyhow!( + "Timeout waiting for client response" + )); + } + } + } else { + let mut debug_state = client_debug_state.write().await; + debug_state.unregister(&client_id); + attempts += 1; + if requested_session.is_some() + || debug_state.clients.is_empty() + || attempts > 8 + { + break Err(anyhow::anyhow!("No TUI client connected")); + } + } + } + } + "tester" => { + // Handle tester commands + execute_tester_command(cmd).await + } + _ => { + // Server commands (default) + if let Some(output) = maybe_handle_job_command(cmd, &debug_jobs).await? { + Ok(output) + } else if let Some(output) = maybe_handle_session_admin_command( + cmd, + &sessions, + &session_id, + &provider, + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + &event_history, + &event_counter, + &swarm_event_tx, + &soft_interrupt_queues, + mcp_pool.clone(), + ) + .await? + { + Ok(output) + } else if let Some(output) = maybe_handle_server_state_command( + cmd, + &sessions, + &client_connections, + &swarm_members, + &client_debug_state, + &server_identity, + server_start_time, + &swarms_by_id, + &shared_context, + &swarm_plans, + &swarm_coordinators, + &file_touch, + &channel_subscriptions, + &channel_subscriptions_by_session, + &debug_jobs, + &event_history, + &shutdown_signals, + &soft_interrupt_queues, + ) + .await? + { + Ok(output) + } else if let Some(output) = maybe_handle_swarm_read_command( + cmd, + &sessions, + &swarm_members, + &swarms_by_id, + &shared_context, + &swarm_plans, + &swarm_coordinators, + &file_touch, + &channel_subscriptions, + &server_identity, + ) + .await? + { + Ok(output) + } else if let Some(output) = maybe_handle_swarm_write_command( + cmd, + &DebugSwarmWriteContext { + session_id: &session_id, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + shared_context: &shared_context, + swarm_plans: &swarm_plans, + swarm_coordinators: &swarm_coordinators, + }, + ) + .await? + { + Ok(output) + } else if let Some(output) = + maybe_handle_ambient_command(cmd, &ambient_runner, &provider).await? + { + Ok(output) + } else if maybe_handle_event_subscription_command( + id, + cmd, + &swarm_event_tx, + &mut writer, + ) + .await? + { + return Ok(()); + } else if let Some(output) = + maybe_handle_event_query_command(cmd, &event_history).await + { + Ok(output) + } else if cmd == "swarm:help" { + Ok(swarm_debug_help_text()) + } else if cmd == "help" { + Ok(debug_help_text()) + } else { + match resolve_debug_session(&sessions, &session_id, requested_session) + .await + { + Ok((_session, agent)) => { + execute_debug_command( + agent, + cmd, + Arc::clone(&debug_jobs), + Some(&server_identity), + Some(DebugInterruptContext { + session_id: _session, + shutdown_signals: Arc::clone(&shutdown_signals), + soft_interrupt_queues: Arc::clone( + &soft_interrupt_queues, + ), + }), + ) + .await + } + Err(e) => Err(e), + } + } + } + }; + + let (ok, output) = match result { + Ok(output) => (true, output), + Err(e) => (false, e.to_string()), + }; + let event = ServerEvent::DebugResponse { id, ok, output }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + } + + _ => { + // Debug socket only allows ping, state, and debug_command + let event = ServerEvent::Error { + id: request.id(), + message: "Debug socket only allows ping, state, and debug_command".to_string(), + retry_after_secs: None, + }; + let json = encode_event(&event); + writer.write_all(json.as_bytes()).await?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +#[path = "debug_tests.rs"] +mod debug_tests; diff --git a/crates/jcode-app-core/src/server/debug_ambient.rs b/crates/jcode-app-core/src/server/debug_ambient.rs new file mode 100644 index 0000000..9263bc4 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_ambient.rs @@ -0,0 +1,175 @@ +use crate::ambient_runner::AmbientRunnerHandle; +use crate::provider::Provider; +use anyhow::Result; +use std::sync::Arc; + +pub(super) async fn maybe_handle_ambient_command( + cmd: &str, + ambient_runner: &Option<AmbientRunnerHandle>, + provider: &Arc<dyn Provider>, +) -> Result<Option<String>> { + if cmd == "ambient:status" { + let output = if let Some(runner) = ambient_runner { + runner.status_json().await + } else { + serde_json::json!({ + "enabled": false, + "status": "disabled", + "message": "Ambient mode is not enabled in config" + }) + .to_string() + }; + return Ok(Some(output)); + } + + if cmd == "ambient:queue" { + let output = if let Some(runner) = ambient_runner { + runner.queue_json().await + } else { + "[]".to_string() + }; + return Ok(Some(output)); + } + + if cmd == "ambient:trigger" { + let output = if let Some(runner) = ambient_runner { + runner.trigger().await; + "Ambient cycle triggered".to_string() + } else { + return Err(anyhow::anyhow!("Ambient mode is not enabled")); + }; + return Ok(Some(output)); + } + + if cmd == "ambient:log" { + let output = if let Some(runner) = ambient_runner { + runner.log_json().await + } else { + "[]".to_string() + }; + return Ok(Some(output)); + } + + if cmd == "ambient:permissions" { + let output = if let Some(runner) = ambient_runner { + let _ = runner + .safety() + .expire_dead_session_requests("debug_socket_gc"); + let pending = runner.safety().pending_requests(); + let items: Vec<serde_json::Value> = pending + .iter() + .map(|request| { + let review_summary = request + .context + .as_ref() + .and_then(|ctx| ctx.get("review")) + .and_then(|review| review.get("summary")) + .and_then(|v| v.as_str()) + .unwrap_or(&request.description); + let review_why = request + .context + .as_ref() + .and_then(|ctx| ctx.get("review")) + .and_then(|review| review.get("why_permission_needed")) + .and_then(|v| v.as_str()) + .unwrap_or(&request.rationale); + serde_json::json!({ + "id": request.id, + "action": request.action, + "description": request.description, + "rationale": request.rationale, + "summary": review_summary, + "why_permission_needed": review_why, + "urgency": format!("{:?}", request.urgency), + "wait": request.wait, + "created_at": request.created_at.to_rfc3339(), + "context": request.context, + }) + }) + .collect(); + serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string()) + } else { + "[]".to_string() + }; + return Ok(Some(output)); + } + + if cmd.starts_with("ambient:approve:") { + let request_id = cmd.strip_prefix("ambient:approve:").unwrap_or("").trim(); + if request_id.is_empty() { + return Err(anyhow::anyhow!("Usage: ambient:approve:<request_id>")); + } + let output = if let Some(runner) = ambient_runner { + runner + .safety() + .record_decision(request_id, true, "debug_socket", None)?; + format!("Approved: {}", request_id) + } else { + return Err(anyhow::anyhow!("Ambient mode is not enabled")); + }; + return Ok(Some(output)); + } + + if cmd.starts_with("ambient:deny:") { + let rest = cmd.strip_prefix("ambient:deny:").unwrap_or("").trim(); + if rest.is_empty() { + return Err(anyhow::anyhow!("Usage: ambient:deny:<request_id> [reason]")); + } + let output = if let Some(runner) = ambient_runner { + let mut parts = rest.splitn(2, char::is_whitespace); + let request_id = parts.next().unwrap_or("").trim(); + let message = parts + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + runner + .safety() + .record_decision(request_id, false, "debug_socket", message)?; + format!("Denied: {}", request_id) + } else { + return Err(anyhow::anyhow!("Ambient mode is not enabled")); + }; + return Ok(Some(output)); + } + + if cmd == "ambient:stop" { + let output = if let Some(runner) = ambient_runner { + runner.stop().await; + "Ambient mode stopped".to_string() + } else { + return Err(anyhow::anyhow!("Ambient mode is not enabled")); + }; + return Ok(Some(output)); + } + + if cmd == "ambient:start" { + let output = if let Some(runner) = ambient_runner { + if runner.start(Arc::clone(provider)).await { + "Ambient mode started".to_string() + } else { + "Ambient mode is already running".to_string() + } + } else { + return Err(anyhow::anyhow!("Ambient mode is not enabled in config")); + }; + return Ok(Some(output)); + } + + if cmd == "ambient:help" { + return Ok(Some( + r#"Ambient mode debug commands (ambient: prefix): + ambient:status - Ambient + schedule runner state, counts, next due items + ambient:queue - Scheduled queue contents with target/session metadata + ambient:trigger - Manually trigger an ambient cycle + ambient:log - Recent transcript summaries + ambient:permissions - List pending permission requests + ambient:approve:<id> - Approve a permission request + ambient:deny:<id> [reason] - Deny a permission request (optional reason) + ambient:start - Start/restart ambient mode + ambient:stop - Stop ambient mode"# + .to_string(), + )); + } + + Ok(None) +} diff --git a/crates/jcode-app-core/src/server/debug_command_exec.rs b/crates/jcode-app-core/src/server/debug_command_exec.rs new file mode 100644 index 0000000..eb8abb9 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_command_exec.rs @@ -0,0 +1,797 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::debug_jobs::{DebugJob, maybe_start_async_debug_job}; +use super::{ServerIdentity, SessionControlHandle, SessionInterruptQueues}; +use crate::agent::Agent; +use crate::build; +use crate::mcp::McpConfig; +use anyhow::Result; +use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Mutex, RwLock}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +#[derive(Clone)] +pub(super) struct DebugInterruptContext { + pub session_id: String, + pub shutdown_signals: Arc<RwLock<HashMap<String, InterruptSignal>>>, + pub soft_interrupt_queues: SessionInterruptQueues, +} + +impl DebugInterruptContext { + async fn control_handle(&self) -> Option<SessionControlHandle> { + let queue = self + .soft_interrupt_queues + .read() + .await + .get(&self.session_id) + .cloned()?; + let signal = self + .shutdown_signals + .read() + .await + .get(&self.session_id) + .cloned()?; + Some(SessionControlHandle::cancel_only( + self.session_id.clone(), + queue, + signal, + )) + } +} + +pub(super) async fn resolve_debug_session( + sessions: &SessionAgents, + session_id: &Arc<RwLock<String>>, + requested: Option<String>, +) -> Result<(String, Arc<Mutex<Agent>>)> { + let mut target = requested; + if target.is_none() { + let current = session_id.read().await.clone(); + if !current.is_empty() { + target = Some(current); + } + } + + let sessions_guard = sessions.read().await; + if let Some(id) = target { + let agent = sessions_guard + .get(&id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Unknown session_id '{}'", id))?; + return Ok((id, agent)); + } + + if sessions_guard.len() == 1 + && let Some((id, agent)) = sessions_guard.iter().next() + { + return Ok((id.clone(), Arc::clone(agent))); + } + + Err(anyhow::anyhow!( + "No active session found. Connect a client or provide session_id." + )) +} + +pub(super) fn debug_message_timeout_secs() -> Option<u64> { + let raw = std::env::var("JCODE_DEBUG_MESSAGE_TIMEOUT_SECS").ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let secs = trimmed.parse::<u64>().ok()?; + if secs == 0 { None } else { Some(secs) } +} + +pub(super) async fn run_debug_message_with_timeout( + agent: Arc<Mutex<Agent>>, + msg: &str, + timeout_secs: u64, +) -> Result<String> { + let msg = msg.to_string(); + let mut handle = tokio::spawn(async move { + let mut agent = agent.lock().await; + agent.run_once_capture(&msg).await + }); + + tokio::select! { + join_result = &mut handle => { + match join_result { + Ok(result) => result, + Err(e) => Err(anyhow::anyhow!("debug message task failed: {}", e)), + } + } + _ = tokio::time::sleep(Duration::from_secs(timeout_secs)) => { + handle.abort(); + Err(anyhow::anyhow!( + "debug message timed out after {}s", + timeout_secs + )) + } + } +} + +pub(super) async fn execute_debug_command( + agent: Arc<Mutex<Agent>>, + command: &str, + debug_jobs: Arc<RwLock<HashMap<String, DebugJob>>>, + server_identity: Option<&ServerIdentity>, + interrupt_context: Option<DebugInterruptContext>, +) -> Result<String> { + let trimmed = command.trim(); + + if let Some(output) = + maybe_start_async_debug_job(Arc::clone(&agent), trimmed, Arc::clone(&debug_jobs)).await? + { + return Ok(output); + } + + if trimmed.starts_with("swarm_message:") { + let msg = trimmed.strip_prefix("swarm_message:").unwrap_or("").trim(); + if msg.is_empty() { + return Err(anyhow::anyhow!("swarm_message: requires content")); + } + + let final_text = super::run_swarm_message(agent.clone(), msg).await?; + return Ok(final_text); + } + + if trimmed.starts_with("message:") { + let msg = trimmed.strip_prefix("message:").unwrap_or("").trim(); + if let Some(timeout_secs) = debug_message_timeout_secs() { + return run_debug_message_with_timeout(agent, msg, timeout_secs).await; + } + let mut agent = agent.lock().await; + let output = agent.run_once_capture(msg).await?; + return Ok(output); + } + + if trimmed.starts_with("queue_interrupt:") { + let content = trimmed + .strip_prefix("queue_interrupt:") + .unwrap_or("") + .trim(); + if content.is_empty() { + return Err(anyhow::anyhow!("queue_interrupt: requires content")); + } + let agent = agent.lock().await; + agent.queue_soft_interrupt(content.to_string(), false, SoftInterruptSource::User); + return Ok("queued".to_string()); + } + + if trimmed.starts_with("queue_interrupt_urgent:") { + let content = trimmed + .strip_prefix("queue_interrupt_urgent:") + .unwrap_or("") + .trim(); + if content.is_empty() { + return Err(anyhow::anyhow!("queue_interrupt_urgent: requires content")); + } + let agent = agent.lock().await; + agent.queue_soft_interrupt(content.to_string(), true, SoftInterruptSource::User); + return Ok("queued (urgent)".to_string()); + } + + if trimmed.starts_with("tool:") { + let raw = trimmed.strip_prefix("tool:").unwrap_or("").trim(); + if raw.is_empty() { + return Err(anyhow::anyhow!("tool: requires a tool name")); + } + let mut parts = raw.splitn(2, |c: char| c.is_whitespace()); + let name = parts.next().unwrap_or("").trim(); + let input_raw = parts.next().unwrap_or("").trim(); + let input = if input_raw.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_str::<serde_json::Value>(input_raw)? + }; + let agent = agent.lock().await; + let output = agent.execute_tool(name, input).await?; + let payload = serde_json::json!({ + "output": output.output, + "title": output.title, + "metadata": output.metadata, + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "history" { + let agent = agent.lock().await; + let history = agent.get_history(); + return Ok(serde_json::to_string_pretty(&history).unwrap_or_else(|_| "[]".to_string())); + } + + if trimmed == "tools" { + let agent = agent.lock().await; + let tools = agent.tool_names().await; + return Ok(serde_json::to_string_pretty(&tools).unwrap_or_else(|_| "[]".to_string())); + } + + if trimmed == "tools:full" { + let agent = agent.lock().await; + let definitions = agent.tool_definitions_for_debug().await; + return Ok(serde_json::to_string_pretty(&definitions).unwrap_or_else(|_| "[]".to_string())); + } + + if trimmed == "mcp" || trimmed == "mcp:servers" { + let agent = agent.lock().await; + let tool_names = agent.tool_names().await; + let mut connected: BTreeMap<String, Vec<String>> = BTreeMap::new(); + for name in tool_names { + if let Some(rest) = name.strip_prefix("mcp__") { + let mut parts = rest.splitn(2, "__"); + if let (Some(server), Some(tool)) = (parts.next(), parts.next()) { + connected + .entry(server.to_string()) + .or_default() + .push(tool.to_string()); + } + } + } + for tools in connected.values_mut() { + tools.sort(); + } + let connected_servers: Vec<String> = connected.keys().cloned().collect(); + + let config = McpConfig::load(); + let config_path = if let Ok(jcode_dir) = crate::storage::jcode_dir() { + let path = jcode_dir.join("mcp.json"); + if path.exists() { + Some(path.to_string_lossy().to_string()) + } else { + None + } + } else { + None + }; + let mut configured_servers: Vec<String> = config.servers.keys().cloned().collect(); + configured_servers.sort(); + + return Ok(serde_json::to_string_pretty(&serde_json::json!({ + "config_path": config_path, + "configured_servers": configured_servers, + "connected_servers": connected_servers, + "connected_tools": connected, + })) + .unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "mcp:tools" { + let agent = agent.lock().await; + let tool_names = agent.tool_names().await; + let mcp_tools: Vec<&str> = tool_names + .iter() + .filter(|name| name.starts_with("mcp__")) + .map(|name| name.as_str()) + .collect(); + return Ok(serde_json::to_string_pretty(&mcp_tools).unwrap_or_else(|_| "[]".to_string())); + } + + if let Some(rest) = trimmed.strip_prefix("mcp:connect:") { + let (server_name, config_json) = match rest.find(' ') { + Some(idx) => (rest[..idx].trim(), &rest[idx + 1..]), + None => { + return Err(anyhow::anyhow!( + "Usage: mcp:connect:<server> {{\"command\":\"...\",\"args\":[...]}}" + )); + } + }; + let mut input: serde_json::Value = serde_json::from_str(config_json) + .map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?; + input["action"] = serde_json::json!("connect"); + input["server"] = serde_json::json!(server_name); + let agent = agent.lock().await; + let result = agent.execute_tool("mcp", input).await?; + return Ok(result.output); + } + + if let Some(server_name) = trimmed.strip_prefix("mcp:disconnect:") { + let server_name = server_name.trim(); + let input = serde_json::json!({"action": "disconnect", "server": server_name}); + let agent = agent.lock().await; + let result = agent.execute_tool("mcp", input).await?; + return Ok(result.output); + } + + if trimmed == "mcp:reload" { + let input = serde_json::json!({"action": "reload"}); + let mut agent = agent.lock().await; + let result = agent.execute_tool("mcp", input).await?; + agent.unlock_tools(); + return Ok(result.output); + } + + if let Some(rest) = trimmed.strip_prefix("mcp:call:") { + let (tool_path, args_json) = match rest.find(' ') { + Some(idx) => (rest[..idx].trim(), rest[idx + 1..].trim()), + None => (rest.trim(), "{}"), + }; + let mut parts = tool_path.splitn(2, ':'); + let server = parts.next().unwrap_or(""); + let tool = parts + .next() + .ok_or_else(|| anyhow::anyhow!("Usage: mcp:call:<server>:<tool> <json>"))?; + let tool_name = format!("mcp__{}__{}", server, tool); + let input: serde_json::Value = + serde_json::from_str(args_json).map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?; + let agent = agent.lock().await; + let result = agent.execute_tool(&tool_name, input).await?; + return Ok(result.output); + } + + if trimmed == "cancel" { + let content = "[CANCELLED] Generation cancelled via debug socket".to_string(); + let mut delivered_without_agent_lock = false; + + if let Some(control) = match &interrupt_context { + Some(ctx) => ctx.control_handle().await, + None => None, + } { + let _queued = + control.queue_soft_interrupt(content.clone(), 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.request_graceful_shutdown(); + } + return Ok(serde_json::json!({ + "status": "cancel_queued", + "message": "Cancel signal sent - running generation should stop promptly" + }) + .to_string()); + } + + if trimmed == "clear" || trimmed == "clear_history" { + let mut agent = agent.lock().await; + agent.clear(); + return Ok(serde_json::json!({ + "status": "cleared", + "message": "Conversation history cleared" + }) + .to_string()); + } + + if trimmed == "agent:info" { + let agent = agent.lock().await; + let info = agent.debug_info(); + return Ok(serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "agent:memory" { + let agent = agent.lock().await; + let info = agent.debug_memory_profile(); + return Ok(serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "allocator" || trimmed == "allocator:info" { + let info = crate::process_memory::allocator_info(); + return Ok(serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "allocator:purge" { + let tuning = crate::process_memory::purge_allocator()?; + let payload = serde_json::json!({ + "status": "ok", + "action": "purge", + "tuning": tuning, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if let Some(ms) = trimmed.strip_prefix("allocator:decay:") { + let ms = ms.trim(); + if ms.is_empty() { + return Err(anyhow::anyhow!( + "allocator:decay:<ms> requires a decay value in milliseconds" + )); + } + let decay_ms: isize = ms.parse().map_err(|_| { + anyhow::anyhow!("allocator:decay:<ms> requires an integer millisecond value") + })?; + let tuning = crate::process_memory::set_allocator_decay_ms(decay_ms, decay_ms)?; + let payload = serde_json::json!({ + "status": "ok", + "action": "set_decay", + "dirty_decay_ms": decay_ms, + "muzzy_decay_ms": decay_ms, + "tuning": tuning, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "allocator:profile:on" { + crate::process_memory::set_allocator_profiling_active(true)?; + let payload = serde_json::json!({ + "status": "ok", + "profiling_active": true, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "allocator:profile:off" { + crate::process_memory::set_allocator_profiling_active(false)?; + let payload = serde_json::json!({ + "status": "ok", + "profiling_active": false, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if let Some(prefix) = trimmed.strip_prefix("allocator:profile:prefix:") { + let prefix = prefix.trim(); + if prefix.is_empty() { + return Err(anyhow::anyhow!( + "allocator:profile:prefix: requires a prefix" + )); + } + crate::process_memory::set_allocator_profile_prefix(prefix)?; + let payload = serde_json::json!({ + "status": "ok", + "prefix": prefix, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "allocator:profile:dump" { + let path = crate::process_memory::dump_allocator_profile(None)?; + let payload = serde_json::json!({ + "status": "ok", + "path": path, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if let Some(path) = trimmed.strip_prefix("allocator:profile:dump ") { + let path = path.trim(); + if path.is_empty() { + return Err(anyhow::anyhow!("allocator:profile:dump requires a path")); + } + let output_path = + crate::process_memory::dump_allocator_profile(Some(std::path::Path::new(path)))?; + let payload = serde_json::json!({ + "status": "ok", + "path": output_path, + "allocator": crate::process_memory::allocator_info(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "last_response" { + let agent = agent.lock().await; + return Ok(agent + .last_assistant_text() + .unwrap_or_else(|| "last_response: none".to_string())); + } + + if trimmed == "state" { + let agent = agent.lock().await; + let mut payload = serde_json::json!({ + "session_id": agent.session_id(), + "messages": agent.message_count(), + "is_canary": agent.is_canary(), + "provider": agent.provider_name(), + "model": agent.provider_model(), + "upstream_provider": agent.last_upstream_provider(), + }); + if let Some(identity) = server_identity { + payload["server_name"] = serde_json::json!(identity.name); + payload["server_icon"] = serde_json::json!(identity.icon); + payload["server_version"] = serde_json::json!(identity.version); + } + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "usage" { + let agent = agent.lock().await; + let usage = agent.last_usage(); + return Ok(serde_json::to_string_pretty(&usage).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "help" { + return Ok( + "debug commands: state, usage, history, tools, tools:full, mcp:servers, mcp:tools, mcp:connect:<server> <json>, mcp:disconnect:<server>, mcp:reload, mcp:call:<server>:<tool> <json>, last_response, message:<text>, message_async:<text>, swarm_message:<text>, swarm_message_async:<text>, tool:<name> <json>, queue_interrupt:<content>, queue_interrupt_urgent:<content>, agent:info, agent:memory, allocator, allocator:profile:on, allocator:profile:off, allocator:profile:prefix:<prefix>, allocator:profile:dump [path], jobs, job_status:<id>, job_wait:<id>, sessions, create_session, create_session:<path>, create_session:selfdev:<path>, set_model:<model>, set_provider:<name>, trigger_extraction, available_models, reload, help".to_string() + ); + } + + if trimmed.starts_with("set_model:") { + let model = trimmed.strip_prefix("set_model:").unwrap_or("").trim(); + if model.is_empty() { + return Err(anyhow::anyhow!("set_model: requires a model name")); + } + let mut agent = agent.lock().await; + agent.set_model(model)?; + let payload = serde_json::json!({ + "model": agent.provider_model(), + "provider": agent.provider_name(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed.starts_with("set_provider:") { + let provider = trimmed + .strip_prefix("set_provider:") + .unwrap_or("") + .trim() + .to_lowercase(); + let claude_usage = crate::usage::get_sync(); + let claude_usage_exhausted = + claude_usage.five_hour >= 0.99 && claude_usage.seven_day >= 0.99; + let default_model = match provider.as_str() { + "claude" | "anthropic" => { + if claude_usage_exhausted { + "claude-sonnet-4-6" + } else { + "claude-fable-5" + } + } + "openai" | "codex" => "gpt-5.5", + "openrouter" => "anthropic/claude-sonnet-4", + "cursor" => "gpt-5", + "copilot" => "copilot:claude-sonnet-4", + "gemini" => "gemini-2.5-pro", + "antigravity" => "default", + _ => { + return Err(anyhow::anyhow!( + "Unknown provider '{}'. Use: claude, openai, openrouter, cursor, copilot, gemini, antigravity", + provider + )); + } + }; + let mut agent = agent.lock().await; + agent.set_model(default_model)?; + crate::telemetry::record_provider_switch(); + let payload = serde_json::json!({ + "model": agent.provider_model(), + "provider": agent.provider_name(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "trigger_extraction" { + let agent = agent.lock().await; + let count = agent.extract_session_memories().await; + let payload = serde_json::json!({ + "extracted": count, + "message_count": agent.message_count(), + }); + return Ok(serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())); + } + + if trimmed == "available_models" { + let agent = agent.lock().await; + let models = agent.available_models_display(); + return Ok(serde_json::to_string_pretty(&models).unwrap_or_else(|_| "[]".to_string())); + } + + if trimmed == "reload" { + let repo_dir = crate::build::get_repo_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find jcode repository directory"))?; + + let target_binary = crate::build::find_dev_binary(&repo_dir) + .unwrap_or_else(|| build::release_binary_path(&repo_dir)); + if !target_binary.exists() { + return Err(anyhow::anyhow!(format!( + "No binary found at {}. Run 'jcode self-dev --build' first, or build with 'scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode' and publish current.", + target_binary.display() + ))); + } + + let source = crate::build::current_source_state(&repo_dir)?; + let hash = source.version_label.clone(); + let published = crate::build::publish_local_current_build_for_source(&repo_dir, &source)?; + crate::build::smoke_test_server_binary(&published.versioned_path)?; + crate::build::update_shared_server_symlink(&hash)?; + crate::build::update_canary_symlink(&hash)?; + + let mut manifest = crate::build::BuildManifest::load()?; + manifest.canary = Some(hash.clone()); + manifest.canary_status = Some(crate::build::CanaryStatus::Testing); + manifest.save()?; + + let jcode_dir = crate::storage::jcode_dir()?; + let info_path = jcode_dir.join("reload-info"); + std::fs::write(&info_path, format!("reload:{}", hash))?; + + let _request_id = super::send_reload_signal(hash.clone(), None, false); + + return Ok(format!( + "Reload signal sent for build {}. Server will restart.", + hash + )); + } + + Err(anyhow::anyhow!("Unknown debug command '{}'", trimmed)) +} + +#[cfg(test)] +mod tests { + use super::{DebugInterruptContext, execute_debug_command}; + use crate::agent::Agent; + use crate::provider::{EventStream, Provider}; + use crate::tool::Registry; + use anyhow::Result; + use async_trait::async_trait; + use jcode_agent_runtime::InterruptSignal; + use std::collections::HashMap; + use std::ffi::OsString; + use std::sync::{Arc, Mutex, OnceLock}; + use std::time::{Duration, Instant}; + use tokio::sync::{Mutex as AsyncMutex, RwLock}; + + static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + struct EnvGuard { + key: &'static str, + original: Option<OsString>, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, original } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + crate::env::set_var(self.key, value); + } else { + crate::env::remove_var(self.key); + } + } + } + + struct TestProvider; + + #[async_trait] + impl Provider for TestProvider { + async fn complete( + &self, + _messages: &[crate::message::Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "test provider complete should not be called in debug command tests" + )) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self) + } + } + + #[tokio::test] + async fn debug_tool_selfdev_reload_returns_promptly_for_direct_execution() { + let _env_lock = lock_env(); + let _test_session = EnvGuard::set("JCODE_TEST_SESSION", "1"); + let _debug_control = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let mut reload_rx = crate::server::subscribe_reload_signal_for_tests(); + + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + registry.register_selfdev_tools().await; + + let mut agent = Agent::new(provider, registry); + agent.set_canary("self-dev"); + let agent = Arc::new(AsyncMutex::new(agent)); + + let debug_jobs = Arc::new(RwLock::new(HashMap::new())); + let started = Instant::now(); + let ack_task = tokio::spawn(async move { + loop { + if let Some(signal) = reload_rx.borrow_and_update().clone() { + crate::server::acknowledge_reload_signal(&signal); + return; + } + reload_rx + .changed() + .await + .expect("reload signal channel should remain open"); + } + }); + let output = tokio::time::timeout( + Duration::from_secs(2), + execute_debug_command( + agent, + r#"tool:selfdev {"action":"reload"}"#, + debug_jobs, + None, + None, + ), + ) + .await + .expect("debug selfdev reload should not hang") + .expect("debug selfdev reload should succeed"); + // Bound the ack wait: the reload must have emitted a signal for the + // acker to observe. If a regression makes `do_reload` short-circuit + // before `send_reload_signal` (e.g. the old "No binary found" path), + // this would otherwise hang forever instead of failing the test. + tokio::time::timeout(Duration::from_secs(2), ack_task) + .await + .expect("reload signal was never emitted (ack task hung)") + .expect("reload ack task should complete"); + + assert!( + started.elapsed() < Duration::from_secs(2), + "debug selfdev reload took too long" + ); + assert!( + output.contains("Reload acknowledged") || output.contains("Server is restarting now"), + "expected reload acknowledgement output, got: {}", + output + ); + } + + #[tokio::test] + async fn debug_cancel_does_not_wait_for_busy_agent_lock() { + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + let agent = Arc::new(AsyncMutex::new(Agent::new(provider, registry))); + let session_id = agent.lock().await.session_id().to_string(); + + let queue = Arc::new(std::sync::Mutex::new(Vec::new())); + let signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + signal.clone(), + )]))); + let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + queue.clone(), + )]))); + + let _busy_agent_lock = agent.lock().await; + let output = tokio::time::timeout( + Duration::from_millis(200), + execute_debug_command( + Arc::clone(&agent), + "cancel", + Arc::new(RwLock::new(HashMap::new())), + None, + Some(DebugInterruptContext { + session_id, + shutdown_signals, + soft_interrupt_queues, + }), + ), + ) + .await + .expect("debug cancel should not block on the busy agent lock") + .expect("debug cancel should succeed"); + + assert!(output.contains("cancel_queued")); + assert!(signal.is_set()); + let pending = queue.lock().expect("queue lock should not be poisoned"); + assert_eq!(pending.len(), 1); + assert!(pending[0].urgent); + } +} diff --git a/crates/jcode-app-core/src/server/debug_events.rs b/crates/jcode-app-core/src/server/debug_events.rs new file mode 100644 index 0000000..c0a0e37 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_events.rs @@ -0,0 +1,174 @@ +use super::state::MAX_EVENT_HISTORY; +use super::{SwarmEvent, SwarmEventType}; +use anyhow::Result; +use std::sync::Arc; +use tokio::io::{AsyncWrite, AsyncWriteExt}; +use tokio::sync::{RwLock, broadcast}; + +pub(super) async fn maybe_handle_event_query_command( + cmd: &str, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, +) -> Option<String> { + if cmd == "events:recent" || cmd.starts_with("events:recent:") { + let count: usize = cmd + .strip_prefix("events:recent:") + .and_then(|s| s.parse().ok()) + .unwrap_or(50); + + let history = event_history.read().await; + let events: Vec<serde_json::Value> = history + .iter() + .rev() + .take(count) + .map(event_payload) + .collect(); + return Some(serde_json::to_string_pretty(&events).unwrap_or_else(|_| "[]".to_string())); + } + + if cmd.starts_with("events:since:") { + let since_id: u64 = cmd + .strip_prefix("events:since:") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let history = event_history.read().await; + let events: Vec<serde_json::Value> = history + .iter() + .filter(|event| event.id > since_id) + .map(event_payload) + .collect(); + return Some(serde_json::to_string_pretty(&events).unwrap_or_else(|_| "[]".to_string())); + } + + if cmd == "events:types" { + return Some( + serde_json::json!({ + "types": [ + "file_touch", + "notification", + "plan_update", + "plan_proposal", + "context_update", + "status_change", + "member_change" + ], + "description": "Use events:recent, events:since:<id>, or events:subscribe to get events" + }) + .to_string(), + ); + } + + if cmd == "events:count" { + let history = event_history.read().await; + let latest_id = history.back().map(|event| event.id).unwrap_or(0); + return Some( + serde_json::json!({ + "count": history.len(), + "latest_id": latest_id, + "max_history": MAX_EVENT_HISTORY, + }) + .to_string(), + ); + } + + None +} + +pub(super) async fn maybe_handle_event_subscription_command<W: AsyncWrite + Unpin>( + id: u64, + cmd: &str, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + writer: &mut W, +) -> Result<bool> { + if cmd != "events:subscribe" && !cmd.starts_with("events:subscribe:") { + return Ok(false); + } + + let type_filter: Option<Vec<String>> = cmd + .strip_prefix("events:subscribe:") + .map(|s| s.split(',').map(|t| t.trim().to_string()).collect()); + + let ack = crate::protocol::ServerEvent::DebugResponse { + id, + ok: true, + output: serde_json::json!({ + "subscribed": true, + "filter": type_filter.as_ref().map(|f| f.join(",")), + }) + .to_string(), + }; + let json = crate::protocol::encode_event(&ack); + writer.write_all(json.as_bytes()).await?; + + let mut rx = swarm_event_tx.subscribe(); + loop { + match rx.recv().await { + Ok(event) => { + let event_type = match &event.event { + SwarmEventType::FileTouch { .. } => "file_touch", + SwarmEventType::Notification { .. } => "notification", + SwarmEventType::PlanUpdate { .. } => "plan_update", + SwarmEventType::PlanProposal { .. } => "plan_proposal", + SwarmEventType::ContextUpdate { .. } => "context_update", + SwarmEventType::StatusChange { .. } => "status_change", + SwarmEventType::MemberChange { .. } => "member_change", + }; + if let Some(ref filter) = type_filter + && !filter.iter().any(|f| f == event_type) + { + continue; + } + let timestamp_unix = event + .absolute_time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let event_json = serde_json::json!({ + "type": "event", + "id": event.id, + "session_id": event.session_id, + "session_name": event.session_name, + "swarm_id": event.swarm_id, + "event": event.event, + "timestamp_unix": timestamp_unix, + }); + let mut line = serde_json::to_string(&event_json).unwrap_or_default(); + line.push('\n'); + if writer.write_all(line.as_bytes()).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(missed)) => { + let lag_json = serde_json::json!({ + "type": "lag", + "missed": missed, + }); + let mut line = serde_json::to_string(&lag_json).unwrap_or_default(); + line.push('\n'); + if writer.write_all(line.as_bytes()).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + + Ok(true) +} + +fn event_payload(event: &SwarmEvent) -> serde_json::Value { + let timestamp_unix = event + .absolute_time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + serde_json::json!({ + "id": event.id, + "session_id": event.session_id, + "session_name": event.session_name, + "swarm_id": event.swarm_id, + "event": event.event, + "age_secs": event.timestamp.elapsed().as_secs(), + "timestamp_unix": timestamp_unix, + }) +} diff --git a/crates/jcode-app-core/src/server/debug_help.rs b/crates/jcode-app-core/src/server/debug_help.rs new file mode 100644 index 0000000..88fe730 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_help.rs @@ -0,0 +1,256 @@ +pub(super) fn parse_namespaced_command(command: &str) -> (&str, &str) { + let trimmed = command.trim(); + if let Some(idx) = trimmed.find(':') { + let namespace = &trimmed[..idx]; + let rest = &trimmed[idx + 1..]; + match namespace { + "server" | "client" | "tester" => (namespace, rest), + _ => ("server", trimmed), + } + } else { + ("server", trimmed) + } +} + +pub(super) fn debug_help_text() -> String { + r#"Debug socket commands (namespaced): + +SERVER COMMANDS (server: prefix or no prefix): + state - Get agent state + history - Get conversation history + tools - List available tools (names only) + tools:full - List tools with full definitions (input_schema) + mcp:servers - List configured + connected MCP servers + last_response - Get last assistant response + message:<text> - Send message to agent + message_async:<text> - Send message async (returns job id) + swarm_message:<text> - Plan and run subtasks via swarm workers, then integrate + swarm_message_async:<text> - Async swarm message (returns job id) + tool:<name> <json> - Execute tool directly + cancel - Cancel in-flight generation (urgent interrupt) + clear - Clear conversation history + agent:info - Get comprehensive agent internal state + agent:memory - Get process + session memory breakdown + allocator - Get allocator info and jemalloc stats, if available + allocator:purge - Release retained heap (jemalloc arena purge / glibc malloc_trim) + allocator:decay:<ms> - Set jemalloc dirty/muzzy decay for all arenas to <ms> + allocator:profile:on - Enable jemalloc sampling at runtime (jemalloc-prof builds) + allocator:profile:off - Disable jemalloc sampling at runtime (jemalloc-prof builds) + allocator:profile:prefix:<prefix> - Set jemalloc heap dump filename prefix + allocator:profile:dump [path] - Write jemalloc heap profile to default or explicit path + jobs - List async debug jobs + job_status:<id> - Get async job status/output + job_wait:<id> - Wait for async job to finish + job_cancel:<id> - Cancel a running job + jobs:purge - Remove completed/failed jobs + jobs:session:<id> - List jobs for a session + background:tasks - List background tasks + server:memory - Get global server memory breakdown + server:memory-history - Recent server process memory samples + memory-judge - No-LLM memory-mode conversion + degradation rates + embeddings:stats - Get embedding model/cache runtime stats + embeddings:load - Force-load the shared embedding model + embeddings:unload - Force-unload the shared embedding model and cache + sessions - List all sessions (with full metadata) + clients - List connected TUI clients + clients:map - Map connected clients to sessions + server:info - Server identity, health, uptime + swarm - List swarm members + status (alias: swarm:members) + swarm:help - Full swarm command reference + create_session - Create headless session + create_session:<path> - Create session with working dir + create_session:selfdev:<path> - Create headless self-dev session + destroy_session:<id> - Destroy a session + set_model:<model> - Switch model (may change provider) + set_provider:<name> - Switch provider (claude/openai/openrouter/cursor/copilot/gemini/antigravity) + trigger_extraction - Force end-of-session memory extraction + available_models - List all available models + reload - Trigger server reload with current binary + +SWARM COMMANDS (swarm: prefix): + swarm:members - List all swarm members with details + swarm:list - List all swarms with member counts + swarm:info:<swarm_id> - Full info for a swarm + swarm:coordinators - List all coordinators + swarm:roles - List all members with roles + swarm:plans - List all swarm plans + swarm:plan_version:<id> - Show plan version for a swarm + swarm:proposals - List pending plan proposals + swarm:context - List all shared context + swarm:touches - List all file touches + swarm:conflicts - Files touched by multiple sessions + swarm:channels - List channel subscriptions + swarm:broadcast:<msg> - Broadcast to swarm members + swarm:notify:<sid> <msg> - Send DM to specific session + swarm:help - Full swarm command reference + +AMBIENT COMMANDS (ambient: prefix): + ambient:status - Ambient + schedule runner state, counts, next due items + ambient:queue - Scheduled queue contents with target/session metadata + ambient:trigger - Manually trigger an ambient cycle + ambient:log - Recent transcript summaries + ambient:permissions - List pending permission requests + ambient:approve:<id> - Approve a permission request + ambient:deny:<id> [reason] - Deny a permission request (optional reason) + ambient:start - Start/restart ambient mode + ambient:stop - Stop ambient mode + ambient:help - Ambient command reference + +EVENTS COMMANDS (events: prefix): + events:recent - Get recent events (default 50) + events:recent:<N> - Get recent N events + events:since:<id> - Get events since event ID + events:count - Event count and latest ID + events:types - List available event types + events:subscribe - Subscribe to all events (streaming) + events:subscribe:<types> - Subscribe filtered (e.g. status_change,member_change) + +CLIENT COMMANDS (client: prefix): + client:state - Get TUI state + client:picker - Get live inline picker state (filter/counts/visible rows) + client:picker:<n> - Get live inline picker state with n-row render window + client:model-picker - Materialize source-of-truth TUI model picker entries/routes + client:model-picker:<n> - Materialize TUI model picker with n-row/route sample limit + client:frame - Get latest visual debug frame (JSON) + client:frame-normalized - Get normalized frame (for diffs) + client:screen - Dump visual debug to file + client:layout - Get latest layout JSON + client:margins - Get layout margins JSON + client:widgets - Get info widget summary/placements + client:render-stats - Get render timing + order + draw-call attribution JSON + client:draw-stats [n] - Get per-draw attribution history (render_ms, changed cells) + client:render-order - Get render order list + client:anomalies - Get latest visual debug anomalies + client:theme - Get palette snapshot + client:mermaid:stats - Get mermaid render/cache stats + client:mermaid:memory - Mermaid memory profile (RSS + cache estimates) + client:mermaid:memory-bench [n] - Synthetic Mermaid memory benchmark + client:mermaid:flicker-bench [n] - Benchmark viewport protocol churn / flicker risk + client:image-scroll-bench [imgs] [frames] [visible] - Benchmark inline-image scroll latency (stat syscalls + fit-state rebuilds) + client:mermaid:ui-bench[:<j>] - Benchmark live Mermaid UI render path + client:mermaid:cache - List mermaid cache entries + client:mermaid:state - Get image state (resize modes) + client:mermaid:test - Render test diagram + client:mermaid:scroll - Run scroll simulation test + client:mermaid:render <c> - Render arbitrary mermaid + client:mermaid:evict - Clear mermaid cache + client:markdown:stats - Get markdown render stats + client:markdown:memory - Markdown highlight cache memory estimate + client:memory - Aggregate client memory profile + client:memory-history - Recent client process memory samples + client:allocator - Get the target TUI client's allocator stats + client:allocator:purge - Release the target TUI client's retained heap + client:flicker-frames [n] - Recent frame-stability / flicker records + client:slow-frames [n] - Recent slow-frame records + client:overlay:on/off - Toggle overlay boxes + client:input - Get current input buffer + client:set_input:<text> - Set input buffer + client:keys:<keyspec> - Inject key events + client:message:<text> - Inject and submit message + client:inject:<role>:<t> - Inject display message (no send) + client:scroll:<dir> - Scroll (up/down/top/bottom) + client:scroll-test[:<j>] - Run offscreen scroll+diagram test + client:scroll-suite[:<j>] - Run scroll+diagram test suite + client:side-panel-latency[:<j>] - Benchmark headless side-panel input->frame latency + client:side-panel:stats - Current side-panel debug snapshot, including live Mermaid utilization + client:diagram-pane:stats - Current pinned diagram pane snapshot, including live Mermaid utilization + client:wait - Check if processing + client:history - Get display messages + client:help - Client command help + +TESTER COMMANDS (tester: prefix): + tester:spawn - Spawn new tester instance + tester:list - List active testers + tester:<id>:frame - Get frame from tester + tester:<id>:message:<t> - Send message to tester + tester:<id>:inject:<t> - Inject display message (no send) + tester:<id>:state - Get tester state + tester:<id>:scroll-test - Run offscreen scroll+diagram test + tester:<id>:scroll-suite - Run scroll+diagram test suite + tester:<id>:side-panel-latency - Benchmark headless side-panel input->frame latency + tester:<id>:mermaid-ui-bench - Benchmark live Mermaid UI render path + tester:<id>:stop - Stop tester + +Examples: + {"type":"debug_command","id":1,"command":"state"} + {"type":"debug_command","id":2,"command":"client:frame"} + {"type":"debug_command","id":3,"command":"tester:list"} + {"type":"debug_command","id":4,"command":"set_provider:openai","session_id":"..."} + {"type":"debug_command","id":5,"command":"swarm:info:/home/user/project"}"# + .to_string() +} + +pub(super) fn swarm_debug_help_text() -> String { + r#"Swarm debug commands (swarm: prefix): + +MEMBERS & STRUCTURE: + swarm - List all swarm members (alias for swarm:members) + swarm:members - List all swarm members with full details + swarm:list - List all swarm IDs with member counts and coordinators + swarm:info:<swarm_id> - Full info: members, coordinator, plan, context, conflicts + +COORDINATORS & ROLES: + swarm:coordinators - List all coordinators (swarm_id -> session_id) + swarm:coordinator:<id> - Get coordinator for specific swarm + swarm:clear_coordinator:<id> - Admin: forcibly clear coordinator so any session can self-promote + swarm:roles - List all members with their roles + +PLANS (server-scoped plan items): + swarm:plans - List all swarm plans with item counts + swarm:plan:<swarm_id> - Get plan items for specific swarm + swarm:plan_version:<id> - Show current plan version for a swarm + swarm:clear_plan:<id> - Admin: delete a swarm's plan (memory + persisted state) + +PLAN PROPOSALS (pending approval): + swarm:proposals - List all pending proposals across swarms + swarm:proposals:<swarm> - List proposals for a specific swarm (with items) + swarm:proposals:<sess> - Get detailed proposal from a session + +SHARED CONTEXT (key-value store): + swarm:context - List all shared context entries + swarm:context:<swarm_id> - List context for specific swarm + swarm:context:<swarm_id>:<key> - Get specific context value + +FILE TOUCHES (conflict detection): + swarm:touches - List all file touches (path, session, op, age, timestamp) + swarm:touches:<path> - Get touches for specific file + swarm:touches:swarm:<id> - Get touches filtered by swarm members + swarm:conflicts - List files touched by multiple sessions + +NOTIFICATIONS: + swarm:broadcast:<msg> - Broadcast message to all members of your swarm + swarm:broadcast:<swarm_id> <msg> - Broadcast to specific swarm + swarm:notify:<session_id> <msg> - Send direct message to specific session + +EXECUTION STATE: + swarm:session:<id> - Detailed session state (interrupts, provider, usage) + swarm:interrupts - List pending interrupts across all sessions + +CHANNELS: + swarm:channels - List channel subscriptions per swarm + +OPERATIONS (debug-only, bypass tool:communicate): + swarm:set_context:<sess> <key> <value> - Set shared context as session + swarm:approve_plan:<coord> <proposer> - Approve plan proposal (coordinator only) + swarm:reject_plan:<coord> <proposer> [reason] - Reject plan proposal + +UTILITIES: + swarm:id:<path> - Compute swarm_id for a path and show provenance + +REAL-TIME EVENTS: + events:recent - Get recent 50 events + events:recent:<N> - Get recent N events + events:since:<id> - Get events since event ID (for polling) + events:count - Get event count and latest ID + events:types - List available event types + events:subscribe - Subscribe to all events (streaming, keeps connection open) + events:subscribe:<types> - Subscribe filtered (e.g. events:subscribe:status_change,member_change) + +Examples: + {"type":"debug_command","id":1,"command":"swarm:list"} + {"type":"debug_command","id":2,"command":"swarm:info:/home/user/myproject"} + {"type":"debug_command","id":3,"command":"swarm:plan:/home/user/myproject"} + {"type":"debug_command","id":4,"command":"swarm:broadcast:Build complete, ready for review"} + {"type":"debug_command","id":5,"command":"swarm:notify:session_fox_123 Please review PR #42"}"# + .to_string() +} diff --git a/crates/jcode-app-core/src/server/debug_jobs.rs b/crates/jcode-app-core/src/server/debug_jobs.rs new file mode 100644 index 0000000..008dcac --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_jobs.rs @@ -0,0 +1,324 @@ +use crate::agent::Agent; +use crate::id; +use anyhow::Result; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; + +#[derive(Clone, Debug)] +pub(super) enum DebugJobStatus { + Queued, + Running, + Completed, + Failed, +} + +impl DebugJobStatus { + pub(super) fn as_str(&self) -> &'static str { + match self { + DebugJobStatus::Queued => "queued", + DebugJobStatus::Running => "running", + DebugJobStatus::Completed => "completed", + DebugJobStatus::Failed => "failed", + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct DebugJob { + pub(super) id: String, + pub(super) status: DebugJobStatus, + pub(super) command: String, + pub(super) session_id: Option<String>, + pub(super) created_at: Instant, + pub(super) started_at: Option<Instant>, + pub(super) finished_at: Option<Instant>, + pub(super) output: Option<String>, + pub(super) error: Option<String>, +} + +impl DebugJob { + pub(super) fn summary_payload(&self) -> Value { + let now = Instant::now(); + let elapsed_secs = now.duration_since(self.created_at).as_secs_f64(); + let run_secs = self.started_at.map(|s| now.duration_since(s).as_secs_f64()); + let total_secs = self + .finished_at + .map(|f| f.duration_since(self.created_at).as_secs_f64()); + + serde_json::json!({ + "id": self.id.clone(), + "status": self.status.as_str(), + "command": self.command.clone(), + "session_id": self.session_id.clone(), + "elapsed_secs": elapsed_secs, + "run_secs": run_secs, + "total_secs": total_secs, + }) + } + + pub(super) fn status_payload(&self) -> Value { + let mut payload = self.summary_payload(); + if let Some(obj) = payload.as_object_mut() { + obj.insert("output".to_string(), serde_json::json!(self.output.clone())); + obj.insert("error".to_string(), serde_json::json!(self.error.clone())); + } + payload + } +} + +pub(super) async fn maybe_start_async_debug_job( + agent: Arc<Mutex<Agent>>, + trimmed: &str, + debug_jobs: Arc<RwLock<HashMap<String, DebugJob>>>, +) -> Result<Option<String>> { + if trimmed.starts_with("swarm_message_async:") { + let msg = trimmed + .strip_prefix("swarm_message_async:") + .unwrap_or("") + .trim(); + if msg.is_empty() { + return Err(anyhow::anyhow!("swarm_message_async: requires content")); + } + + let job_id = create_job(&agent, &debug_jobs, format!("swarm_message:{}", msg)).await; + + let jobs = Arc::clone(&debug_jobs); + let agent = Arc::clone(&agent); + let msg = msg.to_string(); + let job_id_inner = job_id.clone(); + tokio::spawn(async move { + mark_job_running(&jobs, &job_id_inner).await; + + let result = super::run_swarm_message(agent.clone(), &msg).await; + let partial_output = if result.is_err() { + let agent = agent.lock().await; + agent.last_assistant_text() + } else { + None + }; + + finish_job(jobs, &job_id_inner, result, partial_output).await; + }); + + return Ok(Some(serde_json::json!({ "job_id": job_id }).to_string())); + } + + if trimmed.starts_with("message_async:") { + let msg = trimmed.strip_prefix("message_async:").unwrap_or("").trim(); + if msg.is_empty() { + return Err(anyhow::anyhow!("message_async: requires content")); + } + + let job_id = create_job(&agent, &debug_jobs, format!("message:{}", msg)).await; + + let jobs = Arc::clone(&debug_jobs); + let agent = Arc::clone(&agent); + let msg = msg.to_string(); + let job_id_inner = job_id.clone(); + tokio::spawn(async move { + mark_job_running(&jobs, &job_id_inner).await; + + let result = { + let mut agent = agent.lock().await; + agent.run_once_capture(&msg).await + }; + let partial_output = if result.is_err() { + let agent = agent.lock().await; + agent.last_assistant_text() + } else { + None + }; + + finish_job(jobs, &job_id_inner, result, partial_output).await; + }); + + return Ok(Some(serde_json::json!({ "job_id": job_id }).to_string())); + } + + Ok(None) +} + +pub(super) async fn maybe_handle_job_command( + cmd: &str, + debug_jobs: &Arc<RwLock<HashMap<String, DebugJob>>>, +) -> Result<Option<String>> { + if cmd == "jobs" { + let jobs_guard = debug_jobs.read().await; + let payload: Vec<Value> = jobs_guard + .values() + .map(|job| job.summary_payload()) + .collect(); + return Ok(Some( + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("job_status:") { + let job_id = cmd.strip_prefix("job_status:").unwrap_or("").trim(); + if job_id.is_empty() { + return Err(anyhow::anyhow!("job_status: requires a job id")); + } + let jobs_guard = debug_jobs.read().await; + let output = jobs_guard + .get(job_id) + .map(|job| { + serde_json::to_string_pretty(&job.status_payload()) + .unwrap_or_else(|_| "{}".to_string()) + }) + .ok_or_else(|| anyhow::anyhow!("Unknown job id '{}'", job_id))?; + return Ok(Some(output)); + } + + if cmd.starts_with("job_cancel:") { + let job_id = cmd.strip_prefix("job_cancel:").unwrap_or("").trim(); + if job_id.is_empty() { + return Err(anyhow::anyhow!("job_cancel: requires a job id")); + } + let mut jobs_guard = debug_jobs.write().await; + let output = if let Some(job) = jobs_guard.get_mut(job_id) { + if matches!(job.status, DebugJobStatus::Running | DebugJobStatus::Queued) { + job.status = DebugJobStatus::Failed; + job.output = Some("[CANCELLED]".to_string()); + serde_json::json!({ + "status": "cancelled", + "job_id": job_id, + }) + .to_string() + } else { + return Err(anyhow::anyhow!("Job '{}' is not running", job_id)); + } + } else { + return Err(anyhow::anyhow!("Unknown job id '{}'", job_id)); + }; + return Ok(Some(output)); + } + + if cmd == "jobs:purge" { + let mut jobs_guard = debug_jobs.write().await; + let before = jobs_guard.len(); + jobs_guard.retain(|_, job| { + matches!(job.status, DebugJobStatus::Running | DebugJobStatus::Queued) + }); + let removed = before - jobs_guard.len(); + return Ok(Some( + serde_json::json!({ + "status": "purged", + "removed": removed, + "remaining": jobs_guard.len(), + }) + .to_string(), + )); + } + + if cmd.starts_with("jobs:session:") { + let sess_id = cmd.strip_prefix("jobs:session:").unwrap_or("").trim(); + let jobs_guard = debug_jobs.read().await; + let payload: Vec<Value> = jobs_guard + .values() + .filter(|job| job.session_id.as_deref() == Some(sess_id)) + .map(|job| job.summary_payload()) + .collect(); + return Ok(Some( + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("job_wait:") { + let job_id = cmd.strip_prefix("job_wait:").unwrap_or("").trim(); + if job_id.is_empty() { + return Err(anyhow::anyhow!("job_wait: requires a job id")); + } + let timeout = Duration::from_secs(900); + let start = Instant::now(); + loop { + { + let jobs_guard = debug_jobs.read().await; + if let Some(job) = jobs_guard.get(job_id) { + if matches!( + job.status, + DebugJobStatus::Completed | DebugJobStatus::Failed + ) { + return Ok(Some( + serde_json::to_string_pretty(&job.status_payload()) + .unwrap_or_else(|_| "{}".to_string()), + )); + } + } else { + return Err(anyhow::anyhow!("Unknown job id '{}'", job_id)); + } + } + if start.elapsed() > timeout { + return Err(anyhow::anyhow!("Timeout waiting for job '{}'", job_id)); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + Ok(None) +} + +async fn create_job( + agent: &Arc<Mutex<Agent>>, + debug_jobs: &Arc<RwLock<HashMap<String, DebugJob>>>, + command: String, +) -> String { + let session = { + let agent = agent.lock().await; + agent.session_id().to_string() + }; + let job_id = id::new_id("job"); + { + let mut jobs = debug_jobs.write().await; + jobs.insert( + job_id.clone(), + DebugJob { + id: job_id.clone(), + status: DebugJobStatus::Queued, + command, + session_id: Some(session), + created_at: Instant::now(), + started_at: None, + finished_at: None, + output: None, + error: None, + }, + ); + } + job_id +} + +async fn mark_job_running(debug_jobs: &Arc<RwLock<HashMap<String, DebugJob>>>, job_id: &str) { + let mut jobs = debug_jobs.write().await; + if let Some(job) = jobs.get_mut(job_id) { + job.status = DebugJobStatus::Running; + job.started_at = Some(Instant::now()); + } +} + +async fn finish_job( + debug_jobs: Arc<RwLock<HashMap<String, DebugJob>>>, + job_id: &str, + result: Result<String>, + partial_output: Option<String>, +) { + let mut jobs = debug_jobs.write().await; + if let Some(job) = jobs.get_mut(job_id) { + job.finished_at = Some(Instant::now()); + match result { + Ok(output) => { + job.status = DebugJobStatus::Completed; + job.output = Some(output); + } + Err(error) => { + job.status = DebugJobStatus::Failed; + job.error = Some(error.to_string()); + if let Some(output) = partial_output { + job.output = Some(output); + } + } + } + } +} diff --git a/crates/jcode-app-core/src/server/debug_server_state.rs b/crates/jcode-app-core/src/server/debug_server_state.rs new file mode 100644 index 0000000..ef0a903 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_server_state.rs @@ -0,0 +1,817 @@ +use super::{ + ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, FileTouchService, ServerIdentity, + SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, +}; +use crate::agent::Agent; +use anyhow::Result; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +async fn connected_session_snapshot( + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> ( + Vec<(String, Arc<Mutex<Agent>>)>, + HashMap<String, SwarmMember>, +) { + // Never hold more than one shared-state lock at a time. Resume transitions + // use client_connections -> sessions atomically, so a debug snapshot that + // retained sessions while awaiting client_connections could deadlock them. + let connected_sessions: HashSet<String> = { + let connections = client_connections.read().await; + connections.values().map(|c| c.session_id.clone()).collect() + }; + let connected_agents = { + let sessions_guard = sessions.read().await; + sessions_guard + .iter() + .filter(|(session_id, _)| connected_sessions.contains(*session_id)) + .map(|(session_id, agent)| (session_id.clone(), Arc::clone(agent))) + .collect() + }; + let members = swarm_members.read().await.clone(); + (connected_agents, members) +} + +#[expect( + clippy::too_many_arguments, + reason = "server-state debug command inspects many shared server structures in one snapshot" +)] +pub(super) async fn maybe_handle_server_state_command( + cmd: &str, + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_debug_state: &Arc<RwLock<ClientDebugState>>, + server_identity: &ServerIdentity, + server_start_time: Instant, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + debug_jobs: &Arc<RwLock<HashMap<String, DebugJob>>>, + event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>, + shutdown_signals: &Arc<RwLock<HashMap<String, jcode_agent_runtime::InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, +) -> Result<Option<String>> { + if cmd == "sessions" { + let (connected_agents, members) = + connected_session_snapshot(sessions, client_connections, swarm_members).await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (sid, agent_arc) in &connected_agents { + let member_info = members.get(sid); + let member_status = member_info.map(|m| m.status.as_str()); + let (provider, model, is_processing, working_dir_str, token_usage): ( + Option<String>, + Option<String>, + bool, + Option<String>, + Option<serde_json::Value>, + ) = if let Ok(agent) = agent_arc.try_lock() { + let usage = agent.last_usage(); + ( + Some(agent.provider_name()), + Some(agent.provider_model()), + member_status == Some("running"), + agent.working_dir().map(|p| p.to_string()), + Some(serde_json::json!({ + "input": usage.input_tokens, + "output": usage.output_tokens, + "cache_read": usage.cache_read_input_tokens, + "cache_write": usage.cache_creation_input_tokens, + })), + ) + } else { + (None, None, member_status == Some("running"), None, None) + }; + let final_working_dir: Option<String> = working_dir_str.or_else(|| { + member_info.and_then(|m| { + m.working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + }) + }); + out.push(serde_json::json!({ + "session_id": sid, + "friendly_name": member_info.and_then(|m| m.friendly_name.clone()), + "provider": provider, + "model": model, + "is_processing": is_processing, + "working_dir": final_working_dir, + "swarm_id": member_info.and_then(|m| m.swarm_id.clone()), + "status": member_info.map(|m| m.status.clone()), + "detail": member_info.and_then(|m| m.detail.clone()), + "token_usage": token_usage, + "server_name": server_identity.name, + "server_icon": server_identity.icon, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd == "background" || cmd == "background:tasks" { + let tasks = crate::background::global().list().await; + return Ok(Some( + serde_json::json!({ + "count": tasks.len(), + "tasks": tasks, + }) + .to_string(), + )); + } + + if cmd == "memory" || cmd == "server:memory" { + let payload = build_server_memory_payload( + sessions, + client_connections, + swarm_members, + client_debug_state, + server_identity, + server_start_time, + swarms_by_id, + shared_context, + swarm_plans, + swarm_coordinators, + file_touch, + channel_subscriptions, + channel_subscriptions_by_session, + debug_jobs, + event_history, + shutdown_signals, + soft_interrupt_queues, + ) + .await; + return Ok(Some( + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()), + )); + } + + if cmd == "memory-history" || cmd == "server:memory-history" { + return Ok(Some( + serde_json::to_string_pretty(&crate::process_memory::history(256)) + .unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd == "memory-judge" || cmd == "memory:judge" || cmd == "server:memory-judge" { + // Attribution of no-LLM memory-mode conversions: how often a surfacing + // turn ran the LLM judge vs converted (intended opt-out/cadence vs the + // degradations we drive to zero). See `memory_judge_metrics`. + return Ok(Some( + serde_json::to_string_pretty(&jcode_base::memory_judge_metrics::snapshot()) + .unwrap_or_else(|_| "{}".to_string()), + )); + } + + if cmd == "embeddings" || cmd == "embeddings:stats" { + return Ok(Some( + serde_json::to_string_pretty(&crate::embedding::stats()) + .unwrap_or_else(|_| "{}".to_string()), + )); + } + + if cmd == "embeddings:load" { + let result = crate::embedding::get_embedder(); + let payload = match result { + Ok(_) => serde_json::json!({ + "status": "loaded", + "embeddings": crate::embedding::stats(), + }), + Err(err) => serde_json::json!({ + "status": "error", + "error": err.to_string(), + "embeddings": crate::embedding::stats(), + }), + }; + return Ok(Some( + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()), + )); + } + + if cmd == "embeddings:unload" { + let unloaded = crate::embedding::unload_now(); + let payload = serde_json::json!({ + "status": if unloaded { "unloaded" } else { "noop" }, + "embeddings": crate::embedding::stats(), + }); + return Ok(Some( + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()), + )); + } + + if cmd == "info" || cmd == "server:info" { + let uptime_secs = server_start_time.elapsed().as_secs(); + let session_count = sessions.read().await.len(); + let member_count = swarm_members.read().await.len(); + let has_update = super::server_has_newer_binary(); + return Ok(Some( + serde_json::json!({ + "id": server_identity.id, + "name": server_identity.name, + "icon": server_identity.icon, + "version": server_identity.version, + "git_hash": server_identity.git_hash, + "uptime_secs": uptime_secs, + "session_count": session_count, + "swarm_member_count": member_count, + "has_update": has_update, + "debug_control_enabled": super::debug_control_allowed(), + }) + .to_string(), + )); + } + + if cmd == "clients:map" || cmd == "clients:mapping" { + let connections = client_connections.read().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for info in connections.values() { + let member = members.get(&info.session_id); + out.push(serde_json::json!({ + "client_id": info.client_id, + "session_id": info.session_id, + "friendly_name": member.and_then(|m| m.friendly_name.clone()), + "working_dir": member.and_then(|m| m.working_dir.clone()), + "swarm_id": member.and_then(|m| m.swarm_id.clone()), + "status": member.map(|m| m.status.clone()), + "detail": member.and_then(|m| m.detail.clone()), + "connected_secs_ago": info.connected_at.elapsed().as_secs(), + "last_seen_secs_ago": info.last_seen.elapsed().as_secs(), + })); + } + return Ok(Some( + serde_json::json!({ + "count": out.len(), + "clients": out, + }) + .to_string(), + )); + } + + if cmd == "clients" { + let debug_state = client_debug_state.read().await; + let client_ids: Vec<&String> = debug_state.clients.keys().collect(); + return Ok(Some( + serde_json::json!({ + "count": debug_state.clients.len(), + "active_id": debug_state.active_id, + "client_ids": client_ids, + }) + .to_string(), + )); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn connected_session_snapshot_releases_connections_before_waiting_for_sessions() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let client_connections = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + + let sessions_gate = sessions.write().await; + let snapshot = connected_session_snapshot(&sessions, &client_connections, &swarm_members); + tokio::pin!(snapshot); + tokio::select! { + _ = &mut snapshot => panic!("snapshot unexpectedly completed"), + _ = tokio::time::sleep(Duration::from_millis(20)) => {} + } + + let connections_guard = + tokio::time::timeout(Duration::from_millis(100), client_connections.write()) + .await + .expect("debug snapshot retained connections while waiting for sessions"); + drop(connections_guard); + + drop(sessions_gate); + let (connected_agents, members) = + tokio::time::timeout(Duration::from_secs(1), &mut snapshot) + .await + .expect("debug snapshot deadlocked"); + assert!(connected_agents.is_empty()); + assert!(members.is_empty()); + } +} + +#[expect( + clippy::too_many_arguments, + reason = "server memory payload aggregates many live server structures into one debug snapshot" +)] +async fn build_server_memory_payload( + sessions: &SessionAgents, + client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + client_debug_state: &Arc<RwLock<ClientDebugState>>, + server_identity: &ServerIdentity, + server_start_time: Instant, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + debug_jobs: &Arc<RwLock<HashMap<String, DebugJob>>>, + event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>, + shutdown_signals: &Arc<RwLock<HashMap<String, jcode_agent_runtime::InterruptSignal>>>, + soft_interrupt_queues: &SessionInterruptQueues, +) -> serde_json::Value { + let process = crate::process_memory::snapshot_with_source("server:memory"); + let background_tasks = crate::background::global().list().await; + let embedder_stats = crate::embedding::stats(); + let (search_index_count, search_index_entries, search_index_bytes) = + crate::tool::session_search_index::cache_memory_stats(); + let embedding_model_available = crate::embedding::is_model_available(); + + let sessions_guard = sessions.read().await; + let mut locked_session_profiles: Vec<serde_json::Value> = Vec::new(); + let mut session_json_bytes = 0u64; + let mut session_payload_text_bytes = 0u64; + let mut session_message_count = 0u64; + let mut session_provider_cache_json_bytes = 0u64; + let mut session_tool_result_bytes = 0u64; + let mut session_provider_cache_tool_result_bytes = 0u64; + let mut session_large_blob_bytes = 0u64; + let mut session_provider_cache_large_blob_bytes = 0u64; + let mut locked_session_count = 0usize; + let mut contended_session_count = 0usize; + for (session_id, agent_arc) in sessions_guard.iter() { + if let Ok(agent) = agent_arc.try_lock() { + locked_session_count += 1; + let profile = agent.debug_memory_profile(); + let session_profile = profile.get("session").cloned().unwrap_or_default(); + let totals = session_profile.get("totals").cloned().unwrap_or_default(); + let messages = session_profile.get("messages").cloned().unwrap_or_default(); + let provider_cache = session_profile + .get("provider_messages_cache") + .cloned() + .unwrap_or_default(); + let json_bytes = totals + .get("json_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let payload_bytes = totals + .get("payload_text_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let message_count = messages + .get("count") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let provider_cache_json_bytes = totals + .get("provider_cache_json_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or_else(|| { + provider_cache + .get("json_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }); + let tool_result_bytes = totals + .get("canonical_tool_result_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or_else(|| { + messages + .get("memory") + .and_then(|value| value.get("tool_result_bytes")) + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }); + let provider_cache_tool_result_bytes = totals + .get("provider_cache_tool_result_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or_else(|| { + provider_cache + .get("memory") + .and_then(|value| value.get("tool_result_bytes")) + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }); + let large_blob_bytes = totals + .get("canonical_large_blob_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or_else(|| { + messages + .get("memory") + .and_then(|value| value.get("large_block_bytes")) + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }); + let provider_cache_large_blob_bytes = totals + .get("provider_cache_large_blob_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or_else(|| { + provider_cache + .get("memory") + .and_then(|value| value.get("large_block_bytes")) + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }); + session_json_bytes += json_bytes; + session_payload_text_bytes += payload_bytes; + session_message_count += message_count; + session_provider_cache_json_bytes += provider_cache_json_bytes; + session_tool_result_bytes += tool_result_bytes; + session_provider_cache_tool_result_bytes += provider_cache_tool_result_bytes; + session_large_blob_bytes += large_blob_bytes; + session_provider_cache_large_blob_bytes += provider_cache_large_blob_bytes; + locked_session_profiles.push(serde_json::json!({ + "session_id": session_id, + "provider": agent.provider_name(), + "model": agent.provider_model(), + "messages": message_count, + "json_bytes": json_bytes, + "payload_text_bytes": payload_bytes, + "provider_cache_json_bytes": provider_cache_json_bytes, + "tool_result_bytes": tool_result_bytes, + "provider_cache_tool_result_bytes": provider_cache_tool_result_bytes, + "large_blob_bytes": large_blob_bytes, + "provider_cache_large_blob_bytes": provider_cache_large_blob_bytes, + "working_dir": agent.working_dir(), + })); + } else { + contended_session_count += 1; + } + } + drop(sessions_guard); + + locked_session_profiles.sort_by(|left, right| { + right["json_bytes"] + .as_u64() + .unwrap_or(0) + .cmp(&left["json_bytes"].as_u64().unwrap_or(0)) + }); + let top_sessions: Vec<serde_json::Value> = + locked_session_profiles.into_iter().take(12).collect(); + + let connections = client_connections.read().await; + let client_connection_estimate_bytes: usize = connections + .values() + .map(estimate_client_connection_bytes) + .sum(); + let connected_client_count = connections.len(); + drop(connections); + + let debug_state = client_debug_state.read().await; + let debug_clients_count = debug_state.clients.len(); + let debug_client_id_bytes: usize = debug_state.clients.keys().map(|id| id.len()).sum(); + drop(debug_state); + + let members = swarm_members.read().await; + let swarm_member_estimate_bytes: usize = + members.values().map(estimate_swarm_member_bytes).sum(); + let swarm_status_counts = + summarize_status_counts(members.values().map(|member| member.status.as_str())); + let swarm_member_count = members.len(); + drop(members); + + let swarms = swarms_by_id.read().await; + let swarm_membership_count: usize = swarms.values().map(|set| set.len()).sum(); + let swarms_estimate_bytes: usize = swarms + .iter() + .map(|(swarm_id, members)| { + swarm_id.len() + members.iter().map(|sid| sid.len()).sum::<usize>() + }) + .sum(); + let swarm_count = swarms.len(); + drop(swarms); + + let context = shared_context.read().await; + let shared_context_entry_count: usize = context.values().map(|entries| entries.len()).sum(); + let shared_context_estimate_bytes: usize = context + .values() + .flat_map(|entries| entries.values()) + .map(estimate_shared_context_bytes) + .sum(); + let shared_context_swarm_count = context.len(); + drop(context); + + let plans = swarm_plans.read().await; + let swarm_plan_count = plans.len(); + let swarm_plan_item_count: usize = plans.values().map(|plan| plan.items.len()).sum(); + let swarm_plan_estimate_bytes: usize = plans + .iter() + .map(|(swarm_id, plan)| { + swarm_id.len() + + crate::process_memory::estimate_json_bytes(&plan.items) + + plan.participants.iter().map(|sid| sid.len()).sum::<usize>() + }) + .sum(); + drop(plans); + + let coordinators = swarm_coordinators.read().await; + let swarm_coordinator_count = coordinators.len(); + let swarm_coordinator_bytes: usize = coordinators + .iter() + .map(|(swarm_id, session_id)| swarm_id.len() + session_id.len()) + .sum(); + drop(coordinators); + + let touches = file_touch.snapshot().await; + let file_touch_path_count = touches.len(); + let file_touch_entry_count: usize = touches.values().map(|entries| entries.len()).sum(); + let file_touch_estimate_bytes: usize = touches + .iter() + .map(|(path, entries)| { + path_len(path) + + entries + .iter() + .map(estimate_file_access_bytes) + .sum::<usize>() + }) + .sum(); + drop(touches); + + let touched_by_session = file_touch.reverse_snapshot().await; + let touched_session_count = touched_by_session.len(); + let touched_session_estimate_bytes: usize = touched_by_session + .iter() + .map(|(session_id, paths)| { + session_id.len() + paths.iter().map(|path| path_len(path)).sum::<usize>() + }) + .sum(); + drop(touched_by_session); + + let subscriptions = channel_subscriptions.read().await; + let subscription_swarm_count = subscriptions.len(); + let subscription_channel_count: usize = subscriptions.values().map(|map| map.len()).sum(); + let subscription_member_count: usize = subscriptions + .values() + .flat_map(|channels| channels.values()) + .map(|members| members.len()) + .sum(); + let subscription_estimate_bytes: usize = subscriptions + .iter() + .map(|(swarm_id, channels)| { + swarm_id.len() + + channels + .iter() + .map(|(channel, members)| { + channel.len() + members.iter().map(|sid| sid.len()).sum::<usize>() + }) + .sum::<usize>() + }) + .sum(); + drop(subscriptions); + + let subscriptions_by_session = channel_subscriptions_by_session.read().await; + let subscriptions_by_session_count = subscriptions_by_session.len(); + let subscriptions_by_session_estimate_bytes: usize = subscriptions_by_session + .iter() + .map(|(session_id, swarms)| { + session_id.len() + + swarms + .iter() + .map(|(swarm_id, channels)| { + swarm_id.len() + channels.iter().map(|channel| channel.len()).sum::<usize>() + }) + .sum::<usize>() + }) + .sum(); + drop(subscriptions_by_session); + + let jobs = debug_jobs.read().await; + let debug_job_count = jobs.len(); + let debug_job_estimate_bytes: usize = jobs.values().map(estimate_debug_job_bytes).sum(); + let debug_job_output_bytes: usize = jobs + .values() + .map(|job| job.output.as_ref().map(|value| value.len()).unwrap_or(0)) + .sum(); + drop(jobs); + + let events = event_history.read().await; + let event_history_count = events.len(); + let event_history_estimate_bytes: usize = events.iter().map(estimate_swarm_event_bytes).sum(); + drop(events); + + let shutdown = shutdown_signals.read().await; + let shutdown_signal_count = shutdown.len(); + let shutdown_signal_bytes: usize = shutdown.keys().map(|sid| sid.len()).sum(); + drop(shutdown); + + let soft_queues = soft_interrupt_queues.read().await; + let mut soft_interrupt_session_count = soft_queues.len(); + let mut soft_interrupt_count = 0usize; + let mut soft_interrupt_text_bytes = 0usize; + for queue in soft_queues.values() { + if let Ok(queue) = queue.lock() { + soft_interrupt_count += queue.len(); + soft_interrupt_text_bytes += queue.iter().map(|item| item.content.len()).sum::<usize>(); + } + } + if soft_interrupt_session_count == 0 && soft_interrupt_count > 0 { + soft_interrupt_session_count = 1; + } + drop(soft_queues); + + let background_task_count = background_tasks.len(); + let background_task_json_bytes: usize = background_tasks + .iter() + .map(crate::process_memory::estimate_json_bytes) + .sum(); + + serde_json::json!({ + "server": { + "id": server_identity.id, + "name": server_identity.name, + "icon": server_identity.icon, + "version": server_identity.version, + "git_hash": server_identity.git_hash, + "uptime_secs": server_start_time.elapsed().as_secs(), + }, + "process": process, + "history": crate::process_memory::history(128), + "clients": { + "connected_clients": connected_client_count, + "debug_clients": debug_clients_count, + "connection_estimate_bytes": client_connection_estimate_bytes, + "debug_client_id_bytes": debug_client_id_bytes, + }, + "sessions": { + "live_count": locked_session_count + contended_session_count, + "locked_count": locked_session_count, + "contended_count": contended_session_count, + "total_message_count": session_message_count, + "total_json_bytes": session_json_bytes, + "total_payload_text_bytes": session_payload_text_bytes, + "total_provider_cache_json_bytes": session_provider_cache_json_bytes, + "total_tool_result_bytes": session_tool_result_bytes, + "total_provider_cache_tool_result_bytes": session_provider_cache_tool_result_bytes, + "total_large_blob_bytes": session_large_blob_bytes, + "total_provider_cache_large_blob_bytes": session_provider_cache_large_blob_bytes, + "top_by_json_bytes": top_sessions, + }, + "swarm": { + "member_count": swarm_member_count, + "status_counts": swarm_status_counts, + "member_estimate_bytes": swarm_member_estimate_bytes, + "swarm_count": swarm_count, + "swarm_membership_count": swarm_membership_count, + "swarms_estimate_bytes": swarms_estimate_bytes, + "shared_context_swarm_count": shared_context_swarm_count, + "shared_context_entry_count": shared_context_entry_count, + "shared_context_estimate_bytes": shared_context_estimate_bytes, + "plan_count": swarm_plan_count, + "plan_item_count": swarm_plan_item_count, + "plan_estimate_bytes": swarm_plan_estimate_bytes, + "coordinator_count": swarm_coordinator_count, + "coordinator_estimate_bytes": swarm_coordinator_bytes, + }, + "file_tracking": { + "paths_with_touches": file_touch_path_count, + "touch_entries": file_touch_entry_count, + "touch_estimate_bytes": file_touch_estimate_bytes, + "files_touched_by_session_count": touched_session_count, + "files_touched_by_session_estimate_bytes": touched_session_estimate_bytes, + }, + "channels": { + "subscription_swarms": subscription_swarm_count, + "subscription_channels": subscription_channel_count, + "subscription_memberships": subscription_member_count, + "subscription_estimate_bytes": subscription_estimate_bytes, + "subscriptions_by_session_count": subscriptions_by_session_count, + "subscriptions_by_session_estimate_bytes": subscriptions_by_session_estimate_bytes, + }, + "debug": { + "job_count": debug_job_count, + "job_estimate_bytes": debug_job_estimate_bytes, + "job_output_bytes": debug_job_output_bytes, + "event_history_count": event_history_count, + "event_history_estimate_bytes": event_history_estimate_bytes, + "shutdown_signal_count": shutdown_signal_count, + "shutdown_signal_bytes": shutdown_signal_bytes, + }, + "interrupts": { + "queue_sessions": soft_interrupt_session_count, + "pending_interrupts": soft_interrupt_count, + "pending_interrupt_text_bytes": soft_interrupt_text_bytes, + }, + "background": { + "task_count": background_task_count, + "tasks_json_bytes": background_task_json_bytes, + }, + "embeddings": { + "model_available": embedding_model_available, + "loaded": embedder_stats.loaded, + "load_count": embedder_stats.load_count, + "unload_count": embedder_stats.unload_count, + "embed_calls": embedder_stats.embed_calls, + "embed_failures": embedder_stats.embed_failures, + "total_embed_ms": embedder_stats.total_embed_ms, + "avg_embed_ms": embedder_stats.avg_embed_ms, + "idle_secs": embedder_stats.idle_secs, + "loaded_secs": embedder_stats.loaded_secs, + "cache_hits": embedder_stats.cache_hits, + "cache_size": embedder_stats.cache_size, + }, + "session_search_index": { + "index_count": search_index_count, + "entry_count": search_index_entries, + "approx_resident_bytes": search_index_bytes, + } + }) +} + +fn summarize_status_counts<'a>(statuses: impl Iterator<Item = &'a str>) -> HashMap<String, usize> { + let mut counts = HashMap::new(); + for status in statuses { + *counts.entry(status.to_string()).or_insert(0) += 1; + } + counts +} + +fn estimate_client_connection_bytes(info: &ClientConnectionInfo) -> usize { + info.client_id.len() + + info.session_id.len() + + info + .client_instance_id + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) + + info + .debug_client_id + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) +} + +fn estimate_swarm_member_bytes(member: &SwarmMember) -> usize { + member.session_id.len() + + member.status.len() + + member.detail.as_ref().map(|value| value.len()).unwrap_or(0) + + member + .friendly_name + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) + + member.role.len() + + member + .working_dir + .as_ref() + .map(|path| path_len(path)) + .unwrap_or(0) + + member + .swarm_id + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) +} + +fn estimate_shared_context_bytes(context: &SharedContext) -> usize { + context.key.len() + + context.value.len() + + context.from_session.len() + + context + .from_name + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) +} + +fn estimate_file_access_bytes(access: &FileAccess) -> usize { + access.session_id.len() + + format!("{:?}", access.op).len() + + access + .summary + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) + + access.detail.as_ref().map(|value| value.len()).unwrap_or(0) +} + +fn estimate_debug_job_bytes(job: &DebugJob) -> usize { + job.id.len() + + job.command.len() + + job + .session_id + .as_ref() + .map(|value| value.len()) + .unwrap_or(0) + + job.output.as_ref().map(|value| value.len()).unwrap_or(0) + + job.error.as_ref().map(|value| value.len()).unwrap_or(0) +} + +fn estimate_swarm_event_bytes(event: &SwarmEvent) -> usize { + format!("{:?}", event).len() +} + +fn path_len(path: &std::path::Path) -> usize { + path.to_string_lossy().len() +} diff --git a/crates/jcode-app-core/src/server/debug_session_admin.rs b/crates/jcode-app-core/src/server/debug_session_admin.rs new file mode 100644 index 0000000..3890ffc --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_session_admin.rs @@ -0,0 +1,224 @@ +use super::{ + SessionInterruptQueues, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan, + broadcast_swarm_status, create_headless_session, persist_swarm_state_for, record_swarm_event, + remove_background_tool_signal, remove_session_interrupt_queue, +}; +use crate::agent::Agent; +use crate::provider::Provider; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock, broadcast}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +fn parse_create_session_command(cmd: &str) -> Option<(Option<String>, bool)> { + if cmd == "create_session" { + return Some((None, false)); + } + + if let Some(rest) = cmd.strip_prefix("create_session:selfdev:") { + let working_dir = rest.trim(); + return Some(( + if working_dir.is_empty() { + None + } else { + Some(working_dir.to_string()) + }, + true, + )); + } + + if cmd == "create_session:selfdev" { + return Some((None, true)); + } + + if let Some(rest) = cmd.strip_prefix("create_session:") { + let working_dir = rest.trim(); + return Some(( + if working_dir.is_empty() { + None + } else { + Some(working_dir.to_string()) + }, + false, + )); + } + + None +} + +#[expect( + clippy::too_many_arguments, + reason = "session admin debug commands need sessions, swarm state, provider template, queues, and event history" +)] +pub(super) async fn maybe_handle_session_admin_command( + cmd: &str, + sessions: &SessionAgents, + session_id: &Arc<RwLock<String>>, + provider: &Arc<dyn Provider>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + soft_interrupt_queues: &SessionInterruptQueues, + mcp_pool: Option<Arc<crate::mcp::SharedMcpPool>>, +) -> Result<Option<String>> { + if let Some((working_dir, selfdev_requested)) = parse_create_session_command(cmd) { + let create_command = match working_dir { + Some(dir) => format!("create_session:{dir}"), + None => "create_session".to_string(), + }; + let created = create_headless_session( + sessions, + session_id, + provider, + &create_command, + swarm_members, + swarms_by_id, + swarm_coordinators, + swarm_plans, + soft_interrupt_queues, + selfdev_requested, + None, + None, + None, + None, + mcp_pool, + None, + ) + .await?; + if let Ok(value) = serde_json::from_str::<serde_json::Value>(&created) + && let Some(swarm_id) = value.get("swarm_id").and_then(|value| value.as_str()) + { + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + } + return Ok(Some(created)); + } + + if cmd.starts_with("destroy_session:") { + let target_id = cmd.strip_prefix("destroy_session:").unwrap_or("").trim(); + if target_id.is_empty() { + 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) + }; + 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 memory_enabled = agent.memory_enabled(); + let transcript = if memory_enabled { + Some(agent.build_transcript_for_extraction()) + } else { + None + }; + let sid = target_id.to_string(); + let working_dir = agent.working_dir().map(|dir| dir.to_string()); + drop(agent); + if let Some(transcript) = transcript { + crate::memory_agent::trigger_final_extraction_with_dir( + transcript, + sid, + working_dir, + ); + } + } + + if removed_agent.is_none() { + return Err(anyhow::anyhow!("Unknown session_id '{}'", target_id)); + } + + let (swarm_id, friendly_name) = { + let mut members = swarm_members.write().await; + members + .remove(target_id) + .map(|member| (member.swarm_id, member.friendly_name)) + .unwrap_or((None, None)) + }; + + if let Some(ref swarm_id) = swarm_id { + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + target_id.to_string(), + friendly_name.clone(), + Some(swarm_id.clone()), + SwarmEventType::StatusChange { + old_status: "ready".to_string(), + new_status: "stopped".to_string(), + }, + ) + .await; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + target_id.to_string(), + friendly_name, + Some(swarm_id.clone()), + SwarmEventType::MemberChange { + action: "left".to_string(), + }, + ) + .await; + + { + let mut swarms = swarms_by_id.write().await; + if let Some(swarm) = swarms.get_mut(swarm_id) { + swarm.remove(target_id); + if swarm.is_empty() { + swarms.remove(swarm_id); + } + } + } + + let was_coordinator = { + let coordinators = swarm_coordinators.read().await; + coordinators + .get(swarm_id) + .map(|coordinator| coordinator == target_id) + .unwrap_or(false) + }; + if was_coordinator { + let new_coordinator = { + let swarms = swarms_by_id.read().await; + swarms + .get(swarm_id) + .and_then(|members| members.iter().min().cloned()) + }; + let mut coordinators = swarm_coordinators.write().await; + coordinators.remove(swarm_id); + if let Some(new_id) = new_coordinator { + coordinators.insert(swarm_id.clone(), new_id); + } + } + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + + broadcast_swarm_status(swarm_id, swarm_members, swarms_by_id).await; + } + + return Ok(Some(format!("Session '{}' destroyed", target_id))); + } + + Ok(None) +} diff --git a/crates/jcode-app-core/src/server/debug_swarm_read.rs b/crates/jcode-app-core/src/server/debug_swarm_read.rs new file mode 100644 index 0000000..bc28fe9 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_swarm_read.rs @@ -0,0 +1,807 @@ +use super::swarm_channels::list_channels_for_swarm; +use super::{ + FileTouchService, ServerIdentity, SharedContext, SwarmMember, SwarmState, VersionedPlan, + git_common_dir_for, swarm_id_for_dir, +}; +use crate::agent::Agent; +use crate::plan::{next_runnable_item_ids, summarize_plan_graph}; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +#[expect( + clippy::too_many_arguments, + reason = "swarm read debug commands inspect sessions, swarm state, shared context, plans, channels, and file touches together" +)] +pub(super) async fn maybe_handle_swarm_read_command( + cmd: &str, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + file_touch: &FileTouchService, + channel_subscriptions: &ChannelSubscriptions, + server_identity: &ServerIdentity, +) -> Result<Option<String>> { + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + + if cmd == "swarm" || cmd == "swarm_status" || cmd == "swarm:members" { + let members = swarm_members.read().await; + let sessions_guard = sessions.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for member in members.values() { + let (provider, model) = if let Some(agent_arc) = sessions_guard.get(&member.session_id) + { + if let Ok(agent) = agent_arc.try_lock() { + (Some(agent.provider_name()), Some(agent.provider_model())) + } else { + (None, None) + } + } else { + (None, None) + }; + out.push(serde_json::json!({ + "session_id": member.session_id, + "friendly_name": member.friendly_name, + "swarm_id": member.swarm_id, + "working_dir": member.working_dir, + "status": member.status, + "detail": member.detail, + "role": member.role, + "is_headless": member.is_headless, + "live_attachments": member.event_txs.len(), + "joined_secs_ago": member.joined_at.elapsed().as_secs(), + "status_changed_secs_ago": member.last_status_change.elapsed().as_secs(), + "provider": provider, + "model": model, + "server_name": server_identity.name, + "server_icon": server_identity.icon, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd == "swarm:list" { + let swarms = swarms_by_id.read().await; + let coordinators = swarm_coordinators.read().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (swarm_id, session_ids) in swarms.iter() { + let coordinator = coordinators.get(swarm_id); + let coordinator_name = + coordinator.and_then(|cid| members.get(cid).and_then(|m| m.friendly_name.clone())); + let mut status_counts: HashMap<String, usize> = HashMap::new(); + let mut headless_count = 0usize; + let mut attached_member_count = 0usize; + let mut live_attachment_count = 0usize; + let member_details: Vec<serde_json::Value> = session_ids + .iter() + .filter_map(|session_id| members.get(session_id)) + .map(|member| { + *status_counts.entry(member.status.clone()).or_default() += 1; + if member.is_headless { + headless_count += 1; + } + if !member.event_txs.is_empty() { + attached_member_count += 1; + } + live_attachment_count += member.event_txs.len(); + serde_json::json!({ + "session_id": member.session_id, + "friendly_name": member.friendly_name, + "status": member.status, + "detail": member.detail, + "role": member.role, + "is_headless": member.is_headless, + "live_attachments": member.event_txs.len(), + }) + }) + .collect(); + out.push(serde_json::json!({ + "swarm_id": swarm_id, + "member_count": session_ids.len(), + "members": session_ids.iter().collect::<Vec<_>>(), + "coordinator": coordinator, + "coordinator_name": coordinator_name, + "headless_count": headless_count, + "attached_member_count": attached_member_count, + "live_attachment_count": live_attachment_count, + "status_counts": status_counts, + "member_details": member_details, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd == "swarm:coordinators" { + let coordinators = swarm_coordinators.read().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (swarm_id, session_id) in coordinators.iter() { + let name = members + .get(session_id) + .and_then(|m| m.friendly_name.clone()); + out.push(serde_json::json!({ + "swarm_id": swarm_id, + "coordinator_session": session_id, + "coordinator_name": name, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:coordinator:") { + let swarm_id = cmd.strip_prefix("swarm:coordinator:").unwrap_or("").trim(); + let coordinators = swarm_coordinators.read().await; + let members = swarm_members.read().await; + let output = if let Some(session_id) = coordinators.get(swarm_id) { + let name = members + .get(session_id) + .and_then(|m| m.friendly_name.clone()); + serde_json::json!({ + "swarm_id": swarm_id, + "coordinator_session": session_id, + "coordinator_name": name, + }) + .to_string() + } else { + return Err(anyhow::anyhow!("No coordinator for swarm '{}'", swarm_id)); + }; + return Ok(Some(output)); + } + + if cmd == "swarm:roles" { + let members = swarm_members.read().await; + let coordinators = swarm_coordinators.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (sid, member) in members.iter() { + let is_coordinator = member + .swarm_id + .as_ref() + .map(|swid| coordinators.get(swid).map(|c| c == sid).unwrap_or(false)) + .unwrap_or(false); + out.push(serde_json::json!({ + "session_id": sid, + "friendly_name": member.friendly_name, + "role": member.role, + "swarm_id": member.swarm_id, + "status": member.status, + "is_coordinator": is_coordinator, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd == "swarm:channels" { + let subs = channel_subscriptions.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for swarm_id in subs.keys() { + let channels = list_channels_for_swarm(swarm_id, channel_subscriptions).await; + let mut channel_data: Vec<serde_json::Value> = Vec::new(); + for (channel, member_count) in channels { + channel_data.push(serde_json::json!({ + "channel": channel, + "count": member_count, + })); + } + out.push(serde_json::json!({ + "swarm_id": swarm_id, + "channels": channel_data, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:plan_version:") { + let swarm_id = cmd.strip_prefix("swarm:plan_version:").unwrap_or("").trim(); + let runtime = swarm_state.load_runtime(swarm_id).await; + let output = if let Some(vp) = runtime.plan.as_ref() { + let summary = summarize_plan_graph(&vp.items); + let next_ready_ids = next_runnable_item_ids(&vp.items, Some(8)); + serde_json::json!({ + "swarm_id": runtime.swarm_id, + "version": vp.version, + "item_count": vp.items.len(), + "member_count": runtime.members.len(), + "coordinator": runtime.coordinator_session_id, + "stale_item_count": vp.items.iter().filter(|item| item.status == "running_stale").count(), + "ready_item_count": summary.ready_ids.len(), + "blocked_item_count": summary.blocked_ids.len(), + "active_item_count": summary.active_ids.len(), + "completed_item_count": summary.completed_ids.len(), + "next_ready_ids": next_ready_ids, + "cycle_ids": summary.cycle_ids, + "unresolved_dependency_ids": summary.unresolved_dependency_ids, + }) + .to_string() + } else { + serde_json::json!({ + "swarm_id": swarm_id, + "version": 0, + "item_count": 0, + "stale_item_count": 0, + "ready_item_count": 0, + "blocked_item_count": 0, + "active_item_count": 0, + "completed_item_count": 0, + "next_ready_ids": Vec::<String>::new(), + "cycle_ids": Vec::<String>::new(), + "unresolved_dependency_ids": Vec::<String>::new(), + }) + .to_string() + }; + return Ok(Some(output)); + } + + if cmd == "swarm:plans" { + let plans = swarm_plans.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for swarm_id in plans.keys() { + let runtime = swarm_state.load_runtime(swarm_id).await; + let Some(vp) = runtime.plan.as_ref() else { + continue; + }; + let summary = summarize_plan_graph(&vp.items); + let next_ready_ids = next_runnable_item_ids(&vp.items, Some(8)); + out.push(serde_json::json!({ + "swarm_id": runtime.swarm_id, + "item_count": vp.items.len(), + "version": vp.version, + "member_count": runtime.members.len(), + "coordinator": runtime.coordinator_session_id, + "plan_definition": vp.plan_definition(), + "execution_state": vp.execution_state(), + "participants": &vp.participants, + "items": &vp.items, + "task_progress": &vp.task_progress, + "mode": &vp.mode, + "node_meta": &vp.node_meta, + "ready_ids": summary.ready_ids, + "blocked_ids": summary.blocked_ids, + "active_ids": summary.active_ids, + "completed_ids": summary.completed_ids, + "next_ready_ids": next_ready_ids, + "cycle_ids": summary.cycle_ids, + "unresolved_dependency_ids": summary.unresolved_dependency_ids, + })); + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:plan:") { + let swarm_id = cmd.strip_prefix("swarm:plan:").unwrap_or("").trim(); + let runtime = swarm_state.load_runtime(swarm_id).await; + let output = if let Some(vp) = runtime.plan.as_ref() { + let summary = summarize_plan_graph(&vp.items); + let next_ready_ids = next_runnable_item_ids(&vp.items, Some(8)); + serde_json::json!({ + "swarm_id": runtime.swarm_id, + "version": vp.version, + "member_count": runtime.members.len(), + "coordinator": runtime.coordinator_session_id, + "plan_definition": vp.plan_definition(), + "execution_state": vp.execution_state(), + "participants": &vp.participants, + "items": &vp.items, + "task_progress": &vp.task_progress, + "mode": &vp.mode, + "node_meta": &vp.node_meta, + "ready_ids": summary.ready_ids, + "blocked_ids": summary.blocked_ids, + "active_ids": summary.active_ids, + "completed_ids": summary.completed_ids, + "next_ready_ids": next_ready_ids, + "cycle_ids": summary.cycle_ids, + "unresolved_dependency_ids": summary.unresolved_dependency_ids, + }) + .to_string() + } else { + "[]".to_string() + }; + return Ok(Some(output)); + } + + if cmd == "swarm:context" { + let ctx = shared_context.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (swarm_id, entries) in ctx.iter() { + for (key, context) in entries.iter() { + out.push(serde_json::json!({ + "swarm_id": swarm_id, + "key": key, + "value": context.value, + "from_session": context.from_session, + "from_name": context.from_name, + "created_secs_ago": context.created_at.elapsed().as_secs(), + "updated_secs_ago": context.updated_at.elapsed().as_secs(), + })); + } + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:context:") { + let arg = cmd.strip_prefix("swarm:context:").unwrap_or("").trim(); + let ctx = shared_context.read().await; + let output = if let Some((swarm_id, key)) = arg.split_once(':') { + if let Some(entries) = ctx.get(swarm_id) { + if let Some(context) = entries.get(key) { + serde_json::json!({ + "swarm_id": swarm_id, + "key": key, + "value": context.value, + "from_session": context.from_session, + "from_name": context.from_name, + "created_secs_ago": context.created_at.elapsed().as_secs(), + "updated_secs_ago": context.updated_at.elapsed().as_secs(), + }) + .to_string() + } else { + return Err(anyhow::anyhow!( + "No context key '{}' in swarm '{}'", + key, + swarm_id + )); + } + } else { + return Err(anyhow::anyhow!("No context for swarm '{}'", swarm_id)); + } + } else if let Some(entries) = ctx.get(arg) { + let mut out: Vec<serde_json::Value> = Vec::new(); + for (key, context) in entries.iter() { + out.push(serde_json::json!({ + "key": key, + "value": context.value, + "from_session": context.from_session, + "from_name": context.from_name, + "created_secs_ago": context.created_at.elapsed().as_secs(), + "updated_secs_ago": context.updated_at.elapsed().as_secs(), + })); + } + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()) + } else { + "[]".to_string() + }; + return Ok(Some(output)); + } + + if cmd == "swarm:touches" { + let touches = file_touch.snapshot().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (path, accesses) in touches.iter() { + for access in accesses.iter() { + let name = members + .get(&access.session_id) + .and_then(|m| m.friendly_name.clone()); + let timestamp_unix = access + .absolute_time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + out.push(serde_json::json!({ + "path": path.to_string_lossy(), + "session_id": access.session_id, + "session_name": name, + "op": access.op.as_str(), + "summary": access.summary, + "age_secs": access.timestamp.elapsed().as_secs(), + "timestamp_unix": timestamp_unix, + })); + } + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:touches:") { + let arg = cmd.strip_prefix("swarm:touches:").unwrap_or("").trim(); + let touches = file_touch.snapshot().await; + let members = swarm_members.read().await; + let output = if arg.starts_with("swarm:") { + let swarm_id = arg.strip_prefix("swarm:").unwrap_or(""); + let swarm_sessions: HashSet<String> = members + .iter() + .filter(|(_, m)| m.swarm_id.as_deref() == Some(swarm_id)) + .map(|(id, _)| id.clone()) + .collect(); + + let mut out: Vec<serde_json::Value> = Vec::new(); + for (path, accesses) in touches.iter() { + for access in accesses.iter() { + if swarm_sessions.contains(&access.session_id) { + let name = members + .get(&access.session_id) + .and_then(|m| m.friendly_name.clone()); + let timestamp_unix = access + .absolute_time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + out.push(serde_json::json!({ + "path": path.to_string_lossy(), + "session_id": access.session_id, + "session_name": name, + "op": access.op.as_str(), + "summary": access.summary, + "age_secs": access.timestamp.elapsed().as_secs(), + "timestamp_unix": timestamp_unix, + })); + } + } + } + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()) + } else { + let path = PathBuf::from(arg); + if let Some(accesses) = touches.get(&path) { + let mut out: Vec<serde_json::Value> = Vec::new(); + for access in accesses.iter() { + let name = members + .get(&access.session_id) + .and_then(|m| m.friendly_name.clone()); + let timestamp_unix = access + .absolute_time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + out.push(serde_json::json!({ + "session_id": access.session_id, + "session_name": name, + "op": access.op.as_str(), + "summary": access.summary, + "age_secs": access.timestamp.elapsed().as_secs(), + "timestamp_unix": timestamp_unix, + })); + } + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()) + } else { + "[]".to_string() + } + }; + return Ok(Some(output)); + } + + if cmd == "swarm:conflicts" { + let touches = file_touch.snapshot().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (path, accesses) in touches.iter() { + let unique_sessions: HashSet<_> = accesses.iter().map(|a| &a.session_id).collect(); + if unique_sessions.len() > 1 { + let access_history: Vec<_> = accesses + .iter() + .map(|access| { + let name = members + .get(&access.session_id) + .and_then(|m| m.friendly_name.clone()); + let timestamp_unix = access + .absolute_time + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + serde_json::json!({ + "session_id": access.session_id, + "session_name": name, + "op": access.op.as_str(), + "summary": access.summary, + "age_secs": access.timestamp.elapsed().as_secs(), + "timestamp_unix": timestamp_unix, + }) + }) + .collect(); + out.push(serde_json::json!({ + "path": path.to_string_lossy(), + "session_count": unique_sessions.len(), + "accesses": access_history, + })); + } + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd == "swarm:proposals" { + let ctx = shared_context.read().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + for (swarm_id, swarm_ctx) in ctx.iter() { + for (key, context) in swarm_ctx.iter() { + if key.starts_with("plan_proposal:") { + let proposer_id = key.strip_prefix("plan_proposal:").unwrap_or(""); + let proposer_name = members + .get(proposer_id) + .and_then(|m| m.friendly_name.clone()); + let item_count = serde_json::from_str::<Vec<serde_json::Value>>(&context.value) + .map(|v| v.len()) + .unwrap_or(0); + out.push(serde_json::json!({ + "swarm_id": swarm_id, + "proposer_session": proposer_id, + "proposer_name": proposer_name, + "item_count": item_count, + "age_secs": context.created_at.elapsed().as_secs(), + "status": "pending", + })); + } + } + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:proposals:") { + let arg = cmd.strip_prefix("swarm:proposals:").unwrap_or("").trim(); + let ctx = shared_context.read().await; + let members = swarm_members.read().await; + let output = if arg.starts_with("session_") { + let proposal_key = format!("plan_proposal:{}", arg); + let mut found_proposal: Option<String> = None; + for (swarm_id, swarm_ctx) in ctx.iter() { + if let Some(context) = swarm_ctx.get(&proposal_key) { + let proposer_name = members.get(arg).and_then(|m| m.friendly_name.clone()); + let items: Vec<serde_json::Value> = + serde_json::from_str(&context.value).unwrap_or_default(); + found_proposal = Some( + serde_json::json!({ + "swarm_id": swarm_id, + "proposer_session": arg, + "proposer_name": proposer_name, + "status": "pending", + "age_secs": context.created_at.elapsed().as_secs(), + "items": items, + }) + .to_string(), + ); + break; + } + } + found_proposal + .ok_or_else(|| anyhow::anyhow!("No proposal found from session '{}'", arg))? + } else { + let mut out: Vec<serde_json::Value> = Vec::new(); + if let Some(swarm_ctx) = ctx.get(arg) { + for (key, context) in swarm_ctx.iter() { + if key.starts_with("plan_proposal:") { + let proposer_id = key.strip_prefix("plan_proposal:").unwrap_or(""); + let proposer_name = members + .get(proposer_id) + .and_then(|m| m.friendly_name.clone()); + let items: Vec<serde_json::Value> = + serde_json::from_str(&context.value).unwrap_or_default(); + out.push(serde_json::json!({ + "proposer_session": proposer_id, + "proposer_name": proposer_name, + "status": "pending", + "age_secs": context.created_at.elapsed().as_secs(), + "items": items, + })); + } + } + } + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()) + }; + return Ok(Some(output)); + } + + if cmd.starts_with("swarm:info:") { + let swarm_id = cmd.strip_prefix("swarm:info:").unwrap_or("").trim(); + let swarms = swarms_by_id.read().await; + let coordinators = swarm_coordinators.read().await; + let members = swarm_members.read().await; + let plans = swarm_plans.read().await; + let ctx = shared_context.read().await; + let touches = file_touch.snapshot().await; + + let output = if let Some(session_ids) = swarms.get(swarm_id) { + let coordinator = coordinators.get(swarm_id); + let coordinator_name = + coordinator.and_then(|cid| members.get(cid).and_then(|m| m.friendly_name.clone())); + + let member_details: Vec<_> = session_ids + .iter() + .filter_map(|sid| { + members.get(sid).map(|m| { + serde_json::json!({ + "session_id": m.session_id, + "friendly_name": m.friendly_name, + "status": m.status, + "detail": m.detail, + "working_dir": m.working_dir, + }) + }) + }) + .collect(); + + let plan = plans + .get(swarm_id) + .map(|vp| { + serde_json::json!({ + "items": &vp.items, + "task_progress": &vp.task_progress, + "version": vp.version, + }) + }) + .unwrap_or_else(|| { + serde_json::json!({ + "items": [], + "task_progress": {}, + "version": 0, + }) + }); + + let context_keys: Vec<_> = ctx + .get(swarm_id) + .map(|entries| entries.keys().cloned().collect()) + .unwrap_or_default(); + + let conflicts: Vec<_> = touches + .iter() + .filter_map(|(path, accesses)| { + let swarm_accesses: Vec<_> = accesses + .iter() + .filter(|a| session_ids.contains(&a.session_id)) + .collect(); + let unique: HashSet<_> = swarm_accesses.iter().map(|a| &a.session_id).collect(); + if unique.len() > 1 { + Some(path.to_string_lossy().to_string()) + } else { + None + } + }) + .collect(); + + serde_json::json!({ + "swarm_id": swarm_id, + "member_count": session_ids.len(), + "members": member_details, + "coordinator": coordinator, + "coordinator_name": coordinator_name, + "plan": plan, + "context_keys": context_keys, + "conflict_files": conflicts, + }) + .to_string() + } else { + return Err(anyhow::anyhow!("No swarm with id '{}'", swarm_id)); + }; + return Ok(Some(output)); + } + + if cmd.starts_with("swarm:session:") { + let target_session = cmd.strip_prefix("swarm:session:").unwrap_or("").trim(); + if target_session.is_empty() { + return Err(anyhow::anyhow!("swarm:session requires a session_id")); + } + let sessions_guard = sessions.read().await; + let members = swarm_members.read().await; + + let output = if let Some(agent_arc) = sessions_guard.get(target_session) { + let member_info = members.get(target_session); + let agent_state = if let Ok(agent) = agent_arc.try_lock() { + Some(serde_json::json!({ + "provider": agent.provider_name(), + "model": agent.provider_model(), + "message_count": agent.message_count(), + "pending_alert_count": agent.pending_alert_count(), + "pending_alerts": agent.pending_alerts_preview(), + "soft_interrupt_count": agent.soft_interrupt_count(), + "soft_interrupts": agent.soft_interrupts_preview(), + "has_urgent_interrupt": agent.has_urgent_interrupt(), + "last_usage": agent.last_usage(), + })) + } else { + None + }; + + let is_processing = member_info + .map(|m| m.status == "running") + .unwrap_or(agent_state.is_none()); + + serde_json::json!({ + "session_id": target_session, + "friendly_name": member_info.and_then(|m| m.friendly_name.clone()), + "swarm_id": member_info.and_then(|m| m.swarm_id.clone()), + "status": member_info.map(|m| m.status.clone()), + "detail": member_info.and_then(|m| m.detail.clone()), + "joined_secs_ago": member_info.map(|m| m.joined_at.elapsed().as_secs()), + "status_changed_secs_ago": member_info.map(|m| m.last_status_change.elapsed().as_secs()), + "is_processing": is_processing, + "agent_state": agent_state, + }) + .to_string() + } else { + return Err(anyhow::anyhow!("Unknown session '{}'", target_session)); + }; + return Ok(Some(output)); + } + + if cmd == "swarm:interrupts" { + let sessions_guard = sessions.read().await; + let members = swarm_members.read().await; + let mut out: Vec<serde_json::Value> = Vec::new(); + + for (session_id, agent_arc) in sessions_guard.iter() { + if let Ok(agent) = agent_arc.try_lock() { + let alert_count = agent.pending_alert_count(); + let interrupt_count = agent.soft_interrupt_count(); + + if alert_count > 0 || interrupt_count > 0 { + let name = members + .get(session_id) + .and_then(|m| m.friendly_name.clone()); + out.push(serde_json::json!({ + "session_id": session_id, + "session_name": name, + "pending_alert_count": alert_count, + "pending_alerts": agent.pending_alerts_preview(), + "soft_interrupt_count": interrupt_count, + "soft_interrupts": agent.soft_interrupts_preview(), + "has_urgent": agent.has_urgent_interrupt(), + })); + } + } + } + return Ok(Some( + serde_json::to_string_pretty(&out).unwrap_or_else(|_| "[]".to_string()), + )); + } + + if cmd.starts_with("swarm:id:") { + let path_str = cmd.strip_prefix("swarm:id:").unwrap_or("").trim(); + if path_str.is_empty() { + return Err(anyhow::anyhow!("swarm:id requires a path")); + } + let path = PathBuf::from(path_str); + let env_override = std::env::var("JCODE_SWARM_ID") + .ok() + .filter(|s| !s.trim().is_empty()); + let git_common = git_common_dir_for(&path); + let swarm_id = swarm_id_for_dir(Some(path.clone())); + let is_git_repo = git_common.is_some(); + return Ok(Some( + serde_json::json!({ + "path": path_str, + "swarm_id": swarm_id, + "source": if env_override.is_some() { "env:JCODE_SWARM_ID" } + else if is_git_repo { "git_common_dir" } + else { "none" }, + "env_override": env_override, + "git_common_dir": git_common.clone(), + "git_root": git_common, + "is_git_repo": is_git_repo, + }) + .to_string(), + )); + } + + Ok(None) +} diff --git a/crates/jcode-app-core/src/server/debug_swarm_write.rs b/crates/jcode-app-core/src/server/debug_swarm_write.rs new file mode 100644 index 0000000..b049721 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_swarm_write.rs @@ -0,0 +1,750 @@ +use super::{SharedContext, SwarmMember, SwarmState, VersionedPlan, persist_swarm_state_for}; +use crate::plan::PlanItem; +use crate::protocol::{NotificationType, ServerEvent}; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; + +pub(super) struct DebugSwarmWriteContext<'a> { + pub(super) session_id: &'a Arc<RwLock<String>>, + pub(super) swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>, + pub(super) swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub(super) shared_context: &'a Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + pub(super) swarm_plans: &'a Arc<RwLock<HashMap<String, VersionedPlan>>>, + pub(super) swarm_coordinators: &'a Arc<RwLock<HashMap<String, String>>>, +} + +pub(super) async fn maybe_handle_swarm_write_command( + cmd: &str, + ctx: &DebugSwarmWriteContext<'_>, +) -> Result<Option<String>> { + if cmd.starts_with("swarm:clear_coordinator:") { + let swarm_id = cmd + .strip_prefix("swarm:clear_coordinator:") + .unwrap_or("") + .trim(); + // Swarm member/coordinator mutations never nest these independent + // locks. In particular, persistence reads coordinators again, so a + // retained write guard here self-deadlocks the command. + let removed = { + let mut coordinators = ctx.swarm_coordinators.write().await; + coordinators.remove(swarm_id).is_some() + }; + if removed { + { + let mut members = ctx.swarm_members.write().await; + for member in members.values_mut() { + if member.swarm_id.as_deref() == Some(swarm_id) && member.role == "coordinator" + { + member.role = "agent".to_string(); + } + } + } + let swarm_state = SwarmState { + members: Arc::clone(ctx.swarm_members), + swarms_by_id: Arc::clone(ctx.swarms_by_id), + plans: Arc::clone(ctx.swarm_plans), + coordinators: Arc::clone(ctx.swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + return Ok(Some(format!( + "Coordinator cleared for swarm '{}'. Any session can now self-promote.", + swarm_id + ))); + } + return Err(anyhow::anyhow!( + "No coordinator set for swarm '{}'", + swarm_id + )); + } + + if cmd.starts_with("swarm:clear_plan:") { + let swarm_id = cmd.strip_prefix("swarm:clear_plan:").unwrap_or("").trim(); + if swarm_id.is_empty() { + return Err(anyhow::anyhow!( + "swarm:clear_plan requires a swarm_id: swarm:clear_plan:<swarm_id>" + )); + } + let removed = { + let mut plans = ctx.swarm_plans.write().await; + plans.remove(swarm_id) + }; + let Some(removed) = removed else { + return Err(anyhow::anyhow!("No plan found for swarm '{}'", swarm_id)); + }; + // Re-persist so the on-disk swarm state drops the plan too; otherwise + // the next server restart resurrects it and every fresh session in + // this working dir gets the stale plan graph pushed on subscribe. + let swarm_state = SwarmState { + members: Arc::clone(ctx.swarm_members), + swarms_by_id: Arc::clone(ctx.swarms_by_id), + plans: Arc::clone(ctx.swarm_plans), + coordinators: Arc::clone(ctx.swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + return Ok(Some( + serde_json::json!({ + "swarm_id": swarm_id, + "cleared_version": removed.version, + "cleared_item_count": removed.items.len(), + }) + .to_string(), + )); + } + + if cmd.starts_with("swarm:broadcast:") { + let rest = cmd.strip_prefix("swarm:broadcast:").unwrap_or("").trim(); + let (target_swarm_id, message) = if let Some(space_idx) = rest.find(' ') { + let potential_id = &rest[..space_idx]; + let msg = rest[space_idx + 1..].trim(); + if potential_id.contains('/') { + (Some(potential_id.to_string()), msg.to_string()) + } else { + (None, rest.to_string()) + } + } else { + (None, rest.to_string()) + }; + + if message.is_empty() { + return Err(anyhow::anyhow!("swarm:broadcast requires a message")); + } + + let swarm_id = if let Some(id) = target_swarm_id { + Some(id) + } else { + let members = ctx.swarm_members.read().await; + let current_session = ctx.session_id.read().await; + members + .get(&*current_session) + .and_then(|member| member.swarm_id.clone()) + }; + + if let Some(swarm_id) = swarm_id { + let swarms = ctx.swarms_by_id.read().await; + let members = ctx.swarm_members.read().await; + let current_session = ctx.session_id.read().await; + let from_name = members + .get(&*current_session) + .and_then(|member| member.friendly_name.clone()); + + if let Some(member_ids) = swarms.get(&swarm_id) { + let mut sent_count = 0; + for member_id in member_ids { + if let Some(member) = members.get(member_id) { + let notification = ServerEvent::Notification { + from_session: current_session.clone(), + from_name: from_name.clone(), + notification_type: NotificationType::Message { + scope: Some("broadcast".to_string()), + channel: None, + tldr: None, + }, + message: message.clone(), + }; + if member.event_tx.send(notification).is_ok() { + sent_count += 1; + } + } + } + return Ok(Some( + serde_json::json!({ + "swarm_id": swarm_id, + "message": message, + "sent_to": sent_count, + }) + .to_string(), + )); + } + + return Err(anyhow::anyhow!("No members in swarm '{}'", swarm_id)); + } + + return Err(anyhow::anyhow!( + "No swarm found. Specify swarm_id: swarm:broadcast:<swarm_id> <message>" + )); + } + + if cmd.starts_with("swarm:notify:") { + let rest = cmd.strip_prefix("swarm:notify:").unwrap_or("").trim(); + if let Some(space_idx) = rest.find(' ') { + let target_session = &rest[..space_idx]; + let message = rest[space_idx + 1..].trim(); + + if message.is_empty() { + return Err(anyhow::anyhow!("swarm:notify requires a message")); + } + + let members = ctx.swarm_members.read().await; + let current_session = ctx.session_id.read().await; + let from_name = members + .get(&*current_session) + .and_then(|member| member.friendly_name.clone()); + + if let Some(target) = members.get(target_session) { + let notification = ServerEvent::Notification { + from_session: current_session.clone(), + from_name: from_name.clone(), + notification_type: NotificationType::Message { + scope: Some("dm".to_string()), + channel: None, + tldr: None, + }, + message: message.to_string(), + }; + if target.event_tx.send(notification).is_ok() { + return Ok(Some( + serde_json::json!({ + "sent_to": target_session, + "sent_to_name": target.friendly_name.clone(), + "message": message, + }) + .to_string(), + )); + } + return Err(anyhow::anyhow!("Failed to send notification")); + } + + return Err(anyhow::anyhow!("Unknown session '{}'", target_session)); + } + + return Err(anyhow::anyhow!( + "Usage: swarm:notify:<session_id> <message>" + )); + } + + if cmd.starts_with("swarm:set_context:") { + let rest = cmd.strip_prefix("swarm:set_context:").unwrap_or("").trim(); + let parts: Vec<&str> = rest.splitn(3, ' ').collect(); + if parts.len() < 3 { + return Err(anyhow::anyhow!( + "Usage: swarm:set_context:<session_id> <key> <value>" + )); + } + + let acting_session = parts[0]; + let key = parts[1].to_string(); + let value = parts[2].to_string(); + + let (swarm_id, friendly_name) = { + let members = ctx.swarm_members.read().await; + let swarm_id = members + .get(acting_session) + .and_then(|member| member.swarm_id.clone()); + let name = members + .get(acting_session) + .and_then(|member| member.friendly_name.clone()); + (swarm_id, name) + }; + + if let Some(swarm_id) = swarm_id { + { + let mut shared_ctx = ctx.shared_context.write().await; + let swarm_ctx = shared_ctx + .entry(swarm_id.clone()) + .or_insert_with(HashMap::new); + let now = Instant::now(); + let created_at = swarm_ctx + .get(&key) + .map(|context| context.created_at) + .unwrap_or(now); + swarm_ctx.insert( + key.clone(), + SharedContext { + key: key.clone(), + value: value.clone(), + from_session: acting_session.to_string(), + from_name: friendly_name.clone(), + created_at, + updated_at: now, + }, + ); + } + + let swarm_session_ids: Vec<String> = { + let swarms = ctx.swarms_by_id.read().await; + swarms + .get(&swarm_id) + .map(|sessions| sessions.iter().cloned().collect()) + .unwrap_or_default() + }; + let members = ctx.swarm_members.read().await; + for sid in &swarm_session_ids { + if sid != acting_session + && let Some(member) = members.get(sid) + { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: acting_session.to_string(), + from_name: friendly_name.clone(), + notification_type: NotificationType::SharedContext { + key: key.clone(), + value: value.clone(), + }, + message: format!("Shared context: {} = {}", key, value), + }); + } + } + + return Ok(Some( + serde_json::json!({ + "swarm_id": swarm_id, + "key": key, + "value": value, + "from_session": acting_session, + }) + .to_string(), + )); + } + + return Err(anyhow::anyhow!( + "Session '{}' is not in a swarm", + acting_session + )); + } + + if cmd.starts_with("swarm:approve_plan:") { + let rest = cmd.strip_prefix("swarm:approve_plan:").unwrap_or("").trim(); + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + if parts.len() < 2 { + return Err(anyhow::anyhow!( + "Usage: swarm:approve_plan:<coordinator_session> <proposer_session>" + )); + } + + let coord_session = parts[0]; + let proposer_session = parts[1]; + + let (swarm_id, is_coordinator) = { + let members = ctx.swarm_members.read().await; + let swarm_id = members + .get(coord_session) + .and_then(|member| member.swarm_id.clone()); + let is_coord = if let Some(ref swarm_id) = swarm_id { + let coordinators = ctx.swarm_coordinators.read().await; + coordinators + .get(swarm_id) + .map(|coordinator| coordinator == coord_session) + .unwrap_or(false) + } else { + false + }; + (swarm_id, is_coord) + }; + + if !is_coordinator { + return Err(anyhow::anyhow!( + "Only the coordinator can approve plan proposals." + )); + } + + if let Some(swarm_id) = swarm_id { + let proposal_key = format!("plan_proposal:{}", proposer_session); + let proposal_value = { + let shared_ctx = ctx.shared_context.read().await; + shared_ctx + .get(&swarm_id) + .and_then(|swarm_ctx| swarm_ctx.get(&proposal_key)) + .map(|context| context.value.clone()) + }; + + return match proposal_value { + None => Err(anyhow::anyhow!( + "No pending plan proposal from session '{}'", + proposer_session + )), + Some(proposal) => { + if let Ok(items) = serde_json::from_str::<Vec<PlanItem>>(&proposal) { + let version = { + let mut plans = ctx.swarm_plans.write().await; + let versioned_plan = plans + .entry(swarm_id.clone()) + .or_insert_with(VersionedPlan::new); + versioned_plan.items.extend(items.clone()); + versioned_plan.version += 1; + versioned_plan + .participants + .insert(coord_session.to_string()); + versioned_plan + .participants + .insert(proposer_session.to_string()); + versioned_plan.version + }; + { + let mut shared_ctx = ctx.shared_context.write().await; + if let Some(swarm_ctx) = shared_ctx.get_mut(&swarm_id) { + swarm_ctx.remove(&proposal_key); + } + } + Ok(Some( + serde_json::json!({ + "approved": true, + "items_added": items.len(), + "plan_version": version, + "swarm_id": swarm_id, + }) + .to_string(), + )) + } else { + Err(anyhow::anyhow!( + "Failed to parse plan proposal as Vec<PlanItem>" + )) + } + } + }; + } + + return Err(anyhow::anyhow!("Not in a swarm.")); + } + + if cmd.starts_with("swarm:reject_plan:") { + let rest = cmd.strip_prefix("swarm:reject_plan:").unwrap_or("").trim(); + let parts: Vec<&str> = rest.splitn(3, ' ').collect(); + if parts.len() < 2 { + return Err(anyhow::anyhow!( + "Usage: swarm:reject_plan:<coordinator_session> <proposer_session> [reason]" + )); + } + + let coord_session = parts[0]; + let proposer_session = parts[1]; + let reason = if parts.len() >= 3 { + Some(parts[2].to_string()) + } else { + None + }; + + let (swarm_id, is_coordinator) = { + let members = ctx.swarm_members.read().await; + let swarm_id = members + .get(coord_session) + .and_then(|member| member.swarm_id.clone()); + let is_coord = if let Some(ref swarm_id) = swarm_id { + let coordinators = ctx.swarm_coordinators.read().await; + coordinators + .get(swarm_id) + .map(|coordinator| coordinator == coord_session) + .unwrap_or(false) + } else { + false + }; + (swarm_id, is_coord) + }; + + if !is_coordinator { + return Err(anyhow::anyhow!( + "Only the coordinator can reject plan proposals." + )); + } + + if let Some(swarm_id) = swarm_id { + let proposal_key = format!("plan_proposal:{}", proposer_session); + let proposal_exists = { + let shared_ctx = ctx.shared_context.read().await; + shared_ctx + .get(&swarm_id) + .and_then(|swarm_ctx| swarm_ctx.get(&proposal_key)) + .is_some() + }; + + if !proposal_exists { + return Err(anyhow::anyhow!( + "No pending plan proposal from session '{}'", + proposer_session + )); + } + + { + let mut shared_ctx = ctx.shared_context.write().await; + if let Some(swarm_ctx) = shared_ctx.get_mut(&swarm_id) { + swarm_ctx.remove(&proposal_key); + } + } + let reason_msg = reason + .as_ref() + .map(|reason| format!(": {}", reason)) + .unwrap_or_default(); + return Ok(Some( + serde_json::json!({ + "rejected": true, + "proposer_session": proposer_session, + "reason": reason_msg, + "swarm_id": swarm_id, + }) + .to_string(), + )); + } + + return Err(anyhow::anyhow!("Not in a swarm.")); + } + + // Task-DAG ops over the debug socket, for testing/operability without a live + // model session. Arg is a JSON object: + // {"op":"seed","swarm_id":"..","mode":"deep","nodes":[{id,content,kind,depends_on}]} + // {"op":"expand","swarm_id":"..","actor":"sess","node_id":"..","children":[..]} + // {"op":"complete","swarm_id":"..","actor":"sess","node_id":"..","artifact":{..}} + // {"op":"inject","swarm_id":"..","actor":"sess","gate_id":"..","nodes":[..]} + if let Some(rest) = cmd.strip_prefix("swarm:graph:") { + return Ok(Some(handle_debug_graph_op(rest.trim(), ctx).await)); + } + + Ok(None) +} + +#[derive(serde::Deserialize)] +struct DebugGraphArg { + op: String, + swarm_id: String, + #[serde(default)] + mode: Option<String>, + #[serde(default)] + actor: Option<String>, + #[serde(default)] + node_id: Option<String>, + #[serde(default)] + gate_id: Option<String>, + #[serde(default)] + nodes: Vec<DebugNodeSpec>, + #[serde(default)] + children: Vec<DebugNodeSpec>, + #[serde(default)] + artifact: Option<serde_json::Value>, +} + +#[derive(serde::Deserialize)] +struct DebugNodeSpec { + id: String, + content: String, + #[serde(default)] + kind: Option<String>, + #[serde(default)] + depends_on: Vec<String>, + #[serde(default)] + priority: u8, +} + +fn debug_specs(specs: Vec<DebugNodeSpec>) -> Vec<jcode_plan::dag::NodeSpec> { + specs + .into_iter() + .map(|s| jcode_plan::dag::NodeSpec { + id: Some(s.id), + content: s.content, + kind: jcode_plan::bridge::parse_kind(s.kind.as_deref()), + depends_on: s.depends_on, + priority: s.priority, + }) + .collect() +} + +async fn handle_debug_graph_op(arg: &str, ctx: &DebugSwarmWriteContext<'_>) -> String { + use jcode_plan::bridge::{apply_task_graph, to_task_graph}; + use jcode_plan::dag; + + fn fail(msg: impl std::fmt::Display) -> String { + serde_json::json!({"ok": false, "error": msg.to_string()}).to_string() + } + + let parsed: DebugGraphArg = match serde_json::from_str(arg) { + Ok(parsed) => parsed, + Err(e) => return fail(format!("invalid swarm:graph JSON: {e}")), + }; + let swarm_id = parsed.swarm_id.clone(); + + let result: Result<(usize, &'static str), String> = { + let mut plans = ctx.swarm_plans.write().await; + let plan = plans + .entry(swarm_id.clone()) + .or_insert_with(VersionedPlan::new); + match parsed.op.as_str() { + "seed" => { + if let Some(mode) = parsed.mode { + plan.mode = mode; + } + let count = parsed.nodes.len(); + let mut graph = to_task_graph(plan); + let before = graph.clone(); + match dag::seed(&mut graph, debug_specs(parsed.nodes)) { + Ok(()) => { + if graph != before { + apply_task_graph(plan, &graph); + plan.version += 1; + } + Ok((count, "seed")) + } + Err(e) => Err(e.to_string()), + } + } + "expand" => { + let Some(actor) = parsed.actor.clone() else { + return fail("'actor' required"); + }; + let Some(node_id) = parsed.node_id.clone() else { + return fail("'node_id' required"); + }; + let count = parsed.children.len(); + // Dispatch the node to the actor so engine ownership checks pass. + if let Some(item) = plan.items.iter_mut().find(|i| i.id == node_id) { + item.assigned_to = Some(actor.clone()); + item.status = "running".to_string(); + } + let mut graph = to_task_graph(plan); + match dag::expand_node(&mut graph, &node_id, &actor, debug_specs(parsed.children)) { + Ok(_) => { + apply_task_graph(plan, &graph); + plan.version += 1; + Ok((count, "expand")) + } + Err(e) => Err(e.to_string()), + } + } + "complete" => { + let Some(actor) = parsed.actor.clone() else { + return fail("'actor' required"); + }; + let Some(node_id) = parsed.node_id.clone() else { + return fail("'node_id' required"); + }; + let artifact: dag::HandoffArtifact = match serde_json::from_value( + parsed.artifact.clone().unwrap_or(serde_json::json!({})), + ) { + Ok(artifact) => artifact, + Err(e) => return fail(format!("invalid artifact: {e}")), + }; + if let Some(item) = plan.items.iter_mut().find(|i| i.id == node_id) { + item.assigned_to = Some(actor.clone()); + item.status = "running".to_string(); + } + let mut graph = to_task_graph(plan); + match dag::complete_node(&mut graph, &node_id, &actor, artifact) { + Ok(()) => { + apply_task_graph(plan, &graph); + plan.version += 1; + Ok((1, "complete")) + } + Err(e) => Err(e.to_string()), + } + } + "inject" => { + let Some(actor) = parsed.actor.clone() else { + return fail("'actor' required"); + }; + let Some(gate_id) = parsed.gate_id.clone() else { + return fail("'gate_id' required"); + }; + let count = parsed.nodes.len(); + if let Some(item) = plan.items.iter_mut().find(|i| i.id == gate_id) { + item.assigned_to = Some(actor.clone()); + item.status = "running".to_string(); + } + let mut graph = to_task_graph(plan); + match dag::inject_from_gate(&mut graph, &gate_id, &actor, debug_specs(parsed.nodes)) + { + Ok(_) => { + apply_task_graph(plan, &graph); + plan.version += 1; + Ok((count, "inject")) + } + Err(e) => Err(e.to_string()), + } + } + other => Err(format!("unknown op '{other}'")), + } + }; + + match result { + Ok((count, op)) => { + let swarm_state = SwarmState { + members: Arc::clone(ctx.swarm_members), + swarms_by_id: Arc::clone(ctx.swarms_by_id), + plans: Arc::clone(ctx.swarm_plans), + coordinators: Arc::clone(ctx.swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + serde_json::json!({"ok": true, "op": op, "count": count, "swarm_id": swarm_id}) + .to_string() + } + Err(e) => fail(format!("graph op rejected: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + runtime: Option<std::ffi::OsString>, + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", value); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } + } + + fn isolated_runtime(dir: &tempfile::TempDir) -> EnvGuard { + let lock = crate::storage::lock_test_env(); + let runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", dir.path()); + EnvGuard { + _lock: lock, + runtime, + } + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn clear_coordinator_releases_coordinator_lock_before_waiting_for_members() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = isolated_runtime(&dir); + let session_id = Arc::new(RwLock::new("session-1".to_string())); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + let swarms_by_id = Arc::new(RwLock::new(HashMap::new())); + let shared_context = Arc::new(RwLock::new(HashMap::new())); + let swarm_plans = Arc::new(RwLock::new(HashMap::new())); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-lock-order".to_string(), + "session-1".to_string(), + )]))); + let ctx = DebugSwarmWriteContext { + session_id: &session_id, + swarm_members: &swarm_members, + swarms_by_id: &swarms_by_id, + shared_context: &shared_context, + swarm_plans: &swarm_plans, + swarm_coordinators: &swarm_coordinators, + }; + + // Force the command to wait at members.write(). A safe path must not + // retain coordinators.write() while it waits for that independent lock. + let members_gate = swarm_members.write().await; + let command = + maybe_handle_swarm_write_command("swarm:clear_coordinator:swarm-lock-order", &ctx); + tokio::pin!(command); + tokio::select! { + result = &mut command => panic!("command unexpectedly completed: {result:?}"), + _ = tokio::time::sleep(Duration::from_millis(20)) => {} + } + + let coordinators = + tokio::time::timeout(Duration::from_millis(100), swarm_coordinators.read()) + .await + .expect("coordinator lock was retained while waiting for members"); + assert!(!coordinators.contains_key("swarm-lock-order")); + drop(coordinators); + + drop(members_gate); + let response = tokio::time::timeout(Duration::from_secs(1), &mut command) + .await + .expect("clear coordinator self-deadlocked") + .expect("command failed") + .expect("command was not handled"); + assert!(response.contains("Coordinator cleared")); + } +} diff --git a/crates/jcode-app-core/src/server/debug_testers.rs b/crates/jcode-app-core/src/server/debug_testers.rs new file mode 100644 index 0000000..b4077a7 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_testers.rs @@ -0,0 +1,335 @@ +use anyhow::Result; +use std::path::PathBuf; + +/// Execute tester commands +pub(super) async fn execute_tester_command(command: &str) -> Result<String> { + let trimmed = command.trim(); + + if trimmed == "list" { + let testers = load_testers()?; + if testers.is_empty() { + return Ok("No active testers.".to_string()); + } + return Ok(serde_json::to_string_pretty(&testers)?); + } + + if trimmed == "spawn" || trimmed.starts_with("spawn ") { + let opts: serde_json::Value = if trimmed == "spawn" { + serde_json::json!({}) + } else { + serde_json::from_str(trimmed.strip_prefix("spawn ").unwrap_or("{}"))? + }; + return spawn_tester(opts).await; + } + + let parts: Vec<&str> = trimmed.splitn(3, ':').collect(); + if parts.len() >= 2 { + let tester_id = parts[0]; + let cmd = parts[1]; + let arg = parts.get(2).copied(); + return execute_tester_subcommand(tester_id, cmd, arg).await; + } + + Err(anyhow::anyhow!( + "Unknown tester command: {}. Use tester:help for usage.", + trimmed + )) +} + +fn load_testers() -> Result<Vec<serde_json::Value>> { + let path = crate::storage::jcode_dir()?.join("testers.json"); + if path.exists() { + let content = std::fs::read_to_string(&path)?; + if content.trim().is_empty() { + return Ok(vec![]); + } + Ok(serde_json::from_str(&content)?) + } else { + Ok(vec![]) + } +} + +fn save_testers(testers: &[serde_json::Value]) -> Result<()> { + let path = crate::storage::jcode_dir()?.join("testers.json"); + std::fs::write(&path, serde_json::to_string_pretty(testers)?)?; + Ok(()) +} + +async fn spawn_tester(opts: serde_json::Value) -> Result<String> { + use std::process::Stdio; + + let id = format!("tester_{}", crate::id::new_id("tui")); + let cwd = opts.get("cwd").and_then(|v| v.as_str()).unwrap_or("."); + let binary = opts.get("binary").and_then(|v| v.as_str()); + let cols = opts.get("cols").and_then(|v| v.as_u64()).unwrap_or(120) as u16; + let rows = opts.get("rows").and_then(|v| v.as_u64()).unwrap_or(40) as u16; + + let binary_path = if let Some(b) = binary { + PathBuf::from(b) + } else if let Ok(current) = crate::build::current_binary_path() { + if current.exists() { + current + } else if let Ok(canary) = crate::build::canary_binary_path() { + if canary.exists() { + canary + } else { + std::env::current_exe()? + } + } else { + std::env::current_exe()? + } + } else if let Ok(canary) = crate::build::canary_binary_path() { + if canary.exists() { + canary + } else { + std::env::current_exe()? + } + } else { + std::env::current_exe()? + }; + + if !binary_path.exists() { + return Err(anyhow::anyhow!( + "Binary not found: {}", + binary_path.display() + )); + } + + let debug_cmd = std::env::temp_dir().join(format!("jcode_debug_cmd_{}", id)); + let debug_resp = std::env::temp_dir().join(format!("jcode_debug_response_{}", id)); + let stdout_path = std::env::temp_dir().join(format!("jcode_tester_stdout_{}", id)); + let stderr_path = std::env::temp_dir().join(format!("jcode_tester_stderr_{}", id)); + + let stdout_file = std::fs::File::create(&stdout_path)?; + let stderr_file = std::fs::File::create(&stderr_path)?; + + let _ = crate::platform::set_permissions_owner_only(&stdout_path); + let _ = crate::platform::set_permissions_owner_only(&stderr_path); + let _ = std::fs::File::create(&debug_cmd) + .and_then(|_| crate::platform::set_permissions_owner_only(&debug_cmd)); + let _ = std::fs::File::create(&debug_resp) + .and_then(|_| crate::platform::set_permissions_owner_only(&debug_resp)); + + let mut cmd = tokio::process::Command::new(&binary_path); + cmd.current_dir(cwd); + cmd.env(jcode_selfdev_types::CLIENT_SELFDEV_ENV, "1"); + cmd.env( + "JCODE_DEBUG_CMD_PATH", + debug_cmd.to_string_lossy().to_string(), + ); + cmd.env( + "JCODE_DEBUG_RESPONSE_PATH", + debug_resp.to_string_lossy().to_string(), + ); + cmd.arg("--debug-socket"); + + // The TUI refuses to start unless stdin/stdout are a TTY, so a headless + // tester must own a real PTY. Allocate one, hand the slave end to the + // child, and drain the master into the stdout log so the child never + // blocks on a full PTY buffer. + #[cfg(unix)] + { + let pty = allocate_pty(cols, rows) + .map_err(|e| anyhow::anyhow!("Failed to allocate tester PTY: {}", e))?; + use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd}; + + let slave: OwnedFd = pty.slave; + let master: OwnedFd = pty.master; + + let stdin_slave = slave.try_clone()?; + let stdout_slave = slave.try_clone()?; + cmd.stdin(Stdio::from(stdin_slave)); + cmd.stdout(Stdio::from(stdout_slave)); + cmd.stderr(Stdio::from(stderr_file)); + drop(slave); + + // Drain the master side so TUI output (including image escapes) does + // not back up. Log it for debugging; the thread ends when the child + // exits and the slave side closes. + let mut stdout_log = stdout_file; + let master_fd = master.into_raw_fd(); + std::thread::Builder::new() + .name(format!("tester-pty-drain-{}", id)) + .spawn(move || { + use std::io::{Read, Write}; + // Safety: we own master_fd; this is the only holder now. + let mut master_file = unsafe { std::fs::File::from_raw_fd(master_fd) }; + let mut buf = [0u8; 8192]; + loop { + match master_file.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let _ = stdout_log.write_all(&buf[..n]); + } + Err(_) => break, + } + } + }) + .ok(); + } + #[cfg(not(unix))] + { + let _ = (cols, rows); + cmd.stdout(Stdio::from(stdout_file)); + cmd.stderr(Stdio::from(stderr_file)); + } + + let child = cmd.spawn()?; + let pid = child.id().unwrap_or(0); + + let info = serde_json::json!({ + "id": id, + "pid": pid, + "binary": binary_path.to_string_lossy(), + "cwd": cwd, + "debug_cmd_path": debug_cmd.to_string_lossy(), + "debug_response_path": debug_resp.to_string_lossy(), + "stdout_path": stdout_path.to_string_lossy(), + "stderr_path": stderr_path.to_string_lossy(), + "started_at": chrono::Utc::now().to_rfc3339(), + }); + + let mut testers = load_testers()?; + testers.push(info); + save_testers(&testers)?; + + Ok(serde_json::json!({ + "id": id, + "pid": pid, + "message": format!("Spawned tester {} (pid {})", id, pid) + }) + .to_string()) +} + +/// Master/slave ends of a freshly allocated PTY. +#[cfg(unix)] +struct TesterPty { + master: std::os::fd::OwnedFd, + slave: std::os::fd::OwnedFd, +} + +/// Allocate a PTY sized `cols` x `rows` for a headless tester. The TUI's +/// terminal init requires stdin/stdout to be a TTY, so testers get the slave +/// end as their stdio while the server drains the master end. +#[cfg(unix)] +#[allow( + clippy::unnecessary_mut_passed, + reason = "libc::openpty takes mutable termios/winsize pointers on Apple/BSD targets" +)] +fn allocate_pty(cols: u16, rows: u16) -> std::io::Result<TesterPty> { + use std::os::fd::{FromRawFd, OwnedFd}; + + let mut winsize = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let mut master_fd: libc::c_int = -1; + let mut slave_fd: libc::c_int = -1; + // Safety: openpty writes two valid fds on success; name is unused (null). + let rc = unsafe { + libc::openpty( + &mut master_fd, + &mut slave_fd, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut winsize, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + // Safety: on success both fds are valid and exclusively owned here. + Ok(unsafe { + TesterPty { + master: OwnedFd::from_raw_fd(master_fd), + slave: OwnedFd::from_raw_fd(slave_fd), + } + }) +} + +async fn execute_tester_subcommand( + tester_id: &str, + cmd: &str, + arg: Option<&str>, +) -> Result<String> { + let testers = load_testers()?; + let tester = testers + .iter() + .find(|t| t.get("id").and_then(|v| v.as_str()) == Some(tester_id)) + .ok_or_else(|| anyhow::anyhow!("Tester not found: {}", tester_id))?; + + let debug_cmd_path = tester + .get("debug_cmd_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid tester config"))?; + let debug_resp_path = tester + .get("debug_response_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid tester config"))?; + + let file_cmd = match cmd { + "frame" => "screen-json".to_string(), + "frame-normalized" => "screen-json-normalized".to_string(), + "state" => "state".to_string(), + "history" => "history".to_string(), + "wait" => "wait".to_string(), + "input" => "input".to_string(), + "message" => format!("message:{}", arg.unwrap_or("")), + "inject" => format!("inject:{}", arg.unwrap_or("")), + "keys" => format!("keys:{}", arg.unwrap_or("")), + "set_input" => format!("set_input:{}", arg.unwrap_or("")), + "scroll" => format!("scroll:{}", arg.unwrap_or("down")), + "scroll-test" => match arg { + Some(raw) => format!("scroll-test:{}", raw), + None => "scroll-test".to_string(), + }, + "scroll-suite" => match arg { + Some(raw) => format!("scroll-suite:{}", raw), + None => "scroll-suite".to_string(), + }, + "side-panel-latency" => match arg { + Some(raw) => format!("side-panel-latency:{}", raw), + None => "side-panel-latency".to_string(), + }, + "mermaid-ui-bench" => match arg { + Some(raw) => format!("mermaid:ui-bench:{}", raw), + None => "mermaid:ui-bench".to_string(), + }, + "stop" => { + if let Some(pid) = tester.get("pid").and_then(|v| v.as_u64()) { + let _ = std::process::Command::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .output(); + } + let mut testers = load_testers()?; + testers.retain(|t| t.get("id").and_then(|v| v.as_str()) != Some(tester_id)); + save_testers(&testers)?; + return Ok("Stopped tester.".to_string()); + } + _ => return Err(anyhow::anyhow!("Unknown tester command: {}", cmd)), + }; + + std::fs::write(debug_cmd_path, &file_cmd)?; + + let timeout = std::time::Duration::from_secs(10); + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + return Err(anyhow::anyhow!("Timeout waiting for tester response")); + } + if let Ok(response) = std::fs::read_to_string(debug_resp_path) + && !response.is_empty() + { + let _ = std::fs::remove_file(debug_resp_path); + return Ok(response); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +#[cfg(test)] +#[path = "debug_testers_tests.rs"] +mod debug_testers_tests; diff --git a/crates/jcode-app-core/src/server/debug_testers_tests.rs b/crates/jcode-app-core/src/server/debug_testers_tests.rs new file mode 100644 index 0000000..83b6fb7 --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_testers_tests.rs @@ -0,0 +1,77 @@ +use super::{load_testers, save_testers}; +use std::ffi::OsString; + +fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::storage::lock_test_env() +} + +struct TestHomeGuard { + _lock: std::sync::MutexGuard<'static, ()>, + prev_home: Option<OsString>, + _temp_home: tempfile::TempDir, +} + +impl TestHomeGuard { + fn new() -> Self { + let lock = lock_env(); + let temp_home = tempfile::Builder::new() + .prefix("jcode-server-debug-testers-home-") + .tempdir() + .expect("create temp home"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + Self { + _lock: lock, + prev_home, + _temp_home: temp_home, + } + } +} + +impl Drop for TestHomeGuard { + fn drop(&mut self) { + if let Some(prev_home) = &self.prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} + +#[test] +fn load_and_save_testers_roundtrip_manifest() { + let _guard = TestHomeGuard::new(); + let testers = vec![serde_json::json!({ + "id": "tester_1", + "pid": 1234, + "cwd": ".", + })]; + + save_testers(&testers).expect("save testers"); + let loaded = load_testers().expect("load testers"); + assert_eq!(loaded.len(), 1); + assert_eq!( + loaded[0].get("id").and_then(|v| v.as_str()), + Some("tester_1") + ); +} + +#[test] +fn load_testers_returns_empty_for_missing_or_empty_manifest() { + let _guard = TestHomeGuard::new(); + assert!( + load_testers() + .expect("missing manifest returns empty") + .is_empty() + ); + + let manifest_path = crate::storage::jcode_dir() + .expect("jcode dir") + .join("testers.json"); + std::fs::write(&manifest_path, "").expect("write empty manifest"); + assert!( + load_testers() + .expect("empty manifest returns empty") + .is_empty() + ); +} diff --git a/crates/jcode-app-core/src/server/debug_tests.rs b/crates/jcode-app-core/src/server/debug_tests.rs new file mode 100644 index 0000000..4c3f6fc --- /dev/null +++ b/crates/jcode-app-core/src/server/debug_tests.rs @@ -0,0 +1,783 @@ +mod tests { + use super::super::*; + use crate::server::debug_jobs::DebugJobStatus; + + #[test] + fn client_debug_state_registers_unregisters_and_falls_back() { + let mut state = ClientDebugState::default(); + let (tx1, _rx1) = mpsc::unbounded_channel(); + let (tx2, _rx2) = mpsc::unbounded_channel(); + + state.register("client-a".to_string(), tx1.clone()); + state.register("client-b".to_string(), tx2.clone()); + + let (active_id, _sender) = state.active_sender().expect("active sender present"); + assert_eq!(active_id, "client-b"); + + state.unregister("client-b"); + let (fallback_id, _sender) = state.active_sender().expect("fallback sender present"); + assert_eq!(fallback_id, "client-a"); + + state.unregister("client-a"); + assert!(state.active_sender().is_none()); + } + + #[test] + fn debug_job_payloads_include_expected_fields() { + let now = Instant::now(); + let job = DebugJob { + id: "job_123".to_string(), + status: DebugJobStatus::Completed, + command: "message:hello".to_string(), + session_id: Some("session_abc".to_string()), + created_at: now, + started_at: Some(now), + finished_at: Some(now), + output: Some("done".to_string()), + error: None, + }; + + let summary = job.summary_payload(); + assert_eq!(summary.get("id").and_then(|v| v.as_str()), Some("job_123")); + assert_eq!( + summary.get("status").and_then(|v| v.as_str()), + Some("completed") + ); + assert_eq!( + summary.get("session_id").and_then(|v| v.as_str()), + Some("session_abc") + ); + + let status = job.status_payload(); + assert_eq!(status.get("output").and_then(|v| v.as_str()), Some("done")); + assert!(status.get("error").is_some()); + } + + #[test] + fn debug_help_text_mentions_key_namespaces_and_commands() { + let help = debug_help_text(); + assert!(help.contains("SERVER COMMANDS")); + assert!(help.contains("CLIENT COMMANDS")); + assert!(help.contains("TESTER COMMANDS")); + assert!(help.contains("message_async:<text>")); + assert!(help.contains("client:frame")); + assert!(help.contains("client:picker")); + } + + #[test] + fn swarm_debug_help_text_mentions_core_swarm_sections() { + let help = swarm_debug_help_text(); + assert!(help.contains("MEMBERS & STRUCTURE")); + assert!(help.contains("PLAN PROPOSALS")); + assert!(help.contains("REAL-TIME EVENTS")); + assert!(help.contains("swarm:list")); + } + + #[test] + fn parse_namespaced_command_defaults_to_server_namespace() { + assert_eq!(parse_namespaced_command("state"), ("server", "state")); + assert_eq!( + parse_namespaced_command("swarm:list"), + ("server", "swarm:list") + ); + } + + #[test] + fn parse_namespaced_command_recognizes_known_namespaces() { + assert_eq!( + parse_namespaced_command("client:frame"), + ("client", "frame") + ); + assert_eq!(parse_namespaced_command("tester:list"), ("tester", "list")); + assert_eq!( + parse_namespaced_command("server:state"), + ("server", "state") + ); + } +} + +mod transcript_routing_tests { + use super::super::{ + ClientConnectionInfo, ClientDebugState, resolve_client_debug_sender, + resolve_transcript_target_session, + }; + use crate::protocol::ServerEvent; + use crate::server::SwarmMember; + use std::collections::HashMap; + #[cfg(target_os = "linux")] + use std::ffi::OsString; + use std::sync::Arc; + use std::time::Instant; + use tokio::sync::{RwLock, mpsc}; + + fn live_member(session_id: &str, connection_id: &str) -> SwarmMember { + let (event_tx, _event_rx) = mpsc::unbounded_channel::<ServerEvent>(); + let now = Instant::now(); + SwarmMember { + session_id: session_id.to_string(), + event_tx: event_tx.clone(), + event_txs: HashMap::from([(connection_id.to_string(), event_tx)]), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + friendly_name: None, + report_back_to_session_id: None, + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: false, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + } + } + + fn connection( + session_id: &str, + debug_client_id: &str, + last_seen: Instant, + ) -> ClientConnectionInfo { + ClientConnectionInfo { + client_id: format!("conn-{session_id}"), + session_id: session_id.to_string(), + client_instance_id: None, + debug_client_id: Some(debug_client_id.to_string()), + connected_at: last_seen, + last_seen, + is_processing: false, + current_tool_name: None, + terminal_env: Vec::new(), + disconnect_tx: mpsc::unbounded_channel().0, + } + } + + #[cfg(target_os = "linux")] + struct EnvVarGuard { + key: &'static str, + previous: Option<OsString>, + } + + #[cfg(target_os = "linux")] + impl EnvVarGuard { + fn set<K: AsRef<std::ffi::OsStr>>(key: &'static str, value: K) -> Self { + let previous = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, previous } + } + } + + #[cfg(target_os = "linux")] + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + crate::env::set_var(self.key, previous); + } else { + crate::env::remove_var(self.key); + } + } + } + + #[cfg(target_os = "linux")] + struct ChildGuard(std::process::Child); + + #[cfg(target_os = "linux")] + impl ChildGuard { + fn spawn_named(name: &str) -> Self { + let child = std::process::Command::new("python3") + .args([ + "-c", + "import ctypes, sys, time; libc = ctypes.CDLL(None); libc.prctl(15, sys.argv[1].encode(), 0, 0, 0); time.sleep(30)", + name, + ]) + .spawn() + .expect("spawn named helper process"); + Self(child) + } + + fn pid(&self) -> u32 { + self.0.id() + } + } + + #[cfg(target_os = "linux")] + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[cfg(target_os = "linux")] + fn install_fake_niri(bin_dir: &std::path::Path, pid: u32, title: &str) { + use std::os::unix::fs::PermissionsExt; + + std::fs::create_dir_all(bin_dir).expect("create fake bin dir"); + let script = bin_dir.join("niri"); + let json = serde_json::json!({ + "pid": pid, + "title": title, + "app_id": "kitty" + }); + std::fs::write(&script, format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", json)) + .expect("write fake niri script"); + let mut perms = std::fs::metadata(&script) + .expect("fake niri metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).expect("chmod fake niri"); + } + + #[tokio::test] + async fn resolve_transcript_target_session_uses_requested_connected_session() { + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "conn-1".to_string(), + connection("session_abc", "debug-1", Instant::now()), + )]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "session_abc".to_string(), + live_member("session_abc", "conn-1"), + )]))); + + let resolved = resolve_transcript_target_session( + Some("session_abc".to_string()), + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + .expect("resolve connected requested session"); + + assert_eq!(resolved, "session_abc"); + } + + #[tokio::test] + async fn resolve_transcript_target_session_prefers_last_focused_live_session() { + let _guard = crate::storage::lock_test_env(); + let jcode_dir = crate::storage::jcode_dir().expect("jcode dir"); + let active_dir = jcode_dir.join("active_pids"); + std::fs::create_dir_all(&active_dir).expect("create active_pids"); + std::fs::write(active_dir.join("session_focus"), "12345").expect("write active pid"); + crate::dictation::remember_last_focused_session("session_focus") + .expect("remember last focused session"); + + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "conn-1".to_string(), + connection("session_focus", "debug-1", Instant::now()), + )]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "session_focus".to_string(), + live_member("session_focus", "conn-1"), + )]))); + + let resolved = resolve_transcript_target_session( + None, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + .expect("resolve last-focused session"); + + assert_eq!(resolved, "session_focus"); + } + + #[tokio::test] + async fn resolve_transcript_target_session_rejects_requested_session_without_connected_tui() { + let client_connections = Arc::new(RwLock::new(HashMap::from([( + "conn-1".to_string(), + connection("session_abc", "debug-1", Instant::now()), + )]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::new())); + + let err = resolve_transcript_target_session( + Some("session_abc".to_string()), + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + .expect_err("requested session without connected tui should error"); + + assert!( + err.to_string() + .contains("does not have a connected TUI client") + ); + } + + #[tokio::test] + async fn resolve_transcript_target_session_falls_back_to_most_recent_live_tui_when_last_focused_not_connected() + { + let _guard = crate::storage::lock_test_env(); + let jcode_dir = crate::storage::jcode_dir().expect("jcode dir"); + let active_dir = jcode_dir.join("active_pids"); + std::fs::create_dir_all(&active_dir).expect("create active_pids"); + std::fs::write(active_dir.join("session_stale"), "12345").expect("write active pid"); + crate::dictation::remember_last_focused_session("session_stale") + .expect("remember last focused session"); + + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn-1".to_string(), + connection( + "session_stale_debug", + "debug-1", + now - std::time::Duration::from_secs(60), + ), + ), + ( + "conn-2".to_string(), + connection("session_recent", "debug-2", now), + ), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState { + active_id: Some("debug-1".to_string()), + clients: HashMap::new(), + })); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "session_recent".to_string(), + live_member("session_recent", "conn-2"), + )]))); + + let resolved = resolve_transcript_target_session( + None, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + .expect("resolve fallback live session"); + + assert_eq!(resolved, "session_recent"); + } + + #[tokio::test] + async fn resolve_transcript_target_session_ignores_non_live_requesting_clients() { + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn-cli".to_string(), + connection("session_cli", "debug-cli", now), + ), + ( + "conn-tui".to_string(), + connection( + "session_tui", + "debug-tui", + now - std::time::Duration::from_secs(30), + ), + ), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState { + active_id: Some("debug-cli".to_string()), + clients: HashMap::new(), + })); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "session_tui".to_string(), + live_member("session_tui", "conn-tui"), + )]))); + + let resolved = resolve_transcript_target_session( + None, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + .expect("resolve live tui session"); + + assert_eq!(resolved, "session_tui"); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn resolve_transcript_target_session_prefers_current_niri_focused_session_over_last_focused() + { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("tempdir"); + let _home = EnvVarGuard::set("JCODE_HOME", temp.path()); + + let active_dir = temp.path().join("active_pids"); + std::fs::create_dir_all(&active_dir).expect("create active_pids"); + + let fox = "session_fox_100"; + let swan = "session_swan_200"; + std::fs::write(active_dir.join(fox), "111").expect("write fox active pid"); + std::fs::write(active_dir.join(swan), "222").expect("write swan active pid"); + crate::dictation::remember_last_focused_session(fox).expect("remember fox session"); + + let focused_process = ChildGuard::spawn_named("jcode:d:swan"); + let bin_dir = temp.path().join("bin"); + install_fake_niri( + &bin_dir, + focused_process.pid(), + "🦢 jcode/cliff Swan [self-dev]", + ); + let prev_path = std::env::var_os("PATH").unwrap_or_default(); + let mut path = OsString::from(bin_dir.as_os_str()); + path.push(":"); + path.push(prev_path); + let _path = EnvVarGuard::set("PATH", path); + + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "conn-fox".to_string(), + connection(fox, "debug-fox", now - std::time::Duration::from_secs(30)), + ), + ("conn-swan".to_string(), connection(swan, "debug-swan", now)), + ]))); + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + (fox.to_string(), live_member(fox, "conn-fox")), + (swan.to_string(), live_member(swan, "conn-swan")), + ]))); + + let resolved = resolve_transcript_target_session( + None, + &client_connections, + &client_debug_state, + &swarm_members, + ) + .await + .expect("resolve transcript target from focused session"); + + assert_eq!(resolved, swan); + } + + #[tokio::test] + async fn resolve_client_debug_sender_uses_requested_session() { + let (tx_target, _rx_target) = mpsc::unbounded_channel(); + let (tx_other, _rx_other) = mpsc::unbounded_channel(); + + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + { + let mut state = client_debug_state.write().await; + state.register("debug-target".to_string(), tx_target.clone()); + state.register("debug-other".to_string(), tx_other.clone()); + state.active_id = Some("debug-other".to_string()); + } + + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "target".to_string(), + connection("session-target", "debug-target", now), + ), + ( + "other".to_string(), + connection("session-other", "debug-other", now), + ), + ]))); + + let (client_id, _sender) = resolve_client_debug_sender( + Some("session-target"), + &client_connections, + &client_debug_state, + ) + .await + .expect("requested session should resolve"); + + assert_eq!(client_id, "debug-target"); + } + + #[tokio::test] + async fn resolve_client_debug_sender_prefers_most_recent_requested_session_connection() { + let (tx_old, _rx_old) = mpsc::unbounded_channel(); + let (tx_new, _rx_new) = mpsc::unbounded_channel(); + + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + { + let mut state = client_debug_state.write().await; + state.register("debug-old".to_string(), tx_old.clone()); + state.register("debug-new".to_string(), tx_new.clone()); + } + + let now = Instant::now(); + let client_connections = Arc::new(RwLock::new(HashMap::from([ + ( + "old".to_string(), + connection( + "session-target", + "debug-old", + now - std::time::Duration::from_secs(30), + ), + ), + ( + "new".to_string(), + connection("session-target", "debug-new", now), + ), + ]))); + + let (client_id, _sender) = resolve_client_debug_sender( + Some("session-target"), + &client_connections, + &client_debug_state, + ) + .await + .expect("most recent session client should resolve"); + + assert_eq!(client_id, "debug-new"); + } + + #[tokio::test] + async fn resolve_client_debug_sender_without_request_uses_active_client() { + let (tx_a, _rx_a) = mpsc::unbounded_channel(); + let (tx_b, _rx_b) = mpsc::unbounded_channel(); + + let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); + { + let mut state = client_debug_state.write().await; + state.register("debug-a".to_string(), tx_a.clone()); + state.register("debug-b".to_string(), tx_b.clone()); + } + + let client_connections = Arc::new(RwLock::new(HashMap::new())); + + let (client_id, _sender) = + resolve_client_debug_sender(None, &client_connections, &client_debug_state) + .await + .expect("active client should resolve"); + + assert_eq!(client_id, "debug-b"); + } +} + +mod debug_execution_tests { + use crate::agent::Agent; + use crate::provider; + use crate::server::debug_command_exec::{debug_message_timeout_secs, resolve_debug_session}; + use crate::tool::Registry; + use std::collections::HashMap; + use std::ffi::OsString; + use std::sync::Arc; + use tokio::sync::{Mutex as AsyncMutex, RwLock}; + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + crate::storage::lock_test_env() + } + + struct EnvVarGuard { + _lock: std::sync::MutexGuard<'static, ()>, + key: &'static str, + previous: Option<OsString>, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let lock = lock_env(); + let previous = std::env::var_os(key); + crate::env::set_var(key, value); + Self { + _lock: lock, + key, + previous, + } + } + + fn remove(key: &'static str) -> Self { + let lock = lock_env(); + let previous = std::env::var_os(key); + crate::env::remove_var(key); + Self { + _lock: lock, + key, + previous, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(prev) = &self.previous { + crate::env::set_var(self.key, prev); + } else { + crate::env::remove_var(self.key); + } + } + } + + struct TestProvider; + + #[async_trait::async_trait] + impl provider::Provider for TestProvider { + fn name(&self) -> &str { + "test" + } + + fn model(&self) -> String { + "test".to_string() + } + + fn available_models(&self) -> Vec<&'static str> { + vec![] + } + + fn available_models_display(&self) -> Vec<String> { + vec![] + } + + async fn prefetch_models(&self) -> anyhow::Result<()> { + Ok(()) + } + + fn set_model(&self, _model: &str) -> anyhow::Result<()> { + Ok(()) + } + + fn handles_tools_internally(&self) -> bool { + false + } + + async fn complete( + &self, + _messages: &[crate::message::Message], + _tools: &[crate::message::ToolDefinition], + _system: &str, + _session_id: Option<&str>, + ) -> anyhow::Result<crate::provider::EventStream> { + Err(anyhow::anyhow!( + "test provider complete should not be called in debug tests" + )) + } + + fn fork(&self) -> Arc<dyn provider::Provider> { + Arc::new(TestProvider) + } + } + + async fn test_agent() -> Arc<AsyncMutex<Agent>> { + let provider = Arc::new(TestProvider) as Arc<dyn provider::Provider>; + let registry = Registry::new(provider.clone()).await; + Arc::new(AsyncMutex::new(Agent::new(provider, registry))) + } + + #[tokio::test] + async fn resolve_debug_session_uses_requested_session_when_present() { + let agent = test_agent().await; + let session_id = { + let agent = agent.lock().await; + agent.session_id().to_string() + }; + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let current = Arc::new(RwLock::new(String::new())); + + let (resolved_id, resolved_agent) = + resolve_debug_session(&sessions, ¤t, Some(session_id.clone())) + .await + .expect("resolve requested session"); + + assert_eq!(resolved_id, session_id); + assert!(Arc::ptr_eq(&resolved_agent, &agent)); + } + + #[tokio::test] + async fn resolve_debug_session_falls_back_to_current_session() { + let agent = test_agent().await; + let session_id = { + let agent = agent.lock().await; + agent.session_id().to_string() + }; + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let current = Arc::new(RwLock::new(session_id.clone())); + + let (resolved_id, resolved_agent) = resolve_debug_session(&sessions, ¤t, None) + .await + .expect("resolve current session"); + + assert_eq!(resolved_id, session_id); + assert!(Arc::ptr_eq(&resolved_agent, &agent)); + } + + #[tokio::test] + async fn resolve_debug_session_uses_only_session_when_singleton() { + let agent = test_agent().await; + let session_id = { + let agent = agent.lock().await; + agent.session_id().to_string() + }; + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let current = Arc::new(RwLock::new(String::new())); + + let (resolved_id, _) = resolve_debug_session(&sessions, ¤t, None) + .await + .expect("resolve single session"); + + assert_eq!(resolved_id, session_id); + } + + #[tokio::test] + async fn resolve_debug_session_errors_for_unknown_or_missing_session() { + let agent_a = test_agent().await; + let id_a = { + let agent = agent_a.lock().await; + agent.session_id().to_string() + }; + let agent_b = test_agent().await; + let id_b = { + let agent = agent_b.lock().await; + agent.session_id().to_string() + }; + + let sessions = Arc::new(RwLock::new(HashMap::from([ + (id_a.clone(), agent_a), + (id_b.clone(), agent_b), + ]))); + let current = Arc::new(RwLock::new(String::new())); + + let unknown = resolve_debug_session(&sessions, ¤t, Some("missing".to_string())).await; + let unknown_err = match unknown { + Ok(_) => panic!("expected unknown session to error"), + Err(err) => err, + }; + assert!(unknown_err.to_string().contains("Unknown session_id")); + + let missing = resolve_debug_session(&sessions, ¤t, None).await; + let missing_err = match missing { + Ok(_) => panic!("expected missing active session to error"), + Err(err) => err, + }; + assert!(missing_err.to_string().contains("No active session found")); + } + + #[test] + fn debug_message_timeout_secs_reads_valid_env_values() { + let _guard = EnvVarGuard::set("JCODE_DEBUG_MESSAGE_TIMEOUT_SECS", "17"); + assert_eq!(debug_message_timeout_secs(), Some(17)); + } + + #[test] + fn debug_message_timeout_secs_ignores_missing_empty_invalid_and_zero() { + let _guard = EnvVarGuard::remove("JCODE_DEBUG_MESSAGE_TIMEOUT_SECS"); + assert_eq!(debug_message_timeout_secs(), None); + drop(_guard); + + let _guard = EnvVarGuard::set("JCODE_DEBUG_MESSAGE_TIMEOUT_SECS", " "); + assert_eq!(debug_message_timeout_secs(), None); + drop(_guard); + + let _guard = EnvVarGuard::set("JCODE_DEBUG_MESSAGE_TIMEOUT_SECS", "abc"); + assert_eq!(debug_message_timeout_secs(), None); + drop(_guard); + + let _guard = EnvVarGuard::set("JCODE_DEBUG_MESSAGE_TIMEOUT_SECS", "0"); + assert_eq!(debug_message_timeout_secs(), None); + } +} diff --git a/crates/jcode-app-core/src/server/durable_state.rs b/crates/jcode-app-core/src/server/durable_state.rs new file mode 100644 index 0000000..bc14554 --- /dev/null +++ b/crates/jcode-app-core/src/server/durable_state.rs @@ -0,0 +1,75 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use std::time::Duration; + +pub(super) fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub(super) fn sanitize_session_id(session_id: &str) -> String { + session_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +pub(super) fn hashed_request_key(session_id: &str, action: &str, components: &[String]) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + session_id.hash(&mut hasher); + action.hash(&mut hasher); + for component in components { + component.hash(&mut hasher); + } + format!( + "{}-{:016x}", + sanitize_session_id(session_id), + hasher.finish() + ) +} + +pub(super) fn state_dir(dir_name: &str) -> PathBuf { + crate::storage::runtime_dir().join(dir_name) +} + +pub(super) fn state_path(dir_name: &str, key: &str) -> PathBuf { + state_dir(dir_name).join(format!("{key}.json")) +} + +pub(super) fn load_json_state<T, F>(dir_name: &str, key: &str, is_stale: F) -> Option<T> +where + T: DeserializeOwned, + F: Fn(&T) -> bool, +{ + let path = state_path(dir_name, key); + let state = crate::storage::read_json::<T>(&path).ok()?; + if is_stale(&state) { + let _ = std::fs::remove_file(path); + return None; + } + Some(state) +} + +pub(super) fn save_json_state<T>(dir_name: &str, key: &str, state: &T, label: &str) +where + T: Serialize, +{ + let path = state_path(dir_name, key); + if let Err(err) = crate::storage::write_json_fast(&path, state) { + crate::logging::warn(&format!("Failed to persist {label} {key}: {err}")); + } +} + +pub(super) fn elapsed_exceeds(created_at_unix_ms: u64, ttl: Duration) -> bool { + now_unix_ms().saturating_sub(created_at_unix_ms) > ttl.as_millis() as u64 +} diff --git a/crates/jcode-app-core/src/server/file_activity.rs b/crates/jcode-app-core/src/server/file_activity.rs new file mode 100644 index 0000000..6c8470c --- /dev/null +++ b/crates/jcode-app-core/src/server/file_activity.rs @@ -0,0 +1,55 @@ +use super::FileAccess; + +pub(crate) fn parse_file_activity_line_range(summary: Option<&str>) -> Option<(u64, u64)> { + let summary = summary?; + let marker_start = summary + .find("lines ") + .map(|idx| idx + "lines ".len()) + .or_else(|| summary.find("line ").map(|idx| idx + "line ".len()))?; + let rest = &summary[marker_start..]; + let mut digits = String::new(); + let mut chars = rest.chars().peekable(); + while let Some(ch) = chars.peek().copied() { + if ch.is_ascii_digit() { + digits.push(ch); + chars.next(); + } else { + break; + } + } + let start = digits.parse::<u64>().ok()?; + if chars.peek() == Some(&'-') { + chars.next(); + let mut end_digits = String::new(); + while let Some(ch) = chars.peek().copied() { + if ch.is_ascii_digit() { + end_digits.push(ch); + chars.next(); + } else { + break; + } + } + let end = end_digits.parse::<u64>().ok().unwrap_or(start); + Some((start.min(end), start.max(end))) + } else { + Some((start, start)) + } +} + +pub(crate) fn file_activity_scope_label( + previous: &FileAccess, + current: &crate::bus::FileTouch, +) -> &'static str { + match ( + parse_file_activity_line_range(previous.summary.as_deref()), + parse_file_activity_line_range(current.summary.as_deref()), + ) { + (Some((prev_start, prev_end)), Some((current_start, current_end))) + if prev_start <= current_end && current_start <= prev_end => + { + "overlapping lines" + } + (Some(_), Some(_)) => "same file, non-overlapping lines", + _ => "same file", + } +} diff --git a/crates/jcode-app-core/src/server/file_activity_tests.rs b/crates/jcode-app-core/src/server/file_activity_tests.rs new file mode 100644 index 0000000..5034370 --- /dev/null +++ b/crates/jcode-app-core/src/server/file_activity_tests.rs @@ -0,0 +1,59 @@ +use super::{FileAccess, latest_peer_touches}; +use crate::bus::FileOp; +use std::collections::HashSet; +use std::time::{Duration, Instant, SystemTime}; + +fn access(session_id: &str, op: FileOp, age_ms: u64) -> FileAccess { + let now = Instant::now(); + FileAccess { + session_id: session_id.to_string(), + op, + timestamp: now + .checked_sub(Duration::from_millis(age_ms)) + .unwrap_or(now), + absolute_time: SystemTime::now(), + intent: None, + summary: None, + detail: None, + } +} + +#[test] +fn latest_peer_touches_excludes_previous_readers_from_modification_alerts() { + let swarm_session_ids = HashSet::from([ + "current".to_string(), + "reader".to_string(), + "writer".to_string(), + ]); + let accesses = vec![ + access("reader", FileOp::Read, 20), + access("current", FileOp::Edit, 10), + access("writer", FileOp::Write, 5), + ]; + + let latest = latest_peer_touches(&accesses, "current", &swarm_session_ids); + + assert_eq!(latest.len(), 1); + assert!(!latest.iter().any(|entry| entry.session_id == "reader")); + assert!( + latest + .iter() + .any(|entry| entry.session_id == "writer" && entry.op == FileOp::Write) + ); +} + +#[test] +fn latest_peer_touches_deduplicates_to_most_recent_touch_per_peer() { + let swarm_session_ids = HashSet::from(["current".to_string(), "peer".to_string()]); + let accesses = vec![ + access("peer", FileOp::Read, 30), + access("peer", FileOp::Edit, 5), + access("current", FileOp::Write, 1), + ]; + + let latest = latest_peer_touches(&accesses, "current", &swarm_session_ids); + + assert_eq!(latest.len(), 1); + assert_eq!(latest[0].session_id, "peer"); + assert_eq!(latest[0].op, FileOp::Edit); +} diff --git a/crates/jcode-app-core/src/server/file_touch_service.rs b/crates/jcode-app-core/src/server/file_touch_service.rs new file mode 100644 index 0000000..ba0a0ca --- /dev/null +++ b/crates/jcode-app-core/src/server/file_touch_service.rs @@ -0,0 +1,147 @@ +//! Service handle that owns the server's file-touch tracking state. +//! +//! Historically the [`Server`](super::Server) struct held two raw +//! `Arc<RwLock<..>>` maps for file-touch tracking and every call site reached +//! directly into them. This service consolidates that state behind +//! intention-revealing methods so the rest of the server no longer needs to +//! know the internal map shapes or locking order. +//! +//! The two indexes are kept in sync: +//! * forward: `path -> chronological accesses` +//! * reverse: `session_id -> set of touched paths` + +use super::FileAccess; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// Shared ownership of the server's file-touch tracking indexes. +/// +/// Cloning is cheap: every clone shares the same underlying `Arc`-backed maps, +/// matching the previous behavior where the raw `Arc<RwLock<..>>` fields were +/// cloned and passed around. +#[derive(Clone)] +pub(crate) struct FileTouchService { + /// Forward index: path -> list of accesses (chronological order). + touches: Arc<RwLock<HashMap<PathBuf, Vec<FileAccess>>>>, + /// Reverse index: session_id -> set of paths the session has touched. + by_session: Arc<RwLock<HashMap<String, HashSet<PathBuf>>>>, +} + +impl FileTouchService { + /// Create an empty file-touch tracker. + pub(crate) fn new() -> Self { + Self { + touches: Arc::new(RwLock::new(HashMap::new())), + by_session: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Record a single file access, updating both the forward and reverse + /// indexes. The forward index is updated first (and its lock released) + /// before the reverse index, preserving the original locking order. + pub(crate) async fn record_touch(&self, path: PathBuf, access: FileAccess) { + let session_id = access.session_id.clone(); + { + let mut touches = self.touches.write().await; + touches + .entry(path.clone()) + .or_insert_with(Vec::new) + .push(access); + } + { + let mut by_session = self.by_session.write().await; + by_session.entry(session_id).or_default().insert(path); + } + } + + /// Cloned snapshot of all accesses recorded for `path`, or `None` if the + /// path has not been touched. Callers rely on the `Some`/`None` distinction + /// (e.g. for logging "no touches yet" vs computing peer touches). + pub(crate) async fn accesses_for_path(&self, path: &Path) -> Option<Vec<FileAccess>> { + self.touches.read().await.get(path).cloned() + } + + /// Sorted, display-formatted list of the distinct files a session has + /// touched (empty if the session has touched nothing). + pub(crate) async fn sorted_file_strings_for_session(&self, session_id: &str) -> Vec<String> { + let by_session = self.by_session.read().await; + let mut files: Vec<String> = by_session + .get(session_id) + .into_iter() + .flat_map(|paths| paths.iter()) + .map(|path| path.display().to_string()) + .collect(); + files.sort(); + files + } + + /// Cloned snapshot of the entire forward (`path -> accesses`) index. + /// + /// Used by read-only reporting paths (debug commands, memory accounting) + /// that need to iterate the whole map. + pub(crate) async fn snapshot(&self) -> HashMap<PathBuf, Vec<FileAccess>> { + self.touches.read().await.clone() + } + + /// Cloned snapshot of the reverse (`session_id -> paths`) index. + pub(crate) async fn reverse_snapshot(&self) -> HashMap<String, HashSet<PathBuf>> { + self.by_session.read().await.clone() + } + + /// Remove every touch recorded for a session from both indexes. + /// + /// Uses the reverse index to bound the forward-index work to only the + /// paths the session actually touched, falling back to a full scan if the + /// reverse entry is missing. + pub(crate) async fn clear_session(&self, session_id: &str) { + let touched_paths = { + let mut reverse = self.by_session.write().await; + reverse.remove(session_id) + }; + + let mut touches = self.touches.write().await; + if let Some(paths) = touched_paths { + for path in paths { + let mut remove_path = false; + if let Some(accesses) = touches.get_mut(&path) { + accesses.retain(|access| access.session_id != session_id); + remove_path = accesses.is_empty(); + } + if remove_path { + touches.remove(&path); + } + } + return; + } + + touches.retain(|_, accesses| { + accesses.retain(|access| access.session_id != session_id); + !accesses.is_empty() + }); + } + + /// Drop accesses older than `max_age` and rebuild the reverse index from the + /// surviving forward entries. + pub(crate) async fn expire_older_than(&self, max_age: Duration) { + let mut touches = self.touches.write().await; + let now = Instant::now(); + touches.retain(|_, accesses| { + accesses.retain(|access| now.duration_since(access.timestamp) < max_age); + !accesses.is_empty() + }); + let mut rebuilt_reverse_index: HashMap<String, HashSet<PathBuf>> = HashMap::new(); + for (path, accesses) in touches.iter() { + for access in accesses { + rebuilt_reverse_index + .entry(access.session_id.clone()) + .or_default() + .insert(path.clone()); + } + } + drop(touches); + *self.by_session.write().await = rebuilt_reverse_index; + } +} diff --git a/crates/jcode-app-core/src/server/headless.rs b/crates/jcode-app-core/src/server/headless.rs new file mode 100644 index 0000000..3143f6e --- /dev/null +++ b/crates/jcode-app-core/src/server/headless.rs @@ -0,0 +1,257 @@ +use crate::agent::Agent; +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, +}; +use crate::tool::Registry; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +#[expect( + clippy::too_many_arguments, + reason = "headless session creation wires provider, global session, swarm state, interrupts, and MCP pool together" +)] +pub(super) async fn create_headless_session( + sessions: &SessionAgents, + global_session_id: &Arc<RwLock<String>>, + provider_template: &Arc<dyn Provider>, + command: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + _swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + soft_interrupt_queues: &SessionInterruptQueues, + selfdev_requested: bool, + model_override: Option<String>, + provider_key_override: Option<String>, + route_api_method_override: Option<String>, + effort_override: Option<String>, + mcp_pool: Option<Arc<crate::mcp::SharedMcpPool>>, + report_back_to_session_id: Option<String>, +) -> Result<String> { + let memory_enabled = crate::config::config().features.memory; + let swarm_enabled = crate::config::config().features.swarm; + + let working_dir = if let Some(path_str) = command.strip_prefix("create_session:") { + let path_str = path_str.trim(); + if !path_str.is_empty() { + Some(std::path::PathBuf::from(path_str)) + } else { + None + } + } else { + None + }; + + let provider = provider_template.fork(); + let registry = Registry::new(provider.clone()).await; + + registry.enable_memory_test_mode().await; + + if selfdev_requested { + registry.register_selfdev_tools().await; + } + + registry + .register_mcp_tools_for_dir( + None, + mcp_pool, + Some("headless".to_string()), + working_dir.clone(), + ) + .await; + + 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( + Arc::clone(&provider), + registry, + working_dir_string.as_deref(), + ); + new_agent.set_memory_enabled(memory_enabled); + // Inline swarm mode renders a live gallery of worker viewports in the + // coordinator TUI; enable the per-agent output tap so this worker streams a + // throttled output tail onto the bus. + if matches!( + crate::config::config().agents.swarm_spawn_mode, + crate::config::SwarmSpawnMode::Inline + ) { + new_agent.set_inline_output_tap(true); + } + if provider_key_override.is_some() { + new_agent.set_session_provider_key(provider_key_override.clone()); + } + let client_session_id = new_agent.session_id().to_string(); + + if let Some(model) = model_override { + // Build a model-switch request that preserves the coordinator's auth + // route (e.g. claude-api vs claude-oauth, or an openai-compatible + // profile) so the spawned headless agent reconstructs the exact + // provider/auth the coordinator was using instead of a config default. + let model_request = crate::provider::MultiProvider::model_switch_request_for_session_route( + &model, + provider_key_override.as_deref(), + route_api_method_override.as_deref(), + ); + if let Err(e) = new_agent.set_model(&model_request) { + crate::logging::warn(&format!( + "Failed to set headless session model override '{}' (request '{}'): {}", + model, model_request, e + )); + } + } + + if let Some(effort) = effort_override + .as_deref() + .map(str::trim) + .filter(|effort| !effort.is_empty()) + && let Err(e) = new_agent.set_reasoning_effort(effort) + { + crate::logging::warn(&format!( + "Failed to set headless session reasoning effort override '{}': {}", + effort, e + )); + } + + new_agent.set_debug(true); + + if selfdev_requested { + new_agent.set_canary("self-dev"); + } + + { + let mut current = global_session_id.write().await; + if current.is_empty() { + *current = client_session_id.clone(); + } + } + + let agent = Arc::new(Mutex::new(new_agent)); + { + let mut sessions_guard = sessions.write().await; + sessions_guard.insert(client_session_id.clone(), Arc::clone(&agent)); + } + let (provider_model, provider_name, auth_method, effort) = { + let agent_guard = agent.lock().await; + register_session_interrupt_queue( + soft_interrupt_queues, + &client_session_id, + agent_guard.soft_interrupt_queue(), + ) + .await; + register_background_tool_signal(&client_session_id, agent_guard.background_tool_signal()); + let route_api_method = agent_guard.session_route_api_method(); + let auth_method = agent_guard + .active_resolved_credential() + .map(|credential| credential.auth_method_label().to_string()) + .or_else(|| { + route_api_method.as_deref().and_then(|route| { + let route = route.to_ascii_lowercase(); + if route.contains("oauth") { + Some("OAuth".to_string()) + } else if route.contains("api") || route.contains("compatible") { + Some("API key".to_string()) + } else { + None + } + }) + }); + ( + agent_guard.provider_model(), + agent_guard.provider_name(), + auth_method, + crate::session_effort::session_effort(&client_session_id), + ) + }; + + let swarm_id = if swarm_enabled { + swarm_id_for_dir(working_dir.clone()) + } else { + None + }; + let friendly_name = crate::id::extract_session_name(&client_session_id) + .map(|s| s.to_string()) + .unwrap_or_else(|| client_session_id[..8.min(client_session_id.len())].to_string()); + + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel::<ServerEvent>(); + tokio::spawn(async move { + while event_rx.recv().await.is_some() { + // Drain events to keep channel alive + } + }); + + { + let now = Instant::now(); + let mut members = swarm_members.write().await; + members.insert( + client_session_id.clone(), + SwarmMember { + session_id: client_session_id.clone(), + event_tx: event_tx.clone(), + event_txs: HashMap::new(), + working_dir: working_dir.clone(), + swarm_id: swarm_id.clone(), + swarm_enabled, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some(friendly_name.clone()), + report_back_to_session_id: report_back_to_session_id.clone(), + latest_completion_report: None, + role: "agent".to_string(), + joined_at: now, + last_status_change: now, + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime { + model: Some(provider_model), + provider: Some(provider_name), + auth_method, + effort, + elapsed_secs: Some(0), + }, + }, + ); + } + + if let Some(ref id) = swarm_id { + let mut swarms = swarms_by_id.write().await; + swarms + .entry(id.clone()) + .or_insert_with(HashSet::new) + .insert(client_session_id.clone()); + } + + // Headless sessions never auto-claim coordinator; only TUI-connected sessions do. + let is_new_coordinator = false; + let _ = swarm_coordinators; + if is_new_coordinator { + let mut members = swarm_members.write().await; + if let Some(m) = members.get_mut(&client_session_id) { + m.role = "coordinator".to_string(); + } + } + + if let Some(ref id) = swarm_id { + broadcast_swarm_status(id, swarm_members, swarms_by_id).await; + } + + Ok(serde_json::json!({ + "session_id": client_session_id, + "working_dir": working_dir, + "swarm_id": swarm_id, + "friendly_name": friendly_name, + "is_canary": selfdev_requested, + }) + .to_string()) +} diff --git a/crates/jcode-app-core/src/server/jade_relay.rs b/crates/jcode-app-core/src/server/jade_relay.rs new file mode 100644 index 0000000..dc3894e --- /dev/null +++ b/crates/jcode-app-core/src/server/jade_relay.rs @@ -0,0 +1,1428 @@ +use super::client_lifecycle::process_message_streaming_mpsc; +use super::state::{ + SessionControlHandle, SessionInterruptQueues, queue_soft_interrupt_for_session, + session_event_fanout_sender, +}; +use super::{SessionAgents, SwarmMember}; +use crate::config::SafetyConfig; +use crate::session::Session; +use anyhow::{Context, Result}; +use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +const RELAY_LONG_POLL_SECONDS: u32 = 20; +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); +const ERROR_BACKOFF: Duration = Duration::from_secs(10); +const LAUNCH_SESSION_WAIT: Duration = Duration::from_secs(45); +const MAX_RESPONSE_CHARS: usize = 12_000; +const CANCEL_SIGNAL_RESET: Duration = Duration::from_secs(5); + +type SessionCancelSignals = Arc<RwLock<HashMap<String, InterruptSignal>>>; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RelayApiConfig { + api_base: String, + token: String, + token_id: Option<String>, + user_id: Option<String>, + device_id: String, +} + +impl RelayApiConfig { + fn from_safety(safety: &SafetyConfig) -> Option<Self> { + if !safety.jade_relay_enabled { + return None; + } + let api_base = non_empty(safety.jade_relay_api_base.as_deref())?; + let token = non_empty(safety.jade_relay_token.as_deref())?; + Some(Self { + api_base: normalize_api_base(api_base), + token: token.to_string(), + token_id: non_empty(safety.jade_relay_token_id.as_deref()).map(str::to_string), + user_id: non_empty(safety.jade_relay_user_id.as_deref()).map(str::to_string), + device_id: default_device_id(), + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.api_base, path.trim_start_matches('/')) + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let mut req = req.header("Authorization", format!("Bearer {}", self.token)); + if let Some(token_id) = &self.token_id { + req = req.header("x-jade-token-id", token_id); + } + req + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RelayListenerConfig { + api: RelayApiConfig, + session_id: String, + process_existing_prompts: bool, +} + +impl RelayListenerConfig { + fn from_safety(safety: &SafetyConfig) -> Option<Self> { + if !safety.jade_relay_reply_enabled { + return None; + } + let api = RelayApiConfig::from_safety(safety)?; + let session_id = non_empty(safety.jade_relay_session_id.as_deref())?; + Some(Self { + api, + session_id: session_id.to_string(), + process_existing_prompts: env_flag("JCODE_JADE_RELAY_PROCESS_EXISTING_PROMPTS"), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RelayLaunchConfig { + api: RelayApiConfig, + default_working_dir: Option<String>, +} + +impl RelayLaunchConfig { + fn from_safety(safety: &SafetyConfig) -> Option<Self> { + if !safety.jade_relay_launch_enabled { + return None; + } + Some(Self { + api: RelayApiConfig::from_safety(safety)?, + default_working_dir: non_empty(safety.jade_relay_launch_working_dir.as_deref()) + .map(str::to_string), + }) + } +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn env_flag(name: &str) -> bool { + std::env::var(name) + .ok() + .map(|value| { + let trimmed = value.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) + .unwrap_or(false) +} + +fn normalize_api_base(api_base: &str) -> String { + let trimmed = api_base.trim(); + if trimmed.ends_with('/') { + trimmed.to_string() + } else { + format!("{trimmed}/") + } +} + +fn session_command_event_types_param() -> String { + "types=prompt,cancel".to_string() +} + +fn default_device_id() -> String { + if let Ok(value) = std::env::var("JCODE_JADE_RELAY_DEVICE_ID") + && !value.trim().is_empty() + { + return value.trim().to_string(); + } + let host = std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "device".to_string()); + format!("jcode-{host}") +} + +pub(super) fn spawn_if_configured( + safety: &SafetyConfig, + sessions: SessionAgents, + soft_interrupt_queues: SessionInterruptQueues, + shutdown_signals: SessionCancelSignals, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, +) { + if let Some(config) = RelayListenerConfig::from_safety(safety) { + crate::logging::info(&format!( + "Starting Jade relay listener session={} user_id={}", + config.session_id, + config.api.user_id.as_deref().unwrap_or("<token-default>") + )); + let session_sessions = Arc::clone(&sessions); + let session_interrupts = Arc::clone(&soft_interrupt_queues); + let session_shutdown_signals = Arc::clone(&shutdown_signals); + let session_swarm = Arc::clone(&swarm_members); + tokio::spawn(async move { + let client = RelayClient::new(config); + client + .run( + session_sessions, + session_interrupts, + session_shutdown_signals, + session_swarm, + ) + .await; + }); + } + + if let Some(config) = RelayLaunchConfig::from_safety(safety) { + crate::logging::info(&format!( + "Starting Jade relay launch listener device={} user_id={}", + config.api.device_id, + config.api.user_id.as_deref().unwrap_or("<token-default>") + )); + tokio::spawn(async move { + let client = RelayLauncherClient::new(config); + client + .run( + sessions, + soft_interrupt_queues, + shutdown_signals, + swarm_members, + ) + .await; + }); + } +} + +struct RelayClient { + config: RelayListenerConfig, + http: reqwest::Client, +} + +impl RelayClient { + fn new(config: RelayListenerConfig) -> Self { + Self { + config, + http: crate::provider::shared_http_client(), + } + } + + fn url(&self, path: &str) -> String { + self.config.api.url(path) + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + self.config.api.auth(req) + } + + async fn run( + &self, + sessions: SessionAgents, + soft_interrupt_queues: SessionInterruptQueues, + shutdown_signals: SessionCancelSignals, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + ) { + let after = if self.config.process_existing_prompts { + 0 + } else { + match self.poll_commands(0, 0).await { + Ok(response) => response.next_after, + Err(error) => { + crate::logging::warn(&format!("Jade relay initial poll failed: {error:#}")); + 0 + } + } + }; + self.run_from_after( + after, + sessions, + soft_interrupt_queues, + shutdown_signals, + swarm_members, + ) + .await; + } + + async fn run_from_after( + &self, + mut after: i64, + sessions: SessionAgents, + soft_interrupt_queues: SessionInterruptQueues, + shutdown_signals: SessionCancelSignals, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + ) { + let mut last_heartbeat = Instant::now() + .checked_sub(HEARTBEAT_INTERVAL) + .unwrap_or_else(Instant::now); + + loop { + if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { + if let Err(error) = self.heartbeat().await { + crate::logging::debug(&format!("Jade relay heartbeat failed: {error:#}")); + } + last_heartbeat = Instant::now(); + } + + match self.poll_commands(after, RELAY_LONG_POLL_SECONDS).await { + Ok(response) => { + after = response.next_after; + for event in response.events { + if event.seq > after { + after = event.seq; + } + let result = match event.event_type() { + "prompt" => { + self.handle_prompt( + event, + &sessions, + &soft_interrupt_queues, + Arc::clone(&swarm_members), + ) + .await + } + "cancel" => { + self.handle_cancel( + event, + &sessions, + &soft_interrupt_queues, + &shutdown_signals, + ) + .await + } + other => { + crate::logging::debug(&format!( + "Jade relay ignoring unsupported session event type={other}" + )); + Ok(()) + } + }; + if let Err(error) = result { + crate::logging::warn(&format!( + "Jade relay event handling failed: {error:#}" + )); + } + } + } + Err(error) => { + crate::logging::warn(&format!("Jade relay poll failed: {error:#}")); + tokio::time::sleep(ERROR_BACKOFF).await; + } + } + } + } + + async fn heartbeat(&self) -> Result<()> { + let mut body = serde_json::json!({ + "device_id": &self.config.api.device_id, + "label": &self.config.api.device_id, + "platform": std::env::consts::OS, + "session_id": &self.config.session_id, + "app": "jcode", + "capabilities": ["session"], + }); + add_user_id(&mut body, self.config.api.user_id.as_deref()); + let response = self + .auth(self.http.post(self.url("v1/devices")).json(&body)) + .send() + .await?; + ensure_success(response, "heartbeat").await.map(|_| ()) + } + + async fn poll_commands(&self, after: i64, wait: u32) -> Result<RelayEventsResponse> { + let session = urlencoding_encode(&self.config.session_id); + let mut params = vec![ + format!("after={}", after.max(0)), + session_command_event_types_param(), + format!("wait={wait}"), + "limit=100".to_string(), + ]; + if let Some(user_id) = &self.config.api.user_id { + params.push(format!("user_id={}", urlencoding_encode(user_id))); + } + let url = self.url(&format!( + "v1/sessions/{}/events?{}", + session, + params.join("&") + )); + let response = self.auth(self.http.get(url)).send().await?; + let response = ensure_success(response, "poll").await?; + response + .json::<RelayEventsResponse>() + .await + .context("decode relay poll response") + } + + async fn post_relay_event( + &self, + event_type: &str, + text: &str, + request_seq: i64, + data: Option<serde_json::Value>, + ) -> Result<i64> { + let session = urlencoding_encode(&self.config.session_id); + let mut body = serde_json::json!({ + "type": event_type, + "text": truncate_chars(text, MAX_RESPONSE_CHARS), + "request_seq": request_seq, + "origin": &self.config.api.device_id, + }); + if let Some(data) = data + && let Some(obj) = body.as_object_mut() + { + obj.insert("data".to_string(), data); + } + add_user_id(&mut body, self.config.api.user_id.as_deref()); + let response = self + .auth( + self.http + .post(self.url(&format!("v1/sessions/{}/events", session))) + .json(&body), + ) + .send() + .await?; + let response = ensure_success(response, "post relay event").await?; + let event = response + .json::<RelayEvent>() + .await + .context("decode relay event append response")?; + Ok(event.seq) + } + + async fn handle_prompt( + &self, + event: RelayEvent, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + ) -> Result<()> { + let text = event.text.unwrap_or_default(); + let text = text.trim(); + if text.is_empty() { + return Ok(()); + } + crate::logging::info(&format!( + "Jade relay delivering prompt seq={} session={} chars={}", + event.seq, + self.config.session_id, + text.chars().count() + )); + + let _ = self + .post_relay_event( + "status", + "Jade relay prompt processing started", + event.seq, + Some(serde_json::json!({ + "phase": "running", + "prompt_seq": event.seq, + "session_id": &self.config.session_id, + })), + ) + .await; + + match deliver_to_session( + &self.config.session_id, + text, + sessions, + soft_interrupt_queues, + swarm_members, + ) + .await + { + Ok(reply) => { + let response_seq = self + .post_relay_event( + "response", + &reply, + event.seq, + Some(serde_json::json!({ + "phase": "completed", + "prompt_seq": event.seq, + "session_id": &self.config.session_id, + })), + ) + .await?; + let _ = self + .post_relay_event( + "status", + "Jade relay prompt processing completed", + event.seq, + Some(serde_json::json!({ + "phase": "completed", + "prompt_seq": event.seq, + "response_seq": response_seq, + "session_id": &self.config.session_id, + })), + ) + .await; + Ok(()) + } + Err(error) => { + let message = format!("delivery failed: {error:#}"); + let _ = self + .post_relay_event( + "error", + &message, + event.seq, + Some(serde_json::json!({ + "phase": "failed", + "prompt_seq": event.seq, + "session_id": &self.config.session_id, + })), + ) + .await; + Err(error) + } + } + } + + async fn handle_cancel( + &self, + event: RelayEvent, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + shutdown_signals: &SessionCancelSignals, + ) -> Result<()> { + let reason = event + .text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Cancel requested via Jade relay"); + let interrupt = format!("[jade relay cancel] {reason}"); + crate::logging::info(&format!( + "Jade relay cancel requested seq={} session={}", + event.seq, self.config.session_id + )); + + let live_agent = { + let guard = sessions.read().await; + guard.contains_key(&self.config.session_id) + }; + let queue = { + let guard = soft_interrupt_queues.read().await; + guard.get(&self.config.session_id).cloned() + }; + let stop_signal = { + let guard = shutdown_signals.read().await; + guard.get(&self.config.session_id).cloned() + }; + + let (mode, signal_reset_ms) = if let (Some(queue), Some(stop_signal)) = (queue, stop_signal) + { + let control = SessionControlHandle::cancel_only( + self.config.session_id.clone(), + queue, + stop_signal, + ); + if !control.queue_soft_interrupt(interrupt, true, SoftInterruptSource::User) { + anyhow::bail!( + "session '{}' could not accept cancel interrupt", + self.config.session_id + ); + } + let cancel_epoch = control.request_cancel(); + let reset_control = control.clone(); + tokio::spawn(async move { + tokio::time::sleep(CANCEL_SIGNAL_RESET).await; + // Epoch-guarded so a newer cancel (e.g. the user pressing Esc + // in an attached TUI) fired during the window is not erased + // before the running turn observes it (issue #428). + reset_control.reset_cancel_if_epoch(cancel_epoch); + }); + ("signalled", Some(CANCEL_SIGNAL_RESET.as_millis() as u64)) + } else if live_agent { + if queue_soft_interrupt_for_session( + &self.config.session_id, + interrupt, + true, + SoftInterruptSource::User, + soft_interrupt_queues, + sessions, + ) + .await + { + ("queued_no_signal", None) + } else { + anyhow::bail!( + "session '{}' could not accept cancel", + self.config.session_id + ) + } + } else if queue_soft_interrupt_for_session( + &self.config.session_id, + interrupt, + true, + SoftInterruptSource::User, + soft_interrupt_queues, + sessions, + ) + .await + { + ("queued_offline", None) + } else { + anyhow::bail!( + "session '{}' is not live and could not queue cancel", + self.config.session_id + ) + }; + + self.post_relay_event( + "status", + "Jade relay cancel requested", + event.seq, + Some(serde_json::json!({ + "phase": "cancel_requested", + "mode": mode, + "signal_reset_ms": signal_reset_ms, + "session_id": &self.config.session_id, + })), + ) + .await + .map(|_| ()) + } +} + +struct RelayLauncherClient { + config: RelayLaunchConfig, + http: reqwest::Client, +} + +impl RelayLauncherClient { + fn new(config: RelayLaunchConfig) -> Self { + Self { + config, + http: crate::provider::shared_http_client(), + } + } + + fn url(&self, path: &str) -> String { + self.config.api.url(path) + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + self.config.api.auth(req) + } + + async fn run( + &self, + sessions: SessionAgents, + soft_interrupt_queues: SessionInterruptQueues, + shutdown_signals: SessionCancelSignals, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + ) { + let mut after = match self.poll_launches(0, 0).await { + Ok(response) => response.next_after, + Err(error) => { + crate::logging::warn(&format!("Jade relay launch initial poll failed: {error:#}")); + 0 + } + }; + let mut last_heartbeat = Instant::now() + .checked_sub(HEARTBEAT_INTERVAL) + .unwrap_or_else(Instant::now); + + loop { + if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { + if let Err(error) = self.heartbeat().await { + crate::logging::debug(&format!( + "Jade relay launch heartbeat failed: {error:#}" + )); + } + last_heartbeat = Instant::now(); + } + + match self.poll_launches(after, RELAY_LONG_POLL_SECONDS).await { + Ok(response) => { + after = response.next_after; + for event in response.events { + if event.seq > after { + after = event.seq; + } + if let Err(error) = self + .handle_launch( + event, + &sessions, + &soft_interrupt_queues, + &shutdown_signals, + Arc::clone(&swarm_members), + ) + .await + { + crate::logging::warn(&format!( + "Jade relay launch handling failed: {error:#}" + )); + } + } + } + Err(error) => { + crate::logging::warn(&format!("Jade relay launch poll failed: {error:#}")); + tokio::time::sleep(ERROR_BACKOFF).await; + } + } + } + } + + async fn heartbeat(&self) -> Result<()> { + let mut body = serde_json::json!({ + "device_id": &self.config.api.device_id, + "label": &self.config.api.device_id, + "platform": std::env::consts::OS, + "app": "jcode", + "capabilities": ["launch"], + }); + add_user_id(&mut body, self.config.api.user_id.as_deref()); + let response = self + .auth(self.http.post(self.url("v1/devices")).json(&body)) + .send() + .await?; + ensure_success(response, "launch heartbeat") + .await + .map(|_| ()) + } + + async fn poll_launches(&self, after: i64, wait: u32) -> Result<RelayEventsResponse> { + let device = urlencoding_encode(&self.config.api.device_id); + let mut params = vec![ + format!("after={}", after.max(0)), + "types=launch".to_string(), + format!("wait={wait}"), + "limit=100".to_string(), + ]; + if let Some(user_id) = &self.config.api.user_id { + params.push(format!("user_id={}", urlencoding_encode(user_id))); + } + let url = self.url(&format!( + "v1/devices/{}/events?{}", + device, + params.join("&") + )); + let response = self.auth(self.http.get(url)).send().await?; + let response = ensure_success(response, "poll launch commands").await?; + response + .json::<RelayEventsResponse>() + .await + .context("decode relay launch poll response") + } + + async fn post_device_event( + &self, + event_type: &str, + text: &str, + request_seq: i64, + data: Option<serde_json::Value>, + ) -> Result<i64> { + let device = urlencoding_encode(&self.config.api.device_id); + let mut body = serde_json::json!({ + "type": event_type, + "text": truncate_chars(text, MAX_RESPONSE_CHARS), + "request_seq": request_seq, + "origin": &self.config.api.device_id, + }); + if let Some(data) = data + && let Some(obj) = body.as_object_mut() + { + obj.insert("data".to_string(), data); + } + add_user_id(&mut body, self.config.api.user_id.as_deref()); + let response = self + .auth( + self.http + .post(self.url(&format!("v1/devices/{}/events", device))) + .json(&body), + ) + .send() + .await?; + let response = ensure_success(response, "post device event").await?; + let event = response + .json::<RelayEvent>() + .await + .context("decode device event append response")?; + Ok(event.seq) + } + + async fn post_session_event( + &self, + session_id: &str, + event_type: &str, + text: &str, + request_seq: i64, + data: Option<serde_json::Value>, + ) -> Result<i64> { + let session = urlencoding_encode(session_id); + let mut body = serde_json::json!({ + "type": event_type, + "text": truncate_chars(text, MAX_RESPONSE_CHARS), + "origin": &self.config.api.device_id, + }); + if request_seq > 0 + && let Some(obj) = body.as_object_mut() + { + obj.insert( + "request_seq".to_string(), + serde_json::Value::Number(serde_json::Number::from(request_seq)), + ); + } + if let Some(data) = data + && let Some(obj) = body.as_object_mut() + { + obj.insert("data".to_string(), data); + } + add_user_id(&mut body, self.config.api.user_id.as_deref()); + let response = self + .auth( + self.http + .post(self.url(&format!("v1/sessions/{}/events", session))) + .json(&body), + ) + .send() + .await?; + let response = ensure_success(response, "post launched session event").await?; + let event = response + .json::<RelayEvent>() + .await + .context("decode session event append response")?; + Ok(event.seq) + } + + async fn handle_launch( + &self, + event: RelayEvent, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + shutdown_signals: &SessionCancelSignals, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + ) -> Result<()> { + let request = + LaunchRequest::from_event(&event, self.config.default_working_dir.as_deref())?; + crate::logging::info(&format!( + "Jade relay launching headed session for device command seq={} chars={}", + event.seq, + request.text.chars().count() + )); + + let (session_id, cwd) = create_launch_session(&request)?; + let launched = spawn_launch_window( + &session_id, + &cwd, + request.selfdev, + request.provider_key.as_deref(), + )?; + if !launched { + anyhow::bail!("no supported terminal found for headed Jcode launch") + } + + let launched_data = serde_json::json!({ + "session_id": &session_id, + "working_dir": cwd.display().to_string(), + "phase": "launched", + }); + let _ = self + .post_device_event( + "launch_status", + &format!("Launched headed Jcode session {session_id}"), + event.seq, + Some(launched_data), + ) + .await; + + if request.text.trim().is_empty() { + self.spawn_session_listener( + session_id, + 0, + sessions, + soft_interrupt_queues, + shutdown_signals, + swarm_members, + ); + return Ok(()); + } + + if let Err(error) = wait_for_live_session(&session_id, sessions, LAUNCH_SESSION_WAIT).await + { + let message = + format!("launched session {session_id} but it did not connect: {error:#}"); + let _ = self + .post_device_event( + "launch_error", + &message, + event.seq, + Some(serde_json::json!({ "session_id": &session_id })), + ) + .await; + return Err(error); + } + + let prompt_seq = self + .post_session_event( + &session_id, + "prompt", + &request.text, + 0, + Some(serde_json::json!({ + "source": "device_launch", + "launch_seq": event.seq, + "device_id": &self.config.api.device_id, + })), + ) + .await?; + + let _ = self + .post_session_event( + &session_id, + "status", + "Jade relay launch prompt processing started", + prompt_seq, + Some(serde_json::json!({ + "phase": "running", + "prompt_seq": prompt_seq, + "session_id": &session_id, + "source": "device_launch", + })), + ) + .await; + + let after = match deliver_to_launched_session( + &session_id, + &request.text, + sessions, + Arc::clone(&swarm_members), + ) + .await + { + Ok(reply) => { + let response_seq = self + .post_session_event( + &session_id, + "response", + &reply, + prompt_seq, + Some(serde_json::json!({ + "phase": "completed", + "prompt_seq": prompt_seq, + "session_id": &session_id, + "source": "device_launch", + })), + ) + .await?; + let status_seq = self + .post_session_event( + &session_id, + "status", + "Jade relay launch prompt processing completed", + prompt_seq, + Some(serde_json::json!({ + "phase": "completed", + "prompt_seq": prompt_seq, + "response_seq": response_seq, + "session_id": &session_id, + "source": "device_launch", + })), + ) + .await + .unwrap_or(response_seq); + let _ = self + .post_device_event( + "launch_status", + &format!("Delivered launch prompt to {session_id}"), + event.seq, + Some(serde_json::json!({ + "session_id": &session_id, + "prompt_seq": prompt_seq, + "response_seq": response_seq, + "phase": "processed", + })), + ) + .await; + status_seq + } + Err(error) => { + let message = format!("delivery failed: {error:#}"); + let error_seq = self + .post_session_event( + &session_id, + "error", + &message, + prompt_seq, + Some(serde_json::json!({ + "phase": "failed", + "prompt_seq": prompt_seq, + "session_id": &session_id, + "source": "device_launch", + })), + ) + .await + .unwrap_or(prompt_seq); + let _ = self + .post_device_event( + "launch_error", + &message, + event.seq, + Some(serde_json::json!({ + "session_id": &session_id, + "prompt_seq": prompt_seq, + "error_seq": error_seq, + })), + ) + .await; + return Err(error); + } + }; + + self.spawn_session_listener( + session_id, + after, + sessions, + soft_interrupt_queues, + shutdown_signals, + swarm_members, + ); + Ok(()) + } + + fn spawn_session_listener( + &self, + session_id: String, + after: i64, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + shutdown_signals: &SessionCancelSignals, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + ) { + let config = RelayListenerConfig { + api: self.config.api.clone(), + session_id: session_id.clone(), + process_existing_prompts: false, + }; + let sessions = Arc::clone(sessions); + let soft_interrupt_queues = Arc::clone(soft_interrupt_queues); + let shutdown_signals = Arc::clone(shutdown_signals); + tokio::spawn(async move { + crate::logging::info(&format!( + "Starting Jade relay listener for launched session {session_id} after seq {after}" + )); + let client = RelayClient::new(config); + client + .run_from_after( + after, + sessions, + soft_interrupt_queues, + shutdown_signals, + swarm_members, + ) + .await; + }); + } +} + +async fn ensure_success(response: reqwest::Response, action: &str) -> Result<reqwest::Response> { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("jade relay {action} failed ({status}): {body}") +} + +fn add_user_id(body: &mut serde_json::Value, user_id: Option<&str>) { + if let Some(user_id) = user_id + && let Some(obj) = body.as_object_mut() + { + obj.insert( + "user_id".to_string(), + serde_json::Value::String(user_id.to_string()), + ); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LaunchRequest { + text: String, + working_dir: Option<String>, + model: Option<String>, + provider_key: Option<String>, + selfdev: bool, +} + +impl LaunchRequest { + fn from_event(event: &RelayEvent, default_working_dir: Option<&str>) -> Result<Self> { + let data = event.data.as_ref(); + let text = event.text.clone().unwrap_or_default(); + let working_dir = data_string(data, "working_dir") + .or_else(|| data_string(data, "cwd")) + .or_else(|| default_working_dir.map(str::to_string)); + let model = data_string(data, "model"); + let provider_key = provider_key_for_launch_model( + model.as_deref(), + data_string(data, "provider") + .or_else(|| data_string(data, "provider_key")) + .as_deref(), + ); + Ok(Self { + text, + working_dir, + model, + provider_key, + selfdev: data_bool(data, "selfdev"), + }) + } +} + +fn data_string(data: Option<&serde_json::Value>, key: &str) -> Option<String> { + data.and_then(|value| value.get(key)) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn data_bool(data: Option<&serde_json::Value>, key: &str) -> bool { + data.and_then(|value| value.get(key)) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +fn provider_key_for_launch_model( + model: Option<&str>, + provider_key_override: Option<&str>, +) -> Option<String> { + if let Some(provider_key) = provider_key_override + .map(str::trim) + .filter(|provider_key| !provider_key.is_empty()) + { + return Some(provider_key.to_string()); + } + + let model = model?.trim(); + if model.is_empty() { + return None; + } + if let Some((prefix, _rest)) = model.split_once(':') { + let prefix = prefix.trim(); + if crate::provider::provider_from_model_key(prefix).is_some() + || crate::provider_catalog::resolve_openai_compatible_profile_selection(prefix) + .is_some() + || crate::config::config().providers.contains_key(prefix) + { + return Some(prefix.to_string()); + } + } + crate::provider::provider_for_model(model).map(str::to_string) +} + +fn create_launch_session(request: &LaunchRequest) -> Result<(String, PathBuf)> { + let cwd = request + .working_dir + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + if !cwd.is_dir() { + anyhow::bail!("launch working_dir is not a directory: {}", cwd.display()); + } + + let mut session = Session::create(None, Some("Jade relay launch".to_string())); + session.working_dir = Some(cwd.display().to_string()); + if let Some(model) = &request.model { + session.model = Some(model.clone()); + } + if let Some(provider_key) = &request.provider_key { + session.provider_key = Some(provider_key.clone()); + } + if request.selfdev { + session.set_canary("self-dev"); + } + session.save()?; + Ok((session.id.clone(), cwd)) +} + +fn spawn_launch_window( + session_id: &str, + cwd: &Path, + selfdev_requested: bool, + provider_key: Option<&str>, +) -> Result<bool> { + let exe = crate::build::client_update_candidate(selfdev_requested) + .map(|(path, _label)| path) + .or_else(|| std::env::current_exe().ok()) + .unwrap_or_else(|| PathBuf::from("jcode")); + let context = crate::session_launch::SessionSpawnContext::kind("jade-relay"); + if selfdev_requested { + crate::session_launch::spawn_selfdev_in_new_terminal_with_context( + &exe, + session_id, + cwd, + provider_key, + &context, + ) + } else { + crate::session_launch::spawn_resume_in_new_terminal_with_context( + &exe, + session_id, + cwd, + provider_key, + &context, + ) + } +} + +async fn wait_for_live_session( + session_id: &str, + sessions: &SessionAgents, + timeout: Duration, +) -> Result<()> { + let started = Instant::now(); + loop { + if sessions.read().await.contains_key(session_id) { + return Ok(()); + } + if started.elapsed() >= timeout { + anyhow::bail!( + "session '{session_id}' did not connect within {}s", + timeout.as_secs() + ); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +async fn deliver_to_session( + session_id: &str, + text: &str, + sessions: &SessionAgents, + soft_interrupt_queues: &SessionInterruptQueues, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Result<String> { + let agent = { + let guard = sessions.read().await; + guard.get(session_id).cloned() + }; + let Some(agent) = agent else { + anyhow::bail!("session '{session_id}' is not live in this Jcode server") + }; + + if agent.try_lock().is_err() { + let queued = queue_soft_interrupt_for_session( + session_id, + format!("[jade relay message from user]\n{text}"), + false, + SoftInterruptSource::User, + soft_interrupt_queues, + sessions, + ) + .await; + if queued { + return Ok("Message queued for the running session.".to_string()); + } + anyhow::bail!("session '{session_id}' is busy and could not accept a queued interrupt") + } + + let start_message_index = { + let agent_guard = agent.lock().await; + agent_guard.message_count() + }; + let event_tx = session_event_fanout_sender(session_id.to_string(), swarm_members); + process_message_streaming_mpsc(Arc::clone(&agent), text, Vec::new(), None, event_tx).await?; + let reply = { + let agent_guard = agent.lock().await; + agent_guard.latest_assistant_text_after(start_message_index) + }; + Ok(reply.unwrap_or_else(|| "Message processed; no assistant text was produced.".to_string())) +} + +async fn deliver_to_launched_session( + session_id: &str, + text: &str, + sessions: &SessionAgents, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Result<String> { + let agent = { + let guard = sessions.read().await; + guard.get(session_id).cloned() + }; + let Some(agent) = agent else { + anyhow::bail!("session '{session_id}' is not live in this Jcode server") + }; + + // A just-spawned headed TUI briefly owns the agent lock while it subscribes + // and restores history. For launch commands, wait for that startup work and + // run the first prompt as a normal turn instead of falling back to a soft + // interrupt queue that may not be processed until a later turn. + let start_message_index = { + let agent_guard = agent.lock().await; + agent_guard.message_count() + }; + let event_tx = session_event_fanout_sender(session_id.to_string(), swarm_members); + process_message_streaming_mpsc(Arc::clone(&agent), text, Vec::new(), None, event_tx).await?; + let reply = { + let agent_guard = agent.lock().await; + agent_guard.latest_assistant_text_after(start_message_index) + }; + Ok(reply.unwrap_or_else(|| "Message processed; no assistant text was produced.".to_string())) +} + +#[derive(Debug, serde::Deserialize)] +struct RelayEventsResponse { + #[serde(default)] + events: Vec<RelayEvent>, + #[serde(default)] + next_after: i64, +} + +#[derive(Debug, serde::Deserialize)] +struct RelayEvent { + #[serde(default)] + seq: i64, + #[serde(default, rename = "type")] + event_type: String, + #[serde(default)] + text: Option<String>, + #[serde(default)] + data: Option<serde_json::Value>, +} + +impl RelayEvent { + fn event_type(&self) -> &str { + if self.event_type.trim().is_empty() { + "prompt" + } else { + self.event_type.trim() + } + } +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + let mut out = text + .chars() + .take(max_chars.saturating_sub(1)) + .collect::<String>(); + out.push('…'); + out +} + +/// Minimal percent-encoding for path/query segments (alnum and -_.~ pass through). +fn urlencoding_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_listener_config_is_opt_in_and_requires_credentials() { + let cfg = SafetyConfig::default(); + assert!(RelayListenerConfig::from_safety(&cfg).is_none()); + + let cfg = SafetyConfig { + jade_relay_enabled: true, + jade_relay_reply_enabled: true, + ..SafetyConfig::default() + }; + assert!(RelayListenerConfig::from_safety(&cfg).is_none()); + } + + #[test] + fn relay_listener_config_accepts_complete_opt_in_config() { + let cfg = SafetyConfig { + jade_relay_enabled: true, + jade_relay_reply_enabled: true, + jade_relay_api_base: Some("https://example.com/api".to_string()), + jade_relay_token: Some("tok".to_string()), + jade_relay_token_id: Some("alice-token".to_string()), + jade_relay_user_id: Some("alice".to_string()), + jade_relay_session_id: Some("sess-1".to_string()), + ..SafetyConfig::default() + }; + let parsed = RelayListenerConfig::from_safety(&cfg).expect("complete config"); + assert_eq!(parsed.api.api_base, "https://example.com/api/"); + assert_eq!(parsed.api.token, "tok"); + assert_eq!(parsed.api.token_id.as_deref(), Some("alice-token")); + assert_eq!(parsed.api.user_id.as_deref(), Some("alice")); + assert_eq!(parsed.session_id, "sess-1"); + } + + #[test] + fn relay_launch_config_is_separately_opt_in() { + let cfg = SafetyConfig { + jade_relay_enabled: true, + jade_relay_api_base: Some("https://example.com".to_string()), + jade_relay_token: Some("tok".to_string()), + ..SafetyConfig::default() + }; + assert!(RelayLaunchConfig::from_safety(&cfg).is_none()); + + let cfg = SafetyConfig { + jade_relay_launch_enabled: true, + jade_relay_launch_working_dir: Some("/tmp/project".to_string()), + ..cfg + }; + let parsed = RelayLaunchConfig::from_safety(&cfg).expect("launch opt-in config"); + assert_eq!(parsed.api.api_base, "https://example.com/"); + assert_eq!(parsed.default_working_dir.as_deref(), Some("/tmp/project")); + } + + #[test] + fn launch_request_reads_structured_data() { + let event = RelayEvent { + seq: 7, + event_type: "launch".to_string(), + text: Some("hello from web".to_string()), + data: Some(serde_json::json!({ + "working_dir": "/tmp/repo", + "model": "openai:gpt-test", + "provider": "openai", + "selfdev": true, + })), + }; + let parsed = LaunchRequest::from_event(&event, Some("/fallback")).expect("launch request"); + assert_eq!(parsed.text, "hello from web"); + assert_eq!(parsed.working_dir.as_deref(), Some("/tmp/repo")); + assert_eq!(parsed.model.as_deref(), Some("openai:gpt-test")); + assert_eq!(parsed.provider_key.as_deref(), Some("openai")); + assert!(parsed.selfdev); + } + + #[test] + fn relay_session_listener_polls_prompt_and_cancel_commands() { + assert_eq!(session_command_event_types_param(), "types=prompt,cancel"); + } + + #[test] + fn relay_event_type_defaults_to_prompt_for_legacy_events() { + let legacy = RelayEvent { + seq: 1, + event_type: String::new(), + text: Some("hello".to_string()), + data: None, + }; + assert_eq!(legacy.event_type(), "prompt"); + + let cancel = RelayEvent { + event_type: "cancel".to_string(), + ..legacy + }; + assert_eq!(cancel.event_type(), "cancel"); + } + + #[test] + fn relay_url_encoding_matches_jade_api_expectations() { + assert_eq!(urlencoding_encode("sess-relay-test"), "sess-relay-test"); + assert_eq!(urlencoding_encode("a/b c"), "a%2Fb%20c"); + assert_eq!(urlencoding_encode("user.name~1_2"), "user.name~1_2"); + } + + #[test] + fn truncation_preserves_short_text_and_marks_long_text() { + assert_eq!(truncate_chars("hello", 10), "hello"); + assert_eq!(truncate_chars("abcdef", 4), "abc…"); + } +} diff --git a/crates/jcode-app-core/src/server/lifecycle.rs b/crates/jcode-app-core/src/server/lifecycle.rs new file mode 100644 index 0000000..3959bbb --- /dev/null +++ b/crates/jcode-app-core/src/server/lifecycle.rs @@ -0,0 +1,322 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +const TEMP_SERVER_ENV: &str = "JCODE_TEMP_SERVER"; +const SERVER_SCOPE_ENV: &str = "JCODE_SERVER_SCOPE"; +const OWNER_PID_ENV: &str = "JCODE_SERVER_OWNER_PID"; +const TEMP_IDLE_SECS_ENV: &str = "JCODE_TEMP_SERVER_IDLE_SECS"; +const DEFAULT_TEMP_IDLE_SECS: u64 = 30 * 60; +const TEMP_SERVER_EXIT_CODE: i32 = super::EXIT_IDLE_TIMEOUT; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TemporaryServerPolicy { + pub(crate) owner_pid: Option<u32>, + pub(crate) idle_timeout_secs: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct TemporaryServerMetadata { + schema_version: u32, + scope: String, + pid: u32, + ppid: Option<u32>, + owner_pid: Option<u32>, + started_at: String, + socket_path: String, + debug_socket_path: String, + idle_timeout_secs: u64, + argv: Vec<String>, +} + +pub fn configure_temporary_server(owner_pid: Option<u32>, idle_timeout_secs: Option<u64>) { + crate::env::set_var(TEMP_SERVER_ENV, "1"); + crate::env::set_var(SERVER_SCOPE_ENV, "temporary"); + if let Some(owner_pid) = owner_pid { + crate::env::set_var(OWNER_PID_ENV, owner_pid.to_string()); + } + if let Some(idle_timeout_secs) = idle_timeout_secs { + crate::env::set_var(TEMP_IDLE_SECS_ENV, idle_timeout_secs.to_string()); + } +} + +pub(crate) fn temporary_server_policy_from_env() -> Option<TemporaryServerPolicy> { + if !temporary_server_env_enabled() { + return None; + } + + let owner_pid = std::env::var(OWNER_PID_ENV) + .ok() + .and_then(|value| value.parse::<u32>().ok()) + .filter(|pid| *pid > 0); + let idle_timeout_secs = std::env::var(TEMP_IDLE_SECS_ENV) + .ok() + .and_then(|value| value.parse::<u64>().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_TEMP_IDLE_SECS); + + Some(TemporaryServerPolicy { + owner_pid, + idle_timeout_secs, + }) +} + +fn temporary_server_env_enabled() -> bool { + env_truthy(TEMP_SERVER_ENV) + || std::env::var(SERVER_SCOPE_ENV) + .ok() + .map(|value| value.eq_ignore_ascii_case("temporary")) + .unwrap_or(false) +} + +fn env_truthy(name: &str) -> bool { + std::env::var(name) + .ok() + .map(|value| matches!(value.as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false) +} + +pub(crate) fn metadata_path(socket_path: &Path) -> PathBuf { + let filename = socket_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("jcode.sock"); + socket_path.with_file_name(format!("{filename}.server.json")) +} + +pub(crate) fn write_temporary_metadata( + socket_path: &Path, + debug_socket_path: &Path, + policy: &TemporaryServerPolicy, +) -> Option<PathBuf> { + let path = metadata_path(socket_path); + let metadata = TemporaryServerMetadata { + schema_version: 1, + scope: "temporary".to_string(), + pid: std::process::id(), + ppid: parent_pid(), + owner_pid: policy.owner_pid, + started_at: chrono::Utc::now().to_rfc3339(), + socket_path: socket_path.display().to_string(), + debug_socket_path: debug_socket_path.display().to_string(), + idle_timeout_secs: policy.idle_timeout_secs, + argv: std::env::args().collect(), + }; + + if let Some(parent) = path.parent() + && let Err(error) = std::fs::create_dir_all(parent) + { + crate::logging::warn(&format!( + "Failed to create temporary server metadata directory {}: {}", + parent.display(), + error + )); + return None; + } + + match serde_json::to_vec_pretty(&metadata) + .ok() + .and_then(|bytes| std::fs::write(&path, bytes).ok().map(|_| ())) + { + Some(()) => Some(path), + None => { + crate::logging::warn(&format!( + "Failed to write temporary server metadata {}", + path.display() + )); + None + } + } +} + +pub(crate) fn cleanup_temporary_metadata(socket_path: &Path) { + let _ = std::fs::remove_file(metadata_path(socket_path)); +} + +pub(crate) fn spawn_temporary_lifecycle_monitor( + client_count: Arc<RwLock<usize>>, + socket_path: PathBuf, + debug_socket_path: PathBuf, + server_name: String, + policy: TemporaryServerPolicy, +) { + tokio::spawn(async move { + let mut idle_since: Option<Instant> = None; + let mut check_interval = tokio::time::interval(Duration::from_secs(10)); + + loop { + check_interval.tick().await; + + if let Some(owner_pid) = policy.owner_pid + && owner_pid != std::process::id() + && !process_alive(owner_pid) + { + crate::logging::info(&format!( + "Temporary server owner pid {} is gone. Shutting down.", + owner_pid + )); + shutdown_temporary_server(&server_name, &socket_path, &debug_socket_path).await; + } + + let count = *client_count.read().await; + if count == 0 { + if idle_since.is_none() { + idle_since = Some(Instant::now()); + crate::logging::info(&format!( + "Temporary server has no clients. It will exit after {} seconds idle.", + policy.idle_timeout_secs + )); + } + + if let Some(since) = idle_since + && since.elapsed().as_secs() >= policy.idle_timeout_secs + { + crate::logging::info(&format!( + "Temporary server idle for {} seconds. Shutting down.", + since.elapsed().as_secs() + )); + shutdown_temporary_server(&server_name, &socket_path, &debug_socket_path).await; + } + } else { + if idle_since.is_some() { + crate::logging::info( + "Temporary server client connected. Idle timer cancelled.", + ); + } + idle_since = None; + } + } + }); +} + +async fn shutdown_temporary_server( + server_name: &str, + socket_path: &Path, + debug_socket_path: &Path, +) -> ! { + let _ = crate::registry::unregister_server(server_name).await; + crate::transport::remove_socket(socket_path); + crate::transport::remove_socket(debug_socket_path); + cleanup_temporary_metadata(socket_path); + std::process::exit(TEMP_SERVER_EXIT_CODE); +} + +#[cfg(unix)] +fn parent_pid() -> Option<u32> { + let ppid = unsafe { libc::getppid() }; + (ppid > 0).then_some(ppid as u32) +} + +#[cfg(not(unix))] +fn parent_pid() -> Option<u32> { + None +} + +#[cfg(unix)] +pub(crate) fn process_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + + let rc = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if rc == 0 { + return true; + } + + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) +} + +#[cfg(not(unix))] +pub(crate) fn process_alive(_pid: u32) -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + static TEST_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + + struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + entries: Vec<(&'static str, Option<std::ffi::OsString>)>, + } + + 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()); + let entries = names + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(); + Self { _lock, entries } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (name, value) in &self.entries { + if let Some(value) = value { + crate::env::set_var(name, value); + } else { + crate::env::remove_var(name); + } + } + } + } + + #[test] + fn temporary_policy_requires_explicit_marker() { + let _guard = EnvGuard::capture(&[TEMP_SERVER_ENV, SERVER_SCOPE_ENV, OWNER_PID_ENV]); + crate::env::remove_var(TEMP_SERVER_ENV); + crate::env::remove_var(SERVER_SCOPE_ENV); + crate::env::set_var(OWNER_PID_ENV, "123"); + + assert_eq!(temporary_server_policy_from_env(), None); + } + + #[test] + fn temporary_policy_reads_owner_and_timeout() { + let _guard = EnvGuard::capture(&[ + TEMP_SERVER_ENV, + SERVER_SCOPE_ENV, + OWNER_PID_ENV, + TEMP_IDLE_SECS_ENV, + ]); + crate::env::set_var(SERVER_SCOPE_ENV, "temporary"); + crate::env::set_var(OWNER_PID_ENV, "123"); + crate::env::set_var(TEMP_IDLE_SECS_ENV, "42"); + + assert_eq!( + temporary_server_policy_from_env(), + Some(TemporaryServerPolicy { + owner_pid: Some(123), + idle_timeout_secs: 42, + }) + ); + } + + #[test] + fn temporary_metadata_path_is_socket_scoped() { + assert_eq!( + metadata_path(Path::new("/tmp/example/jcode.sock")), + PathBuf::from("/tmp/example/jcode.sock.server.json") + ); + } + + #[cfg(unix)] + #[test] + fn current_process_is_alive() { + assert!(process_alive(std::process::id())); + assert!(!process_alive(0)); + } +} diff --git a/crates/jcode-app-core/src/server/live_turn.rs b/crates/jcode-app-core/src/server/live_turn.rs new file mode 100644 index 0000000..5604ef1 --- /dev/null +++ b/crates/jcode-app-core/src/server/live_turn.rs @@ -0,0 +1,197 @@ +//! Server-initiated ("wake") turns for live sessions. +//! +//! Several server paths start a full conversation turn in a session without +//! that session's client sending a message: swarm DM/broadcast wake delivery, +//! background-task completion wakes, scheduled-task delivery, and post-reload +//! resume. Those turns must keep the same bookkeeping as client-initiated +//! turns, otherwise the swarm member status stays "ready/idle" while the agent +//! is actually streaming and attached TUIs never learn the turn finished. +//! +//! This module is the single shared implementation: it marks the member +//! `running` while the turn streams, flips it back to `ready` (with a +//! completion report) or `failed` at the end, and fans out a terminal +//! `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::{ + SwarmEvent, SwarmMember, session_event_fanout_sender, truncate_detail, update_member_status, + update_member_status_with_report, +}; +use crate::agent::Agent; +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}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +/// Swarm bookkeeping handles needed to keep member status accurate around a +/// server-initiated turn. +#[derive(Clone)] +pub(super) struct LiveTurnSwarmContext { + pub members: Arc<RwLock<HashMap<String, SwarmMember>>>, + pub swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub event_history: Arc<RwLock<VecDeque<SwarmEvent>>>, + pub event_counter: Arc<AtomicU64>, + pub event_tx: broadcast::Sender<SwarmEvent>, +} + +impl LiveTurnSwarmContext { + pub(super) fn new( + members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>, + event_counter: &Arc<AtomicU64>, + event_tx: &broadcast::Sender<SwarmEvent>, + ) -> Self { + Self { + members: Arc::clone(members), + swarms_by_id: Arc::clone(swarms_by_id), + event_history: Arc::clone(event_history), + event_counter: Arc::clone(event_counter), + event_tx: event_tx.clone(), + } + } +} + +/// 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). +pub(super) async fn idle_live_agent( + session_id: &str, + sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> Option<Arc<Mutex<Agent>>> { + let agent = { + let guard = sessions.read().await; + guard.get(session_id).cloned() + }?; + + let has_live_attachments = { + let members = swarm_members.read().await; + members + .get(session_id) + .map(|member| !member.event_txs.is_empty() || !member.event_tx.is_closed()) + .unwrap_or(false) + }; + if !has_live_attachments { + return None; + } + + let is_idle = agent.try_lock().is_ok(); + is_idle.then_some(agent) +} + +/// Spawn `message` as a full tracked turn in a live session. +/// +/// Mirrors the client-initiated turn lifecycle: the swarm member is marked +/// `running` before the turn starts and `ready` (with a completion report) or +/// `failed` when it finishes. A synthetic terminal `Done { id: 0 }` (or +/// `Error { id: 0, .. }`) is fanned out to attached clients so their UI can +/// finish rendering the externally started turn. +pub(super) async fn spawn_tracked_live_turn( + session_id: &str, + agent: Arc<Mutex<Agent>>, + message: String, + system_reminder: Option<String>, + status_detail: Option<String>, + swarm: LiveTurnSwarmContext, +) { + update_member_status( + session_id, + "running", + status_detail, + &swarm.members, + &swarm.swarms_by_id, + Some(&swarm.event_history), + Some(&swarm.event_counter), + Some(&swarm.event_tx), + ) + .await; + + 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 result = process_message_streaming_mpsc( + Arc::clone(&agent), + &message, + vec![], + system_reminder, + event_tx.clone(), + ) + .await; + 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", + None, + completion_report, + &swarm.members, + &swarm.swarms_by_id, + Some(&swarm.event_history), + Some(&swarm.event_counter), + Some(&swarm.event_tx), + ) + .await; + let _ = event_tx.send(ServerEvent::Done { id: 0 }); + } + Err(error) => { + crate::logging::error(&format!( + "Server-initiated turn failed for live session {}: {}", + session_id, error + )); + update_member_status( + &session_id, + "failed", + Some(truncate_detail(&error.to_string(), 120)), + &swarm.members, + &swarm.swarms_by_id, + Some(&swarm.event_history), + Some(&swarm.event_counter), + Some(&swarm.event_tx), + ) + .await; + let _ = event_tx.send(ServerEvent::Error { + id: 0, + message: crate::util::format_error_chain(&error), + retry_after_secs: None, + }); + } + } + }); +} + +/// Run `message` immediately as a tracked turn if the session is live and +/// idle. Returns `true` when the turn was started. +pub(super) async fn run_live_turn_if_idle( + session_id: &str, + message: &str, + system_reminder: Option<String>, + 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(), + system_reminder, + detail, + swarm, + ) + .await; + true +} diff --git a/crates/jcode-app-core/src/server/provider_control.rs b/crates/jcode-app-core/src/server/provider_control.rs new file mode 100644 index 0000000..59a93e7 --- /dev/null +++ b/crates/jcode-app-core/src/server/provider_control.rs @@ -0,0 +1,1439 @@ +#![cfg_attr(test, allow(clippy::items_after_test_module))] + +use crate::agent::Agent; +use crate::auth::lifecycle::{AuthActivationRequest, AuthActivationResult}; +use crate::protocol::{AuthChanged, NotificationType, ServerEvent}; +use crate::provider::{ModelCatalogRefreshSummary, Provider}; +use jcode_provider_core::ModelCatalogSnapshot; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{Mutex, RwLock, mpsc}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +struct AuthRefreshTargets { + providers: Vec<Arc<dyn Provider>>, + session_providers: Vec<Arc<dyn Provider>>, + deferred_agents: Vec<Arc<Mutex<Agent>>>, +} + +fn available_models_snapshot_into_event(snapshot: ModelCatalogSnapshot) -> ServerEvent { + ServerEvent::AvailableModelsUpdated { + provider_name: snapshot.provider_name, + provider_model: snapshot.provider_model, + available_models: snapshot.available_models, + available_model_routes: snapshot.model_routes, + } +} + +fn available_models_updated_event_from_agent(agent: &Agent) -> ServerEvent { + available_models_snapshot_into_event(agent.model_catalog_snapshot()) +} + +async fn available_models_snapshot(agent: &Arc<Mutex<Agent>>) -> ModelCatalogSnapshot { + let agent_guard = agent.lock().await; + agent_guard.model_catalog_snapshot() +} + +fn available_models_snapshot_from_provider(provider: &Arc<dyn Provider>) -> ModelCatalogSnapshot { + ModelCatalogSnapshot::from_provider(provider.as_ref()) +} + +pub(super) async fn available_models_updated_event(agent: &Arc<Mutex<Agent>>) -> ServerEvent { + let agent_guard = agent.lock().await; + available_models_updated_event_from_agent(&agent_guard) +} + +pub(super) fn try_available_models_updated_event(agent: &Arc<Mutex<Agent>>) -> Option<ServerEvent> { + let agent_guard = agent.try_lock().ok()?; + Some(available_models_updated_event_from_agent(&agent_guard)) +} + +fn format_model_name_list(models: &[String], limit: usize) -> String { + let shown = models + .iter() + .take(limit) + .map(|model| format!("`{}`", model)) + .collect::<Vec<_>>() + .join(", "); + if models.len() > limit { + format!("{} … and {} more", shown, models.len() - limit) + } else { + shown + } +} + +fn format_auth_catalog_refresh_complete( + provider_name: Option<&str>, + provider_model: Option<&str>, + summary: &ModelCatalogRefreshSummary, +) -> String { + let provider_label = provider_name.unwrap_or("provider"); + let mut message = format!( + "**Auth Model Catalog Updated**\n\n{} credentials are active. Catalog diff:\n\nModels: {} → {} (+{} / -{})\nRoutes: {} → {} (+{} / -{} / ~{})", + provider_label, + summary.model_count_before, + summary.model_count_after, + summary.models_added, + summary.models_removed, + summary.route_count_before, + summary.route_count_after, + summary.routes_added, + summary.routes_removed, + summary.routes_changed, + ); + if !summary.models_added_names.is_empty() { + message.push_str("\nAdded models: "); + message.push_str(&format_model_name_list(&summary.models_added_names, 12)); + } + if !summary.models_removed_names.is_empty() { + message.push_str("\nRemoved models: "); + message.push_str(&format_model_name_list(&summary.models_removed_names, 12)); + } + if let Some(model) = provider_model { + message.push_str(&format!("\n\nSelected model: `{}`.", model)); + } + message.push_str("\n\nUse `/model` if you want to choose a different accessible model."); + message +} + +fn auth_model_refresh_quiet_period() -> std::time::Duration { + if cfg!(test) { + std::time::Duration::from_millis(20) + } else { + std::time::Duration::from_millis(750) + } +} + +fn log_provider_control_deferred(operation: &'static str, id: u64) -> Instant { + let queued_at = Instant::now(); + crate::logging::event_warn( + "SERVER_PROVIDER_CONTROL_DEFERRED", + vec![ + ("phase", "queued".to_string()), + ("operation", operation.to_string()), + ("request_id", id.to_string()), + ("reason", "agent_busy".to_string()), + ], + ); + queued_at +} + +fn log_provider_control_lock_acquired(operation: &'static str, id: u64, queued_at: Instant) { + crate::logging::event_info( + "SERVER_PROVIDER_CONTROL_DEFERRED", + vec![ + ("phase", "lock_acquired".to_string()), + ("operation", operation.to_string()), + ("request_id", id.to_string()), + ("wait_ms", queued_at.elapsed().as_millis().to_string()), + ], + ); +} + +fn log_provider_control_completed(operation: &'static str, id: u64, queued_at: Instant) { + crate::logging::event_info( + "SERVER_PROVIDER_CONTROL_DEFERRED", + vec![ + ("phase", "completed".to_string()), + ("operation", operation.to_string()), + ("request_id", id.to_string()), + ("total_ms", queued_at.elapsed().as_millis().to_string()), + ], + ); +} + +fn spawn_deferred_agent_mutation<F>( + operation: &'static str, + id: u64, + agent: Arc<Mutex<Agent>>, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, + apply: F, +) where + F: FnOnce(&mut Agent, &mpsc::UnboundedSender<ServerEvent>) + Send + 'static, +{ + let queued_at = log_provider_control_deferred(operation, id); + tokio::spawn(async move { + let mut agent_guard = agent.lock().await; + log_provider_control_lock_acquired(operation, id, queued_at); + apply(&mut agent_guard, &client_event_tx); + log_provider_control_completed(operation, id, queued_at); + }); +} + +fn spawn_deferred_provider_operation<F>( + operation: &'static str, + id: u64, + agent: Arc<Mutex<Agent>>, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, + apply: F, +) where + F: FnOnce(Arc<dyn Provider>, &mpsc::UnboundedSender<ServerEvent>) + Send + 'static, +{ + let queued_at = log_provider_control_deferred(operation, id); + tokio::spawn(async move { + let provider = { + let agent_guard = agent.lock().await; + log_provider_control_lock_acquired(operation, id, queued_at); + agent_guard.provider_handle() + }; + apply(provider, &client_event_tx); + log_provider_control_completed(operation, id, queued_at); + }); +} + +async fn auth_refresh_targets( + provider_template: &Arc<dyn Provider>, + current_provider: &Arc<dyn Provider>, + sessions: &SessionAgents, +) -> AuthRefreshTargets { + fn push_unique(handles: &mut Vec<Arc<dyn Provider>>, provider: Arc<dyn Provider>) { + if !handles + .iter() + .any(|existing| Arc::ptr_eq(existing, &provider)) + { + handles.push(provider); + } + } + + let mut handles = Vec::new(); + let mut session_handles = Vec::new(); + let mut deferred_agents = Vec::new(); + push_unique(&mut handles, Arc::clone(provider_template)); + push_unique(&mut handles, Arc::clone(current_provider)); + + let agents: Vec<Arc<Mutex<Agent>>> = { + let sessions_guard = sessions.read().await; + sessions_guard.values().cloned().collect() + }; + + for agent in agents { + let Ok(agent_guard) = agent.try_lock() else { + crate::logging::info( + "Deferring busy session provider auth-change refresh until the session is idle", + ); + deferred_agents.push(agent); + continue; + }; + let provider = agent_guard.provider_handle(); + if handles + .iter() + .any(|existing| Arc::ptr_eq(existing, &provider)) + { + continue; + } + push_unique(&mut session_handles, provider); + } + + AuthRefreshTargets { + providers: handles, + session_providers: session_handles, + deferred_agents, + } +} + +fn spawn_deferred_auth_refreshes(agents: Vec<Arc<Mutex<Agent>>>) { + for agent in agents { + tokio::spawn(async move { + let provider = { + let agent_guard = agent.lock().await; + agent_guard.provider_handle() + }; + provider.on_auth_changed_preserve_current_provider(); + crate::bus::Bus::global().publish_models_updated(); + }); + } +} + +async fn apply_auth_runtime_model_to_agent( + activation: &AuthActivationResult, + model: Option<&str>, + agent: &Arc<Mutex<Agent>>, +) { + let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) else { + return; + }; + + let provider = activation.provider_id.as_deref().unwrap_or("auth"); + let result = { + let mut agent_guard = agent.lock().await; + let provider_name = agent_guard.provider_handle().name().to_string(); + let model_request = activation.model_switch_request(&provider_name, model); + let result = agent_guard.set_model_from_auth(&model_request); + if result.is_ok() { + agent_guard.reset_provider_session(); + } + result.map(|_| agent_guard.provider_model()) + }; + + match result { + Ok(resolved_model) => crate::logging::auth_event( + "auth_changed_runtime_model_applied", + provider, + &[ + ("requested_model", model), + ("resolved_model", resolved_model.as_str()), + ("provider_session", "reset"), + ], + ), + Err(error) => { + let message = error.to_string(); + crate::logging::auth_event( + "auth_changed_runtime_model_failed", + provider, + &[("requested_model", model), ("reason", message.as_str())], + ); + } + } +} + +fn model_switching_unavailable_current(agent: &Agent) -> Option<String> { + if agent.available_models_for_switching().is_empty() { + Some(agent.provider_model()) + } else { + None + } +} + +fn send_model_changed_result( + id: u64, + result: anyhow::Result<(String, String)>, + fallback_model: String, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + match result { + Ok((updated, provider_name)) => { + crate::telemetry::record_model_switch(); + crate::logging::event_info( + "server_model_changed", + vec![ + ("id", id.to_string()), + ("model", updated.clone()), + ("provider", provider_name.clone()), + ], + ); + let _ = client_event_tx.send(ServerEvent::ModelChanged { + id, + model: updated, + provider_name: Some(provider_name), + error: None, + }); + } + Err(error) => { + crate::logging::event_error( + "server_model_change_failed", + vec![ + ("id", id.to_string()), + ("fallback_model", fallback_model.clone()), + ("error", error.to_string()), + ], + ); + let _ = client_event_tx.send(ServerEvent::ModelChanged { + id, + model: fallback_model, + provider_name: None, + error: Some(error.to_string()), + }); + } + } +} + +fn apply_cycle_model( + id: u64, + direction: i8, + agent: &mut Agent, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let models = agent.available_models_for_switching(); + if models.is_empty() { + let _ = client_event_tx.send(ServerEvent::ModelChanged { + id, + model: agent.provider_model(), + provider_name: None, + error: Some("Model switching is not available for this provider.".to_string()), + }); + return; + } + + let current = agent.provider_model(); + let current_index = models.iter().position(|m| *m == current).unwrap_or(0); + let len = models.len(); + let next_index = if direction >= 0 { + (current_index + 1) % len + } else { + (current_index + len - 1) % len + }; + let next_model = models[next_index].clone(); + crate::logging::event_info( + "server_cycle_model_request", + vec![ + ("id", id.to_string()), + ("direction", (direction as i64).to_string()), + ("current_model", current.clone()), + ("next_model", next_model.clone()), + ("available_models", len.to_string()), + ], + ); + let result = { + let result = agent.set_model(&next_model); + if result.is_ok() { + agent.reset_provider_session(); + } + result.map(|_| (agent.provider_model(), agent.provider_name())) + }; + send_model_changed_result(id, result, current, client_event_tx); +} + +pub(super) async fn handle_cycle_model( + id: u64, + direction: i8, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if let Ok(mut agent_guard) = agent.try_lock() { + apply_cycle_model(id, direction, &mut agent_guard, client_event_tx); + } else { + spawn_deferred_agent_mutation( + "cycle_model", + id, + Arc::clone(agent), + client_event_tx.clone(), + move |agent_guard, client_event_tx| { + apply_cycle_model(id, direction, agent_guard, client_event_tx); + }, + ); + } +} + +fn premium_mode_label(mode: crate::provider::copilot::PremiumMode) -> &'static str { + use crate::provider::copilot::PremiumMode; + match mode { + PremiumMode::Zero => "zero premium requests", + PremiumMode::OnePerSession => "one premium per session", + PremiumMode::Normal => "normal", + } +} + +fn apply_set_premium_mode( + id: u64, + mode: u8, + premium_mode: crate::provider::copilot::PremiumMode, + agent: &Agent, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + agent.set_premium_mode(premium_mode); + crate::logging::info(&format!( + "Server: premium mode set to {} ({})", + mode, + premium_mode_label(premium_mode) + )); + let _ = client_event_tx.send(ServerEvent::Ack { id }); +} + +pub(super) async fn handle_set_premium_mode( + id: u64, + mode: u8, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + use crate::provider::copilot::PremiumMode; + + let premium_mode = match mode { + 2 => PremiumMode::Zero, + 1 => PremiumMode::OnePerSession, + _ => PremiumMode::Normal, + }; + if let Ok(agent_guard) = agent.try_lock() { + apply_set_premium_mode(id, mode, premium_mode, &agent_guard, client_event_tx); + } else { + spawn_deferred_agent_mutation( + "set_premium_mode", + id, + Arc::clone(agent), + client_event_tx.clone(), + move |agent_guard, client_event_tx| { + apply_set_premium_mode(id, mode, premium_mode, agent_guard, client_event_tx); + }, + ); + } +} + +fn apply_set_model( + id: u64, + model: String, + agent: &mut Agent, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + crate::logging::event_info( + "server_set_model_request", + vec![ + ("id", id.to_string()), + ("requested_model", model.clone()), + ("current_model", agent.provider_model()), + ("current_provider", agent.provider_name()), + ], + ); + + if let Some(current) = model_switching_unavailable_current(agent) { + crate::logging::event_warn( + "server_set_model_unavailable", + vec![ + ("id", id.to_string()), + ("requested_model", model.clone()), + ("current_model", current.clone()), + ], + ); + let _ = client_event_tx.send(ServerEvent::ModelChanged { + id, + model: current, + provider_name: None, + error: Some("Model switching is not available for this provider.".to_string()), + }); + return; + } + + let current = agent.provider_model(); + let result = { + let result = agent.set_model(&model); + if result.is_ok() { + agent.reset_provider_session(); + } + result.map(|_| (agent.provider_model(), agent.provider_name())) + }; + send_model_changed_result(id, result, current, client_event_tx); +} + +fn apply_set_route( + id: u64, + selection: crate::provider::RouteSelection, + agent: &mut Agent, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + crate::logging::event_info( + "server_set_route_request", + vec![ + ("id", id.to_string()), + ("requested_model", selection.model.clone()), + ("requested_provider", selection.provider_label.clone()), + ("requested_api_method", selection.api_method.clone()), + ("current_model", agent.provider_model()), + ("current_provider", agent.provider_name()), + ], + ); + + if let Some(current) = model_switching_unavailable_current(agent) { + crate::logging::event_warn( + "server_set_route_unavailable", + vec![ + ("id", id.to_string()), + ("requested_model", selection.model.clone()), + ("requested_provider", selection.provider_label.clone()), + ("current_model", current.clone()), + ], + ); + let _ = client_event_tx.send(ServerEvent::ModelChanged { + id, + model: current, + provider_name: None, + error: Some("Model switching is not available for this provider.".to_string()), + }); + return; + } + + let current = agent.provider_model(); + let result = { + let result = agent.set_route_selection(&selection); + if result.is_ok() { + agent.reset_provider_session(); + } + result.map(|_| (agent.provider_model(), agent.provider_name())) + }; + send_model_changed_result(id, result, current, client_event_tx); +} + +pub(super) async fn handle_set_model( + id: u64, + model: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if let Ok(mut agent_guard) = agent.try_lock() { + apply_set_model(id, model, &mut agent_guard, client_event_tx); + } else { + spawn_deferred_agent_mutation( + "set_model", + id, + Arc::clone(agent), + client_event_tx.clone(), + move |agent_guard, client_event_tx| { + apply_set_model(id, model, agent_guard, client_event_tx); + }, + ); + } +} + +pub(super) async fn handle_set_route( + id: u64, + selection: crate::provider::RouteSelection, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if let Ok(mut agent_guard) = agent.try_lock() { + apply_set_route(id, selection, &mut agent_guard, client_event_tx); + } else { + spawn_deferred_agent_mutation( + "set_route", + id, + Arc::clone(agent), + client_event_tx.clone(), + move |agent_guard, client_event_tx| { + apply_set_route(id, selection, agent_guard, client_event_tx); + }, + ); + } +} + +pub(super) async fn handle_refresh_models( + id: u64, + provider: &Arc<dyn Provider>, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let provider_clone = provider.clone(); + let agent_clone = agent.clone(); + let client_event_tx_clone = client_event_tx.clone(); + tokio::spawn(async move { + send_catalog_activity( + &client_event_tx_clone, + &crate::message::format_model_refresh_progress_markdown( + "Starting provider model catalog refresh", + Some(5), + ), + ); + + let refresh_started = Instant::now(); + let refresh_future = provider_clone.refresh_model_catalog(); + tokio::pin!(refresh_future); + let mut heartbeat = tokio::time::interval(std::time::Duration::from_secs(2)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let result = loop { + tokio::select! { + result = &mut refresh_future => break result, + _ = heartbeat.tick() => { + let elapsed_secs = refresh_started.elapsed().as_secs(); + if elapsed_secs > 0 { + send_catalog_activity( + &client_event_tx_clone, + &crate::message::format_model_refresh_progress_markdown( + &format!("Waiting on provider APIs ({elapsed_secs}s elapsed)"), + None, + ), + ); + } + } + } + }; + match result { + Ok(_) => { + send_catalog_activity( + &client_event_tx_clone, + &crate::message::format_model_refresh_progress_markdown( + "Updating model picker", + Some(95), + ), + ); + crate::bus::Bus::global().publish_models_updated(); + let event = available_models_updated_event(&agent_clone).await; + let _ = client_event_tx_clone.send(event); + send_catalog_activity( + &client_event_tx_clone, + &crate::message::format_model_refresh_progress_markdown( + "Model list refresh complete", + Some(100), + ), + ); + } + Err(err) => { + send_catalog_activity( + &client_event_tx_clone, + &crate::message::format_model_refresh_progress_markdown( + "Model list refresh failed", + None, + ), + ); + let _ = client_event_tx_clone.send(ServerEvent::Error { + id, + message: format!("Failed to refresh models: {}", err), + retry_after_secs: None, + }); + } + } + }); + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +fn send_catalog_activity(client_event_tx: &mpsc::UnboundedSender<ServerEvent>, message: &str) { + let _ = client_event_tx.send(ServerEvent::Notification { + from_session: "jcode".to_string(), + from_name: Some("Jcode".to_string()), + notification_type: NotificationType::Message { + scope: Some("catalog_activity".to_string()), + channel: None, + tldr: None, + }, + message: message.to_string(), + }); +} + +pub(super) async fn handle_set_reasoning_effort( + id: u64, + effort: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let result = if let Ok(mut agent_guard) = agent.try_lock() { + agent_guard.set_reasoning_effort(&effort) + } else { + spawn_deferred_reasoning_effort_change( + id, + effort, + Arc::clone(agent), + client_event_tx.clone(), + ); + return; + }; + + send_reasoning_effort_result(id, result, client_event_tx); +} + +fn send_reasoning_effort_result( + id: u64, + result: anyhow::Result<Option<String>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + match result { + Ok(effort) => { + let _ = client_event_tx.send(ServerEvent::ReasoningEffortChanged { + id, + effort, + error: None, + }); + } + Err(e) => { + let _ = client_event_tx.send(ServerEvent::ReasoningEffortChanged { + id, + effort: None, + error: Some(e.to_string()), + }); + } + } +} + +fn spawn_deferred_reasoning_effort_change( + id: u64, + effort: String, + agent: Arc<Mutex<Agent>>, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, +) { + let queued_at = log_provider_control_deferred("set_reasoning_effort", id); + tokio::spawn(async move { + let mut agent_guard = agent.lock().await; + log_provider_control_lock_acquired("set_reasoning_effort", id, queued_at); + let result = agent_guard.set_reasoning_effort(&effort); + crate::logging::info(&format!( + "Deferred reasoning effort change completed request_id={} requested={} success={}", + id, + effort, + result.is_ok() + )); + send_reasoning_effort_result(id, result, &client_event_tx); + log_provider_control_completed("set_reasoning_effort", id, queued_at); + }); +} + +pub(super) async fn handle_set_service_tier( + id: u64, + service_tier: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let apply = move |provider: Arc<dyn Provider>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>| { + match provider.set_service_tier(&service_tier) { + Ok(()) => { + let _ = client_event_tx.send(ServerEvent::ServiceTierChanged { + id, + service_tier: provider.service_tier(), + error: None, + }); + } + Err(e) => { + let _ = client_event_tx.send(ServerEvent::ServiceTierChanged { + id, + service_tier: None, + error: Some(e.to_string()), + }); + } + } + }; + + if let Ok(agent_guard) = agent.try_lock() { + apply(agent_guard.provider_handle(), client_event_tx); + } else { + spawn_deferred_provider_operation( + "set_service_tier", + id, + Arc::clone(agent), + client_event_tx.clone(), + apply, + ); + } +} + +pub(super) async fn handle_set_transport( + id: u64, + transport: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let apply = move |provider: Arc<dyn Provider>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>| { + match provider.set_transport(&transport) { + Ok(()) => { + let _ = client_event_tx.send(ServerEvent::TransportChanged { + id, + transport: provider.transport(), + error: None, + }); + } + Err(e) => { + let _ = client_event_tx.send(ServerEvent::TransportChanged { + id, + transport: None, + error: Some(e.to_string()), + }); + } + } + }; + + if let Ok(agent_guard) = agent.try_lock() { + apply(agent_guard.provider_handle(), client_event_tx); + } else { + spawn_deferred_provider_operation( + "set_transport", + id, + Arc::clone(agent), + client_event_tx.clone(), + apply, + ); + } +} + +pub(super) async fn handle_set_compaction_mode( + id: u64, + mode: crate::config::CompactionMode, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + if let Ok(agent_guard) = agent.try_lock() { + let registry = agent_guard.registry(); + drop(agent_guard); + apply_set_compaction_mode(id, mode, registry, client_event_tx).await; + } else { + spawn_deferred_set_compaction_mode(id, mode, Arc::clone(agent), client_event_tx.clone()); + } +} + +async fn apply_set_compaction_mode( + id: u64, + mode: crate::config::CompactionMode, + registry: crate::tool::Registry, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + let result = { + let compaction = registry.compaction(); + let mut manager = compaction.write().await; + manager.set_mode(mode); + Ok::<(), anyhow::Error>(()) + }; + + match result { + Ok(()) => { + let updated_mode = registry.compaction().read().await.mode(); + let _ = client_event_tx.send(ServerEvent::CompactionModeChanged { + id, + mode: updated_mode, + error: None, + }); + } + Err(e) => { + let fallback_mode = registry.compaction().read().await.mode(); + let _ = client_event_tx.send(ServerEvent::CompactionModeChanged { + id, + mode: fallback_mode, + error: Some(e.to_string()), + }); + } + } +} + +fn spawn_deferred_set_compaction_mode( + id: u64, + mode: crate::config::CompactionMode, + agent: Arc<Mutex<Agent>>, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, +) { + let queued_at = log_provider_control_deferred("set_compaction_mode", id); + tokio::spawn(async move { + let registry = { + let agent_guard = agent.lock().await; + log_provider_control_lock_acquired("set_compaction_mode", id, queued_at); + agent_guard.registry() + }; + apply_set_compaction_mode(id, mode, registry, &client_event_tx).await; + log_provider_control_completed("set_compaction_mode", id, queued_at); + }); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_notify_auth_changed( + id: u64, + provider_hint: Option<String>, + auth: Option<AuthChanged>, + provider: &Arc<dyn Provider>, + provider_template: &Arc<dyn Provider>, + sessions: &SessionAgents, + client_session_id: &str, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + crate::auth::AuthStatus::invalidate_cache(); + let (session_id, before_snapshot) = if let Ok(agent_guard) = agent.try_lock() { + ( + agent_guard.session_id().to_string(), + agent_guard.model_catalog_snapshot(), + ) + } else { + crate::logging::event_warn( + "SERVER_PROVIDER_CONTROL_DEFERRED", + vec![ + ("phase", "fallback_snapshot".to_string()), + ("operation", "notify_auth_changed".to_string()), + ("request_id", id.to_string()), + ("session_id", client_session_id.to_string()), + ("reason", "agent_busy".to_string()), + ], + ); + ( + client_session_id.to_string(), + available_models_snapshot_from_provider(provider), + ) + }; + let activation_request = AuthActivationRequest::new(provider_hint, auth); + crate::bus::Bus::global().publish(crate::bus::BusEvent::UiActivity( + crate::bus::UiActivity::auth( + Some(session_id.clone()), + "**Auth Change Received**\n\nThe server is reloading provider credentials and refreshing model route availability for this session.", + Some("Auth: refreshing providers..."), + ), + )); + let targets = auth_refresh_targets(provider_template, provider, sessions).await; + let client_event_tx_clone = client_event_tx.clone(); + let agent_clone = agent.clone(); + tokio::spawn(async move { + let activation = crate::auth::lifecycle::activate_auth_change(&activation_request); + // Snapshot which providers jcode now believes are configured right after + // an auth change activates. This is the cornerstone for diagnosing + // "logged in but model picker still empty / only OpenAI+Anthropic" and + // "paste key silently returns to menu" reports (#312, #292, #304): if a + // provider the user just configured is not Available here, the failure is + // upstream of the picker. + crate::auth::AuthStatus::check_fast().log_snapshot("auth_changed"); + let mut bus_rx = crate::bus::Bus::global().subscribe(); + for provider in targets.providers { + provider.on_auth_changed(); + } + for provider in targets.session_providers { + provider.on_auth_changed_preserve_current_provider(); + } + + // Auth refresh is global so every live session learns about newly + // configured credentials, but the automatic post-login model switch is + // session-local. A user logging Groq/Cerebras into one workspace should + // not silently move unrelated sessions off their chosen provider/model. + apply_auth_runtime_model_to_agent( + &activation, + activation.activated_model.as_deref(), + &agent_clone, + ) + .await; + let auth_selection_generation = { + let agent_guard = agent_clone.lock().await; + agent_guard.provider_model_selection_generation() + }; + + crate::bus::Bus::global().publish_models_updated(); + crate::bus::Bus::global().publish(crate::bus::BusEvent::UiActivity( + crate::bus::UiActivity::catalog( + Some(session_id.clone()), + "**Auth Model Routes Updating**\n\nCredentials are reloaded. Jcode is pushing an updated model catalog snapshot to connected clients.", + Some("Auth: model routes updating..."), + ), + )); + + spawn_deferred_auth_refreshes(targets.deferred_agents); + + // Hot-initializing providers is synchronous, while dynamic catalogs may + // continue refreshing in the background. Push an immediate snapshot so + // the model picker/header stop looking stale right after login, then + // push another snapshot when the background refresh announces itself. + let mut latest_snapshot = available_models_snapshot(&agent_clone).await; + let _ = client_event_tx_clone.send(available_models_snapshot_into_event( + latest_snapshot.clone(), + )); + + let max_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let quiet_period = auth_model_refresh_quiet_period(); + let mut quiet_deadline: Option<tokio::time::Instant> = None; + loop { + let now = tokio::time::Instant::now(); + let deadline = quiet_deadline + .map(|quiet| std::cmp::min(max_deadline, quiet)) + .unwrap_or(max_deadline); + let remaining = deadline.saturating_duration_since(now); + if remaining.is_zero() { + break; + } + tokio::select! { + event = bus_rx.recv() => { + if matches!(event, Ok(crate::bus::BusEvent::ModelsUpdated)) { + latest_snapshot = available_models_snapshot(&agent_clone).await; + let _ = client_event_tx_clone.send(available_models_snapshot_into_event(latest_snapshot.clone())); + quiet_deadline = Some(tokio::time::Instant::now() + quiet_period); + } + } + _ = tokio::time::sleep(remaining) => break, + } + } + + let manual_model_selected_during_auth_refresh = { + let agent_guard = agent_clone.lock().await; + agent_guard.user_selected_provider_model_after(auth_selection_generation) + }; + if manual_model_selected_during_auth_refresh { + crate::logging::auth_event( + "auth_changed_auto_model_skipped_after_manual_switch", + activation.provider_id.as_deref().unwrap_or("auth"), + &[("reason", "user_selected_provider_model_during_refresh")], + ); + latest_snapshot = available_models_snapshot(&agent_clone).await; + let _ = client_event_tx_clone.send(available_models_snapshot_into_event( + latest_snapshot.clone(), + )); + } else if let Some(model_to_select) = + crate::auth::lifecycle::provider_model_to_select_after_auth( + &activation, + latest_snapshot.provider_model.as_deref(), + &latest_snapshot.model_routes, + ) + { + apply_auth_runtime_model_to_agent(&activation, Some(&model_to_select), &agent_clone) + .await; + latest_snapshot = available_models_snapshot(&agent_clone).await; + let _ = client_event_tx_clone.send(available_models_snapshot_into_event( + latest_snapshot.clone(), + )); + } + + let summary = crate::provider::summarize_model_catalog_refresh( + before_snapshot.available_models, + latest_snapshot.available_models.clone(), + before_snapshot.model_routes, + latest_snapshot.model_routes.clone(), + ); + let catalog_invariants = crate::auth::lifecycle::validate_catalog_invariants( + &activation, + latest_snapshot.provider_model.as_deref(), + &latest_snapshot.model_routes, + ); + let mut catalog_message = format_auth_catalog_refresh_complete( + activation + .provider_label + .as_deref() + .or(latest_snapshot.provider_name.as_deref()), + latest_snapshot.provider_model.as_deref(), + &summary, + ); + if let Some(warning) = catalog_invariants.warning_message() { + catalog_message.push_str(&warning); + } + crate::bus::Bus::global().publish(crate::bus::BusEvent::UiActivity( + crate::bus::UiActivity::catalog( + Some(session_id), + catalog_message, + Some("Auth: model catalog updated"), + ), + )); + }); + let _ = client_event_tx.send(ServerEvent::Done { id }); +} + +#[cfg(test)] +#[path = "provider_control_tests.rs"] +mod provider_control_tests; + +pub(super) async fn handle_switch_anthropic_account( + id: u64, + label: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + match crate::auth::claude::set_active_account(&label) { + Ok(()) => { + crate::auth::AuthStatus::invalidate_cache(); + spawn_account_switch_refresh( + id, + "anthropic", + Arc::clone(agent), + client_event_tx.clone(), + ); + } + Err(e) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to switch Anthropic account: {}", e), + retry_after_secs: None, + }); + } + } +} + +pub(super) async fn handle_switch_openai_account( + id: u64, + label: String, + agent: &Arc<Mutex<Agent>>, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) { + match crate::auth::codex::set_active_account(&label) { + Ok(()) => { + crate::auth::AuthStatus::invalidate_cache(); + spawn_account_switch_refresh(id, "openai", Arc::clone(agent), client_event_tx.clone()); + } + Err(e) => { + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to switch OpenAI account: {}", e), + retry_after_secs: None, + }); + } + } +} + +fn spawn_account_switch_refresh( + id: u64, + provider_kind: &'static str, + agent: Arc<Mutex<Agent>>, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, +) { + tokio::spawn(async move { + let started = Instant::now(); + crate::logging::event_info( + "SERVER_PROVIDER_CONTROL_ACCOUNT_SWITCH", + vec![ + ("phase", "refresh_start".to_string()), + ("provider", provider_kind.to_string()), + ("request_id", id.to_string()), + ], + ); + let provider = if let Ok(mut agent_guard) = agent.try_lock() { + let provider = agent_guard.provider_handle(); + agent_guard.reset_provider_session(); + provider + } else { + let queued_at = log_provider_control_deferred("account_switch_refresh", id); + let mut agent_guard = agent.lock().await; + log_provider_control_lock_acquired("account_switch_refresh", id, queued_at); + let provider = agent_guard.provider_handle(); + agent_guard.reset_provider_session(); + log_provider_control_completed("account_switch_refresh", id, queued_at); + provider + }; + provider.invalidate_credentials().await; + + crate::provider::clear_all_provider_unavailability_for_account(); + crate::provider::clear_all_model_unavailability_for_account(); + + match provider_kind { + "anthropic" => { + tokio::spawn(async { + let _ = crate::usage::get().await; + }); + } + "openai" => { + tokio::spawn(async { + let _ = crate::usage::get_openai_usage().await; + }); + } + _ => {} + } + + crate::bus::Bus::global().publish_models_updated(); + let event = available_models_updated_event(&agent).await; + let _ = client_event_tx.send(event); + let _ = client_event_tx.send(ServerEvent::Done { id }); + crate::logging::event_info( + "SERVER_PROVIDER_CONTROL_ACCOUNT_SWITCH", + vec![ + ("phase", "refresh_done".to_string()), + ("provider", provider_kind.to_string()), + ("request_id", id.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + }); +} + +#[cfg(test)] +#[allow(clippy::await_holding_lock)] +mod tests { + use super::*; + use crate::message::{Message, ToolDefinition}; + use crate::provider::EventStream; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + use tokio::time::{Duration, timeout}; + + struct IsolatedRuntimeDir { + _prev_runtime: Option<std::ffi::OsString>, + _temp: tempfile::TempDir, + } + + impl IsolatedRuntimeDir { + fn new() -> Self { + let temp = tempfile::TempDir::new().expect("runtime dir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + Self { + _prev_runtime: prev_runtime, + _temp: temp, + } + } + } + + impl Drop for IsolatedRuntimeDir { + fn drop(&mut self) { + if let Some(prev_runtime) = self._prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } + } + + #[derive(Default)] + struct TestEffortProvider { + model: StdMutex<Option<String>>, + effort: StdMutex<Option<String>>, + service_tier: StdMutex<Option<String>>, + transport: StdMutex<Option<String>>, + } + + #[async_trait] + impl Provider for TestEffortProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> anyhow::Result<EventStream> { + panic!("complete should not run in provider control test") + } + + fn name(&self) -> &str { + "test-effort" + } + + fn model(&self) -> String { + self.model + .lock() + .expect("model lock") + .clone() + .unwrap_or_else(|| "test-model-a".to_string()) + } + + fn set_model(&self, model: &str) -> anyhow::Result<()> { + *self.model.lock().expect("model lock") = Some(model.to_string()); + Ok(()) + } + + fn available_models_for_switching(&self) -> Vec<String> { + vec!["test-model-a".to_string(), "test-model-b".to_string()] + } + + fn reasoning_effort(&self) -> Option<String> { + self.effort.lock().expect("effort lock").clone() + } + + fn set_reasoning_effort(&self, effort: &str) -> anyhow::Result<()> { + *self.effort.lock().expect("effort lock") = Some(effort.to_string()); + Ok(()) + } + + fn service_tier(&self) -> Option<String> { + self.service_tier.lock().expect("service lock").clone() + } + + fn set_service_tier(&self, service_tier: &str) -> anyhow::Result<()> { + *self.service_tier.lock().expect("service lock") = Some(service_tier.to_string()); + Ok(()) + } + + fn transport(&self) -> Option<String> { + self.transport.lock().expect("transport lock").clone() + } + + fn set_transport(&self, transport: &str) -> anyhow::Result<()> { + *self.transport.lock().expect("transport lock") = Some(transport.to_string()); + Ok(()) + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self { + model: StdMutex::new(Some(self.model())), + effort: StdMutex::new(self.reasoning_effort()), + service_tier: StdMutex::new(self.service_tier()), + transport: StdMutex::new(self.transport()), + }) + } + } + + async fn test_agent( + session_id: &str, + ) -> ( + Arc<TestEffortProvider>, + Arc<Mutex<Agent>>, + mpsc::UnboundedSender<ServerEvent>, + mpsc::UnboundedReceiver<ServerEvent>, + ) { + let provider = Arc::new(TestEffortProvider::default()); + let provider_dyn: Arc<dyn Provider> = provider.clone(); + let registry = crate::tool::Registry::new(Arc::clone(&provider_dyn)).await; + let mut session = + crate::session::Session::create_with_id(session_id.to_string(), None, None); + session.model = Some(provider.model()); + let agent = Arc::new(Mutex::new(Agent::new_with_session( + Arc::clone(&provider_dyn), + registry, + session, + None, + ))); + let (client_event_tx, client_event_rx) = mpsc::unbounded_channel(); + (provider, agent, client_event_tx, client_event_rx) + } + + #[tokio::test] + async fn set_reasoning_effort_does_not_wait_for_busy_agent_lock() { + let _guard = crate::storage::lock_test_env(); + let _runtime = IsolatedRuntimeDir::new(); + + let (provider, agent, client_event_tx, mut client_event_rx) = + test_agent("session_busy_reasoning_effort").await; + let busy_agent_lock = agent.lock().await; + + timeout( + Duration::from_millis(100), + handle_set_reasoning_effort(7, "low".to_string(), &agent, &client_event_tx), + ) + .await + .expect("reasoning effort changes must not wait for a busy agent mutex"); + + assert!(client_event_rx.try_recv().is_err()); + + drop(busy_agent_lock); + + let event = timeout(Duration::from_secs(1), client_event_rx.recv()) + .await + .expect("deferred reasoning effort change should finish after agent is idle"); + assert_eq!(provider.reasoning_effort().as_deref(), Some("low")); + assert!(matches!( + event, + Some(ServerEvent::ReasoningEffortChanged { + id: 7, + effort: Some(effort), + error: None, + }) if effort == "low" + )); + } + + #[tokio::test] + async fn set_model_does_not_wait_for_busy_agent_lock() { + let _guard = crate::storage::lock_test_env(); + let _runtime = IsolatedRuntimeDir::new(); + + let (provider, agent, client_event_tx, mut client_event_rx) = + test_agent("session_busy_set_model").await; + let busy_agent_lock = agent.lock().await; + + timeout( + Duration::from_millis(100), + handle_set_model(8, "test-model-b".to_string(), &agent, &client_event_tx), + ) + .await + .expect("model changes must not wait for a busy agent mutex"); + + assert!(client_event_rx.try_recv().is_err()); + + drop(busy_agent_lock); + + let event = timeout(Duration::from_secs(1), client_event_rx.recv()) + .await + .expect("deferred model change should finish after agent is idle"); + assert_eq!(provider.model(), "test-model-b"); + assert!(matches!( + event, + Some(ServerEvent::ModelChanged { + id: 8, + model, + provider_name: Some(provider_name), + error: None, + }) if model == "test-model-b" && provider_name == "test-effort" + )); + } + + #[tokio::test] + async fn set_service_tier_does_not_wait_for_busy_agent_lock() { + let _guard = crate::storage::lock_test_env(); + let _runtime = IsolatedRuntimeDir::new(); + + let (provider, agent, client_event_tx, mut client_event_rx) = + test_agent("session_busy_set_service_tier").await; + let busy_agent_lock = agent.lock().await; + + timeout( + Duration::from_millis(100), + handle_set_service_tier(9, "priority".to_string(), &agent, &client_event_tx), + ) + .await + .expect("service tier changes must not wait for a busy agent mutex"); + + assert!(client_event_rx.try_recv().is_err()); + + drop(busy_agent_lock); + + let event = timeout(Duration::from_secs(1), client_event_rx.recv()) + .await + .expect("deferred service tier change should finish after agent is idle"); + assert_eq!(provider.service_tier().as_deref(), Some("priority")); + assert!(matches!( + event, + Some(ServerEvent::ServiceTierChanged { + id: 9, + service_tier: Some(service_tier), + error: None, + }) if service_tier == "priority" + )); + } +} diff --git a/crates/jcode-app-core/src/server/provider_control_tests.rs b/crates/jcode-app-core/src/server/provider_control_tests.rs new file mode 100644 index 0000000..822214c --- /dev/null +++ b/crates/jcode-app-core/src/server/provider_control_tests.rs @@ -0,0 +1,1223 @@ +use super::*; +use crate::message::{Message, StreamEvent, ToolDefinition}; +use crate::provider::{EventStream, ModelRoute, Provider}; +use crate::tool::Registry; +use async_trait::async_trait; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::RwLock as StdRwLock; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard, OnceLock}; + +#[derive(Default)] +struct AuthChangeMockState { + logged_in: StdRwLock<bool>, + selected_model: StdRwLock<Option<String>>, + route_provider: StdRwLock<String>, + route_api_method: StdRwLock<String>, + expose_selected_model_in_routes: StdRwLock<bool>, + complete_calls: AtomicUsize, + complete_models: StdMutex<Vec<String>>, +} + +struct AuthChangeMockProvider { + state: Arc<AuthChangeMockState>, +} + +impl AuthChangeMockProvider { + fn new() -> Self { + let state = AuthChangeMockState { + route_provider: StdRwLock::new("MockAuth".to_string()), + route_api_method: StdRwLock::new("mock-auth".to_string()), + expose_selected_model_in_routes: StdRwLock::new(true), + ..AuthChangeMockState::default() + }; + Self { + state: Arc::new(state), + } + } +} + +#[async_trait] +impl Provider for AuthChangeMockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> anyhow::Result<EventStream> { + self.state.complete_calls.fetch_add(1, Ordering::SeqCst); + self.state + .complete_models + .lock() + .unwrap() + .push(self.model()); + let stream = futures::stream::iter([ + Ok(StreamEvent::TextDelta("ok".to_string())), + Ok(StreamEvent::MessageEnd { stop_reason: None }), + ]); + Ok(Box::pin(stream) as Pin<Box<dyn futures::Stream<Item = _> + Send>>) + } + + fn name(&self) -> &str { + "mock-auth" + } + + fn model(&self) -> String { + if let Some(model) = self.state.selected_model.read().unwrap().clone() { + return model; + } + + if *self.state.logged_in.read().unwrap() { + "logged-in-model".to_string() + } else { + "logged-out-model".to_string() + } + } + + fn available_models_display(&self) -> Vec<String> { + let mut models = if *self.state.logged_in.read().unwrap() { + vec!["logged-in-model".to_string(), "second-model".to_string()] + } else { + vec!["logged-out-model".to_string()] + }; + + if *self.state.expose_selected_model_in_routes.read().unwrap() + && let Some(model) = self.state.selected_model.read().unwrap().clone() + && !models.iter().any(|candidate| candidate == &model) + { + models.insert(0, model); + } + + models + } + + fn available_models_for_switching(&self) -> Vec<String> { + self.available_models_display() + } + + fn set_model(&self, model: &str) -> anyhow::Result<()> { + let model = model.trim(); + let model = model + .split_once(':') + .map(|(_, model)| model) + .unwrap_or(model) + .trim(); + if model.is_empty() { + anyhow::bail!("model cannot be empty"); + } + + *self.state.selected_model.write().unwrap() = Some(model.to_string()); + Ok(()) + } + + fn model_routes(&self) -> Vec<ModelRoute> { + let provider = self.state.route_provider.read().unwrap().clone(); + let api_method = self.state.route_api_method.read().unwrap().clone(); + self.available_models_display() + .into_iter() + .map(|model| ModelRoute { + model, + provider: provider.clone(), + api_method: api_method.clone(), + available: true, + detail: String::new(), + cheapness: None, + }) + .collect() + } + + fn on_auth_changed(&self) { + *self.state.logged_in.write().unwrap() = true; + crate::bus::Bus::global().publish_models_updated(); + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self { + state: Arc::clone(&self.state), + }) + } +} + +fn lock_env() -> StdMutexGuard<'static, ()> { + static LOCK: OnceLock<StdMutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| StdMutex::new(())).lock().unwrap() +} + +struct EnvGuard { + saved: Vec<(&'static str, Option<String>)>, + _temp_home: tempfile::TempDir, + _lock: StdMutexGuard<'static, ()>, +} + +impl EnvGuard { + /// Save and clear the given env vars, and redirect `JCODE_HOME` to a fresh + /// empty temp dir for the lifetime of the guard. + /// + /// The temp home keeps these tests hermetic: provider activation reads + /// on-disk model catalog caches (`~/.jcode/cache/<profile>_models.json`) to + /// pick a profile's newest default model, so without an isolated home the + /// host's real caches leak in and a stale or non-chat model (e.g. Groq's + /// `canopylabs/orpheus-*` TTS) can be auto-selected, breaking the test on + /// developer machines while passing on clean CI. + fn save(keys: &[&'static str]) -> Self { + let lock = lock_env(); + let mut all_keys: Vec<&'static str> = keys.to_vec(); + if !all_keys.contains(&"JCODE_HOME") { + all_keys.push("JCODE_HOME"); + } + let saved = all_keys + .iter() + .map(|key| (*key, std::env::var(key).ok())) + .collect(); + for key in &all_keys { + crate::env::remove_var(key); + } + let temp_home = tempfile::tempdir().expect("create temp JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + Self { + saved, + _temp_home: temp_home, + _lock: lock, + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in self.saved.drain(..) { + if let Some(value) = value { + crate::env::set_var(key, value); + } else { + crate::env::remove_var(key); + } + } + } +} + +#[tokio::test] +async fn notify_auth_changed_emits_available_models_updated_after_provider_update() { + let _guard = EnvGuard::save(&[]); + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider: Arc<dyn Provider> = Arc::new(AuthChangeMockProvider::new()); + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let session_id = { agent.lock().await.session_id().to_string() }; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + "test-session".to_string(), + Arc::clone(&agent), + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + let mut bus_rx = crate::bus::Bus::global().subscribe(); + while bus_rx.try_recv().is_ok() {} + + handle_notify_auth_changed( + 42, + None, + None, + &provider, + &provider, + &sessions, + session_id.as_str(), + &agent, + &client_event_tx, + ) + .await; + + let mut saw_done = false; + let mut saw_models = None; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let event = tokio::time::timeout(remaining, client_event_rx.recv()) + .await + .expect("receive server event before timeout"); + match event.expect("channel open") { + ServerEvent::Done { id } => { + assert_eq!(id, 42); + saw_done = true; + } + ServerEvent::AvailableModelsUpdated { + provider_name, + provider_model, + available_models, + available_model_routes, + } => { + saw_models = Some(( + provider_name, + provider_model, + available_models, + available_model_routes, + )); + break; + } + _ => {} + } + } + + assert!(saw_done, "expected immediate Done ack"); + let (provider_name, provider_model, available_models, available_model_routes) = + saw_models.expect("expected AvailableModelsUpdated event"); + assert_eq!(provider_name.as_deref(), Some("mock-auth")); + assert_eq!(provider_model.as_deref(), Some("logged-in-model")); + assert_eq!( + available_models, + vec!["logged-in-model".to_string(), "second-model".to_string()] + ); + assert!(available_model_routes.iter().any(|route| { + route.model == "logged-in-model" + && route.provider == "MockAuth" + && route.api_method == "mock-auth" + })); + + let final_activity = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match bus_rx.recv().await.expect("bus should stay open") { + crate::bus::BusEvent::UiActivity(activity) + if activity.kind == crate::bus::UiActivityKind::Catalog + && activity.session_id.as_deref() == Some(session_id.as_str()) + && activity.message.contains("Auth Model Catalog Updated") => + { + break activity; + } + _ => continue, + } + } + }) + .await + .expect("expected final auth catalog activity"); + assert!(final_activity.message.contains("Added models:")); + assert!(final_activity.message.contains("`logged-in-model`")); + assert!(final_activity.message.contains("`second-model`")); + assert!( + final_activity + .message + .contains("Selected model: `logged-in-model`") + ); + assert!(final_activity.message.contains("Use `/model`")); +} + +#[tokio::test] +async fn notify_auth_changed_defers_busy_session_refresh_until_idle() { + let _guard = EnvGuard::save(&[]); + crate::bus::reset_models_updated_publish_state_for_tests(); + let current_provider: Arc<dyn Provider> = Arc::new(AuthChangeMockProvider::new()); + let busy_provider = Arc::new(AuthChangeMockProvider::new()); + let busy_state = Arc::clone(&busy_provider.state); + let busy_provider: Arc<dyn Provider> = busy_provider; + let registry = Registry::empty(); + let current_agent = Arc::new(Mutex::new(Agent::new( + Arc::clone(¤t_provider), + registry.clone(), + ))); + let current_session_id = { current_agent.lock().await.session_id().to_string() }; + let busy_agent = Arc::new(Mutex::new(Agent::new(busy_provider, registry))); + let busy_guard = busy_agent.lock().await; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + "busy-session".to_string(), + Arc::clone(&busy_agent), + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + handle_notify_auth_changed( + 43, + None, + None, + ¤t_provider, + ¤t_provider, + &sessions, + current_session_id.as_str(), + ¤t_agent, + &client_event_tx, + ) + .await; + + assert!( + matches!( + client_event_rx.recv().await, + Some(ServerEvent::Done { id: 43 }) + ), + "expected immediate Done ack before waiting for the busy session" + ); + assert!( + !*busy_state.logged_in.read().unwrap(), + "busy session provider should not refresh until its agent lock is released" + ); + + drop(busy_guard); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + if *busy_state.logged_in.read().unwrap() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + panic!("busy session provider was not refreshed after it became idle"); +} + +#[tokio::test] +async fn notify_auth_changed_with_azure_hint_applies_runtime_model_without_completion() { + let _guard = EnvGuard::save(&[ + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_MODEL", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_USE_ENTRA", + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + crate::env::set_var("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com"); + crate::env::set_var("AZURE_OPENAI_MODEL", "azure-deployment"); + crate::env::set_var("AZURE_OPENAI_API_KEY", "test-key"); + crate::env::set_var("AZURE_OPENAI_USE_ENTRA", "0"); + + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider = Arc::new(AuthChangeMockProvider::new()); + let state = Arc::clone(&provider.state); + let provider: Arc<dyn Provider> = provider; + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let session_id = { agent.lock().await.session_id().to_string() }; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + "test-session".to_string(), + Arc::clone(&agent), + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + handle_notify_auth_changed( + 44, + Some("Azure OpenAI".to_string()), + None, + &provider, + &provider, + &sessions, + session_id.as_str(), + &agent, + &client_event_tx, + ) + .await; + + let mut saw_done = false; + let mut saw_models = None; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let event = tokio::time::timeout(remaining, client_event_rx.recv()) + .await + .expect("receive server event before timeout"); + match event.expect("channel open") { + ServerEvent::Done { id } => { + assert_eq!(id, 44); + saw_done = true; + } + ServerEvent::AvailableModelsUpdated { + provider_model, + available_models, + .. + } => { + saw_models = Some((provider_model, available_models)); + break; + } + _ => {} + } + } + + assert!(saw_done, "expected immediate Done ack"); + let (provider_model, available_models) = saw_models.expect("expected model refresh event"); + assert_eq!(provider_model.as_deref(), Some("azure-deployment")); + assert!( + available_models + .iter() + .any(|model| model == "azure-deployment") + ); + assert_eq!( + std::env::var("JCODE_RUNTIME_PROVIDER").as_deref(), + Ok("azure-openai") + ); + assert_eq!( + std::env::var("JCODE_ACTIVE_PROVIDER").as_deref(), + Ok("openrouter") + ); + assert_eq!( + state.complete_calls.load(Ordering::SeqCst), + 0, + "auth refresh must not issue a completion with the old prompt/model" + ); +} + +#[test] +fn cerebras_auth_hint_applies_openai_compatible_runtime_profile() { + let _guard = EnvGuard::save(&[ + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + + let request = + crate::auth::lifecycle::AuthActivationRequest::new(Some("Cerebras".to_string()), None); + assert_eq!(request.provider_id().as_deref(), Some("cerebras")); + + let activation = crate::auth::lifecycle::activate_auth_change(&request); + let default_model = activation.activated_model.as_deref(); + assert_eq!(default_model, Some("gpt-oss-120b")); + assert_eq!( + std::env::var("JCODE_RUNTIME_PROVIDER").as_deref(), + Ok("openai-compatible") + ); + assert_eq!( + std::env::var("JCODE_ACTIVE_PROVIDER").as_deref(), + Ok("openrouter") + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_API_BASE").as_deref(), + Ok("https://api.cerebras.ai/v1") + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_API_KEY_NAME").as_deref(), + Ok("CEREBRAS_API_KEY") + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_ENV_FILE").as_deref(), + Ok("cerebras.env") + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE").as_deref(), + Ok("cerebras") + ); + assert_eq!( + activation.model_switch_request("mock-auth", "llama3.1-8b"), + "cerebras:llama3.1-8b" + ); +} + +#[tokio::test] +async fn notify_auth_changed_typed_cerebras_event_controls_user_visible_catalog_identity() { + let _guard = EnvGuard::save(&[ + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider = Arc::new(AuthChangeMockProvider::new()); + let provider: Arc<dyn Provider> = provider; + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let session_id = { agent.lock().await.session_id().to_string() }; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + "test-session".to_string(), + Arc::clone(&agent), + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + let mut bus_rx = crate::bus::Bus::global().subscribe(); + while bus_rx.try_recv().is_ok() {} + + let mut auth = crate::protocol::AuthChanged::new("cerebras"); + auth.credential_source = Some(crate::protocol::AuthCredentialSource::ApiKeyFile); + auth.auth_method = Some(crate::protocol::AuthMethod::RemoteTuiPasteApiKey); + auth.expected_runtime = Some(crate::protocol::RuntimeProviderKey::new( + "openai-compatible", + )); + auth.expected_catalog_namespace = Some(crate::protocol::CatalogNamespace::new("cerebras")); + + handle_notify_auth_changed( + 45, + Some("openai".to_string()), + Some(auth), + &provider, + &provider, + &sessions, + session_id.as_str(), + &agent, + &client_event_tx, + ) + .await; + + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Done { id: 45 }) + )); + + let final_activity = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match bus_rx.recv().await.expect("bus should stay open") { + crate::bus::BusEvent::UiActivity(activity) + if activity.kind == crate::bus::UiActivityKind::Catalog + && activity.session_id.as_deref() == Some(session_id.as_str()) + && activity.message.contains("Auth Model Catalog Updated") => + { + break activity; + } + _ => continue, + } + } + }) + .await + .expect("expected final auth catalog activity"); + + assert!( + final_activity + .message + .contains("Cerebras credentials are active"), + "typed auth event should control user-visible provider label, got: {}", + final_activity.message + ); + assert!( + !final_activity + .message + .contains("OpenAI credentials are active"), + "stale legacy provider identity leaked into user-visible auth message: {}", + final_activity.message + ); + assert!( + final_activity + .message + .contains("Auth Model Catalog Warning"), + "typed auth event should warn when matching provider routes are missing: {}", + final_activity.message + ); + assert!( + final_activity + .message + .contains("Expected selectable Cerebras model routes"), + "warning should identify the expected provider: {}", + final_activity.message + ); + assert_eq!( + std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE").as_deref(), + Ok("cerebras") + ); +} + +#[tokio::test] +async fn notify_auth_changed_switches_from_stale_model_to_matching_provider_route() { + let _guard = EnvGuard::save(&[ + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider = Arc::new(AuthChangeMockProvider::new()); + *provider.state.selected_model.write().unwrap() = Some("gpt-5.5".to_string()); + *provider.state.route_provider.write().unwrap() = "Cerebras".to_string(); + *provider.state.route_api_method.write().unwrap() = "openai-compatible:cerebras".to_string(); + let provider: Arc<dyn Provider> = provider; + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let session_id = { agent.lock().await.session_id().to_string() }; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + "test-session".to_string(), + Arc::clone(&agent), + )]))); + let (client_event_tx, _client_event_rx) = mpsc::unbounded_channel(); + let mut bus_rx = crate::bus::Bus::global().subscribe(); + while bus_rx.try_recv().is_ok() {} + + let mut auth = crate::protocol::AuthChanged::new("cerebras"); + auth.credential_source = Some(crate::protocol::AuthCredentialSource::ApiKeyFile); + auth.auth_method = Some(crate::protocol::AuthMethod::RemoteTuiPasteApiKey); + auth.expected_runtime = Some(crate::protocol::RuntimeProviderKey::new( + "openai-compatible", + )); + auth.expected_catalog_namespace = Some(crate::protocol::CatalogNamespace::new("cerebras")); + + handle_notify_auth_changed( + 46, + Some("openai".to_string()), + Some(auth), + &provider, + &provider, + &sessions, + session_id.as_str(), + &agent, + &client_event_tx, + ) + .await; + + let final_activity = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match bus_rx.recv().await.expect("bus should stay open") { + crate::bus::BusEvent::UiActivity(activity) + if activity.kind == crate::bus::UiActivityKind::Catalog + && activity.session_id.as_deref() == Some(session_id.as_str()) + && activity.message.contains("Auth Model Catalog Updated") => + { + break activity; + } + _ => continue, + } + } + }) + .await + .expect("expected final auth catalog activity"); + + assert!( + final_activity + .message + .contains("Cerebras credentials are active"), + "{}", + final_activity.message + ); + assert!( + final_activity + .message + .contains("Selected model: `gpt-oss-120b`"), + "final auth catalog update should switch away from stale OpenAI model: {}", + final_activity.message + ); + assert!( + !final_activity.message.contains("Selected model: `gpt-5.5`"), + "stale selected model leaked into final auth update: {}", + final_activity.message + ); + assert!( + !final_activity + .message + .contains("Auth Model Catalog Warning"), + "successful recovery should not warn: {}", + final_activity.message + ); +} + +#[tokio::test] +async fn notify_auth_changed_does_not_override_manual_model_selected_during_refresh() { + let _guard = EnvGuard::save(&[ + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider = Arc::new(AuthChangeMockProvider::new()); + *provider.state.selected_model.write().unwrap() = Some("stale-model".to_string()); + *provider.state.route_provider.write().unwrap() = "Cerebras".to_string(); + *provider.state.route_api_method.write().unwrap() = "openai-compatible:cerebras".to_string(); + *provider + .state + .expose_selected_model_in_routes + .write() + .unwrap() = false; + let provider: Arc<dyn Provider> = provider; + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let session_id = { agent.lock().await.session_id().to_string() }; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + "test-session".to_string(), + Arc::clone(&agent), + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + let mut bus_rx = crate::bus::Bus::global().subscribe(); + while bus_rx.try_recv().is_ok() {} + + let mut auth = crate::protocol::AuthChanged::new("cerebras"); + auth.credential_source = Some(crate::protocol::AuthCredentialSource::ApiKeyFile); + auth.auth_method = Some(crate::protocol::AuthMethod::RemoteTuiPasteApiKey); + auth.expected_runtime = Some(crate::protocol::RuntimeProviderKey::new( + "openai-compatible", + )); + auth.expected_catalog_namespace = Some(crate::protocol::CatalogNamespace::new("cerebras")); + + handle_notify_auth_changed( + 48, + None, + Some(auth), + &provider, + &provider, + &sessions, + session_id.as_str(), + &agent, + &client_event_tx, + ) + .await; + + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Done { id: 48 }) + )); + + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if matches!( + client_event_rx.recv().await, + Some(ServerEvent::AvailableModelsUpdated { .. }) + ) { + break; + } + } + }) + .await + .expect("expected immediate auth model snapshot"); + + { + let mut agent_guard = agent.lock().await; + agent_guard + .set_model("user-picked-model") + .expect("manual model switch should succeed"); + } + + let final_activity = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match bus_rx.recv().await.expect("bus should stay open") { + crate::bus::BusEvent::UiActivity(activity) + if activity.kind == crate::bus::UiActivityKind::Catalog + && activity.session_id.as_deref() == Some(session_id.as_str()) + && activity.message.contains("Auth Model Catalog Updated") => + { + break activity; + } + _ => continue, + } + } + }) + .await + .expect("expected final auth catalog activity"); + + assert!( + final_activity + .message + .contains("Selected model: `user-picked-model`"), + "late auth reconciliation must not override manual model selection: {}", + final_activity.message + ); + assert!( + !final_activity + .message + .contains("Selected model: `logged-in-model`"), + "late auth auto-selection overrode manual choice: {}", + final_activity.message + ); +} + +#[derive(Clone, Copy)] +struct AuthModelE2eScenario { + name: &'static str, + manual_pick_after_first_snapshot: Option<&'static str>, + prompt_immediately_after_model_pick: bool, + expected_first_prompt_model: &'static str, +} + +#[tokio::test] +async fn auth_model_first_prompt_e2e_state_space_is_bounded_by_selection_source() { + let scenarios = [ + AuthModelE2eScenario { + name: "auth auto-selects matching route when user does not intervene", + manual_pick_after_first_snapshot: None, + prompt_immediately_after_model_pick: false, + expected_first_prompt_model: "logged-in-model", + }, + AuthModelE2eScenario { + name: "manual picker selection during auth refresh wins first prompt", + manual_pick_after_first_snapshot: Some("user-picked-model"), + prompt_immediately_after_model_pick: true, + expected_first_prompt_model: "user-picked-model", + }, + ]; + + for scenario in scenarios { + let _guard = EnvGuard::save(&[ + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider_concrete = Arc::new(AuthChangeMockProvider::new()); + *provider_concrete.state.selected_model.write().unwrap() = Some("stale-model".to_string()); + *provider_concrete.state.route_provider.write().unwrap() = "Cerebras".to_string(); + *provider_concrete.state.route_api_method.write().unwrap() = + "openai-compatible:cerebras".to_string(); + *provider_concrete + .state + .expose_selected_model_in_routes + .write() + .unwrap() = false; + let provider: Arc<dyn Provider> = provider_concrete.clone(); + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let session_id = { agent.lock().await.session_id().to_string() }; + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([( + format!("test-session-{}", scenario.name), + Arc::clone(&agent), + )]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + let mut bus_rx = crate::bus::Bus::global().subscribe(); + while bus_rx.try_recv().is_ok() {} + + let mut auth = crate::protocol::AuthChanged::new("cerebras"); + auth.credential_source = Some(crate::protocol::AuthCredentialSource::ApiKeyFile); + auth.auth_method = Some(crate::protocol::AuthMethod::RemoteTuiPasteApiKey); + auth.expected_runtime = Some(crate::protocol::RuntimeProviderKey::new( + "openai-compatible", + )); + auth.expected_catalog_namespace = Some(crate::protocol::CatalogNamespace::new("cerebras")); + + handle_notify_auth_changed( + 148, + None, + Some(auth), + &provider, + &provider, + &sessions, + session_id.as_str(), + &agent, + &client_event_tx, + ) + .await; + + assert!( + matches!( + client_event_rx.recv().await, + Some(ServerEvent::Done { id: 148 }) + ), + "{}: expected auth Done", + scenario.name + ); + + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if matches!( + client_event_rx.recv().await, + Some(ServerEvent::AvailableModelsUpdated { .. }) + ) { + break; + } + } + }) + .await + .unwrap_or_else(|_| panic!("{}: expected immediate auth model snapshot", scenario.name)); + + let mut first_prompt_output = None; + if let Some(model) = scenario.manual_pick_after_first_snapshot { + handle_set_model(248, model.to_string(), &agent, &client_event_tx).await; + loop { + match client_event_rx.recv().await { + Some(ServerEvent::ModelChanged { + id: 248, + model: changed, + error, + .. + }) => { + assert_eq!(error, None, "{}: manual model switch failed", scenario.name); + assert_eq!( + changed, model, + "{}: manual model switch mismatch", + scenario.name + ); + break; + } + Some(_) => continue, + None => panic!("{}: model switch channel closed", scenario.name), + } + } + + if scenario.prompt_immediately_after_model_pick { + let agent_for_prompt = Arc::clone(&agent); + let scenario_name = scenario.name; + first_prompt_output = Some( + tokio::time::timeout(std::time::Duration::from_secs(2), async move { + let mut agent_guard = agent_for_prompt.lock().await; + agent_guard + .run_once_capture("first prompt immediately after model selection") + .await + }) + .await + .unwrap_or_else(|_| panic!("{}: first prompt stalled", scenario_name)) + .unwrap_or_else(|error| { + panic!("{}: first prompt failed: {error:?}", scenario_name) + }), + ); + } + } + + let final_activity = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match bus_rx.recv().await.expect("bus should stay open") { + crate::bus::BusEvent::UiActivity(activity) + if activity.kind == crate::bus::UiActivityKind::Catalog + && activity.session_id.as_deref() == Some(session_id.as_str()) + && activity.message.contains("Auth Model Catalog Updated") => + { + break activity; + } + _ => continue, + } + } + }) + .await + .unwrap_or_else(|_| panic!("{}: expected final auth catalog activity", scenario.name)); + assert!( + final_activity.message.contains(&format!( + "Selected model: `{}`", + scenario.expected_first_prompt_model + )), + "{}: final activity selected wrong model: {}", + scenario.name, + final_activity.message + ); + + let first_prompt_output = if let Some(output) = first_prompt_output { + output + } else { + let mut agent_guard = agent.lock().await; + agent_guard + .run_once_capture("first prompt after auth/model selection") + .await + .unwrap_or_else(|error| panic!("{}: first prompt failed: {error:?}", scenario.name)) + }; + assert!( + first_prompt_output.contains("ok"), + "{}: fake provider response not observed: {}", + scenario.name, + first_prompt_output + ); + let completed_models = provider_concrete + .state + .complete_models + .lock() + .unwrap() + .clone(); + assert_eq!( + completed_models.last().map(String::as_str), + Some(scenario.expected_first_prompt_model), + "{}: first provider request used wrong model; all completions: {:?}", + scenario.name, + completed_models + ); + } +} + +#[tokio::test] +async fn notify_auth_changed_switches_only_current_session_model() { + let _guard = EnvGuard::save(&[ + "JCODE_OPENROUTER_API_BASE", + "JCODE_OPENROUTER_API_KEY_NAME", + "JCODE_OPENROUTER_ENV_FILE", + "JCODE_OPENROUTER_CACHE_NAMESPACE", + "JCODE_OPENROUTER_PROVIDER_FEATURES", + "JCODE_OPENROUTER_TRANSPORT_STATE", + "JCODE_OPENROUTER_MODEL_CATALOG", + "JCODE_OPENROUTER_AUTH_HEADER", + "JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER", + "JCODE_OPENROUTER_MODEL", + "JCODE_RUNTIME_PROVIDER", + "JCODE_ACTIVE_PROVIDER", + "JCODE_FORCE_PROVIDER", + ]); + + crate::bus::reset_models_updated_publish_state_for_tests(); + let current_provider = Arc::new(AuthChangeMockProvider::new()); + let current_state = Arc::clone(¤t_provider.state); + *current_state.selected_model.write().unwrap() = Some("gpt-5.5".to_string()); + *current_state.route_provider.write().unwrap() = "Groq".to_string(); + *current_state.route_api_method.write().unwrap() = "openai-compatible:groq".to_string(); + let peer_provider = Arc::new(AuthChangeMockProvider::new()); + let peer_state = Arc::clone(&peer_provider.state); + *peer_state.selected_model.write().unwrap() = Some("gpt-5.5".to_string()); + *peer_state.route_provider.write().unwrap() = "Groq".to_string(); + *peer_state.route_api_method.write().unwrap() = "openai-compatible:groq".to_string(); + + let current_provider: Arc<dyn Provider> = current_provider; + let peer_provider: Arc<dyn Provider> = peer_provider; + let registry = Registry::empty(); + let current_agent = Arc::new(Mutex::new(Agent::new( + Arc::clone(¤t_provider), + registry.clone(), + ))); + let current_session_id = { current_agent.lock().await.session_id().to_string() }; + let peer_agent = Arc::new(Mutex::new(Agent::new(peer_provider, registry))); + let sessions: SessionAgents = Arc::new(RwLock::new(HashMap::from([ + ("current-session".to_string(), Arc::clone(¤t_agent)), + ("peer-session".to_string(), Arc::clone(&peer_agent)), + ]))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + let mut auth = crate::protocol::AuthChanged::new("groq"); + auth.credential_source = Some(crate::protocol::AuthCredentialSource::ApiKeyFile); + auth.auth_method = Some(crate::protocol::AuthMethod::RemoteTuiPasteApiKey); + auth.expected_runtime = Some(crate::protocol::RuntimeProviderKey::new( + "openai-compatible", + )); + auth.expected_catalog_namespace = Some(crate::protocol::CatalogNamespace::new("groq")); + + handle_notify_auth_changed( + 47, + Some("openai".to_string()), + Some(auth), + ¤t_provider, + ¤t_provider, + &sessions, + current_session_id.as_str(), + ¤t_agent, + &client_event_tx, + ) + .await; + + assert!(matches!( + client_event_rx.recv().await, + Some(ServerEvent::Done { id: 47 }) + )); + + let expected = "llama-3.1-8b-instant"; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + let current = current_state.selected_model.read().unwrap().clone(); + let peer = peer_state.selected_model.read().unwrap().clone(); + let peer_refreshed = *peer_state.logged_in.read().unwrap(); + if current.as_deref() == Some(expected) + && peer.as_deref() == Some("gpt-5.5") + && peer_refreshed + { + let peer_snapshot = available_models_updated_event(&peer_agent).await; + let ServerEvent::AvailableModelsUpdated { + provider_name, + provider_model, + available_model_routes, + .. + } = peer_snapshot + else { + panic!("expected available models snapshot for peer session"); + }; + assert_eq!(provider_name.as_deref(), Some("mock-auth")); + assert_eq!(provider_model.as_deref(), Some("gpt-5.5")); + assert!(available_model_routes.iter().any(|route| { + route.model == "gpt-5.5" + && route.provider == "Groq" + && route.api_method == "openai-compatible:groq" + })); + assert!( + available_model_routes + .iter() + .all(|route| route.model != expected), + "auth-triggered Groq model leaked into peer session routes: {:?}", + available_model_routes + ); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + panic!( + "auth change did not keep model switch session-local: current={:?}, peer={:?}, peer_refreshed={}", + current_state.selected_model.read().unwrap().clone(), + peer_state.selected_model.read().unwrap().clone(), + *peer_state.logged_in.read().unwrap() + ); +} + +#[tokio::test] +async fn refresh_models_emits_available_models_updated_after_prefetch() { + crate::bus::reset_models_updated_publish_state_for_tests(); + let provider: Arc<dyn Provider> = Arc::new(AuthChangeMockProvider::new()); + let registry = Registry::empty(); + let agent = Arc::new(Mutex::new(Agent::new(provider.clone(), registry))); + let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel(); + + handle_refresh_models(7, &provider, &agent, &client_event_tx).await; + + let mut saw_done = false; + let mut saw_models = None; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let event = tokio::time::timeout(remaining, client_event_rx.recv()) + .await + .expect("receive server event before timeout"); + match event.expect("channel open") { + ServerEvent::Done { id } => { + assert_eq!(id, 7); + saw_done = true; + } + ServerEvent::AvailableModelsUpdated { + provider_name, + provider_model, + available_models, + available_model_routes, + } => { + saw_models = Some(( + provider_name, + provider_model, + available_models, + available_model_routes, + )); + break; + } + _ => {} + } + } + + assert!(saw_done, "expected immediate Done ack"); + let (provider_name, provider_model, available_models, available_model_routes) = + saw_models.expect("expected AvailableModelsUpdated event"); + assert_eq!(provider_name.as_deref(), Some("mock-auth")); + assert_eq!(provider_model.as_deref(), Some("logged-out-model")); + assert_eq!(available_models, vec!["logged-out-model".to_string()]); + assert!(available_model_routes.iter().any(|route| { + route.model == "logged-out-model" + && route.provider == "MockAuth" + && route.api_method == "mock-auth" + })); +} diff --git a/crates/jcode-app-core/src/server/queue_tests.rs b/crates/jcode-app-core/src/server/queue_tests.rs new file mode 100644 index 0000000..27eae2c --- /dev/null +++ b/crates/jcode-app-core/src/server/queue_tests.rs @@ -0,0 +1,185 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::{ + SessionInterruptQueues, queue_soft_interrupt_for_session, register_session_interrupt_queue, +}; +use crate::agent::Agent; +use crate::message::{Message, ToolDefinition}; +use crate::provider::{EventStream, Provider}; +use crate::tool::Registry; +use anyhow::Result; +use async_trait::async_trait; +use jcode_agent_runtime::SoftInterruptSource; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +struct TestProvider; + +#[async_trait] +impl Provider for TestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "test provider complete should not be called in queue tests" + )) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(TestProvider) + } +} + +async fn test_agent() -> Arc<Mutex<Agent>> { + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + Arc::new(Mutex::new(Agent::new(provider, registry))) +} + +#[tokio::test] +async fn queue_soft_interrupt_for_session_uses_registered_queue_when_agent_busy() { + let agent = test_agent().await; + let session_id = { + let guard = agent.lock().await; + guard.session_id().to_string() + }; + let queue = { + let guard = agent.lock().await; + guard.soft_interrupt_queue() + }; + let queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + register_session_interrupt_queue(&queues, &session_id, queue.clone()).await; + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + + let _busy_guard = agent.lock().await; + let queued = queue_soft_interrupt_for_session( + &session_id, + "queued while busy".to_string(), + false, + SoftInterruptSource::User, + &queues, + &sessions, + ) + .await; + + assert!( + queued, + "interrupt should queue even while agent lock is held" + ); + let pending = queue.lock().expect("queue lock"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].content, "queued while busy"); + assert!(!pending[0].urgent); + assert_eq!(pending[0].source, SoftInterruptSource::User); +} + +#[tokio::test] +async fn queue_soft_interrupt_for_session_registers_queue_on_fallback_lookup() { + let agent = test_agent().await; + let session_id = { + let guard = agent.lock().await; + guard.session_id().to_string() + }; + let queue = { + let guard = agent.lock().await; + guard.soft_interrupt_queue() + }; + let queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + + let queued = queue_soft_interrupt_for_session( + &session_id, + "fallback lookup".to_string(), + true, + SoftInterruptSource::System, + &queues, + &sessions, + ) + .await; + + assert!(queued, "interrupt should queue via session fallback"); + assert!( + queues.read().await.contains_key(&session_id), + "fallback should cache the session queue for later busy sends" + ); + let pending = queue.lock().expect("queue lock"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].content, "fallback lookup"); + assert!(pending[0].urgent); + assert_eq!(pending[0].source, SoftInterruptSource::System); +} + +#[tokio::test] +async fn queue_soft_interrupt_for_session_persists_when_live_queue_is_unavailable() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let agent = test_agent().await; + let session_id = { + let guard = agent.lock().await; + guard.session_id().to_string() + }; + crate::session::Session::create_with_id(session_id.clone(), None, None) + .save() + .expect("save session snapshot"); + + let queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new())); + let sessions = Arc::new(RwLock::new(HashMap::new())); + + let queued = queue_soft_interrupt_for_session( + &session_id, + "persist while reloading".to_string(), + false, + SoftInterruptSource::BackgroundTask, + &queues, + &sessions, + ) + .await; + + assert!( + queued, + "interrupt should persist when live queue is unavailable" + ); + + let persisted = + crate::soft_interrupt_store::load(&session_id).expect("load persisted interrupts"); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].content, "persist while reloading"); + assert_eq!(persisted[0].source, SoftInterruptSource::BackgroundTask); + + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let registry = Registry::new(provider.clone()).await; + let mut restored = Agent::new(provider, registry); + restored + .restore_session(&session_id) + .expect("restore session should rehydrate interrupts"); + assert_eq!(restored.soft_interrupt_count(), 1); + assert!( + crate::soft_interrupt_store::load(&session_id) + .expect("load persisted interrupts after restore") + .is_empty() + ); + + 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/server/reload.rs b/crates/jcode-app-core/src/server/reload.rs new file mode 100644 index 0000000..e804560 --- /dev/null +++ b/crates/jcode-app-core/src/server/reload.rs @@ -0,0 +1,514 @@ +use crate::agent::Agent; +use crate::server::reload_recovery::ReloadRecoveryRole; +use crate::server::{SwarmEvent, SwarmEventType, SwarmMember}; +use crate::tool::selfdev::ReloadContext; +use jcode_agent_runtime::InterruptSignal; +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock, broadcast, watch}; + +type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>; + +const RELOAD_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); + +fn prepare_server_exec(cmd: &mut std::process::Command, socket_path: &std::path::Path) { + // The replacement daemon must own the published socket paths. Unlink them + // before exec so we never inherit a stale on-disk endpoint through reload. + crate::server::cleanup_socket_pair(socket_path); + cmd.env_remove("JCODE_READY_FD"); + + // The shared daemon may have inherited stderr from the client process that + // originally spawned it. Once that client exits, later reload execs can hit + // SIGPIPE during boot when they emit provider/model notices to stderr, + // killing the replacement server before it binds the socket. The daemon + // logs to the file logger, so detach stdio for exec-based reloads. + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); +} + +async fn receive_reload_signal( + rx: &mut watch::Receiver<Option<crate::server::ReloadSignal>>, + last_request_id: &mut Option<String>, +) -> Option<crate::server::ReloadSignal> { + // The reload watch channel keeps holding the last `Some(signal)` after it is + // sent (it is never reset to `None`), so `borrow_and_update` would keep + // handing back the same signal on every loop iteration. In production the + // caller exec()s/exits after the first one, but a test-session listener just + // `continue`s -- which previously turned into a hot busy-loop that starved + // the runtime (single-threaded #[tokio::test]) and hung the reload e2e tests. + // Dedupe by request_id so each distinct signal is delivered exactly once. + loop { + if let Some(signal) = rx.borrow_and_update().clone() + && last_request_id.as_deref() != Some(signal.request_id.as_str()) + { + *last_request_id = Some(signal.request_id.clone()); + return Some(signal); + } + + if rx.changed().await.is_err() { + return None; + } + } +} + +pub(super) async fn await_reload_signal( + sessions: Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, + shutdown_signals: Arc<RwLock<HashMap<String, InterruptSignal>>>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, +) { + use std::process::Command as ProcessCommand; + + let mut rx = super::reload_state::reload_signal().1.clone(); + // Treat any signal already sitting in the (process-global) reload channel as + // already handled: a server should only react to reload signals issued after + // it started listening, never to a stale one left over from a previous run. + // Without this, in-process e2e servers (which share the global channel) would + // each immediately re-process the last test's reload signal on startup. + let mut last_request_id: Option<String> = rx + .borrow_and_update() + .as_ref() + .map(|signal| signal.request_id.clone()); + + loop { + let signal = match receive_reload_signal(&mut rx, &mut last_request_id).await { + Some(signal) => signal, + None => return, + }; + + crate::logging::info(&format!( + "Server: reload signal received via channel request={} hash={} triggering_session={:?} prefer_selfdev_binary={}", + signal.request_id, signal.hash, signal.triggering_session, signal.prefer_selfdev_binary + )); + super::reload_trace::record_value( + &signal.request_id, + "signal_received", + serde_json::json!({ + "hash": signal.hash, + "triggering_session": signal.triggering_session, + "prefer_selfdev_binary": signal.prefer_selfdev_binary, + }), + ); + let reload_started = std::time::Instant::now(); + crate::server::write_reload_state( + &signal.request_id, + &signal.hash, + crate::server::ReloadPhase::Starting, + signal.triggering_session.clone(), + ); + super::acknowledge_reload_signal(&signal); + + if std::env::var("JCODE_TEST_SESSION") + .map(|value| { + let trimmed = value.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) + .unwrap_or(false) + { + crate::logging::info( + "Server: JCODE_TEST_SESSION set, skipping process exec for reload test", + ); + continue; + } + + persist_reload_recovery_intents( + &signal.request_id, + &swarm_members, + signal.triggering_session.as_deref(), + ) + .await; + super::reload_trace::record_value( + &signal.request_id, + "intent_persistence_complete", + serde_json::json!({}), + ); + + graceful_shutdown_sessions( + &signal.request_id, + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + signal.triggering_session.as_deref(), + ) + .await; + crate::logging::info(&format!( + "Server: graceful shutdown completed for reload request={} after {}ms state={}", + signal.request_id, + reload_started.elapsed().as_millis(), + crate::server::reload_state_summary(std::time::Duration::from_secs(60)) + )); + super::reload_trace::record_value( + &signal.request_id, + "graceful_shutdown_complete", + serde_json::json!({ + "elapsed_ms": reload_started.elapsed().as_millis(), + "state": crate::server::reload_state_summary(std::time::Duration::from_secs(60)), + }), + ); + + let prefers_selfdev = signal.prefer_selfdev_binary; + + if let Some((binary, label)) = super::reload_exec_target(prefers_selfdev) { + if binary.exists() { + let socket = super::socket_path(); + crate::logging::info(&format!( + "Server: exec'ing into {} binary {:?} (socket: {:?}, prep={}ms, state={})", + label, + binary, + socket, + reload_started.elapsed().as_millis(), + crate::server::reload_state_summary(std::time::Duration::from_secs(60)) + )); + super::reload_trace::record_value( + &signal.request_id, + "exec_start", + serde_json::json!({ + "binary_label": label, + "binary": binary, + "socket": socket, + "elapsed_ms": reload_started.elapsed().as_millis(), + }), + ); + let mut cmd = ProcessCommand::new(&binary); + cmd.arg("serve").arg("--socket").arg(socket.as_os_str()); + prepare_server_exec(&mut cmd, &socket); + let err = crate::platform::replace_process(&mut cmd); + crate::server::write_reload_state( + &signal.request_id, + &signal.hash, + crate::server::ReloadPhase::Failed, + Some(err.to_string()), + ); + crate::logging::error(&format!( + "Failed to exec into {} {:?}: {}", + label, binary, err + )); + } else { + crate::server::write_reload_state( + &signal.request_id, + &signal.hash, + crate::server::ReloadPhase::Failed, + Some(format!("missing binary: {}", binary.display())), + ); + } + } else { + crate::server::write_reload_state( + &signal.request_id, + &signal.hash, + crate::server::ReloadPhase::Failed, + Some("no reloadable binary found".to_string()), + ); + } + std::process::exit(42); + } +} + +async fn persist_reload_recovery_intents( + reload_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + triggering_session: Option<&str>, +) { + let mut candidates: Vec<(String, bool)> = { + let members = swarm_members.read().await; + let snapshot = members + .iter() + .map(|(session_id, member)| { + serde_json::json!({ + "session_id": session_id, + "status": member.status, + "is_headless": member.is_headless, + "swarm_id": member.swarm_id, + "role": member.role, + }) + }) + .collect::<Vec<_>>(); + super::reload_trace::record_value( + reload_id, + "candidate_snapshot", + serde_json::json!({ + "triggering_session": triggering_session, + "members": snapshot, + }), + ); + members + .iter() + .filter(|(_, member)| member.status == "running") + .map(|(session_id, member)| (session_id.clone(), member.is_headless)) + .collect() + }; + + if let Some(triggering_session) = triggering_session + && !candidates + .iter() + .any(|(session_id, _)| session_id == triggering_session) + { + candidates.push((triggering_session.to_string(), false)); + } + + candidates.sort_by(|a, b| a.0.cmp(&b.0)); + candidates.dedup_by(|a, b| a.0 == b.0); + + for (session_id, is_headless) in candidates { + let reload_ctx = ReloadContext::peek_for_session(&session_id).ok().flatten(); + let is_triggering = Some(session_id.as_str()) == triggering_session; + let Some(directive) = ReloadContext::recovery_directive_for_session( + &session_id, + reload_ctx.as_ref(), + is_headless || !is_triggering, + None, + ) else { + super::reload_trace::record_value( + reload_id, + "intent_skipped", + serde_json::json!({ + "session_id": session_id, + "triggering": is_triggering, + "is_headless": is_headless, + "has_reload_ctx": reload_ctx.is_some(), + "reason": "no directive generated", + }), + ); + crate::logging::info(&format!( + "reload recovery store: no directive generated for reload_id={} session={} triggering={} headless={} has_reload_ctx={}", + reload_id, + session_id, + is_triggering, + is_headless, + reload_ctx.is_some() + )); + continue; + }; + + let role = if is_headless { + ReloadRecoveryRole::Headless + } else if is_triggering { + ReloadRecoveryRole::Initiator + } else { + ReloadRecoveryRole::InterruptedPeer + }; + let reason = if is_triggering { + "triggering session for reload" + } else if is_headless { + "headless session running during reload" + } else { + "attached peer session running during reload" + }; + + if let Err(err) = + super::reload_recovery::persist_intent(reload_id, &session_id, role, directive, reason) + { + super::reload_trace::record_value( + reload_id, + "intent_persist_failed", + serde_json::json!({ + "session_id": session_id, + "error": err.to_string(), + }), + ); + crate::logging::warn(&format!( + "reload recovery store: failed to persist intent reload_id={} session={}: {}", + reload_id, session_id, err + )); + } else { + super::reload_trace::record_value( + reload_id, + "intent_persisted", + serde_json::json!({ + "session_id": session_id, + "triggering": is_triggering, + "is_headless": is_headless, + }), + ); + } + } +} + +pub(super) async fn graceful_shutdown_sessions( + reload_id: &str, + _sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + triggering_session: Option<&str>, +) { + graceful_shutdown_sessions_with_timeout( + reload_id, + _sessions, + swarm_members, + shutdown_signals, + swarm_event_tx, + RELOAD_GRACEFUL_SHUTDOWN_TIMEOUT, + triggering_session, + ) + .await; +} + +async fn graceful_shutdown_sessions_with_timeout( + reload_id: &str, + _sessions: &SessionAgents, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + timeout: Duration, + triggering_session: Option<&str>, +) { + let actively_generating: Vec<String> = { + let members = swarm_members.read().await; + members + .iter() + .filter(|(_, m)| m.status == "running") + .map(|(id, _)| id.clone()) + .collect() + }; + + let (signalable_sessions, unsignalable_sessions) = { + let signals = shutdown_signals.read().await; + actively_generating + .into_iter() + .partition::<Vec<_>, _>(|session_id| signals.contains_key(session_id)) + }; + + if !unsignalable_sessions.is_empty() { + super::reload_trace::record_value( + reload_id, + "shutdown_unsignalable_sessions", + serde_json::json!({ + "sessions": unsignalable_sessions, + }), + ); + crate::logging::warn(&format!( + "Server: {} running session(s) had no shutdown signal and will not block reload: {:?}", + unsignalable_sessions.len(), + unsignalable_sessions + )); + } + + if signalable_sessions.is_empty() { + crate::logging::info( + "Server: no sessions actively generating, proceeding with reload immediately", + ); + return; + } + + crate::logging::info(&format!( + "Server: signaling {} actively generating session(s) to checkpoint: {:?}", + signalable_sessions.len(), + signalable_sessions + )); + + { + let signals = shutdown_signals.read().await; + for session_id in &signalable_sessions { + let Some(signal) = signals.get(session_id) else { + crate::logging::warn(&format!( + "Server: shutdown signal disappeared before graceful reload handoff for session {}", + session_id + )); + continue; + }; + signal.fire(); + super::reload_trace::record_value( + reload_id, + "shutdown_signal_sent", + serde_json::json!({ + "session_id": session_id, + "triggering_session": triggering_session, + }), + ); + crate::logging::info(&format!( + "Server: sent graceful shutdown signal to session {}", + session_id + )); + } + } + + let watched: std::collections::HashSet<String> = signalable_sessions + .into_iter() + .filter(|session_id| Some(session_id.as_str()) != triggering_session) + .collect(); + + if let Some(triggering_session) = triggering_session { + crate::logging::info(&format!( + "Server: excluding triggering session {} from reload checkpoint wait set", + triggering_session + )); + } + + if watched.is_empty() { + crate::logging::info( + "Server: no non-triggering running sessions remain to checkpoint, proceeding with reload", + ); + return; + } + + let mut event_rx = swarm_event_tx.subscribe(); + let deadline = Instant::now() + timeout; + + loop { + let still_running: Vec<String> = { + let members = swarm_members.read().await; + watched + .iter() + .filter(|id| { + members + .get(*id) + .map(|m| m.status == "running") + .unwrap_or(false) + }) + .cloned() + .collect() + }; + + if still_running.is_empty() { + crate::logging::info("Server: all sessions checkpointed, proceeding with reload"); + break; + } + + crate::logging::info(&format!( + "Server: waiting for {} session(s) to checkpoint before reload: {:?}", + still_running.len(), + still_running + )); + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + crate::logging::warn(&format!( + "Server: reload graceful shutdown timed out after {}ms; proceeding with still-running sessions: {:?}", + timeout.as_millis(), + still_running + )); + break; + } + + match tokio::time::timeout(remaining, event_rx.recv()).await { + Ok(Ok(event)) => match &event.event { + SwarmEventType::StatusChange { .. } if watched.contains(&event.session_id) => {} + SwarmEventType::MemberChange { action } + if action == "left" && watched.contains(&event.session_id) => {} + _ => continue, + }, + Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, + Ok(Err(broadcast::error::RecvError::Closed)) => { + crate::logging::warn( + "Server: swarm event channel closed while waiting for reload checkpoint", + ); + break; + } + Err(_) => { + crate::logging::warn(&format!( + "Server: reload graceful shutdown timed out after {}ms; proceeding without waiting for remaining checkpoint events", + timeout.as_millis() + )); + break; + } + } + } +} + +#[cfg(test)] +#[path = "reload_tests.rs"] +mod reload_tests; diff --git a/crates/jcode-app-core/src/server/reload_recovery.rs b/crates/jcode-app-core/src/server/reload_recovery.rs new file mode 100644 index 0000000..d5a2914 --- /dev/null +++ b/crates/jcode-app-core/src/server/reload_recovery.rs @@ -0,0 +1,680 @@ +use crate::tool::selfdev::ReloadRecoveryDirective; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +const PENDING_RECORD_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ReloadRecoveryRole { + Initiator, + InterruptedPeer, + Headless, +} + +impl ReloadRecoveryRole { + fn as_str(&self) -> &'static str { + match self { + Self::Initiator => "initiator", + Self::InterruptedPeer => "interrupted_peer", + Self::Headless => "headless", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ReloadRecoveryStatus { + Pending, + Delivered, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(super) struct ReloadRecoveryRecord { + pub reload_id: String, + pub session_id: String, + pub role: ReloadRecoveryRole, + pub status: ReloadRecoveryStatus, + pub directive: ReloadRecoveryDirective, + pub reason: String, + pub created_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delivered_at: Option<String>, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct GarbageCollectionStats { + pub removed: usize, + pub retained: usize, + pub errors: usize, +} + +fn sanitize_session_id(session_id: &str) -> String { + session_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +fn recovery_dir() -> Result<PathBuf> { + Ok(crate::storage::jcode_dir()?.join("reload-recovery")) +} + +pub(super) fn path_for_session(session_id: &str) -> Result<PathBuf> { + Ok(recovery_dir()?.join(format!("{}.json", sanitize_session_id(session_id)))) +} + +fn remove_record_files(path: &std::path::Path) -> Result<()> { + for candidate in [path.to_path_buf(), path.with_extension("bak")] { + match std::fs::remove_file(&candidate) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + + #[cfg(unix)] + if let Some(parent) = path.parent() + && let Ok(directory) = std::fs::File::open(parent) + { + let _ = directory.sync_all(); + } + Ok(()) +} + +fn pending_record_is_expired(record: &ReloadRecoveryRecord, now: SystemTime) -> Option<bool> { + let created_at = chrono::DateTime::parse_from_rfc3339(&record.created_at).ok()?; + let created_at = SystemTime::from(created_at.with_timezone(&chrono::Utc)); + Some( + now.duration_since(created_at) + .map(|age| age >= PENDING_RECORD_MAX_AGE) + .unwrap_or(false), + ) +} + +fn file_is_expired(path: &std::path::Path, now: SystemTime) -> bool { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .map(|age| age >= PENDING_RECORD_MAX_AGE) + .unwrap_or(false) +} + +/// Removes consumed records and pending/corrupt records too old to be useful. +/// +/// This is run synchronously before the server starts accepting clients, so it +/// cannot race in-process recovery writes. A record path is unique per session, +/// which also bounds repeated reloads for active sessions between sweeps. +pub(super) fn collect_garbage() -> Result<GarbageCollectionStats> { + collect_garbage_at(SystemTime::now()) +} + +fn collect_garbage_at(now: SystemTime) -> Result<GarbageCollectionStats> { + let dir = recovery_dir()?; + if !dir.exists() { + return Ok(GarbageCollectionStats::default()); + } + + let mut stats = GarbageCollectionStats::default(); + for entry in std::fs::read_dir(&dir)? { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + stats.errors += 1; + continue; + } + }; + let path = entry.path(); + let extension = path.extension().and_then(|extension| extension.to_str()); + if extension == Some("bak") { + let primary = path.with_extension("json"); + if !primary.exists() && file_is_expired(&path, now) { + match std::fs::remove_file(&path) { + Ok(()) => stats.removed += 1, + Err(error) => { + stats.errors += 1; + crate::logging::warn(&format!( + "reload recovery store: failed to collect orphan backup {}: {}", + path.display(), + error + )); + } + } + } + continue; + } + if extension != Some("json") { + continue; + } + + let should_remove = match crate::storage::read_json::<ReloadRecoveryRecord>(&path) { + Ok(record) => { + record.status == ReloadRecoveryStatus::Delivered + || pending_record_is_expired(&record, now) + .unwrap_or_else(|| file_is_expired(&path, now)) + } + Err(_) => file_is_expired(&path, now), + }; + if !should_remove { + stats.retained += 1; + continue; + } + + match remove_record_files(&path) { + Ok(()) => stats.removed += 1, + Err(error) => { + stats.errors += 1; + crate::logging::warn(&format!( + "reload recovery store: failed to collect {}: {}", + path.display(), + error + )); + } + } + } + Ok(stats) +} + +pub(super) fn persist_intent( + reload_id: &str, + session_id: &str, + role: ReloadRecoveryRole, + directive: ReloadRecoveryDirective, + reason: impl Into<String>, +) -> Result<()> { + let role_label = role.as_str(); + let record = ReloadRecoveryRecord { + reload_id: reload_id.to_string(), + session_id: session_id.to_string(), + role, + status: ReloadRecoveryStatus::Pending, + directive, + reason: reason.into(), + created_at: chrono::Utc::now().to_rfc3339(), + delivered_at: None, + }; + let path = path_for_session(session_id)?; + crate::storage::write_json(&path, &record)?; + crate::logging::info(&format!( + "reload recovery store: persisted intent reload_id={} session={} role={} path={}", + reload_id, + session_id, + role_label, + path.display() + )); + Ok(()) +} + +pub(super) fn peek_for_session(session_id: &str) -> Result<Option<ReloadRecoveryRecord>> { + let path = path_for_session(session_id)?; + if !path.exists() { + return Ok(None); + } + crate::storage::read_json(&path).map(Some) +} + +#[cfg(test)] +pub(super) fn has_pending_for_session(session_id: &str) -> bool { + peek_for_session(session_id) + .ok() + .flatten() + .map(|record| record.status == ReloadRecoveryStatus::Pending) + .unwrap_or(false) +} + +/// Return the pending recovery directive for inclusion in a bootstrap/history +/// payload without consuming it. +/// +/// A History frame can be lost if the client disconnects or re-execs after the +/// server writes the payload but before the TUI queues/sends the hidden +/// continuation. Therefore History generation must not mark the durable intent +/// delivered. Delivery is recorded only when the replacement server accepts the +/// matching continuation message. +pub(super) fn pending_directive_for_session( + session_id: &str, +) -> Result<Option<ReloadRecoveryDirective>> { + let path = path_for_session(session_id)?; + if !path.exists() { + return Ok(None); + } + + let record: ReloadRecoveryRecord = crate::storage::read_json(&path)?; + if record.status != ReloadRecoveryStatus::Pending { + super::reload_trace::record_value( + &record.reload_id, + "intent_peek_skipped", + serde_json::json!({ + "session_id": session_id, + "status": format!("{:?}", record.status), + }), + ); + crate::logging::info(&format!( + "reload recovery store: skipping non-pending intent session={} reload_id={} status={:?}", + session_id, record.reload_id, record.status + )); + return Ok(None); + } + + let directive = record.directive.clone(); + super::reload_trace::record_value( + &record.reload_id, + "intent_attached_to_history", + serde_json::json!({ + "session_id": session_id, + "role": record.role.as_str(), + "path": path, + }), + ); + crate::logging::info(&format!( + "reload recovery store: attached pending intent reload_id={} session={} role={} without marking delivered", + record.reload_id, + session_id, + record.role.as_str() + )); + Ok(Some(directive)) +} + +pub(super) fn mark_delivered_if_matching_continuation( + session_id: &str, + continuation_message: &str, + accepted_by: &str, +) -> Result<bool> { + let path = path_for_session(session_id)?; + if !path.exists() { + return Ok(false); + } + + let mut record: ReloadRecoveryRecord = crate::storage::read_json(&path)?; + if record.status != ReloadRecoveryStatus::Pending { + super::reload_trace::record_value( + &record.reload_id, + "intent_delivery_skipped", + serde_json::json!({ + "session_id": session_id, + "status": format!("{:?}", record.status), + "accepted_by": accepted_by, + }), + ); + return Ok(false); + } + + if record.directive.continuation_message != continuation_message { + super::reload_trace::record_value( + &record.reload_id, + "intent_delivery_mismatch", + serde_json::json!({ + "session_id": session_id, + "accepted_by": accepted_by, + "expected_chars": record.directive.continuation_message.len(), + "received_chars": continuation_message.len(), + }), + ); + crate::logging::warn(&format!( + "reload recovery store: continuation mismatch session={} reload_id={} accepted_by={} expected_chars={} received_chars={}", + session_id, + record.reload_id, + accepted_by, + record.directive.continuation_message.len(), + continuation_message.len() + )); + return Ok(false); + } + + record.status = ReloadRecoveryStatus::Delivered; + record.delivered_at = Some(chrono::Utc::now().to_rfc3339()); + crate::storage::write_json(&path, &record)?; + super::reload_trace::record_value( + &record.reload_id, + "intent_delivered", + serde_json::json!({ + "session_id": session_id, + "role": record.role.as_str(), + "accepted_by": accepted_by, + "path": path, + }), + ); + crate::logging::info(&format!( + "reload recovery store: delivered intent reload_id={} session={} role={} accepted_by={}", + record.reload_id, + session_id, + record.role.as_str(), + accepted_by + )); + if let Err(error) = remove_record_files(&path) { + // Delivery is already durable. Leave the consumed record for the next + // startup sweep rather than reporting the accepted continuation as a + // failure and risking duplicate recovery work. + crate::logging::warn(&format!( + "reload recovery store: could not remove delivered intent session={} path={}: {}", + session_id, + path.display(), + error + )); + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct IsolatedHome { + prev_home: Option<std::ffi::OsString>, + _temp: tempfile::TempDir, + } + + impl IsolatedHome { + fn new() -> Self { + let temp = tempfile::TempDir::new().expect("jcode home"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + Self { + prev_home, + _temp: temp, + } + } + } + + impl Drop for IsolatedHome { + fn drop(&mut self) { + if let Some(prev) = self.prev_home.take() { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + } + + fn directive(message: &str) -> ReloadRecoveryDirective { + ReloadRecoveryDirective { + reconnect_notice: Some("reconnected".to_string()), + continuation_message: message.to_string(), + } + } + + #[test] + fn sanitize_session_id_strips_path_traversal_and_separators() { + // A malicious or merely unusual session id must never be able to escape + // the recovery directory or collide with sibling paths. + assert_eq!(sanitize_session_id("../../etc/passwd"), "______etc_passwd"); + assert_eq!(sanitize_session_id("a/b\\c"), "a_b_c"); + assert_eq!(sanitize_session_id("sess.with space"), "sess_with_space"); + // Already-safe ids are preserved verbatim. + assert_eq!(sanitize_session_id("session-abc_123"), "session-abc_123"); + } + + #[test] + fn path_for_session_stays_inside_recovery_dir() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + + let dir = recovery_dir()?; + let evil = path_for_session("../../escape")?; + assert!( + evil.starts_with(&dir), + "traversal session id escaped recovery dir: {} not under {}", + evil.display(), + dir.display() + ); + assert_eq!( + evil.file_name().and_then(|n| n.to_str()), + Some("______escape.json") + ); + Ok(()) + } + + #[test] + fn persist_then_peek_roundtrips_record() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + + let session_id = "session-roundtrip"; + persist_intent( + "reload-roundtrip", + session_id, + ReloadRecoveryRole::Headless, + directive("resume the headless task"), + "headless test", + )?; + + let record = peek_for_session(session_id)?.expect("record should exist"); + assert_eq!(record.reload_id, "reload-roundtrip"); + assert_eq!(record.session_id, session_id); + assert_eq!(record.role, ReloadRecoveryRole::Headless); + assert_eq!(record.status, ReloadRecoveryStatus::Pending); + assert_eq!( + record.directive.continuation_message, + "resume the headless task" + ); + assert!(record.delivered_at.is_none()); + assert!(has_pending_for_session(session_id)); + Ok(()) + } + + #[test] + fn peek_for_missing_session_is_none() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + assert!(peek_for_session("never-persisted")?.is_none()); + assert!(!has_pending_for_session("never-persisted")); + assert!(pending_directive_for_session("never-persisted")?.is_none()); + Ok(()) + } + + #[test] + fn pending_directive_does_not_consume_intent() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + + let session_id = "session-non-consuming"; + persist_intent( + "reload-non-consuming", + session_id, + ReloadRecoveryRole::InterruptedPeer, + directive("continue please"), + "peek test", + )?; + + // Reading the directive (for History payloads) must leave the durable + // intent pending so a lost frame can be retried after reconnect. + for _ in 0..3 { + let directive = pending_directive_for_session(session_id)?.expect("directive present"); + assert_eq!(directive.continuation_message, "continue please"); + assert!(has_pending_for_session(session_id)); + } + Ok(()) + } + + #[test] + fn mark_delivered_is_idempotent_and_matches_exact_continuation() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + + let session_id = "session-deliver"; + let continuation = "exact continuation body"; + persist_intent( + "reload-deliver", + session_id, + ReloadRecoveryRole::Initiator, + directive(continuation), + "delivery test", + )?; + + // A non-matching continuation must not consume the intent. + assert!(!mark_delivered_if_matching_continuation( + session_id, + "some other message", + "server-a", + )?); + assert!( + has_pending_for_session(session_id), + "mismatched continuation must leave intent pending" + ); + + // The exact continuation consumes it exactly once. + assert!(mark_delivered_if_matching_continuation( + session_id, + continuation, + "server-a", + )?); + assert!(!has_pending_for_session(session_id)); + + // Re-delivery is a no-op (idempotent) even with the right body. + assert!(!mark_delivered_if_matching_continuation( + session_id, + continuation, + "server-b", + )?); + + // Consumed intents are removed immediately; startup GC covers a crash + // between the delivered write and this deletion. + assert!(peek_for_session(session_id)?.is_none()); + assert!(!path_for_session(session_id)?.with_extension("bak").exists()); + Ok(()) + } + + #[test] + fn garbage_collection_removes_delivered_and_stale_records() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + let now = SystemTime::now(); + let old = chrono::Utc::now() - chrono::Duration::days(8); + + let records = [ + ReloadRecoveryRecord { + reload_id: "reload-delivered".to_string(), + session_id: "session-delivered".to_string(), + role: ReloadRecoveryRole::Initiator, + status: ReloadRecoveryStatus::Delivered, + directive: directive("done"), + reason: "delivered".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + delivered_at: Some(chrono::Utc::now().to_rfc3339()), + }, + ReloadRecoveryRecord { + reload_id: "reload-stale".to_string(), + session_id: "session-stale".to_string(), + role: ReloadRecoveryRole::InterruptedPeer, + status: ReloadRecoveryStatus::Pending, + directive: directive("too late"), + reason: "stale".to_string(), + created_at: old.to_rfc3339(), + delivered_at: None, + }, + ReloadRecoveryRecord { + reload_id: "reload-fresh".to_string(), + session_id: "session-fresh".to_string(), + role: ReloadRecoveryRole::Headless, + status: ReloadRecoveryStatus::Pending, + directive: directive("continue"), + reason: "fresh".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + delivered_at: None, + }, + ]; + for record in &records { + crate::storage::write_json(&path_for_session(&record.session_id)?, record)?; + } + // Ensure backup artifacts are collected with their primary record. + std::fs::write( + path_for_session("session-delivered")?.with_extension("bak"), + b"old backup", + )?; + + let stats = collect_garbage_at(now)?; + assert_eq!(stats.removed, 2); + assert_eq!(stats.retained, 1); + assert_eq!(stats.errors, 0); + assert!(peek_for_session("session-delivered")?.is_none()); + assert!(peek_for_session("session-stale")?.is_none()); + assert!(peek_for_session("session-fresh")?.is_some()); + assert!( + !path_for_session("session-delivered")? + .with_extension("bak") + .exists() + ); + Ok(()) + } + + #[test] + fn garbage_collection_uses_file_age_for_malformed_and_orphaned_records() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + let malformed = ReloadRecoveryRecord { + reload_id: "reload-malformed-time".to_string(), + session_id: "session-malformed-time".to_string(), + role: ReloadRecoveryRole::Headless, + status: ReloadRecoveryStatus::Pending, + directive: directive("continue"), + reason: "invalid timestamp".to_string(), + created_at: "not-an-rfc3339-timestamp".to_string(), + delivered_at: None, + }; + crate::storage::write_json(&path_for_session(&malformed.session_id)?, &malformed)?; + + let orphan_backup = path_for_session("orphan")?.with_extension("bak"); + std::fs::write(&orphan_backup, b"orphaned backup")?; + let corrupt_record = path_for_session("corrupt")?; + std::fs::write(&corrupt_record, b"not json")?; + + let future = SystemTime::now() + PENDING_RECORD_MAX_AGE + Duration::from_secs(1); + let stats = collect_garbage_at(future)?; + assert_eq!(stats.removed, 3); + assert_eq!(stats.retained, 0); + assert_eq!(stats.errors, 0); + assert!(peek_for_session(&malformed.session_id)?.is_none()); + assert!(!orphan_backup.exists()); + assert!(!corrupt_record.exists()); + Ok(()) + } + + #[test] + fn mark_delivered_for_missing_session_is_false() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + assert!(!mark_delivered_if_matching_continuation( + "missing-session", + "anything", + "server", + )?); + Ok(()) + } + + #[test] + fn persist_intent_overwrites_prior_record_for_same_session() -> Result<()> { + let _lock = crate::storage::lock_test_env(); + let _home = IsolatedHome::new(); + + let session_id = "session-overwrite"; + persist_intent( + "reload-old", + session_id, + ReloadRecoveryRole::InterruptedPeer, + directive("old continuation"), + "first", + )?; + persist_intent( + "reload-new", + session_id, + ReloadRecoveryRole::Headless, + directive("new continuation"), + "second", + )?; + + let record = peek_for_session(session_id)?.expect("record should exist"); + assert_eq!(record.reload_id, "reload-new"); + assert_eq!(record.role, ReloadRecoveryRole::Headless); + assert_eq!(record.directive.continuation_message, "new continuation"); + assert_eq!(record.status, ReloadRecoveryStatus::Pending); + Ok(()) + } +} diff --git a/crates/jcode-app-core/src/server/reload_state.rs b/crates/jcode-app-core/src/server/reload_state.rs new file mode 100644 index 0000000..d6e81d9 --- /dev/null +++ b/crates/jcode-app-core/src/server/reload_state.rs @@ -0,0 +1,925 @@ +use super::{has_live_listener, is_server_ready}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(unix)] +const RELOAD_HANDOFF_EVENT_POLL_MS: i32 = 100; + +pub fn reload_marker_path() -> PathBuf { + crate::storage::runtime_dir().join("jcode.reload") +} + +pub fn write_reload_marker() { + ReloadState { + request_id: "unknown".to_string(), + hash: "unknown".to_string(), + phase: ReloadPhase::Starting, + pid: std::process::id(), + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); +} + +pub fn clear_reload_marker() { + let _ = std::fs::remove_file(reload_marker_path()); +} + +pub(super) fn clear_reload_marker_if_stale_for_pid(current_pid: u32) { + if let Some(state) = ReloadState::load() { + if state.phase == ReloadPhase::Starting && state.pid == current_pid { + return; + } + clear_reload_marker(); + } +} + +pub fn reload_marker_exists() -> bool { + reload_marker_path().exists() +} + +pub fn reload_marker_active(max_age: Duration) -> bool { + matches!( + recent_reload_state(max_age), + Some(state) + if matches!(state.phase, ReloadPhase::Starting | ReloadPhase::SocketReady) + ) +} + +pub fn recent_reload_state(max_age: Duration) -> Option<ReloadState> { + let path = reload_marker_path(); + let state = ReloadState::load()?; + let Ok(metadata) = std::fs::metadata(&path) else { + return None; + }; + let Ok(modified) = metadata.modified() else { + let _ = std::fs::remove_file(&path); + return None; + }; + let Ok(elapsed) = modified.elapsed() else { + return Some(state); + }; + if elapsed <= max_age { + Some(state) + } else { + let _ = std::fs::remove_file(&path); + None + } +} + +pub fn write_reload_state( + request_id: &str, + hash: &str, + phase: ReloadPhase, + detail: Option<String>, +) { + ReloadState { + request_id: request_id.to_string(), + hash: hash.to_string(), + phase, + pid: std::process::id(), + timestamp: chrono::Utc::now().to_rfc3339(), + detail, + } + .write(); +} + +pub fn publish_reload_socket_ready() { + let Some(state) = ReloadState::load() else { + crate::logging::warn( + "Server reached socket-ready publish point, but no reload marker was present", + ); + return; + }; + + let current_pid = std::process::id(); + if state.phase == ReloadPhase::Starting && state.pid == current_pid { + write_reload_state( + &state.request_id, + &state.hash, + ReloadPhase::SocketReady, + state.detail.clone(), + ); + crate::logging::info(&format!( + "Published reload socket-ready state for request {}", + state.request_id + )); + } else if state.phase != ReloadPhase::Starting { + crate::logging::warn(&format!( + "Server reached socket-ready publish point, but reload marker phase was {:?} (pid={}, current_pid={})", + state.phase, state.pid, current_pid + )); + } else if state.pid != current_pid { + crate::logging::warn(&format!( + "Server reached socket-ready publish point, but reload marker pid {} did not match current pid {}; clearing stale marker", + state.pid, current_pid + )); + clear_reload_marker(); + } +} + +pub fn reload_process_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + + #[cfg(unix)] + { + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + let err = std::io::Error::last_os_error(); + matches!(err.raw_os_error(), Some(libc::EPERM)) + } + + #[cfg(not(unix))] + { + let _ = pid; + true + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReloadWaitStatus { + Ready, + Waiting { pid: Option<u32> }, + Failed(Option<String>), + Idle, +} + +pub async fn inspect_reload_wait_status( + socket_path: &std::path::Path, + max_age: Duration, + last_known_pid: Option<u32>, +) -> ReloadWaitStatus { + if let Some(state) = recent_reload_state(max_age) { + let status = match state.phase { + ReloadPhase::SocketReady => ReloadWaitStatus::Ready, + ReloadPhase::Failed => ReloadWaitStatus::Failed(state.detail), + ReloadPhase::Starting => { + if reload_process_alive(state.pid) { + ReloadWaitStatus::Waiting { + pid: Some(state.pid), + } + } else { + ReloadWaitStatus::Failed(Some(format!( + "reload process {} exited before becoming ready", + state.pid + ))) + } + } + }; + crate::logging::info(&format!( + "inspect_reload_wait_status: socket {} marker-driven status={:?} (last_known_pid={:?}, state={})", + socket_path.display(), + status, + last_known_pid, + reload_state_summary(max_age) + )); + return status; + } + + if is_server_ready(socket_path).await || has_live_listener(socket_path).await { + if last_known_pid.is_some() { + crate::logging::info(&format!( + "inspect_reload_wait_status: socket {} is ready/live without active marker (last_known_pid={:?}, state={})", + socket_path.display(), + last_known_pid, + reload_state_summary(max_age) + )); + } + return ReloadWaitStatus::Ready; + } + + if let Some(pid) = last_known_pid { + if reload_process_alive(pid) { + crate::logging::info(&format!( + "inspect_reload_wait_status: socket {} waiting on last known pid {} without marker", + socket_path.display(), + pid + )); + return ReloadWaitStatus::Waiting { pid: Some(pid) }; + } + crate::logging::warn(&format!( + "inspect_reload_wait_status: socket {} last known pid {} is no longer alive and no reload marker remains", + socket_path.display(), + pid + )); + } + + if last_known_pid.is_some() { + crate::logging::info(&format!( + "inspect_reload_wait_status: socket {} is idle after previous reload wait state", + socket_path.display() + )); + } + ReloadWaitStatus::Idle +} + +pub async fn await_reload_handoff( + socket_path: &std::path::Path, + max_age: Duration, +) -> ReloadWaitStatus { + let mut last_known_pid = None; + crate::logging::info(&format!( + "await_reload_handoff: begin socket={} max_age_ms={} state={}", + socket_path.display(), + max_age.as_millis(), + reload_state_summary(max_age) + )); + + loop { + match inspect_reload_wait_status(socket_path, max_age, last_known_pid).await { + ReloadWaitStatus::Waiting { pid } => { + last_known_pid = pid; + crate::logging::info(&format!( + "await_reload_handoff: waiting for reload event socket={} pid={:?}", + socket_path.display(), + pid + )); + wait_for_reload_handoff_event(pid, socket_path).await; + } + other => { + crate::logging::info(&format!( + "await_reload_handoff: completed socket={} result={:?} state={}", + socket_path.display(), + other, + reload_state_summary(max_age) + )); + return other; + } + } + } +} + +pub async fn wait_for_reload_handoff_event( + reloading_pid: Option<u32>, + socket_path: &std::path::Path, +) { + crate::logging::info(&format!( + "wait_for_reload_handoff_event: start socket={} pid={:?}", + socket_path.display(), + reloading_pid + )); + #[cfg(target_os = "linux")] + { + let marker_path = reload_marker_path(); + let socket_path = socket_path.to_path_buf(); + let _ = tokio::task::spawn_blocking(move || { + wait_for_reload_handoff_event_blocking(&marker_path, &socket_path, reloading_pid) + }) + .await; + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (reloading_pid, socket_path); + tokio::time::sleep(Duration::from_millis(100)).await; + } + crate::logging::info(&format!( + "wait_for_reload_handoff_event: wake socket={} pid={:?}", + socket_path.display(), + reloading_pid + )); +} + +#[cfg(target_os = "linux")] +fn wait_for_reload_handoff_event_blocking( + marker_path: &std::path::Path, + socket_path: &std::path::Path, + reloading_pid: Option<u32>, +) { + use std::collections::HashSet; + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let mut watch_paths: HashSet<std::path::PathBuf> = HashSet::new(); + if let Some(parent) = marker_path.parent() { + watch_paths.insert(parent.to_path_buf()); + } + if let Some(parent) = socket_path.parent() { + watch_paths.insert(parent.to_path_buf()); + } + if let Some(pid) = reloading_pid { + let proc_path = std::path::PathBuf::from(format!("/proc/{pid}")); + if proc_path.exists() { + watch_paths.insert(proc_path); + } + } + + if watch_paths.is_empty() { + crate::logging::warn("wait_for_reload_handoff_event_blocking: no watch paths available"); + return; + } + + crate::logging::info(&format!( + "wait_for_reload_handoff_event_blocking: marker={} socket={} pid={:?} watch_paths={:?}", + marker_path.display(), + socket_path.display(), + reloading_pid, + watch_paths + )); + + unsafe { + let fd = libc::inotify_init1(libc::IN_CLOEXEC); + if fd < 0 { + crate::logging::warn(&format!( + "wait_for_reload_handoff_event_blocking: inotify_init1 failed: {} ({})", + std::io::Error::last_os_error(), + crate::util::process_fd_diagnostic_snapshot() + )); + return; + } + + let mask = libc::IN_CREATE + | libc::IN_MOVED_TO + | libc::IN_ATTRIB + | libc::IN_MODIFY + | libc::IN_CLOSE_WRITE + | libc::IN_DELETE + | libc::IN_MOVE_SELF + | libc::IN_DELETE_SELF; + + let mut has_watch = false; + for path in watch_paths { + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + continue; + }; + if libc::inotify_add_watch(fd, path.as_ptr(), mask) >= 0 { + has_watch = true; + } + } + + if !has_watch { + crate::logging::warn( + "wait_for_reload_handoff_event_blocking: failed to register any inotify watches", + ); + let _ = libc::close(fd); + return; + } + + let mut poll_fd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + + loop { + let ready = libc::poll(&mut poll_fd, 1, RELOAD_HANDOFF_EVENT_POLL_MS); + if ready > 0 && (poll_fd.revents & libc::POLLIN) != 0 { + let mut buf = [0u8; 512]; + let _ = libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()); + crate::logging::info( + "wait_for_reload_handoff_event_blocking: observed filesystem/process event", + ); + break; + } + if ready == 0 { + crate::logging::info( + "wait_for_reload_handoff_event_blocking: timed poll elapsed; rechecking reload state", + ); + break; + } + if ready < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + crate::logging::warn(&format!( + "wait_for_reload_handoff_event_blocking: poll failed: {}", + err + )); + break; + } + } + + let _ = libc::close(fd); + } +} + +#[derive(Clone, Debug)] +pub struct ReloadSignal { + pub hash: String, + pub triggering_session: Option<String>, + pub prefer_selfdev_binary: bool, + pub request_id: String, +} + +#[derive(Clone, Debug)] +pub struct ReloadAck { + pub hash: String, + pub request_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReloadPhase { + Starting, + SocketReady, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ReloadState { + pub request_id: String, + pub hash: String, + pub phase: ReloadPhase, + pub pid: u32, + pub timestamp: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option<String>, +} + +impl ReloadState { + fn path() -> PathBuf { + reload_marker_path() + } + + pub(crate) fn write(&self) { + let path = Self::path(); + if let Some(parent) = path.parent() { + let _ = crate::storage::ensure_dir(parent); + } + let _ = crate::storage::write_json(&path, self); + } + + pub fn load() -> Option<Self> { + let path = Self::path(); + if !path.exists() { + return None; + } + crate::storage::read_json(&path).ok() + } +} + +pub fn reload_state_summary(max_age: Duration) -> String { + match recent_reload_state(max_age) { + Some(state) => format!( + "request={} hash={} phase={:?} pid={} detail={}", + state.request_id, + state.hash, + state.phase, + state.pid, + state.detail.unwrap_or_else(|| "<none>".to_string()) + ), + None => "no recent reload state".to_string(), + } +} + +type ReloadSignalChannel = ( + tokio::sync::watch::Sender<Option<ReloadSignal>>, + tokio::sync::watch::Receiver<Option<ReloadSignal>>, +); + +type ReloadAckChannel = ( + tokio::sync::watch::Sender<Option<ReloadAck>>, + tokio::sync::watch::Receiver<Option<ReloadAck>>, +); + +/// Global reload signal channel. The selfdev tool and debug commands fire this; +/// the server awaits it instead of polling the filesystem. +static RELOAD_SIGNAL: std::sync::OnceLock<ReloadSignalChannel> = std::sync::OnceLock::new(); + +static RELOAD_ACK: std::sync::OnceLock<ReloadAckChannel> = std::sync::OnceLock::new(); + +pub(super) fn reload_signal() -> &'static ReloadSignalChannel { + RELOAD_SIGNAL.get_or_init(|| tokio::sync::watch::channel(None)) +} + +#[cfg(test)] +pub(crate) fn subscribe_reload_signal_for_tests() +-> tokio::sync::watch::Receiver<Option<ReloadSignal>> { + reload_signal().1.clone() +} + +pub(super) fn reload_ack() -> &'static ReloadAckChannel { + RELOAD_ACK.get_or_init(|| tokio::sync::watch::channel(None)) +} + +/// Send a reload signal to the server (called by selfdev tool / debug commands). +pub fn send_reload_signal( + hash: String, + triggering_session: Option<String>, + prefer_selfdev_binary: bool, +) -> String { + let request_id = crate::id::new_id("reload"); + crate::logging::info(&format!( + "send_reload_signal: request={} hash={} triggering_session={:?} prefer_selfdev_binary={} current_pid={}", + request_id, + hash, + triggering_session, + prefer_selfdev_binary, + std::process::id() + )); + let (tx, _) = reload_signal(); + let _ = tx.send(Some(ReloadSignal { + hash, + triggering_session, + prefer_selfdev_binary, + request_id: request_id.clone(), + })); + request_id +} + +pub fn acknowledge_reload_signal(signal: &ReloadSignal) { + crate::logging::info(&format!( + "acknowledge_reload_signal: request={} hash={} triggering_session={:?} prefer_selfdev_binary={}", + signal.request_id, signal.hash, signal.triggering_session, signal.prefer_selfdev_binary + )); + let (tx, _) = reload_ack(); + let _ = tx.send(Some(ReloadAck { + hash: signal.hash.clone(), + request_id: signal.request_id.clone(), + })); +} + +pub async fn wait_for_reload_ack( + request_id: &str, + timeout: std::time::Duration, +) -> anyhow::Result<ReloadAck> { + let mut rx = reload_ack().1.clone(); + let started = std::time::Instant::now(); + crate::logging::info(&format!( + "wait_for_reload_ack: waiting request={} timeout_ms={}", + request_id, + timeout.as_millis() + )); + + if let Some(ack) = rx.borrow_and_update().clone() + && ack.request_id == request_id + { + crate::logging::info(&format!( + "wait_for_reload_ack: immediate ack request={} after {}ms", + request_id, + started.elapsed().as_millis() + )); + return Ok(ack); + } + + let request_id = request_id.to_string(); + tokio::time::timeout(timeout, async move { + loop { + rx.changed() + .await + .map_err(|_| anyhow::anyhow!("reload acknowledgement channel closed"))?; + if let Some(ack) = rx.borrow_and_update().clone() + && ack.request_id == request_id + { + crate::logging::info(&format!( + "wait_for_reload_ack: received ack request={} after {}ms", + request_id, + started.elapsed().as_millis() + )); + return Ok(ack); + } + } + }) + .await + .map_err(|_| { + anyhow::anyhow!( + "timed out waiting for reload acknowledgement after {}ms (state={})", + started.elapsed().as_millis(), + reload_state_summary(Duration::from_secs(60)) + ) + })? +} + +#[cfg(test)] +mod tests { + use super::*; + + struct EnvGuard { + key: &'static str, + old: Option<std::ffi::OsString>, + } + + impl EnvGuard { + fn set_runtime_dir(path: &std::path::Path) -> Self { + let key = "JCODE_RUNTIME_DIR"; + let old = std::env::var_os(key); + crate::env::set_var(key, path); + Self { key, old } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(old) = &self.old { + crate::env::set_var(self.key, old); + } else { + crate::env::remove_var(self.key); + } + } + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn inspect_reload_wait_status_returns_failed_with_marker_detail() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + write_reload_state( + "req-test", + "hash-test", + ReloadPhase::Failed, + Some("reload failed for test".to_string()), + ); + + let status = inspect_reload_wait_status( + &temp.path().join("jcode.sock"), + Duration::from_secs(5), + None, + ) + .await; + + assert_eq!( + status, + ReloadWaitStatus::Failed(Some("reload failed for test".to_string())) + ); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn inspect_reload_wait_status_returns_ready_for_socket_ready_marker() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + write_reload_state( + "req-ready", + "hash-ready", + ReloadPhase::SocketReady, + Some("ready for handoff".to_string()), + ); + + let status = inspect_reload_wait_status( + &temp.path().join("jcode.sock"), + Duration::from_secs(5), + None, + ) + .await; + + assert_eq!(status, ReloadWaitStatus::Ready); + } + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn wait_for_reload_ack_returns_matching_ack() { + let _lock = crate::storage::lock_test_env(); + let request_id = crate::id::new_id("reload-test"); + let ack = ReloadAck { + hash: "hash-test".to_string(), + request_id: request_id.clone(), + }; + let (tx, _) = reload_ack(); + let _ = tx.send(Some(ack.clone())); + + let received = wait_for_reload_ack(&request_id, Duration::from_millis(50)) + .await + .expect("ack should be received"); + + assert_eq!(received.request_id, ack.request_id); + assert_eq!(received.hash, ack.hash); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn wait_for_reload_ack_handles_repeated_unique_requests() { + let _lock = crate::storage::lock_test_env(); + let (tx, _) = reload_ack(); + + for _ in 0..5 { + let request_id = crate::id::new_id("reload-repeat"); + let ack = ReloadAck { + hash: format!("hash-{}", request_id), + request_id: request_id.clone(), + }; + let _ = tx.send(Some(ack.clone())); + + let received = wait_for_reload_ack(&request_id, Duration::from_millis(50)) + .await + .expect("ack should be received for repeated request"); + + assert_eq!(received.request_id, ack.request_id); + assert_eq!(received.hash, ack.hash); + } + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn inspect_reload_wait_status_handles_repeated_ready_markers() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + let socket_path = temp.path().join("jcode.sock"); + + for idx in 0..5 { + write_reload_state( + &format!("req-{idx}"), + &format!("hash-{idx}"), + ReloadPhase::SocketReady, + Some(format!("ready-{idx}")), + ); + + let status = + inspect_reload_wait_status(&socket_path, Duration::from_secs(5), None).await; + assert_eq!(status, ReloadWaitStatus::Ready); + } + } + + /// Spawn a short-lived child and reap it so we hold a pid that is reliably + /// dead at the moment of selection. Retries guard against the (extremely + /// rare) case where the kernel immediately recycles the pid for another + /// test thread's process. + #[cfg(unix)] + fn spawn_and_reap_dead_pid() -> u32 { + use std::process::Command; + for _ in 0..16 { + let mut child = Command::new("/bin/sh") + .arg("-c") + .arg("exit 0") + .spawn() + .expect("spawn short-lived child"); + let pid = child.id(); + let _ = child.wait(); + if !reload_process_alive(pid) { + return pid; + } + } + panic!("could not obtain a reliably-dead pid"); + } + + // Note: the marker-driven Ready/Idle/Failed/Waiting verdicts and the + // foreign-pid socket-ready clearing are already covered in + // `server::socket_tests`. The tests below intentionally cover only the + // gaps not exercised there: stale-marker cleanup, Failed-marker + // preservation, corrupt-marker tolerance, and the dead last-known-pid + // fallback. + + #[cfg(unix)] + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn inspect_reload_wait_status_idle_when_last_known_pid_is_dead_without_marker() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + clear_reload_marker(); + + let dead_pid = spawn_and_reap_dead_pid(); + let status = inspect_reload_wait_status( + &temp.path().join("missing.sock"), + Duration::from_secs(5), + Some(dead_pid), + ) + .await; + assert_eq!(status, ReloadWaitStatus::Idle); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn clear_reload_marker_if_stale_for_pid_keeps_own_starting_marker() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + let current = std::process::id(); + ReloadState { + request_id: "req-keep".to_string(), + hash: "hash-keep".to_string(), + phase: ReloadPhase::Starting, + pid: current, + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + + clear_reload_marker_if_stale_for_pid(current); + assert!( + reload_marker_exists(), + "an in-flight Starting marker owned by this pid must survive cleanup" + ); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn clear_reload_marker_if_stale_for_pid_clears_foreign_or_completed_markers() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + let current = std::process::id(); + + // Foreign pid still in Starting -> stale, must be cleared. + ReloadState { + request_id: "req-foreign".to_string(), + hash: "hash-foreign".to_string(), + phase: ReloadPhase::Starting, + pid: current.wrapping_add(1), + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + clear_reload_marker_if_stale_for_pid(current); + assert!( + !reload_marker_exists(), + "a foreign Starting marker must be cleared" + ); + + // Own pid but already completed (SocketReady) -> not an in-flight boot. + ReloadState { + request_id: "req-ready".to_string(), + hash: "hash-ready".to_string(), + phase: ReloadPhase::SocketReady, + pid: current, + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + clear_reload_marker_if_stale_for_pid(current); + assert!( + !reload_marker_exists(), + "a completed marker must be cleared on stale check" + ); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn publish_reload_socket_ready_leaves_failed_marker_untouched() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + write_reload_state( + "req-failed", + "hash-failed", + ReloadPhase::Failed, + Some("boom".to_string()), + ); + publish_reload_socket_ready(); + + let state = ReloadState::load().expect("marker should still exist"); + assert_eq!( + state.phase, + ReloadPhase::Failed, + "publish must not overwrite a Failed marker" + ); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn recent_reload_state_ignores_corrupt_marker() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + let path = reload_marker_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create runtime dir"); + } + std::fs::write(&path, b"{ this is not valid json").expect("write corrupt marker"); + + assert!( + ReloadState::load().is_none(), + "corrupt marker should not deserialize" + ); + assert!( + recent_reload_state(Duration::from_secs(5)).is_none(), + "corrupt marker should be treated as no recent state" + ); + assert!( + !reload_marker_active(Duration::from_secs(5)), + "corrupt marker must not be reported as active" + ); + } + + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn reload_marker_active_treats_failed_phase_as_inactive() { + let _lock = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_runtime_dir(temp.path()); + + write_reload_state("req", "hash", ReloadPhase::Failed, Some("x".to_string())); + assert!( + !reload_marker_active(Duration::from_secs(5)), + "a Failed reload must not look active" + ); + } + + #[cfg(unix)] + #[test] + fn reload_process_alive_handles_zero_and_dead_pids() { + assert!(!reload_process_alive(0), "pid 0 is never a live reload pid"); + let dead = spawn_and_reap_dead_pid(); + assert!( + !reload_process_alive(dead), + "a reaped child pid must be reported dead" + ); + assert!( + reload_process_alive(std::process::id()), + "the current process must be reported alive" + ); + } +} diff --git a/crates/jcode-app-core/src/server/reload_tests.rs b/crates/jcode-app-core/src/server/reload_tests.rs new file mode 100644 index 0000000..72d5f54 --- /dev/null +++ b/crates/jcode-app-core/src/server/reload_tests.rs @@ -0,0 +1,650 @@ +use super::{ + graceful_shutdown_sessions, graceful_shutdown_sessions_with_timeout, + persist_reload_recovery_intents, receive_reload_signal, +}; +use crate::server::{ReloadSignal, SwarmEvent, SwarmEventType, SwarmMember}; +use jcode_agent_runtime::InterruptSignal; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{RwLock, broadcast, mpsc, watch}; + +fn set_member_status(members: &mut HashMap<String, SwarmMember>, session_id: &str, status: &str) { + assert!( + members.contains_key(session_id), + "missing test member {session_id}" + ); + if let Some(member) = members.get_mut(session_id) { + member.status = status.to_string(); + } +} + +fn member(session_id: &str, status: &str) -> SwarmMember { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: status.to_string(), + detail: None, + task_label: None, + friendly_name: None, + 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(), + } +} + +#[tokio::test] +async fn receive_reload_signal_consumes_already_pending_value() { + let (tx, mut rx) = watch::channel(None::<ReloadSignal>); + tx.send(Some(ReloadSignal { + hash: "abc1234".to_string(), + triggering_session: Some("sess-1".to_string()), + prefer_selfdev_binary: true, + request_id: "reload-1".to_string(), + })) + .expect("send pending reload signal"); + + let signal = tokio::time::timeout( + std::time::Duration::from_millis(100), + receive_reload_signal(&mut rx, &mut None), + ) + .await + .expect("pending signal should be observed immediately") + .expect("channel should still be open"); + + assert_eq!(signal.hash, "abc1234"); + assert_eq!(signal.triggering_session.as_deref(), Some("sess-1")); + assert!(signal.prefer_selfdev_binary); + assert_eq!(signal.request_id, "reload-1"); +} + +#[tokio::test] +async fn receive_reload_signal_waits_for_future_value_when_initially_empty() { + let (tx, mut rx) = watch::channel(None::<ReloadSignal>); + + let waiter = tokio::spawn(async move { receive_reload_signal(&mut rx, &mut None).await }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + + tx.send(Some(ReloadSignal { + hash: "def5678".to_string(), + triggering_session: Some("sess-2".to_string()), + prefer_selfdev_binary: false, + request_id: "reload-2".to_string(), + })) + .expect("send future reload signal"); + + let signal = tokio::time::timeout(std::time::Duration::from_millis(100), waiter) + .await + .expect("future signal should wake waiter") + .expect("waiter task should succeed") + .expect("channel should still be open"); + + assert_eq!(signal.hash, "def5678"); + assert_eq!(signal.triggering_session.as_deref(), Some("sess-2")); + assert!(!signal.prefer_selfdev_binary); + assert_eq!(signal.request_id, "reload-2"); +} + +#[test] +fn persist_reload_recovery_intents_records_running_peer_recovery() -> anyhow::Result<()> { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new()?; + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("initiator".to_string(), member("initiator", "running")), + ("peer".to_string(), member("peer", "running")), + ("idle".to_string(), member("idle", "ready")), + ]))); + + runtime.block_on(persist_reload_recovery_intents( + "reload-store-test", + &swarm_members, + Some("initiator"), + )); + + let peer_directive = crate::server::reload_recovery::pending_directive_for_session("peer") + .expect("claim peer recovery") + .expect("peer recovery intent should exist"); + assert!( + peer_directive + .continuation_message + .contains("interrupted by a server reload") + ); + assert!( + crate::server::reload_recovery::pending_directive_for_session("idle") + .expect("claim idle recovery") + .is_none(), + "idle sessions should not get reload recovery intents" + ); + assert!( + crate::server::reload_recovery::pending_directive_for_session("initiator") + .expect("claim initiator recovery") + .is_none(), + "initiator without selfdev reload context should not get a generic interrupted-peer intent" + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + Ok(()) +} + +#[tokio::test] +async fn graceful_shutdown_sessions_signals_all_running_sessions_including_initiator() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("initiator".to_string(), member("initiator", "running")), + ("peer".to_string(), member("peer", "running")), + ]))); + let initiator_signal = InterruptSignal::new(); + let peer_signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([ + ("initiator".to_string(), initiator_signal.clone()), + ("peer".to_string(), peer_signal.clone()), + ]))); + let (swarm_event_tx, _) = broadcast::channel(8); + let swarm_members_for_task = swarm_members.clone(); + let swarm_event_tx_for_task = swarm_event_tx.clone(); + + let checkpoint_task = tokio::spawn(async move { + tokio::task::yield_now().await; + { + let mut members = swarm_members_for_task.write().await; + set_member_status(&mut members, "initiator", "ready"); + set_member_status(&mut members, "peer", "ready"); + } + let _ = swarm_event_tx_for_task.send(SwarmEvent { + id: 1, + session_id: "initiator".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "ready".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + let _ = swarm_event_tx_for_task.send(SwarmEvent { + id: 2, + session_id: "peer".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "ready".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + }); + + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + None, + ) + .await; + checkpoint_task.await.expect("checkpoint task"); + + assert!( + initiator_signal.is_set(), + "initiating selfdev session should also be interrupted so reload tool cannot hang" + ); + assert!( + peer_signal.is_set(), + "other running sessions should be interrupted too" + ); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_does_not_wait_for_triggering_session_checkpoint() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("initiator".to_string(), member("initiator", "running")), + ("peer".to_string(), member("peer", "running")), + ]))); + let initiator_signal = InterruptSignal::new(); + let peer_signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([ + ("initiator".to_string(), initiator_signal.clone()), + ("peer".to_string(), peer_signal.clone()), + ]))); + let (swarm_event_tx, _) = broadcast::channel(8); + let swarm_members_for_task = swarm_members.clone(); + let swarm_event_tx_for_task = swarm_event_tx.clone(); + + let checkpoint_task = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + { + let mut members = swarm_members_for_task.write().await; + set_member_status(&mut members, "peer", "ready"); + } + let _ = swarm_event_tx_for_task.send(SwarmEvent { + id: 1, + session_id: "peer".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "ready".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + }); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + Some("initiator"), + ), + ) + .await + .expect("reload shutdown should not wait for triggering session"); + checkpoint_task.await.expect("checkpoint task"); + + assert!( + initiator_signal.is_set(), + "triggering session should still receive graceful shutdown signal" + ); + assert!( + peer_signal.is_set(), + "peer session should still receive graceful shutdown signal" + ); + assert_eq!( + swarm_members + .read() + .await + .get("initiator") + .expect("initiator") + .status, + "running", + "initiator may remain running without blocking reload" + ); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_skips_idle_sessions() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "idle".to_string(), + member("idle", "ready"), + )]))); + let idle_signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + "idle".to_string(), + idle_signal.clone(), + )]))); + let (swarm_event_tx, _) = broadcast::channel(8); + + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + None, + ) + .await; + + assert!( + !idle_signal.is_set(), + "idle sessions should not be interrupted during reload" + ); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_does_not_wait_on_running_sessions_without_signal() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "orphan_running".to_string(), + member("orphan_running", "running"), + )]))); + let shutdown_signals = Arc::new(RwLock::new(HashMap::new())); + let (swarm_event_tx, _) = broadcast::channel(8); + + let started = Instant::now(); + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + None, + ) + .await; + + assert!( + started.elapsed() < std::time::Duration::from_millis(100), + "running sessions without shutdown signals should not consume the reload grace period" + ); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_waits_until_target_status_change_arrives() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "target".to_string(), + member("target", "running"), + )]))); + let signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + "target".to_string(), + signal.clone(), + )]))); + let (swarm_event_tx, _) = broadcast::channel(8); + + let mut waiter = tokio::spawn({ + let sessions = sessions.clone(); + let swarm_members = swarm_members.clone(); + let shutdown_signals = shutdown_signals.clone(); + let swarm_event_tx = swarm_event_tx.clone(); + async move { + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + None, + ) + .await; + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!( + signal.is_set(), + "running target should be interrupted promptly" + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut waiter) + .await + .is_err(), + "reload shutdown should stay pending until target leaves running" + ); + + { + let mut members = swarm_members.write().await; + set_member_status(&mut members, "target", "ready"); + } + let _ = swarm_event_tx.send(SwarmEvent { + id: 1, + session_id: "target".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "ready".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("waiter should complete after target checkpoint") + .expect("waiter task should succeed"); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_ignores_unrelated_events_until_target_leaves() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("target".to_string(), member("target", "running")), + ("other".to_string(), member("other", "running")), + ]))); + let signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([("target".to_string(), signal)]))); + let (swarm_event_tx, _) = broadcast::channel(8); + + let mut waiter = tokio::spawn({ + let sessions = sessions.clone(); + let swarm_members = swarm_members.clone(); + let shutdown_signals = shutdown_signals.clone(); + let swarm_event_tx = swarm_event_tx.clone(); + async move { + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + None, + ) + .await; + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + { + let mut members = swarm_members.write().await; + set_member_status(&mut members, "other", "ready"); + } + let _ = swarm_event_tx.send(SwarmEvent { + id: 1, + session_id: "other".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "ready".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut waiter) + .await + .is_err(), + "unrelated status changes should not unblock reload shutdown" + ); + + { + let mut members = swarm_members.write().await; + set_member_status(&mut members, "target", "stopped"); + } + let _ = swarm_event_tx.send(SwarmEvent { + id: 2, + session_id: "target".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "stopped".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("waiter should complete after target transition") + .expect("waiter task should succeed"); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_treats_member_left_as_unblocked() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "target".to_string(), + member("target", "running"), + )]))); + let signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([("target".to_string(), signal)]))); + let (swarm_event_tx, _) = broadcast::channel(8); + + let waiter = tokio::spawn({ + let sessions = sessions.clone(); + let swarm_members = swarm_members.clone(); + let shutdown_signals = shutdown_signals.clone(); + let swarm_event_tx = swarm_event_tx.clone(); + async move { + graceful_shutdown_sessions( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + None, + ) + .await; + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + { + let mut members = swarm_members.write().await; + members.remove("target"); + } + let _ = swarm_event_tx.send(SwarmEvent { + id: 1, + session_id: "target".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::MemberChange { + action: "left".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("waiter should complete after member leaves") + .expect("waiter task should succeed"); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_times_out_and_proceeds() { + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([( + "target".to_string(), + member("target", "running"), + )]))); + let signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([( + "target".to_string(), + signal.clone(), + )]))); + let (swarm_event_tx, _) = broadcast::channel(8); + + let started = Instant::now(); + graceful_shutdown_sessions_with_timeout( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + std::time::Duration::from_millis(50), + None, + ) + .await; + + assert!( + signal.is_set(), + "running target should still be signaled promptly" + ); + assert!( + started.elapsed() >= std::time::Duration::from_millis(50) + && started.elapsed() < std::time::Duration::from_millis(250), + "graceful shutdown should honor the timeout instead of waiting indefinitely" + ); +} + +#[tokio::test] +async fn graceful_shutdown_sessions_times_out_on_partial_checkpoint() { + // One watched session checkpoints, the other never does. The wait must + // still terminate at the timeout instead of blocking on the laggard. + let sessions = Arc::new(RwLock::new(HashMap::new())); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("fast".to_string(), member("fast", "running")), + ("slow".to_string(), member("slow", "running")), + ]))); + let fast_signal = InterruptSignal::new(); + let slow_signal = InterruptSignal::new(); + let shutdown_signals = Arc::new(RwLock::new(HashMap::from([ + ("fast".to_string(), fast_signal.clone()), + ("slow".to_string(), slow_signal.clone()), + ]))); + let (swarm_event_tx, _) = broadcast::channel(8); + + let swarm_members_for_task = swarm_members.clone(); + let swarm_event_tx_for_task = swarm_event_tx.clone(); + let checkpoint_task = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + { + let mut members = swarm_members_for_task.write().await; + set_member_status(&mut members, "fast", "ready"); + } + let _ = swarm_event_tx_for_task.send(SwarmEvent { + id: 1, + session_id: "fast".to_string(), + session_name: None, + swarm_id: None, + event: SwarmEventType::StatusChange { + old_status: "running".to_string(), + new_status: "ready".to_string(), + }, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }); + // "slow" intentionally never leaves running. + }); + + let started = Instant::now(); + graceful_shutdown_sessions_with_timeout( + "test-reload", + &sessions, + &swarm_members, + &shutdown_signals, + &swarm_event_tx, + std::time::Duration::from_millis(120), + None, + ) + .await; + checkpoint_task.await.expect("checkpoint task"); + + assert!(fast_signal.is_set() && slow_signal.is_set()); + assert!( + started.elapsed() >= std::time::Duration::from_millis(120) + && started.elapsed() < std::time::Duration::from_millis(600), + "partial checkpoint must still honor the timeout, elapsed={:?}", + started.elapsed() + ); + assert_eq!( + swarm_members.read().await.get("slow").expect("slow").status, + "running", + "the laggard session may remain running without blocking reload past the deadline" + ); +} diff --git a/crates/jcode-app-core/src/server/reload_trace.rs b/crates/jcode-app-core/src/server/reload_trace.rs new file mode 100644 index 0000000..cb7ac76 --- /dev/null +++ b/crates/jcode-app-core/src/server/reload_trace.rs @@ -0,0 +1,219 @@ +use anyhow::Result; +use serde_json::{Map, Value}; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Hard cap on a single reload-trace file. Reload tracing is meant to capture a +/// handful of lifecycle events per reload; a healthy trace is a few KB. A stuck +/// reload loop (e.g. a hung e2e harness re-receiving the same signal) can +/// otherwise append `signal_received` lines without bound and, because the test +/// home lives on a RAM-backed tmpfs, balloon a single `.jsonl` to multiple GiB, +/// starving the machine of memory and throttling concurrent builds. Capping the +/// file keeps a runaway emitter from taking down the host; once the cap is hit +/// we stop appending and log once per path. +const MAX_TRACE_FILE_BYTES: u64 = 16 * 1024 * 1024; + +fn sanitize_file_component(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } +} + +fn trace_dir() -> Result<std::path::PathBuf> { + Ok(crate::storage::jcode_dir()?.join("reload-traces")) +} + +pub(super) fn trace_path(reload_id: &str) -> Result<std::path::PathBuf> { + Ok(trace_dir()?.join(format!("{}.jsonl", sanitize_file_component(reload_id)))) +} + +pub(super) fn record(reload_id: &str, phase: &str, mut fields: Map<String, Value>) { + let path = match trace_path(reload_id) { + Ok(path) => path, + Err(error) => { + crate::logging::warn(&format!( + "reload trace: failed to resolve trace path reload_id={} phase={}: {}", + reload_id, phase, error + )); + return; + } + }; + + if let Some(parent) = path.parent() + && let Err(error) = std::fs::create_dir_all(parent) + { + crate::logging::warn(&format!( + "reload trace: failed to create trace dir reload_id={} phase={} path={}: {}", + reload_id, + phase, + parent.display(), + error + )); + return; + } + + // Guard against a runaway emitter (e.g. a stuck reload loop) growing a + // single trace file without bound. Tracing on a RAM-backed test tmpfs can + // otherwise consume multiple GiB of memory and starve the host. Once a file + // exceeds the cap, stop appending and warn a single time for that path. + if let Ok(metadata) = std::fs::metadata(&path) + && metadata.len() >= MAX_TRACE_FILE_BYTES + { + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + crate::logging::warn(&format!( + "reload trace: dropping events for reload_id={} phase={} path={}; file reached {} byte cap (possible stuck reload loop)", + reload_id, + phase, + path.display(), + MAX_TRACE_FILE_BYTES + )); + } + return; + } + + fields.insert("schema_version".to_string(), Value::from(1)); + fields.insert( + "timestamp".to_string(), + Value::from(chrono::Utc::now().to_rfc3339()), + ); + fields.insert( + "timestamp_ms".to_string(), + Value::from(chrono::Utc::now().timestamp_millis()), + ); + fields.insert("pid".to_string(), Value::from(std::process::id())); + fields.insert("reload_id".to_string(), Value::from(reload_id.to_string())); + fields.insert("phase".to_string(), Value::from(phase.to_string())); + + let line = match serde_json::to_string(&Value::Object(fields)) { + Ok(line) => line, + Err(error) => { + crate::logging::warn(&format!( + "reload trace: failed to encode event reload_id={} phase={}: {}", + reload_id, phase, error + )); + return; + } + }; + + match std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + Ok(mut file) => { + if let Err(error) = writeln!(file, "{}", line) { + crate::logging::warn(&format!( + "reload trace: failed to append event reload_id={} phase={} path={}: {}", + reload_id, + phase, + path.display(), + error + )); + } + } + Err(error) => crate::logging::warn(&format!( + "reload trace: failed to open trace reload_id={} phase={} path={}: {}", + reload_id, + phase, + path.display(), + error + )), + } +} + +pub(super) fn record_value(reload_id: &str, phase: &str, fields: Value) { + let map = match fields { + Value::Object(map) => map, + other => { + let mut map = Map::new(); + map.insert("detail".to_string(), other); + map + } + }; + record(reload_id, phase, map); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn record_value_appends_jsonl_trace_event() -> anyhow::Result<()> { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new()?; + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + record_value( + "reload/id:with/slashes", + "unit_phase", + json!({"session_id": "session-1", "ok": true}), + ); + + let path = trace_path("reload/id:with/slashes")?; + let content = std::fs::read_to_string(path)?; + let line = content + .lines() + .next() + .expect("trace should contain one line"); + let event: serde_json::Value = serde_json::from_str(line)?; + assert_eq!(event["reload_id"], "reload/id:with/slashes"); + assert_eq!(event["phase"], "unit_phase"); + assert_eq!(event["session_id"], "session-1"); + assert_eq!(event["ok"], true); + assert_eq!(event["schema_version"], 1); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + Ok(()) + } + + #[test] + fn record_value_stops_appending_past_size_cap() -> anyhow::Result<()> { + let _guard = crate::storage::lock_test_env(); + let temp_home = tempfile::TempDir::new()?; + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp_home.path()); + + let reload_id = "reload-cap-test"; + let path = trace_path(reload_id)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + // Pre-fill the trace file beyond the cap so the next append is dropped. + std::fs::write(&path, vec![b'x'; MAX_TRACE_FILE_BYTES as usize + 1])?; + let size_before = std::fs::metadata(&path)?.len(); + + record_value(reload_id, "should_be_dropped", json!({"n": 1})); + + let size_after = std::fs::metadata(&path)?.len(); + assert_eq!( + size_before, size_after, + "appending past the cap must not grow the file" + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + Ok(()) + } +} diff --git a/crates/jcode-app-core/src/server/runtime.rs b/crates/jcode-app-core/src/server/runtime.rs new file mode 100644 index 0000000..c8f7d93 --- /dev/null +++ b/crates/jcode-app-core/src/server/runtime.rs @@ -0,0 +1,484 @@ +use super::client_lifecycle::handle_client; +use super::debug::{ClientConnectionInfo, ClientDebugState, handle_debug_client}; +use super::debug_jobs::DebugJob; +use super::util::get_shared_mcp_pool; +use super::{ + AwaitMembersRuntime, FileTouchService, ServerIdentity, SessionInterruptQueues, SharedContext, + SwarmEvent, SwarmMutationRuntime, SwarmState, +}; +use crate::agent::Agent; +use crate::ambient_runner::AmbientRunnerHandle; +use crate::gateway::GatewayClient; +use crate::protocol::ServerEvent; +use crate::provider::Provider; +use crate::transport::{Listener, Stream}; +use jcode_agent_runtime::InterruptSignal; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::time::Instant; +use tokio::sync::{Mutex, OnceCell, RwLock, broadcast, mpsc}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +/// Owns every connection task spawned by a server runtime. +/// +/// Dropping a `JoinHandle` detaches its task, so accepting a connection must not +/// discard the handle. This scope gives the accept loops and their children one +/// cancellation boundary and lets server shutdown wait until all children have +/// observed cancellation and released their resources. +#[derive(Default)] +struct RuntimeTaskScope { + cancellation: CancellationToken, + tasks: Mutex<JoinSet<()>>, +} + +impl RuntimeTaskScope { + async fn spawn<F, Fut>(&self, task: F) -> bool + where + F: FnOnce(CancellationToken) -> Fut, + Fut: Future<Output = ()> + Send + 'static, + { + if self.cancellation.is_cancelled() { + return false; + } + + let mut tasks = self.tasks.lock().await; + while let Some(result) = tasks.try_join_next() { + log_task_completion(result); + } + if self.cancellation.is_cancelled() { + return false; + } + + tasks.spawn(task(self.cancellation.child_token())); + true + } + + async fn shutdown(&self) { + self.cancellation.cancel(); + // Drain the set before awaiting children. An accept task may already be + // waiting to register a just-accepted connection; leaving the mutex + // held while joining would deadlock that task. Once cancelled, any + // late registration observes cancellation and is rejected. + let mut tasks = { + let mut owned_tasks = self.tasks.lock().await; + std::mem::take(&mut *owned_tasks) + }; + while let Some(result) = tasks.join_next().await { + log_task_completion(result); + } + } + + #[cfg(test)] + async fn task_count(&self) -> usize { + self.tasks.lock().await.len() + } +} + +fn log_task_completion(result: Result<(), tokio::task::JoinError>) { + if let Err(error) = result + && !error.is_cancelled() + { + crate::logging::error(&format!("Server connection task failed: {error}")); + } +} + +#[derive(Clone)] +pub(super) struct ServerRuntime { + sessions: Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>, + event_tx: broadcast::Sender<ServerEvent>, + provider: Arc<dyn Provider>, + is_processing: Arc<RwLock<bool>>, + session_id: Arc<RwLock<String>>, + client_count: Arc<RwLock<usize>>, + client_connections: Arc<RwLock<HashMap<String, ClientConnectionInfo>>>, + swarm_state: SwarmState, + shared_context: Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>, + file_touch: FileTouchService, + channel_subscriptions: ChannelSubscriptions, + channel_subscriptions_by_session: ChannelSubscriptions, + client_debug_state: Arc<RwLock<ClientDebugState>>, + client_debug_response_tx: broadcast::Sender<(u64, String)>, + debug_jobs: Arc<RwLock<HashMap<String, DebugJob>>>, + event_history: Arc<RwLock<VecDeque<SwarmEvent>>>, + event_counter: Arc<AtomicU64>, + swarm_event_tx: broadcast::Sender<SwarmEvent>, + server_name: String, + server_icon: String, + server_identity: ServerIdentity, + ambient_runner: Option<AmbientRunnerHandle>, + mcp_pool: Arc<OnceCell<Arc<crate::mcp::SharedMcpPool>>>, + shutdown_signals: Arc<RwLock<HashMap<String, InterruptSignal>>>, + soft_interrupt_queues: SessionInterruptQueues, + await_members_runtime: AwaitMembersRuntime, + swarm_mutation_runtime: SwarmMutationRuntime, + tasks: Arc<RuntimeTaskScope>, +} + +impl ServerRuntime { + pub(super) fn from_server(server: &super::Server) -> Self { + Self { + sessions: Arc::clone(&server.sessions), + event_tx: server.event_tx.clone(), + provider: Arc::clone(&server.provider), + is_processing: Arc::clone(&server.is_processing), + session_id: Arc::clone(&server.session_id), + client_count: Arc::clone(&server.client_count), + client_connections: Arc::clone(&server.client_connections), + swarm_state: server.swarm_state.clone(), + shared_context: Arc::clone(&server.shared_context), + file_touch: server.file_touch.clone(), + channel_subscriptions: Arc::clone(&server.channel_subscriptions), + channel_subscriptions_by_session: Arc::clone(&server.channel_subscriptions_by_session), + client_debug_state: Arc::clone(&server.client_debug_state), + client_debug_response_tx: server.client_debug_response_tx.clone(), + debug_jobs: Arc::clone(&server.debug_jobs), + event_history: Arc::clone(&server.event_history), + event_counter: Arc::clone(&server.event_counter), + swarm_event_tx: server.swarm_event_tx.clone(), + server_name: server.identity.name.clone(), + server_icon: server.identity.icon.clone(), + server_identity: server.identity.clone(), + ambient_runner: server.ambient_runner.clone(), + mcp_pool: Arc::clone(&server.mcp_pool), + shutdown_signals: Arc::clone(&server.shutdown_signals), + soft_interrupt_queues: Arc::clone(&server.soft_interrupt_queues), + await_members_runtime: server.await_members_runtime.clone(), + swarm_mutation_runtime: server.swarm_mutation_runtime.clone(), + tasks: Arc::new(RuntimeTaskScope::default()), + } + } + + pub(super) fn spawn_main_accept_loop(&self, listener: Listener) -> tokio::task::JoinHandle<()> { + let runtime = self.clone(); + let cancellation = self.tasks.cancellation.child_token(); + tokio::spawn(async move { + #[cfg(windows)] + let mut listener = listener; + + loop { + let accepted = tokio::select! { + _ = cancellation.cancelled() => break, + accepted = listener.accept() => accepted, + }; + match accepted { + Ok((stream, _)) => { + runtime.increment_client_count().await; + if !runtime + .spawn_client_task(stream, "Client error", true) + .await + { + runtime.decrement_client_count().await; + break; + } + } + Err(e) => { + crate::logging::error(&format!("Main accept error: {}", e)); + } + } + } + }) + } + + pub(super) fn spawn_debug_accept_loop( + &self, + listener: Listener, + server_start_time: Instant, + ) -> tokio::task::JoinHandle<()> { + let runtime = self.clone(); + let cancellation = self.tasks.cancellation.child_token(); + tokio::spawn(async move { + #[cfg(windows)] + let mut listener = listener; + + loop { + let accepted = tokio::select! { + _ = cancellation.cancelled() => break, + accepted = listener.accept() => accepted, + }; + match accepted { + Ok((stream, _)) => { + // Debug clients do not participate in idle-timeout accounting. + if !runtime + .spawn_debug_client_task(stream, server_start_time) + .await + { + break; + } + } + Err(e) => { + crate::logging::error(&format!("Debug accept error: {}", e)); + } + } + } + }) + } + + pub(super) async fn spawn_gateway_accept_loop( + &self, + mut client_rx: mpsc::UnboundedReceiver<GatewayClient>, + ) -> bool { + let runtime = self.clone(); + self.tasks + .spawn(move |cancellation| async move { + loop { + let gw_client = tokio::select! { + _ = cancellation.cancelled() => break, + client = client_rx.recv() => match client { + Some(client) => client, + None => break, + }, + }; + runtime.increment_client_count().await; + crate::logging::info(&format!( + "Gateway client connected: {} ({})", + gw_client.device_name, gw_client.device_id + )); + // Preserve prior behavior: gateway sessions do not nudge the + // ambient runner on disconnect. + if !runtime.spawn_gateway_client_task(gw_client).await { + runtime.decrement_client_count().await; + break; + } + } + }) + .await + } + + pub(super) async fn spawn_background_task<Fut>(&self, task: Fut) -> bool + where + Fut: Future<Output = ()> + Send + 'static, + { + self.tasks + .spawn(move |cancellation| async move { + tokio::select! { + _ = cancellation.cancelled() => {} + _ = task => {} + } + }) + .await + } + + async fn spawn_client_task( + &self, + stream: Stream, + error_prefix: &'static str, + nudge_ambient: bool, + ) -> bool { + let runtime = self.clone(); + self.tasks + .spawn(move |cancellation| async move { + runtime + .run_client_stream(stream, error_prefix, nudge_ambient, cancellation) + .await; + }) + .await + } + + async fn spawn_gateway_client_task(&self, gw_client: GatewayClient) -> bool { + let runtime = self.clone(); + self.tasks + .spawn(move |cancellation| async move { + runtime + .run_client_stream( + gw_client.stream, + "Gateway client error", + false, + cancellation, + ) + .await; + }) + .await + } + + async fn spawn_debug_client_task(&self, stream: Stream, server_start_time: Instant) -> bool { + let runtime = self.clone(); + self.tasks + .spawn(move |cancellation| async move { + runtime + .run_debug_stream(stream, server_start_time, cancellation) + .await; + }) + .await + } + + pub(super) async fn shutdown(&self) { + self.tasks.shutdown().await; + } + + async fn increment_client_count(&self) { + *self.client_count.write().await += 1; + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "client_connected", + "client_count_incremented", + ), + ); + } + + async fn decrement_client_count(&self) { + *self.client_count.write().await -= 1; + crate::runtime_memory_log::emit_event( + crate::runtime_memory_log::RuntimeMemoryLogEvent::new( + "client_disconnected", + "client_count_decremented", + ), + ); + } + + async fn run_client_stream( + self, + stream: Stream, + error_prefix: &'static str, + nudge_ambient: bool, + cancellation: CancellationToken, + ) { + let result = { + let client = async { + let mcp_pool = get_shared_mcp_pool(&self.mcp_pool).await; + handle_client( + stream, + Arc::clone(&self.sessions), + self.event_tx.clone(), + Arc::clone(&self.provider), + Arc::clone(&self.is_processing), + Arc::clone(&self.session_id), + Arc::clone(&self.client_count), + Arc::clone(&self.client_connections), + Arc::clone(&self.swarm_state.members), + Arc::clone(&self.swarm_state.swarms_by_id), + Arc::clone(&self.shared_context), + Arc::clone(&self.swarm_state.plans), + Arc::clone(&self.swarm_state.coordinators), + self.file_touch.clone(), + Arc::clone(&self.channel_subscriptions), + Arc::clone(&self.channel_subscriptions_by_session), + Arc::clone(&self.client_debug_state), + self.client_debug_response_tx.clone(), + Arc::clone(&self.event_history), + Arc::clone(&self.event_counter), + self.swarm_event_tx.clone(), + self.server_name.clone(), + self.server_icon.clone(), + mcp_pool, + Arc::clone(&self.shutdown_signals), + Arc::clone(&self.soft_interrupt_queues), + self.await_members_runtime.clone(), + self.swarm_mutation_runtime.clone(), + ) + .await + }; + tokio::pin!(client); + tokio::select! { + result = &mut client => Some(result), + _ = cancellation.cancelled() => None, + } + }; + + self.decrement_client_count().await; + + if nudge_ambient && let Some(ref runner) = self.ambient_runner { + runner.nudge(); + } + + if let Some(Err(e)) = result { + crate::logging::error(&format!("{}: {}", error_prefix, e)); + } + } + + async fn run_debug_stream( + self, + stream: Stream, + server_start_time: Instant, + cancellation: CancellationToken, + ) { + let client = async { + let mcp_pool = Some(get_shared_mcp_pool(&self.mcp_pool).await); + handle_debug_client( + stream, + Arc::clone(&self.sessions), + Arc::clone(&self.is_processing), + Arc::clone(&self.session_id), + Arc::clone(&self.provider), + Arc::clone(&self.client_connections), + Arc::clone(&self.swarm_state.members), + Arc::clone(&self.swarm_state.swarms_by_id), + Arc::clone(&self.shared_context), + Arc::clone(&self.swarm_state.plans), + Arc::clone(&self.swarm_state.coordinators), + self.file_touch.clone(), + Arc::clone(&self.channel_subscriptions), + Arc::clone(&self.channel_subscriptions_by_session), + Arc::clone(&self.client_debug_state), + self.client_debug_response_tx.clone(), + Arc::clone(&self.debug_jobs), + Arc::clone(&self.event_history), + Arc::clone(&self.event_counter), + self.swarm_event_tx.clone(), + self.server_identity.clone(), + server_start_time, + self.ambient_runner.clone(), + mcp_pool, + Arc::clone(&self.shutdown_signals), + Arc::clone(&self.soft_interrupt_queues), + ) + .await + }; + tokio::pin!(client); + if let Some(Err(e)) = tokio::select! { + result = &mut client => Some(result), + _ = cancellation.cancelled() => None, + } { + crate::logging::error(&format!("Debug client error: {}", e)); + } + } +} + +#[cfg(test)] +mod tests { + use super::RuntimeTaskScope; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + struct DropFlag(Arc<AtomicBool>); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn runtime_task_scope_cancels_and_joins_owned_tasks() { + let scope = RuntimeTaskScope::default(); + let dropped = Arc::new(AtomicBool::new(false)); + let task_dropped = Arc::clone(&dropped); + + assert!( + scope + .spawn(move |cancellation| async move { + let _drop_flag = DropFlag(task_dropped); + cancellation.cancelled().await; + }) + .await + ); + assert_eq!(scope.task_count().await, 1); + + tokio::time::timeout(Duration::from_secs(1), scope.shutdown()) + .await + .expect("runtime task scope should join cancelled tasks"); + + assert!(dropped.load(Ordering::SeqCst)); + assert_eq!(scope.task_count().await, 0); + assert!( + !scope + .spawn(|_| async { panic!("task spawned after shutdown") }) + .await + ); + } +} diff --git a/crates/jcode-app-core/src/server/socket.rs b/crates/jcode-app-core/src/server/socket.rs new file mode 100644 index 0000000..41ed0ab --- /dev/null +++ b/crates/jcode-app-core/src/server/socket.rs @@ -0,0 +1,448 @@ +use super::Client; +use crate::transport::Stream; +use anyhow::Result; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +pub fn socket_path() -> PathBuf { + if let Ok(custom) = std::env::var("JCODE_SOCKET") { + return PathBuf::from(custom); + } + crate::storage::runtime_dir().join("jcode.sock") +} + +/// Debug socket path for testing/introspection +/// Derived from main socket path +pub fn debug_socket_path() -> PathBuf { + let main_path = socket_path(); + let filename = main_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("jcode.sock"); + let debug_filename = filename.replace(".sock", "-debug.sock"); + main_path.with_file_name(debug_filename) +} + +pub(super) fn sibling_socket_path(path: &std::path::Path) -> Option<PathBuf> { + let filename = path.file_name()?.to_str()?; + + if let Some(base) = filename.strip_suffix("-debug.sock") { + return Some(path.with_file_name(format!("{}.sock", base))); + } + + if let Some(base) = filename.strip_suffix(".sock") { + return Some(path.with_file_name(format!("{}-debug.sock", base))); + } + + None +} + +/// Remove a socket file and its sibling (main/debug) if present. +pub fn cleanup_socket_pair(path: &std::path::Path) { + let _ = std::fs::remove_file(path); + if let Some(sibling) = sibling_socket_path(path) { + let _ = std::fs::remove_file(sibling); + } +} + +/// Connect to a socket path. +/// +/// Do not unlink the path on connection-refused here. A client-side cleanup can +/// strand a live daemon behind an unlinked Unix socket pathname, leaving the +/// process running with the daemon lock held while new clients can no longer +/// discover or connect to it. +pub async fn connect_socket(path: &std::path::Path) -> Result<Stream> { + match Stream::connect(path).await { + Ok(stream) => Ok(stream), + Err(err) if err.kind() == std::io::ErrorKind::ConnectionRefused && path.exists() => { + anyhow::bail!( + "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!( + "{} ({})", + err, + crate::util::process_fd_diagnostic_snapshot() + )), + Err(err) => Err(err.into()), + } +} + +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() +} + +/// Reap a provably-stale socket left behind by a dead daemon. +/// +/// Background: after an upgrade or crash, the runtime socket file can survive +/// even though the daemon that owned it is gone. A client that finds the socket +/// path present but cannot connect/handshake gets wedged into a connect-retry +/// loop and never recovers on its own (see issues #277 and #291). +/// +/// Safety: a live daemon holds an exclusive `flock` on `jcode-daemon.lock` for +/// its entire lifetime. So if (a) the socket has no live listener AND (b) we can +/// acquire that exclusive lock, then no daemon is running and the socket is +/// provably stale. Only then do we unlink the socket pair (main + debug) and the +/// lock file. This can never strand a live daemon, because a live daemon would +/// either still be answering on the socket or still be holding the lock. +/// +/// Returns true if a stale socket was reaped. +#[cfg(unix)] +pub async fn reap_stale_socket_if_dead(path: &std::path::Path) -> bool { + // Nothing to reap if the path isn't even present. + if !crate::transport::is_socket_path(path) { + return false; + } + + // If a listener answers, the daemon is alive; never touch it. + if socket_has_live_listener(path).await { + return false; + } + + // Try to grab the daemon lock. If a live daemon holds it, this fails and we + // leave everything alone (the daemon may just be slow to answer). + let lock_path = daemon_lock_path(); + let Ok(Some(_lock)) = try_acquire_daemon_lock(&lock_path) else { + return false; + }; + + // Re-check the listener now that we hold the lock, to close the race where a + // daemon bound the socket between our probe and acquiring the lock. (A real + // daemon could not have given us the lock, but a brand-new daemon spawned in + // the gap might be mid-startup before it acquired the lock itself.) + 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); + // `_lock` (a DaemonLockGuard) removes the lock file when it drops at the end + // of this scope, so the leftover lock is cleaned up too. + true +} + +#[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 +} + +/// Return true if a live server process is listening on the socket path. +/// +/// This is intentionally weaker than [`is_server_ready`]: a live listener may +/// still be finishing startup or be temporarily too busy to answer a ping +/// within the short readiness timeout. Callers that must avoid spawning a +/// duplicate daemon should prefer this check over a ping-only probe. +pub async fn has_live_listener(path: &std::path::Path) -> bool { + socket_has_live_listener(path).await +} + +#[cfg(unix)] +pub(super) fn daemon_lock_path() -> PathBuf { + crate::storage::runtime_dir().join("jcode-daemon.lock") +} + +#[cfg(unix)] +pub(super) struct DaemonLockGuard { + _file: std::fs::File, + path: PathBuf, +} + +#[cfg(unix)] +impl Drop for DaemonLockGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[cfg(unix)] +pub(super) fn try_acquire_daemon_lock(path: &std::path::Path) -> Result<Option<DaemonLockGuard>> { + use std::fs::OpenOptions; + use std::os::fd::AsRawFd; + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(path)?; + let fd = file.as_raw_fd(); + let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) }; + if ret == 0 { + Ok(Some(DaemonLockGuard { + _file: file, + path: path.to_path_buf(), + })) + } else { + Ok(None) + } +} + +#[cfg(unix)] +pub(super) fn acquire_daemon_lock() -> Result<DaemonLockGuard> { + let path = daemon_lock_path(); + try_acquire_daemon_lock(&path)?.ok_or_else(|| { + anyhow::anyhow!( + "Another jcode server process is already running for runtime dir {}", + crate::storage::runtime_dir().display() + ) + }) +} + +#[cfg(unix)] +pub(super) fn mark_close_on_exec<T: std::os::fd::AsRawFd>(io: &T) { + let fd = io.as_raw_fd(); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags >= 0 { + let _ = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + } +} + +pub fn set_socket_path(path: &str) { + crate::env::set_var("JCODE_SOCKET", path); +} + +/// Spawn a server child process and wait until it signals readiness. +/// +/// Creates an anonymous pipe, passes the write-end fd to the child via +/// `JCODE_READY_FD`, and awaits a single byte on the read end. The server +/// calls `signal_ready_fd()` once its accept loops are spawned, so the future +/// resolves only after the daemon can start servicing client requests. +/// +/// Falls back to a short poll loop if the pipe read times out (e.g. server +/// built without ready-fd support, or crash before bind). +#[cfg(unix)] +pub async fn spawn_server_notify(cmd: &mut std::process::Command) -> Result<std::process::Child> { + use std::os::unix::io::FromRawFd; + use std::os::unix::process::CommandExt; + + // Create a pipe: fds[0] = read end, fds[1] = write end. + // Use pipe2 with O_CLOEXEC on the read end (parent keeps it). + // The write end needs CLOEXEC cleared so it survives exec in the child. + let mut fds = [0i32; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { + anyhow::bail!("pipe() failed: {}", std::io::Error::last_os_error()); + } + let read_fd = fds[0]; + let write_fd = fds[1]; + + // Set CLOEXEC on the read end (parent only) + unsafe { + let flags = libc::fcntl(read_fd, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(read_fd, libc::F_SETFD, flags | libc::FD_CLOEXEC); + } + } + + // Pass the write-end fd to the child and tell it the fd number. + unsafe { + cmd.pre_exec(move || { + // Clear CLOEXEC on the write end so it survives exec + let flags = libc::fcntl(write_fd, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC); + } + libc::setsid(); + Ok(()) + }); + } + cmd.env("JCODE_READY_FD", write_fd.to_string()); + + let mut child = cmd.spawn()?; + + // Close our copy of the write end so we get EOF if the child dies. + unsafe { libc::close(write_fd) }; + + // Wait for the ready signal (or timeout / child death). + let read_file = unsafe { std::fs::File::from_raw_fd(read_fd) }; + let mut async_file = tokio::fs::File::from_std(read_file); + let mut buf = [0u8; 1]; + match tokio::time::timeout( + Duration::from_secs(10), + tokio::io::AsyncReadExt::read(&mut async_file, &mut buf), + ) + .await + { + Ok(Ok(1)) => { + crate::logging::info("Server signalled ready via pipe"); + } + Ok(Ok(_)) => { + if let Some(status) = child.try_wait()? { + handle_server_start_exit(&mut child, status).await?; + } + crate::logging::info( + "Server closed ready pipe without signalling; falling back to poll", + ); + wait_for_server_ready(&socket_path(), Duration::from_secs(5)).await?; + } + Ok(Err(e)) => { + crate::logging::info(&format!( + "Ready pipe read error: {}; falling back to poll", + e + )); + wait_for_server_ready(&socket_path(), Duration::from_secs(5)).await?; + } + Err(_) => { + crate::logging::info("Timed out waiting for server ready signal; falling back to poll"); + wait_for_server_ready(&socket_path(), Duration::from_secs(5)).await?; + } + } + + if let Some(mut stderr) = child.stderr.take() { + // The shared daemon outlives the spawning client. Keep draining the + // stderr pipe after startup so later reloads cannot die on SIGPIPE + // when they emit provider/model selection notices during boot. + std::thread::spawn(move || { + let mut sink = std::io::sink(); + let _ = std::io::copy(&mut stderr, &mut sink); + }); + } + + Ok(child) +} + +/// Wait until a server socket is connectable and responds to a ping. +pub async fn wait_for_server_ready(path: &std::path::Path, timeout: Duration) -> 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 + && let Ok(Ok(true)) = + tokio::time::timeout(Duration::from_millis(250), client.ping()).await + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + anyhow::bail!( + "Timed out waiting for responsive server socket {}", + path.display() + ); +} + +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 { + return false; + }; + + matches!( + tokio::time::timeout(ping_timeout, client.ping()).await, + Ok(Ok(true)) + ) +} + +pub async fn is_server_ready(path: &std::path::Path) -> bool { + probe_server_ready(path, Duration::from_millis(50)).await +} + +#[cfg(unix)] +pub(super) fn take_server_start_stderr(child: &mut std::process::Child) -> String { + use std::io::Read; + + child + .stderr + .take() + .and_then(|mut stderr| { + let mut buf = String::new(); + stderr.read_to_string(&mut buf).ok()?; + Some(buf) + }) + .unwrap_or_default() +} + +#[cfg(unix)] +pub(super) fn server_start_matches_existing_server(stderr_output: &str) -> bool { + stderr_output.contains("Another jcode server process is already running") + || stderr_output.contains("Refusing to replace active server socket") +} + +#[cfg(any(unix, test))] +pub(super) async fn wait_for_existing_server(path: &std::path::Path, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if is_server_ready(path).await || has_live_listener(path).await { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +#[cfg(unix)] +pub(super) fn format_server_start_error( + status: std::process::ExitStatus, + stderr_output: &str, +) -> String { + if stderr_output.trim().is_empty() { + format!( + "Server exited before signalling ready ({}). Check logs at ~/.jcode/logs/", + status + ) + } else { + format!( + "Server exited before signalling ready ({}):\n{}", + status, + stderr_output.trim() + ) + } +} + +#[cfg(unix)] +pub(super) async fn handle_server_start_exit( + child: &mut std::process::Child, + status: std::process::ExitStatus, +) -> Result<()> { + let stderr_output = take_server_start_stderr(child); + if server_start_matches_existing_server(&stderr_output) { + let socket_path = socket_path(); + if wait_for_existing_server(&socket_path, Duration::from_secs(5)).await { + crate::logging::info( + "Server spawn raced with an existing daemon; treating startup as successful", + ); + return Ok(()); + } + } + + anyhow::bail!(format_server_start_error(status, &stderr_output)); +} + +/// Write a single byte to the fd in `JCODE_READY_FD` and close it. +/// Called after startup plumbing is ready so the parent process knows the +/// server can accept and service client requests. The env var is cleared so child +/// processes (e.g. tool subprocesses) don't inherit a stale fd. +pub(super) fn signal_ready_fd() { + #[cfg(unix)] + { + use std::os::unix::io::FromRawFd; + + if let Ok(fd_str) = std::env::var("JCODE_READY_FD") { + crate::env::remove_var("JCODE_READY_FD"); + if let Ok(fd) = fd_str.parse::<i32>() { + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + let _ = std::io::Write::write_all(&mut file, b"R"); + // file is dropped here which closes the fd + } + } + } +} diff --git a/crates/jcode-app-core/src/server/socket_tests.rs b/crates/jcode-app-core/src/server/socket_tests.rs new file mode 100644 index 0000000..6e3b5fb --- /dev/null +++ b/crates/jcode-app-core/src/server/socket_tests.rs @@ -0,0 +1,539 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::socket::sibling_socket_path; +#[cfg(unix)] +use super::socket::{ + daemon_lock_path, server_start_matches_existing_server, try_acquire_daemon_lock, +}; +use super::{ + ReloadPhase, ReloadState, ReloadWaitStatus, await_reload_handoff, cleanup_socket_pair, + clear_reload_marker, inspect_reload_wait_status, publish_reload_socket_ready, + reload_marker_active, reload_marker_path, reload_process_alive, write_reload_state, +}; +#[cfg(unix)] +use super::{connect_socket, reap_stale_socket_if_dead}; +#[cfg(unix)] +use crate::transport::Listener; +use std::time::Duration; + +#[test] +fn sibling_socket_path_roundtrip() { + let main = std::path::PathBuf::from("/tmp/jcode.sock"); + let debug = std::path::PathBuf::from("/tmp/jcode-debug.sock"); + + assert_eq!(sibling_socket_path(&main), Some(debug.clone())); + assert_eq!(sibling_socket_path(&debug), Some(main)); +} + +#[test] +fn cleanup_socket_pair_removes_main_and_debug_files() { + let stamp = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let dir = std::env::temp_dir(); + let main = dir.join(format!("jcode-test-{}.sock", stamp)); + let debug = dir.join(format!("jcode-test-{}-debug.sock", stamp)); + + std::fs::write(&main, b"").expect("create main socket placeholder"); + std::fs::write(&debug, b"").expect("create debug socket placeholder"); + + cleanup_socket_pair(&main); + + assert!(!main.exists(), "main socket file should be removed"); + assert!(!debug.exists(), "debug socket file should be removed"); +} + +#[cfg(unix)] +#[tokio::test] +async fn connect_socket_preserves_refused_socket_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("jcode.sock"); + + { + let _listener = Listener::bind(&socket_path).expect("bind listener"); + } + + assert!( + socket_path.exists(), + "listener drop should leave the socket path behind for stale-socket checks" + ); + + let err = connect_socket(&socket_path) + .await + .expect_err("connect should fail once the listener is gone"); + assert!( + err.to_string().contains("refused the connection"), + "unexpected error: {err:#}" + ); + assert!( + socket_path.exists(), + "connect_socket should not unlink the socket path on connection refusal" + ); +} + +#[cfg(unix)] +#[test] +fn daemon_lock_serializes_server_processes() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let lock_path = daemon_lock_path(); + let first = try_acquire_daemon_lock(&lock_path) + .expect("acquire first daemon lock") + .expect("first daemon lock should succeed"); + let second = try_acquire_daemon_lock(&lock_path).expect("acquire second daemon lock"); + assert!(second.is_none(), "second daemon lock should fail"); + drop(first); + + let third = try_acquire_daemon_lock(&lock_path) + .expect("acquire third daemon lock") + .expect("third daemon lock should succeed after release"); + drop(third); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn reap_stale_socket_removes_dead_socket_pair_and_lock() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let socket = temp.path().join("jcode.sock"); + let debug = temp.path().join("jcode-debug.sock"); + let lock = daemon_lock_path(); + + // Simulate the post-upgrade/crash state: socket + debug + lock files left + // behind, but no process is listening on the socket. + std::fs::write(&socket, b"").expect("write stale socket"); + std::fs::write(&debug, b"").expect("write stale debug socket"); + std::fs::write(&lock, b"").expect("write stale lock"); + + let reaped = reap_stale_socket_if_dead(&socket).await; + assert!(reaped, "a dead socket with no listener should be reaped"); + assert!(!socket.exists(), "stale socket should be removed"); + assert!(!debug.exists(), "stale debug socket should be removed"); + assert!(!lock.exists(), "stale daemon lock should be removed"); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn reap_stale_socket_spares_live_listener() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let socket = temp.path().join("jcode.sock"); + // A live listener means a daemon is bound; reaping must be a no-op. + let listener = Listener::bind(&socket).expect("bind listener"); + + let reaped = reap_stale_socket_if_dead(&socket).await; + assert!(!reaped, "a live listener must never be reaped"); + assert!(socket.exists(), "live socket must be left intact"); + + drop(listener); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn reap_stale_socket_spares_socket_when_lock_is_held() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let socket = temp.path().join("jcode.sock"); + std::fs::write(&socket, b"").expect("write stale-looking socket"); + + // Hold the daemon lock, emulating a live daemon whose socket probe happens + // to be momentarily unanswerable. The reaper must not unlink the socket. + let lock_path = daemon_lock_path(); + let held = try_acquire_daemon_lock(&lock_path) + .expect("acquire lock") + .expect("lock should be free"); + + let reaped = reap_stale_socket_if_dead(&socket).await; + assert!( + !reaped, + "socket must be spared while the daemon lock is held" + ); + assert!( + socket.exists(), + "socket must be left intact while lock is held" + ); + + drop(held); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[cfg(unix)] +#[test] +fn existing_server_start_errors_are_detected() { + assert!(server_start_matches_existing_server( + "Error: Another jcode server process is already running for runtime dir /run/user/1000" + )); + assert!(server_start_matches_existing_server( + "Error: Refusing to replace active server socket at /run/user/1000/jcode.sock" + )); + assert!(!server_start_matches_existing_server( + "Error: failed to bind socket: permission denied" + )); +} + +#[test] +fn reload_marker_active_expires_stale_marker() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let marker = reload_marker_path(); + if let Some(parent) = marker.parent() { + let _ = std::fs::create_dir_all(parent); + } + write_reload_state("test-request", "test-hash", ReloadPhase::Starting, None); + assert!(reload_marker_active(Duration::from_secs(30))); + std::thread::sleep(Duration::from_millis(5)); + assert!(!reload_marker_active(Duration::ZERO)); + assert!(!marker.exists(), "stale reload marker should be cleaned up"); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[test] +fn reload_marker_active_for_recent_socket_ready_marker() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + write_reload_state("test-request", "test-hash", ReloadPhase::SocketReady, None); + assert!(reload_marker_active(Duration::from_secs(30))); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[test] +fn publish_reload_socket_ready_updates_current_process_marker() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + write_reload_state( + "test-request", + "test-hash", + ReloadPhase::Starting, + Some("detail".to_string()), + ); + publish_reload_socket_ready(); + + let state = ReloadState::load().expect("reload state should exist"); + assert_eq!(state.phase, ReloadPhase::SocketReady); + assert_eq!(state.request_id, "test-request"); + assert_eq!(state.hash, "test-hash"); + assert_eq!(state.detail.as_deref(), Some("detail")); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[test] +fn publish_reload_socket_ready_clears_marker_for_foreign_pid() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + ReloadState { + request_id: "test-request".to_string(), + hash: "test-hash".to_string(), + phase: ReloadPhase::Starting, + pid: std::process::id().saturating_add(1_000_000), + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + + publish_reload_socket_ready(); + assert!( + ReloadState::load().is_none(), + "foreign reload marker should be cleared" + ); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[tokio::test] +async fn inspect_reload_wait_status_reports_ready_for_socket_ready_marker() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + write_reload_state("test-request", "test-hash", ReloadPhase::SocketReady, None); + + let socket_path = temp.path().join("missing.sock"); + let status = inspect_reload_wait_status(&socket_path, Duration::from_secs(30), None).await; + assert_eq!(status, ReloadWaitStatus::Ready); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn inspect_reload_wait_status_keeps_waiting_while_starting_marker_is_active_even_if_socket_is_live() + { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + ReloadState { + request_id: "test-request".to_string(), + hash: "test-hash".to_string(), + phase: ReloadPhase::Starting, + pid: std::process::id(), + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + + let socket_path = temp.path().join("jcode.sock"); + let _listener = Listener::bind(&socket_path).expect("bind listener"); + + let status = inspect_reload_wait_status(&socket_path, Duration::from_secs(30), None).await; + assert_eq!( + status, + ReloadWaitStatus::Waiting { + pid: Some(std::process::id()) + } + ); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[tokio::test] +async fn wait_for_reload_handoff_event_returns_promptly_when_no_event_arrives() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + let socket_path = temp.path().join("missing.sock"); + let started = std::time::Instant::now(); + crate::server::wait_for_reload_handoff_event(Some(std::process::id()), &socket_path).await; + assert!( + started.elapsed() < Duration::from_millis(500), + "reload handoff event wait should be a bounded edge wait, not an indefinite block" + ); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[tokio::test] +async fn inspect_reload_wait_status_reports_idle_without_marker_or_listener() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("missing.sock"); + + let status = inspect_reload_wait_status(&socket_path, Duration::from_secs(30), None).await; + assert_eq!(status, ReloadWaitStatus::Idle); +} + +#[tokio::test] +async fn inspect_reload_wait_status_uses_last_known_pid_when_marker_missing() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("missing.sock"); + + let status = inspect_reload_wait_status( + &socket_path, + Duration::from_secs(30), + Some(std::process::id()), + ) + .await; + assert_eq!( + status, + ReloadWaitStatus::Waiting { + pid: Some(std::process::id()) + } + ); +} + +#[tokio::test] +async fn inspect_reload_wait_status_reports_failed_when_reload_pid_is_dead() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + let dead_pid = std::process::id().saturating_add(1_000_000); + assert!( + !reload_process_alive(dead_pid), + "test requires a definitely-dead pid" + ); + + ReloadState { + request_id: "test-request".to_string(), + hash: "test-hash".to_string(), + phase: ReloadPhase::Starting, + pid: dead_pid, + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + + let socket_path = temp.path().join("missing.sock"); + let status = inspect_reload_wait_status(&socket_path, Duration::from_secs(30), None).await; + assert!(matches!(status, ReloadWaitStatus::Failed(Some(_)))); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[tokio::test] +async fn await_reload_handoff_returns_ready_after_marker_transition() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + ReloadState { + request_id: "test-request".to_string(), + hash: "test-hash".to_string(), + phase: ReloadPhase::Starting, + pid: std::process::id(), + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + + tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(50)).await; + write_reload_state("test-request", "test-hash", ReloadPhase::SocketReady, None); + }); + + let socket_path = temp.path().join("missing.sock"); + let status = tokio::time::timeout( + Duration::from_secs(2), + await_reload_handoff(&socket_path, Duration::from_secs(30)), + ) + .await + .expect("await reload handoff should finish"); + assert_eq!(status, ReloadWaitStatus::Ready); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[tokio::test] +async fn await_reload_handoff_returns_failed_after_marker_transition() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + + ReloadState { + request_id: "test-request".to_string(), + hash: "test-hash".to_string(), + phase: ReloadPhase::Starting, + pid: std::process::id(), + timestamp: chrono::Utc::now().to_rfc3339(), + detail: None, + } + .write(); + + tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(50)).await; + write_reload_state( + "test-request", + "test-hash", + ReloadPhase::Failed, + Some("boom".to_string()), + ); + }); + + let socket_path = temp.path().join("missing.sock"); + let status = tokio::time::timeout( + Duration::from_secs(2), + await_reload_handoff(&socket_path, Duration::from_secs(30)), + ) + .await + .expect("await reload handoff should finish"); + assert_eq!(status, ReloadWaitStatus::Failed(Some("boom".to_string()))); + + clear_reload_marker(); + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} diff --git a/crates/jcode-app-core/src/server/startup_tests.rs b/crates/jcode-app-core/src/server/startup_tests.rs new file mode 100644 index 0000000..e00d50c --- /dev/null +++ b/crates/jcode-app-core/src/server/startup_tests.rs @@ -0,0 +1,143 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::runtime::ServerRuntime; +use super::socket::wait_for_existing_server; +use super::{Client, Server, is_server_ready}; +use crate::message::{Message, ToolDefinition}; +use crate::provider::{EventStream, Provider}; +use crate::transport::Listener; +use anyhow::Result; +use async_trait::async_trait; +use std::sync::Arc; +use std::time::Duration; + +struct TestProvider; + +#[async_trait] +impl Provider for TestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + Err(anyhow::anyhow!( + "test provider complete should not be called in startup tests" + )) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(TestProvider) + } +} + +#[tokio::test] +async fn server_run_refuses_to_replace_live_socket() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + let socket_path = temp.path().join("jcode.sock"); + let debug_socket_path = temp.path().join("jcode-debug.sock"); + let _listener = Listener::bind(&socket_path).expect("bind existing live socket"); + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let server = Server::new_with_paths(provider, socket_path, debug_socket_path); + + let error = server + .run() + .await + .expect_err("should refuse live socket takeover"); + assert!( + error + .to_string() + .contains("Refusing to replace active server socket"), + "unexpected error: {error:#}" + ); + + if let Some(prev_runtime) = prev_runtime { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } +} + +#[tokio::test] +async fn is_server_ready_returns_false_immediately_for_missing_socket() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("missing.sock"); + + let ready = tokio::time::timeout(Duration::from_millis(50), is_server_ready(&socket_path)) + .await + .expect("missing socket probe should return quickly"); + + assert!(!ready, "missing socket should not report ready"); +} + +#[tokio::test] +async fn wait_for_existing_server_tolerates_delayed_listener() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("jcode.sock"); + let bind_path = socket_path.clone(); + + let bind_task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + let listener = Listener::bind(&bind_path).expect("bind delayed listener"); + tokio::time::sleep(Duration::from_millis(200)).await; + drop(listener); + }); + + let ready = wait_for_existing_server(&socket_path, Duration::from_secs(1)).await; + assert!(ready, "delayed live listener should be detected"); + + bind_task.await.expect("bind task should complete"); +} + +#[test] +fn server_initializes_schedule_runner_even_when_ambient_disabled() { + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let server = Server::new(provider); + + assert!( + server.ambient_runner.is_some(), + "schedule/session tasks need the runner even when ambient is disabled" + ); +} + +#[tokio::test] +async fn debug_accept_loop_responds_to_ping_without_affecting_client_count() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let socket_path = temp.path().join("jcode.sock"); + let debug_socket_path = temp.path().join("jcode-debug.sock"); + let provider: Arc<dyn Provider> = Arc::new(TestProvider); + let server = Server::new_with_paths(provider, socket_path, debug_socket_path.clone()); + let runtime = ServerRuntime::from_server(&server); + let debug_listener = Listener::bind(&debug_socket_path).expect("bind debug socket"); + let debug_handle = runtime.spawn_debug_accept_loop(debug_listener, std::time::Instant::now()); + + let mut client = tokio::time::timeout( + Duration::from_secs(1), + Client::connect_debug_with_path(debug_socket_path), + ) + .await + .expect("debug connect should complete") + .expect("debug client should connect"); + + assert!(client.ping().await.expect("debug ping should succeed")); + assert_eq!(*server.client_count.read().await, 0); + + tokio::time::timeout(Duration::from_secs(1), runtime.shutdown()) + .await + .expect("runtime shutdown should join debug connection tasks"); + tokio::time::timeout(Duration::from_secs(1), debug_handle) + .await + .expect("debug accept loop should observe runtime cancellation") + .expect("debug accept loop should exit cleanly"); +} diff --git a/crates/jcode-app-core/src/server/state.rs b/crates/jcode-app-core/src/server/state.rs new file mode 100644 index 0000000..60fbe03 --- /dev/null +++ b/crates/jcode-app-core/src/server/state.rs @@ -0,0 +1,742 @@ +use crate::bus::FileOp; +use crate::plan::VersionedPlan; +use crate::protocol::ServerEvent; +use jcode_agent_runtime::{ + InterruptSignal, SoftInterruptMessage, SoftInterruptQueue, SoftInterruptSource, +}; +use jcode_swarm_core::{SwarmLifecycleStatus, SwarmMemberRecord, SwarmRole}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; +use std::time::Instant; +use tokio::sync::{RwLock, mpsc}; + +/// Process-global registry mapping session id -> background-tool signal. +/// +/// The background-tool ("move tool to background", Alt+B/Ctrl+B) signal lives on +/// the `Agent`, so a `SessionControlHandle` can normally only obtain it by +/// locking the agent mutex. When a turn is busy (e.g. running `await_members`), +/// `refresh_session_control_handle` falls back to a lock-free `cancel_only` +/// handle that historically dropped the background signal entirely, which made +/// Alt+B/Ctrl+B silently no-op (`BACKGROUND_TOOL_SIGNAL_FIRE result=no_signal_handle`). +/// +/// This registry is populated every time a full `SessionControlHandle` is built +/// (which always has both the session id and the correct signal), so the +/// lock-free fallback can still fire the background signal without the agent +/// lock. Entries are keyed by session id; renames/removals reuse +/// [`rename_background_tool_signal`]/[`remove_background_tool_signal`] alongside +/// the existing shutdown-signal lifecycle. +static BACKGROUND_TOOL_SIGNALS: LazyLock<StdMutex<HashMap<String, InterruptSignal>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +/// Register (or replace) the background-tool signal for a session. +pub(super) fn register_background_tool_signal(session_id: &str, signal: InterruptSignal) { + if let Ok(mut map) = BACKGROUND_TOOL_SIGNALS.lock() { + map.insert(session_id.to_string(), signal); + } +} + +/// Look up the registered background-tool signal for a session, if any. +pub(super) fn background_tool_signal_for_session(session_id: &str) -> Option<InterruptSignal> { + BACKGROUND_TOOL_SIGNALS + .lock() + .ok() + .and_then(|map| map.get(session_id).cloned()) +} + +/// Move a session's background-tool signal registration to a new session id. +pub(super) fn rename_background_tool_signal(old_session_id: &str, new_session_id: &str) { + if old_session_id == new_session_id { + return; + } + if let Ok(mut map) = BACKGROUND_TOOL_SIGNALS.lock() + && let Some(signal) = map.remove(old_session_id) + { + map.insert(new_session_id.to_string(), signal); + } +} + +/// Drop a session's background-tool signal registration. +pub(super) fn remove_background_tool_signal(session_id: &str) { + if let Ok(mut map) = BACKGROUND_TOOL_SIGNALS.lock() { + map.remove(session_id); + } +} + +/// Record of a file access by an agent +#[derive(Clone, Debug)] +pub struct FileAccess { + pub session_id: String, + pub op: FileOp, + pub timestamp: Instant, + pub absolute_time: std::time::SystemTime, + pub intent: Option<String>, + pub summary: Option<String>, + pub detail: Option<String>, +} + +pub(super) fn latest_peer_touches( + accesses: &[FileAccess], + current_session_id: &str, + swarm_session_ids: &HashSet<String>, +) -> Vec<FileAccess> { + let mut latest_by_session: HashMap<&str, &FileAccess> = HashMap::new(); + + for access in accesses.iter().filter(|access| { + access.session_id != current_session_id + && swarm_session_ids.contains(&access.session_id) + && access.op.is_modification() + }) { + latest_by_session + .entry(&access.session_id) + .and_modify(|existing| { + if access.timestamp > existing.timestamp { + *existing = access; + } + }) + .or_insert(access); + } + + let mut latest: Vec<FileAccess> = latest_by_session.into_values().cloned().collect(); + latest.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + latest +} + +/// Shared ownership of the core persisted swarm coordination state. +#[derive(Clone)] +pub struct SwarmState { + pub members: Arc<RwLock<HashMap<String, SwarmMember>>>, + pub swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>, + pub plans: Arc<RwLock<HashMap<String, VersionedPlan>>>, + pub coordinators: Arc<RwLock<HashMap<String, String>>>, +} + +/// First-class snapshot of a single swarm's logical runtime state. +#[derive(Clone, Debug)] +pub struct SwarmRuntime { + pub swarm_id: String, + pub coordinator_session_id: Option<String>, + pub member_session_ids: HashSet<String>, + pub members: Vec<SwarmMember>, + pub plan: Option<VersionedPlan>, +} + +impl SwarmRuntime { + pub fn has_any_state(&self) -> bool { + self.plan.is_some() || self.coordinator_session_id.is_some() || !self.members.is_empty() + } +} + +/// Live transport attachment for a connected session. +#[derive(Clone, Debug)] +pub struct LiveSessionAttachment { + pub connection_id: String, + pub event_tx: mpsc::UnboundedSender<ServerEvent>, +} + +impl SwarmState { + pub fn new( + members: HashMap<String, SwarmMember>, + swarms_by_id: HashMap<String, HashSet<String>>, + plans: HashMap<String, VersionedPlan>, + coordinators: HashMap<String, String>, + ) -> Self { + Self { + members: Arc::new(RwLock::new(members)), + swarms_by_id: Arc::new(RwLock::new(swarms_by_id)), + plans: Arc::new(RwLock::new(plans)), + coordinators: Arc::new(RwLock::new(coordinators)), + } + } + + pub async fn load_runtime(&self, swarm_id: &str) -> SwarmRuntime { + let plan = { + let plans = self.plans.read().await; + plans.get(swarm_id).cloned() + }; + let coordinator_session_id = { + let coordinators = self.coordinators.read().await; + coordinators.get(swarm_id).cloned() + }; + let member_session_ids = { + let swarms = self.swarms_by_id.read().await; + swarms.get(swarm_id).cloned().unwrap_or_default() + }; + let mut members = { + let members = self.members.read().await; + members + .values() + .filter(|member| member.swarm_id.as_deref() == Some(swarm_id)) + .cloned() + .collect::<Vec<_>>() + }; + members.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + + SwarmRuntime { + swarm_id: swarm_id.to_string(), + coordinator_session_id, + member_session_ids, + members, + plan, + } + } +} + +/// Information about a session in a swarm +#[derive(Clone, Debug)] +pub struct SwarmMember { + pub session_id: String, + /// Primary channel to send events to this session. + /// + /// This remains for backward-compatible single-sender call sites and for + /// headless sessions that do not maintain a live attachment map. + pub event_tx: mpsc::UnboundedSender<ServerEvent>, + /// Live client attachments for this session keyed by connection id. + pub event_txs: HashMap<String, mpsc::UnboundedSender<ServerEvent>>, + /// Working directory (used to derive swarm id) + pub working_dir: Option<PathBuf>, + /// Swarm identifier (shared across worktrees) + pub swarm_id: Option<String>, + /// Whether swarm coordination is enabled for this member + pub swarm_enabled: bool, + /// Lifecycle status (ready, running, completed, failed, stopped, etc.) + pub status: String, + /// Optional detail (current task, error, etc.) + pub detail: Option<String>, + /// Stable, human-readable label of the task/role this member was spawned + /// or assigned for (compacted from the spawn prompt or plan item). Unlike + /// `detail`, this is not overwritten by transient status updates. + pub task_label: Option<String>, + /// Friendly name like "fox" + pub friendly_name: Option<String>, + /// Session that should receive direct completion report-back for this member, if any. + pub report_back_to_session_id: Option<String>, + /// Latest explicit completion report submitted by this member. + pub latest_completion_report: Option<String>, + /// Role: "agent" or "coordinator" + pub role: String, + /// When this member joined the swarm + pub joined_at: Instant, + /// When status was last changed + pub last_status_change: Instant, + /// Whether this is a headless (spawned) session vs a TUI-connected session. + /// Headless sessions should not be automatically elected as coordinator. + pub is_headless: bool, + /// Recent streamed output tail (last few lines of in-progress assistant + /// text), captured for inline swarm gallery rendering. Updated by the bus + /// monitor from worker streaming taps; not persisted. + pub output_tail: Option<String>, + /// Aggregate todo progress (completed, total) for this member's session, + /// updated from `TodoUpdated` bus events. Surfaced on the inline swarm + /// strip; not persisted. + pub todo_progress: Option<(u32, u32)>, + /// Compact snapshot of this member's todo list (content + status), capped + /// at a few entries by the bus monitor. Rendered in the focused inline + /// swarm panel; not persisted. + pub todo_items: Vec<crate::protocol::SwarmTodoItem>, + /// Ephemeral model/timing metadata for the inline swarm card. + pub runtime: crate::protocol::SwarmMemberRuntime, +} + +impl SwarmMember { + pub fn durable_record(&self) -> SwarmMemberRecord { + SwarmMemberRecord { + session_id: self.session_id.clone(), + working_dir: self.working_dir.clone(), + swarm_id: self.swarm_id.clone(), + swarm_enabled: self.swarm_enabled, + status: SwarmLifecycleStatus::from(self.status.clone()), + detail: self.detail.clone(), + task_label: self.task_label.clone(), + friendly_name: self.friendly_name.clone(), + report_back_to_session_id: self.report_back_to_session_id.clone(), + latest_completion_report: self.latest_completion_report.clone(), + role: SwarmRole::from(self.role.clone()), + is_headless: self.is_headless, + } + } + + pub fn live_attachments(&self) -> Vec<LiveSessionAttachment> { + self.event_txs + .iter() + .map(|(connection_id, event_tx)| LiveSessionAttachment { + connection_id: connection_id.clone(), + event_tx: event_tx.clone(), + }) + .collect() + } + + pub fn from_record( + record: SwarmMemberRecord, + event_tx: mpsc::UnboundedSender<ServerEvent>, + ) -> Self { + Self { + session_id: record.session_id, + event_tx, + event_txs: HashMap::new(), + working_dir: record.working_dir, + swarm_id: record.swarm_id, + swarm_enabled: record.swarm_enabled, + status: record.status.as_str().into_owned(), + detail: record.detail, + task_label: record.task_label, + friendly_name: record.friendly_name, + report_back_to_session_id: record.report_back_to_session_id, + latest_completion_report: record.latest_completion_report, + role: record.role.as_str().into_owned(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: record.is_headless, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + } + } +} + +/// A shared context entry stored by the server +#[derive(Clone, Debug)] +pub struct SharedContext { + pub key: String, + pub value: String, + pub from_session: String, + pub from_name: Option<String>, + /// When this context was created + pub created_at: Instant, + /// When this context was last updated + pub updated_at: Instant, +} + +/// Event types for real-time event subscription +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SwarmEventType { + /// A file was touched (read/write/edit) + FileTouch { + path: String, + op: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + intent: Option<String>, + summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option<String>, + }, + /// A notification was broadcast + Notification { + notification_type: String, + message: String, + }, + /// A swarm plan was updated + PlanUpdate { swarm_id: String, item_count: usize }, + /// A plan proposal was submitted + PlanProposal { + swarm_id: String, + proposer_session: String, + item_count: usize, + }, + /// Shared context was updated + ContextUpdate { swarm_id: String, key: String }, + /// Session status changed + StatusChange { + old_status: String, + new_status: String, + }, + /// Session joined/left swarm + MemberChange { + action: String, // "joined" or "left" + }, +} + +/// A swarm event with metadata +#[derive(Clone, Debug)] +pub struct SwarmEvent { + pub id: u64, + pub session_id: String, + pub session_name: Option<String>, + pub swarm_id: Option<String>, + pub event: SwarmEventType, + pub timestamp: Instant, + pub absolute_time: std::time::SystemTime, +} + +/// Ring buffer for recent swarm events +pub(super) const MAX_EVENT_HISTORY: usize = 5000; + +pub(super) type SessionInterruptQueues = Arc<RwLock<HashMap<String, SoftInterruptQueue>>>; + +pub(super) async fn register_session_event_sender( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + session_id: &str, + connection_id: &str, + event_tx: mpsc::UnboundedSender<ServerEvent>, +) { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(session_id) { + member.event_tx = event_tx.clone(); + member.event_txs.insert(connection_id.to_string(), event_tx); + } +} + +pub(super) async fn unregister_session_event_sender( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + session_id: &str, + connection_id: &str, +) { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(session_id) { + member.event_txs.remove(connection_id); + if let Some((_, tx)) = member.event_txs.iter().next() { + member.event_tx = tx.clone(); + } + } +} + +pub(super) async fn fanout_session_event( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + session_id: &str, + event: ServerEvent, +) -> usize { + let targets = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(session_id) else { + return 0; + }; + + member.event_txs.retain(|_, tx| !tx.is_closed()); + + if member.event_txs.is_empty() { + vec![member.event_tx.clone()] + } else { + if let Some((_, tx)) = member.event_txs.iter().next() { + member.event_tx = tx.clone(); + } + member.event_txs.values().cloned().collect::<Vec<_>>() + } + }; + + let mut delivered = 0; + for tx in targets { + if tx.send(event.clone()).is_ok() { + delivered += 1; + } + } + delivered +} + +pub(super) async fn fanout_live_client_event( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + session_id: &str, + event: ServerEvent, +) -> usize { + let targets = { + let mut members = swarm_members.write().await; + let Some(member) = members.get_mut(session_id) else { + return 0; + }; + + member.event_txs.retain(|_, tx| !tx.is_closed()); + member.event_txs.values().cloned().collect::<Vec<_>>() + }; + + let mut delivered = 0; + for tx in targets { + if tx.send(event.clone()).is_ok() { + delivered += 1; + } + } + delivered +} + +pub(super) fn session_event_fanout_sender( + session_id: String, + swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>, +) -> mpsc::UnboundedSender<ServerEvent> { + let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let _ = fanout_session_event(&swarm_members, &session_id, event).await; + } + }); + tx +} + +pub(super) fn enqueue_soft_interrupt( + queue: &SoftInterruptQueue, + content: String, + urgent: bool, + source: SoftInterruptSource, +) -> bool { + let content_bytes = content.len(); + let content_chars = content.chars().count(); + if let Ok(mut pending) = queue.lock() { + let pending_before = pending.len(); + pending.push(SoftInterruptMessage { + content, + urgent, + source, + }); + crate::logging::info(&format!( + "SOFT_INTERRUPT_QUEUE_PUSH source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}", + source, + urgent, + content_bytes, + content_chars, + pending_before, + pending.len() + )); + true + } else { + crate::logging::warn(&format!( + "SOFT_INTERRUPT_QUEUE_PUSH_FAILED source={:?} urgent={} content_bytes={} content_chars={} reason=queue_lock_poisoned", + source, urgent, content_bytes, content_chars + )); + false + } +} + +/// Lock-free control-plane handles for a live session. +/// +/// This intentionally exposes only out-of-band controls that are safe to use +/// while a turn owns the Agent mutex. Stateful operations such as history +/// mutation, model changes, or direct tool execution should continue to +/// coordinate through the Agent lock after the turn is idle/stopped. +#[derive(Clone)] +pub struct SessionControlHandle { + pub session_id: String, + soft_interrupt_queue: SoftInterruptQueue, + background_tool_signal: Option<InterruptSignal>, + stop_current_turn_signal: InterruptSignal, +} + +impl SessionControlHandle { + pub fn new( + session_id: impl Into<String>, + soft_interrupt_queue: SoftInterruptQueue, + background_tool_signal: InterruptSignal, + stop_current_turn_signal: InterruptSignal, + ) -> Self { + let session_id = session_id.into(); + // Mirror the signal into the process-global registry so the lock-free + // `cancel_only` fallback (used while the agent mutex is busy, e.g. during + // `await_members`) can still fire it. Without this, Alt+B/Ctrl+B silently + // no-ops for busy turns. + register_background_tool_signal(&session_id, background_tool_signal.clone()); + Self { + session_id, + soft_interrupt_queue, + background_tool_signal: Some(background_tool_signal), + stop_current_turn_signal, + } + } + + pub fn cancel_only( + session_id: impl Into<String>, + soft_interrupt_queue: SoftInterruptQueue, + stop_current_turn_signal: InterruptSignal, + ) -> Self { + Self { + session_id: session_id.into(), + soft_interrupt_queue, + background_tool_signal: None, + stop_current_turn_signal, + } + } + + pub fn queue_soft_interrupt( + &self, + content: String, + urgent: bool, + source: SoftInterruptSource, + ) -> bool { + enqueue_soft_interrupt(&self.soft_interrupt_queue, content, urgent, source) + } + + pub fn clear_soft_interrupts(&self) { + if let Ok(mut queue) = self.soft_interrupt_queue.lock() { + let cleared = queue.len(); + queue.clear(); + crate::logging::info(&format!( + "SOFT_INTERRUPT_QUEUE_CLEAR session={} cleared={}", + self.session_id, cleared + )); + } else { + crate::logging::warn(&format!( + "SOFT_INTERRUPT_QUEUE_CLEAR_FAILED session={} reason=queue_lock_poisoned", + self.session_id + )); + } + } + + /// Fire the stop-current-turn signal. Returns the signal's fire epoch so + /// callers that schedule a deferred [`reset_cancel_if_epoch`](Self::reset_cancel_if_epoch) + /// can avoid erasing a newer cancel that fired in the meantime (issue #428). + /// + /// Also fires every cancel signal registered for currently running turns + /// of this session. The handle's own signal can be a stale instance that + /// the streaming turn never observes (reattach after reload/disconnect, + /// server-initiated turns, headless recovery), which used to make Esc show + /// "Interrupting..." while the model kept generating for minutes + /// (issue #428). + pub fn request_cancel(&self) -> u64 { + crate::logging::info(&format!( + "SESSION_CANCEL_SIGNAL_FIRE session={}", + self.session_id + )); + self.stop_current_turn_signal.fire(); + let active_turn_signals = + crate::turn_cancel_registry::active_turn_signals(&self.session_id); + let mut fired_active = 0usize; + for signal in &active_turn_signals { + if signal.same_instance(&self.stop_current_turn_signal) { + continue; + } + signal.fire(); + fired_active += 1; + } + if fired_active > 0 { + crate::logging::info(&format!( + "SESSION_CANCEL_ACTIVE_TURN_SIGNALS_FIRED session={} fired={} registered={}", + self.session_id, + fired_active, + active_turn_signals.len() + )); + } + self.stop_current_turn_signal.epoch() + } + + pub fn reset_cancel(&self) { + crate::logging::info(&format!( + "SESSION_CANCEL_SIGNAL_RESET session={}", + self.session_id + )); + self.stop_current_turn_signal.reset(); + } + + /// Reset the cancel signal only if no newer cancel fired since `epoch` + /// was captured from [`request_cancel`](Self::request_cancel). Timed + /// resets (used when the running turn is not owned by this connection) + /// must use this instead of [`reset_cancel`](Self::reset_cancel): + /// an unconditional deferred reset can erase a newer, not-yet-observed + /// cancel, making repeated Esc presses appear to be ignored (issue #428). + pub fn reset_cancel_if_epoch(&self, epoch: u64) -> bool { + let reset = self.stop_current_turn_signal.reset_if_epoch(epoch); + crate::logging::info(&format!( + "SESSION_CANCEL_SIGNAL_RESET session={} epoch={} applied={}", + self.session_id, epoch, reset + )); + reset + } + + pub fn request_background_current_tool(&self) -> bool { + // Prefer the directly-held signal; fall back to the process-global + // registry for lock-free (`cancel_only`) handles built while the agent + // mutex was busy. This is what makes Alt+B/Ctrl+B work during a busy + // turn such as `await_members`. + let signal = self + .background_tool_signal + .clone() + .or_else(|| background_tool_signal_for_session(&self.session_id)); + if let Some(signal) = signal { + signal.fire(); + crate::logging::info(&format!( + "BACKGROUND_TOOL_SIGNAL_FIRE session={} result=sent", + self.session_id + )); + true + } else { + crate::logging::warn(&format!( + "BACKGROUND_TOOL_SIGNAL_FIRE session={} result=no_signal_handle", + self.session_id + )); + false + } + } + + pub fn stop_current_turn_signal(&self) -> InterruptSignal { + self.stop_current_turn_signal.clone() + } +} + +pub(super) async fn register_session_interrupt_queue( + queues: &SessionInterruptQueues, + session_id: &str, + queue: SoftInterruptQueue, +) { + let mut guard = queues.write().await; + guard.insert(session_id.to_string(), queue); +} + +pub(super) async fn rename_session_interrupt_queue( + queues: &SessionInterruptQueues, + old_session_id: &str, + new_session_id: &str, +) { + let mut guard = queues.write().await; + if let Some(queue) = guard.remove(old_session_id) { + guard.insert(new_session_id.to_string(), queue); + } +} + +pub(super) async fn remove_session_interrupt_queue( + queues: &SessionInterruptQueues, + session_id: &str, +) { + let mut guard = queues.write().await; + guard.remove(session_id); +} + +pub(super) async fn queue_soft_interrupt_for_session( + session_id: &str, + content: String, + urgent: bool, + source: SoftInterruptSource, + queues: &SessionInterruptQueues, + sessions: &super::SessionAgents, +) -> bool { + if let Some(queue) = queues.read().await.get(session_id).cloned() { + return enqueue_soft_interrupt(&queue, content, urgent, source); + } + + let queue = { + let guard = sessions.read().await; + guard.get(session_id).and_then(|agent| { + agent + .try_lock() + .ok() + .map(|agent_guard| agent_guard.soft_interrupt_queue()) + }) + }; + + if let Some(queue) = queue { + register_session_interrupt_queue(queues, session_id, queue.clone()).await; + enqueue_soft_interrupt(&queue, content, urgent, source) + } else { + let session_exists = { + let guard = sessions.read().await; + guard.contains_key(session_id) + } || crate::session::session_exists(session_id); + + if !session_exists { + return false; + } + + crate::soft_interrupt_store::append( + session_id, + SoftInterruptMessage { + content, + urgent, + source, + }, + ) + .map(|_| true) + .unwrap_or_else(|err| { + crate::logging::warn(&format!( + "Failed to persist deferred soft interrupt for session {}: {}", + session_id, err + )); + false + }) + } +} diff --git a/crates/jcode-app-core/src/server/swarm.rs b/crates/jcode-app-core/src/server/swarm.rs new file mode 100644 index 0000000..6397741 --- /dev/null +++ b/crates/jcode-app-core/src/server/swarm.rs @@ -0,0 +1,3013 @@ +use super::state::{MAX_EVENT_HISTORY, fanout_session_event}; +use super::{SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan}; +use super::{persist_swarm_state_for, remove_persisted_swarm_state_for}; +use crate::agent::Agent; +use crate::plan::{PlanItem, newly_ready_item_ids}; +use crate::protocol::{NotificationType, ServerEvent}; +use crate::session::Session; +use anyhow::Result; +use futures::future::try_join_all; +use jcode_swarm_core::{ + completion_notification_message, normalize_completion_report, truncate_detail, +}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Mutex as StdMutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, RwLock, broadcast}; + +fn status_age_secs(last_status_change: Instant) -> u64 { + last_status_change.elapsed().as_secs() +} + +/// 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. +pub(super) use jcode_swarm_core::MAX_SWARM_MEMBERS; + +/// Walk the `report_back_to_session_id` chain upward from `session_id`, +/// returning the list of ancestor session ids (parent first, root last). +/// +/// The spawner/parent edge is encoded by `report_back_to_session_id`: a child +/// spawned by `P` reports back to `P`. Walking that chain reconstructs the spawn +/// tree without persisting a separate parent field. Cycles (which should never +/// happen) are guarded against with a visited set. +pub(super) fn swarm_ancestors( + members: &HashMap<String, SwarmMember>, + session_id: &str, +) -> Vec<String> { + let mut ancestors = Vec::new(); + let mut visited: HashSet<String> = HashSet::new(); + visited.insert(session_id.to_string()); + let mut current = session_id.to_string(); + while let Some(parent) = members + .get(¤t) + .and_then(|member| member.report_back_to_session_id.clone()) + { + if parent == current || !visited.insert(parent.clone()) { + break; + } + ancestors.push(parent.clone()); + current = parent; + } + ancestors +} + +/// Depth of `session_id` in the spawn tree: number of ancestors reachable via +/// the report-back chain. Root coordinators (no report-back owner) are depth 0. +/// +/// Test-only: the spawn tree no longer enforces a depth cap, so production code +/// does not consult depth. Kept (behind `cfg(test)`) because the spawn-tree tests +/// assert ancestor-chain depth directly. +#[cfg(test)] +pub(super) fn swarm_spawn_depth(members: &HashMap<String, SwarmMember>, session_id: &str) -> u32 { + swarm_ancestors(members, session_id).len() as u32 +} + +/// True when `ancestor` is `session_id` itself or any transitive spawner of it. +/// Used to decide whether a requester may manage (stop/control) a target: an +/// agent owns its entire spawned subtree. +pub(super) fn swarm_is_self_or_ancestor( + members: &HashMap<String, SwarmMember>, + ancestor: &str, + session_id: &str, +) -> bool { + ancestor == session_id + || swarm_ancestors(members, session_id) + .iter() + .any(|candidate| candidate == ancestor) +} + +const DEFAULT_SWARM_STATUS_DEBOUNCE_MEMBER_THRESHOLD: usize = 2; +const DEFAULT_SWARM_STATUS_DEBOUNCE_MS: u64 = 75; +const DEFAULT_SWARM_TASK_HEARTBEAT_SECS: u64 = 10; +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; +#[derive(Default, Clone, Copy)] +struct PendingSwarmStatusBroadcast { + scheduled: bool, + dirty: bool, +} + +fn pending_swarm_status_broadcasts() +-> &'static StdMutex<HashMap<String, PendingSwarmStatusBroadcast>> { + static PENDING: OnceLock<StdMutex<HashMap<String, PendingSwarmStatusBroadcast>>> = + OnceLock::new(); + PENDING.get_or_init(|| StdMutex::new(HashMap::new())) +} + +fn swarm_status_debounce_member_threshold() -> usize { + static CACHED: OnceLock<AtomicUsize> = OnceLock::new(); + CACHED + .get_or_init(|| { + let configured = std::env::var("JCODE_SWARM_STATUS_DEBOUNCE_MEMBER_THRESHOLD") + .ok() + .and_then(|value| value.trim().parse::<usize>().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_SWARM_STATUS_DEBOUNCE_MEMBER_THRESHOLD); + AtomicUsize::new(configured) + }) + .load(Ordering::Relaxed) +} + +fn swarm_status_debounce_ms() -> u64 { + static CACHED: OnceLock<AtomicU64> = OnceLock::new(); + CACHED + .get_or_init(|| { + let configured = std::env::var("JCODE_SWARM_STATUS_DEBOUNCE_MS") + .ok() + .and_then(|value| value.trim().parse::<u64>().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_SWARM_STATUS_DEBOUNCE_MS); + AtomicU64::new(configured) + }) + .load(Ordering::Relaxed) +} + +fn configured_positive_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::<u64>().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + +pub(super) fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn log_swarm_lifecycle(phase: &str, fields: Vec<(&str, String)>) { + crate::logging::event_info( + "SWARM_LIFECYCLE", + Vec::from([("phase", phase.to_string())]) + .into_iter() + .chain(fields) + .collect::<Vec<_>>(), + ); +} + +pub(super) fn swarm_task_heartbeat_interval() -> Duration { + Duration::from_secs(configured_positive_u64( + "JCODE_SWARM_TASK_HEARTBEAT_SECS", + DEFAULT_SWARM_TASK_HEARTBEAT_SECS, + )) +} + +pub(super) fn swarm_task_stale_after() -> Duration { + Duration::from_secs(configured_positive_u64( + "JCODE_SWARM_TASK_STALE_AFTER_SECS", + DEFAULT_SWARM_TASK_STALE_AFTER_SECS, + )) +} + +pub(super) fn swarm_task_sweep_interval() -> Duration { + Duration::from_secs(configured_positive_u64( + "JCODE_SWARM_TASK_SWEEP_INTERVAL_SECS", + DEFAULT_SWARM_TASK_SWEEP_INTERVAL_SECS, + )) +} + +/// How long terminal members remain visible in the active swarm listing. This +/// keeps completion reports available for inspection without allowing durable +/// history to grow forever. +pub(super) fn swarm_terminal_member_retention() -> Duration { + Duration::from_secs(configured_positive_u64( + "JCODE_SWARM_TERMINAL_MEMBER_RETENTION_SECS", + DEFAULT_SWARM_TERMINAL_MEMBER_RETENTION_SECS, + )) +} + +/// How often the live server removes terminal members whose retention window +/// has elapsed. Startup loading performs the same pruning synchronously. +pub(super) fn swarm_terminal_member_gc_interval() -> Duration { + Duration::from_secs(configured_positive_u64( + "JCODE_SWARM_TERMINAL_MEMBER_GC_INTERVAL_SECS", + DEFAULT_SWARM_TERMINAL_MEMBER_GC_INTERVAL_SECS, + )) +} + +/// 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. +pub(super) fn member_status_is_terminal(status: &str) -> bool { + matches!( + status, + "completed" | "done" | "failed" | "stopped" | "crashed" | "closed" | "disconnected" + ) +} + +pub(super) fn member_consumes_swarm_capacity(member: &SwarmMember) -> bool { + !member_status_is_terminal(&member.status) +} + +pub(super) fn expired_terminal_member_ids( + members: &HashMap<String, SwarmMember>, + retention: Duration, +) -> Vec<String> { + members + .values() + .filter(|member| member_status_is_terminal(&member.status)) + .filter(|member| member.last_status_change.elapsed() >= retention) + .map(|member| member.session_id.clone()) + .collect() +} + +/// Lifecycle statuses that mean a member can no longer drive an assignment: +/// the session's agent loop is gone, so no heartbeat or turn end will ever +/// arrive for tasks it holds. +pub(super) fn member_status_is_dead(status: &str) -> bool { + matches!(status, "failed" | "stopped" | "crashed") +} + +/// Outcome of salvaging one dead member's plan assignments. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct DeadMemberSalvage { + /// Tasks released back to `queued` for automatic re-dispatch. + pub requeued_task_ids: Vec<String>, + /// Tasks marked `failed` because the automatic reclaim cap was reached. + pub failed_task_ids: Vec<String>, +} + +impl DeadMemberSalvage { + pub(super) fn is_empty(&self) -> bool { + self.requeued_task_ids.is_empty() && self.failed_task_ids.is_empty() + } + + /// Human-readable notification body for the coordinator/owner. + fn describe(&self, worker_label: &str) -> String { + let mut parts = vec![format!( + "⚠ Worker {} died while holding swarm task assignment(s).", + worker_label + )]; + if !self.requeued_task_ids.is_empty() { + parts.push(format!( + "Requeued for automatic re-dispatch: {}.", + self.requeued_task_ids.join(", ") + )); + } + if !self.failed_task_ids.is_empty() { + parts.push(format!( + "Marked failed (automatic reclaim cap reached): {}. Use retry or assign_task to redispatch explicitly.", + self.failed_task_ids.join(", ") + )); + } + parts.push( + "Queued tasks will be picked up by assign_next/run_plan; check plan_status for details." + .to_string(), + ); + parts.join(" ") + } +} + +/// Requeue (or, past [`crate::plan::MAX_DEAD_ASSIGNEE_RECLAIMS`], fail) every +/// non-terminal plan item assigned to `session_id`. +/// +/// This is the eager counterpart to the assign-time stranded-task reclaim: a +/// worker that crashes, stops, or leaves the swarm mid-task leaves its items +/// `running`/`queued` and assigned to a corpse, where the scheduler cannot see +/// them and a driving `run_plan` stalls into its transient-stall error. +/// Salvaging at the moment the member dies converts that silent strand into +/// normal queued work. Uses the same per-node reclaim counter and cap as the +/// assign-time path so repeatedly lethal nodes fail loudly instead of cycling +/// workers forever. +fn salvage_plan_assignments_of(plan: &mut VersionedPlan, session_id: &str) -> DeadMemberSalvage { + let now_ms = now_unix_ms(); + let mut outcome = DeadMemberSalvage::default(); + let assigned_ids: Vec<String> = plan + .items + .iter() + .filter(|item| { + item.assigned_to.as_deref() == Some(session_id) + && !crate::plan::is_terminal_status(&item.status) + }) + .map(|item| item.id.clone()) + .collect(); + for task_id in assigned_ids { + let reclaims = plan + .task_progress + .get(&task_id) + .and_then(|progress| progress.dead_assignee_reclaims) + .unwrap_or(0); + if reclaims >= crate::plan::MAX_DEAD_ASSIGNEE_RECLAIMS { + if let Some(item) = plan.items.iter_mut().find(|item| item.id == task_id) { + item.status = "failed".to_string(); + item.assigned_to = None; + } + let progress = plan.task_progress.entry(task_id.clone()).or_default(); + progress.assigned_session_id = None; + progress.completed_at_unix_ms = Some(now_ms); + progress.stale_since_unix_ms = None; + progress.checkpoint_summary = Some(truncate_detail( + &format!( + "failed: assigned worker {} died and the automatic reclaim cap was reached", + session_id + ), + 120, + )); + plan.version += 1; + outcome.failed_task_ids.push(task_id); + } else if crate::plan::reclaim_stranded_assignment(plan, &task_id) { + if let Some(item) = plan.items.iter_mut().find(|item| item.id == task_id) { + item.status = "queued".to_string(); + } + let progress = plan.task_progress.entry(task_id.clone()).or_default(); + progress.stale_since_unix_ms = None; + outcome.requeued_task_ids.push(task_id); + } + } + outcome +} + +/// Salvage `session_id`'s plan assignments in `swarm_id`, then persist, +/// broadcast the plan change, and notify the swarm coordinator so the death is +/// visible instead of silent. No-ops (and skips all I/O) when the member held +/// no non-terminal assignments. +pub(super) async fn salvage_assignments_of_dead_member( + session_id: &str, + swarm_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) -> DeadMemberSalvage { + let outcome = { + let mut plans = swarm_plans.write().await; + match plans.get_mut(swarm_id) { + Some(plan) => salvage_plan_assignments_of(plan, session_id), + None => DeadMemberSalvage::default(), + } + }; + if outcome.is_empty() { + return outcome; + } + + log_swarm_lifecycle( + "dead_member_tasks_salvaged", + vec![ + ("session_id", session_id.to_string()), + ("swarm_id", swarm_id.to_string()), + ("requeued_task_ids", outcome.requeued_task_ids.join(",")), + ("failed_task_ids", outcome.failed_task_ids.join(",")), + ], + ); + + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + broadcast_swarm_plan( + swarm_id, + Some("task_salvaged_dead_worker".to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + notify_coordinator_of_salvage( + session_id, + swarm_id, + &outcome, + swarm_members, + swarm_coordinators, + ) + .await; + outcome +} + +/// Deliver a salvage notification to the swarm's current coordinator (when it +/// is not the dead session itself). +async fn notify_coordinator_of_salvage( + session_id: &str, + swarm_id: &str, + outcome: &DeadMemberSalvage, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) { + let coordinator_id = { + let coordinators = swarm_coordinators.read().await; + coordinators.get(swarm_id).cloned() + }; + let Some(coordinator_id) = coordinator_id.filter(|id| id != session_id) else { + return; + }; + let label = { + let members = swarm_members.read().await; + members + .get(session_id) + .and_then(|member| member.friendly_name.clone()) + } + .unwrap_or_else(|| session_id[..8.min(session_id.len())].to_string()); + let _ = fanout_session_event( + swarm_members, + &coordinator_id, + ServerEvent::Notification { + from_session: session_id.to_string(), + from_name: Some(label.clone()), + notification_type: NotificationType::Message { + scope: Some("swarm".to_string()), + channel: None, + tldr: None, + }, + message: outcome.describe(&label), + }, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "task progress touch updates durable progress plus swarm persistence and coordinator-facing state in one helper" +)] +pub(super) async fn touch_swarm_task_progress( + swarm_id: &str, + task_id: &str, + assigned_session_id: Option<&str>, + detail: Option<String>, + checkpoint_summary: Option<String>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) -> bool { + let now_ms = now_unix_ms(); + let revived = { + let mut plans = swarm_plans.write().await; + let Some(plan) = plans.get_mut(swarm_id) else { + return false; + }; + let Some(item) = plan.items.iter_mut().find(|item| item.id == task_id) else { + return false; + }; + let progress = plan.task_progress.entry(task_id.to_string()).or_default(); + if let Some(session_id) = assigned_session_id { + progress.assigned_session_id = Some(session_id.to_string()); + } + // Heartbeats/checkpoints are proof of life for the assigned session: + // fold them into the member activity clock so swarm status reflects + // busy workers whose lifecycle status has not changed in a while. + if let Some(session_id) = progress.assigned_session_id.as_deref() { + crate::session_metrics::record_activity(session_id); + } + progress.last_heartbeat_unix_ms = Some(now_ms); + progress.heartbeat_count = Some(progress.heartbeat_count.unwrap_or(0) + 1); + if let Some(detail) = detail { + progress.last_detail = Some(truncate_detail(&detail, 120)); + } + if let Some(summary) = checkpoint_summary { + progress.last_checkpoint_unix_ms = Some(now_ms); + progress.checkpoint_summary = Some(truncate_detail(&summary, 120)); + progress.checkpoint_count = Some(progress.checkpoint_count.unwrap_or(0) + 1); + } + if item.status == "running_stale" { + item.status = "running".to_string(); + progress.stale_since_unix_ms = None; + plan.version += 1; + true + } else { + false + } + }; + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + revived +} + +pub(super) async fn refresh_swarm_task_staleness( + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, +) { + let now_ms = now_unix_ms(); + let stale_after_ms = swarm_task_stale_after().as_millis() as u64; + let changed_swarm_ids = { + let mut plans = swarm_plans.write().await; + let mut changed = Vec::new(); + for (swarm_id, plan) in plans.iter_mut() { + let mut swarm_changed = false; + for item in &mut plan.items { + if !matches!(item.status.as_str(), "running" | "running_stale") { + continue; + } + let progress = plan.task_progress.entry(item.id.clone()).or_default(); + let last_heartbeat = progress + .last_heartbeat_unix_ms + .or(progress.started_at_unix_ms) + .or(progress.assigned_at_unix_ms); + let is_stale = last_heartbeat + .map(|ts| now_ms.saturating_sub(ts) >= stale_after_ms) + .unwrap_or(true); + match (item.status.as_str(), is_stale) { + ("running", true) => { + item.status = "running_stale".to_string(); + progress.stale_since_unix_ms.get_or_insert(now_ms); + plan.version += 1; + swarm_changed = true; + } + ("running_stale", false) => { + item.status = "running".to_string(); + progress.stale_since_unix_ms = None; + plan.version += 1; + swarm_changed = true; + } + _ => {} + } + } + if swarm_changed { + changed.push(swarm_id.clone()); + } + } + changed + }; + + for swarm_id in changed_swarm_ids { + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(&swarm_id, &swarm_state).await; + broadcast_swarm_plan( + &swarm_id, + Some("task_staleness_changed".to_string()), + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; + } + + // Second phase: salvage in-flight items whose assignee is dead. Staleness + // marking above only reflects missing heartbeats; when the assigned member + // is gone from the swarm or sits in a terminal lifecycle status, no + // heartbeat or turn-end will ever arrive, so the item must be requeued + // (or failed at the reclaim cap) instead of pulsing running_stale forever. + // A terminal-status member gets a grace period before salvage: reload + // recovery briefly marks resumable members `crashed` before restoring + // them, and salvaging inside that window would double-assign their work. + let salvage_grace = swarm_task_stale_after(); + let salvage_candidates: Vec<(String, String)> = { + let plans = swarm_plans.read().await; + let members = swarm_members.read().await; + let mut pairs = std::collections::BTreeSet::new(); + for (swarm_id, plan) in plans.iter() { + for item in &plan.items { + if !matches!(item.status.as_str(), "running" | "running_stale" | "queued") { + continue; + } + let assignee = item.assigned_to.as_deref().or_else(|| { + plan.task_progress + .get(&item.id) + .and_then(|progress| progress.assigned_session_id.as_deref()) + }); + let Some(assignee) = assignee else { + continue; + }; + let assignee_is_dead = match members.get(assignee) { + None => true, + Some(member) => { + member_status_is_dead(&member.status) + && member.last_status_change.elapsed() >= salvage_grace + } + }; + if assignee_is_dead { + pairs.insert((swarm_id.clone(), assignee.to_string())); + } + } + } + pairs.into_iter().collect() + }; + for (swarm_id, session_id) in salvage_candidates { + salvage_assignments_of_dead_member( + &session_id, + &swarm_id, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + ) + .await; + } +} + +fn swarm_broadcast_key( + swarm_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) -> String { + format!( + "{:p}:{:p}:{swarm_id}", + Arc::as_ptr(swarm_members), + Arc::as_ptr(swarms_by_id) + ) +} + +async fn broadcast_swarm_status_now( + session_ids: Vec<String>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) { + if session_ids.is_empty() { + return; + } + + let members_guard = swarm_members.read().await; + let members_list: Vec<crate::protocol::SwarmMemberStatus> = session_ids + .iter() + .filter_map(|sid| { + members_guard + .get(sid) + .map(|m| crate::protocol::SwarmMemberStatus { + session_id: m.session_id.clone(), + friendly_name: m.friendly_name.clone(), + status: m.status.clone(), + detail: m.detail.clone(), + task_label: m.task_label.clone(), + role: Some(m.role.clone()), + is_headless: Some(m.is_headless), + live_attachments: Some(m.event_txs.len()), + status_age_secs: Some(status_age_secs(m.last_status_change)), + output_tail: m.output_tail.clone(), + report_back_to_session_id: m.report_back_to_session_id.clone(), + todo_progress: m.todo_progress, + todo_items: m.todo_items.clone(), + runtime: crate::protocol::SwarmMemberRuntime { + model: m.runtime.model.clone(), + provider: m.runtime.provider.clone(), + auth_method: m.runtime.auth_method.clone(), + effort: m.runtime.effort.clone(), + elapsed_secs: if matches!( + m.status.as_str(), + "running" | "streaming" | "thinking" + ) { + Some(m.joined_at.elapsed().as_secs()) + } else { + Some(m.runtime.elapsed_secs.unwrap_or(0)) + }, + }, + }) + }) + .collect(); + + drop(members_guard); + let event = ServerEvent::SwarmStatus { + members: members_list, + }; + for sid in session_ids { + let _ = fanout_session_event(swarm_members, &sid, event.clone()).await; + } +} + +pub(super) async fn broadcast_swarm_status( + swarm_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + let session_ids: Vec<String> = { + let swarms = swarms_by_id.read().await; + swarms + .get(swarm_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + }; + if session_ids.is_empty() { + return; + } + + if session_ids.len() < swarm_status_debounce_member_threshold() { + broadcast_swarm_status_now(session_ids, swarm_members).await; + return; + } + + let key = swarm_broadcast_key(swarm_id, swarm_members, swarms_by_id); + let should_spawn = { + let mut pending = pending_swarm_status_broadcasts() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = pending.entry(key.clone()).or_default(); + if entry.scheduled { + entry.dirty = true; + false + } else { + entry.scheduled = true; + entry.dirty = false; + true + } + }; + + if !should_spawn { + return; + } + + let swarm_id = swarm_id.to_string(); + let swarm_members = Arc::clone(swarm_members); + let swarms_by_id = Arc::clone(swarms_by_id); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(swarm_status_debounce_ms())).await; + let session_ids: Vec<String> = { + let swarms = swarms_by_id.read().await; + swarms + .get(&swarm_id) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + }; + broadcast_swarm_status_now(session_ids, &swarm_members).await; + + let mut pending = pending_swarm_status_broadcasts() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(entry) = pending.get_mut(&key) else { + break; + }; + if entry.dirty { + entry.dirty = false; + continue; + } + pending.remove(&key); + break; + } + }); +} + +/// Broadcast the authoritative swarm plan snapshot. +/// +/// Plan snapshots are sent to explicit plan participants. If a plan has no +/// participants yet, fall back to all current swarm members. +pub(super) async fn broadcast_swarm_plan( + swarm_id: &str, + reason: Option<String>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + broadcast_swarm_plan_with_previous( + swarm_id, + reason, + None, + swarm_plans, + swarm_members, + swarms_by_id, + ) + .await; +} + +pub(super) async fn broadcast_swarm_plan_with_previous( + swarm_id: &str, + reason: Option<String>, + previous_items: Option<&[PlanItem]>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, +) { + let (version, items, summary, mut participants): ( + u64, + Vec<PlanItem>, + crate::protocol::PlanGraphStatus, + Vec<String>, + ) = { + let plans = swarm_plans.read().await; + let Some(vp) = plans.get(swarm_id) else { + return; + }; + let newly_ready_ids = previous_items + .map(|before| newly_ready_item_ids(before, &vp.items)) + .unwrap_or_default(); + let mut p: Vec<String> = vp.participants.iter().cloned().collect(); + p.sort(); + ( + vp.version, + vp.items.clone(), + crate::protocol::PlanGraphStatus::from_versioned_plan( + swarm_id, + vp, + Some(3), + newly_ready_ids, + ), + p, + ) + }; + + if participants.is_empty() { + let swarms = swarms_by_id.read().await; + participants = swarms + .get(swarm_id) + .map(|s| { + let mut ids: Vec<String> = s.iter().cloned().collect(); + ids.sort(); + ids + }) + .unwrap_or_default(); + } + + if participants.is_empty() { + return; + } + + let item_count = items.len(); + let reason_label = reason.clone().unwrap_or_else(|| "unspecified".to_string()); + let event = ServerEvent::SwarmPlan { + swarm_id: swarm_id.to_string(), + version, + items, + participants: participants.clone(), + reason, + summary: Some(summary), + }; + + let members = swarm_members.read().await; + let participant_count = participants.len(); + let mut delivered_count = 0usize; + for sid in participants { + if let Some(member) = members.get(&sid) + && member.event_tx.send(event.clone()).is_ok() + { + delivered_count += 1; + } + } + log_swarm_lifecycle( + "plan_broadcast", + vec![ + ("swarm_id", swarm_id.to_string()), + ("version", version.to_string()), + ("item_count", item_count.to_string()), + ("participant_count", participant_count.to_string()), + ("delivered_count", delivered_count.to_string()), + ("reason", reason_label), + ], + ); +} + +/// Send the current swarm plan snapshot to ONE session (subscribe/resume +/// refresh). Unlike [`broadcast_swarm_plan`] this does not fan out to all +/// participants: reconnecting clients would otherwise show no plan graph +/// until the next plan mutation happens to broadcast. +pub(super) async fn send_swarm_plan_to_session( + session_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) { + let swarm_id = { + let members = swarm_members.read().await; + members + .get(session_id) + .and_then(|member| member.swarm_id.clone()) + }; + let Some(swarm_id) = swarm_id else { + return; + }; + + let event = { + let plans = swarm_plans.read().await; + let Some(vp) = plans.get(&swarm_id) else { + return; + }; + if vp.items.is_empty() { + return; + } + let mut participants: Vec<String> = vp.participants.iter().cloned().collect(); + participants.sort(); + ServerEvent::SwarmPlan { + swarm_id: swarm_id.clone(), + version: vp.version, + items: vp.items.clone(), + participants, + reason: Some("reconnect".to_string()), + summary: Some(crate::protocol::PlanGraphStatus::from_versioned_plan( + &swarm_id, + vp, + Some(3), + Vec::new(), + )), + } + }; + + let members = swarm_members.read().await; + if let Some(member) = members.get(session_id) { + let _ = member.event_tx.send(event); + } +} + +pub(super) async fn rename_plan_participant( + swarm_id: &str, + old_session_id: &str, + new_session_id: &str, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) { + 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()); + } + } + } +} + +pub(super) async fn remove_plan_participant( + swarm_id: &str, + session_id: &str, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) { + let mut plans = swarm_plans.write().await; + if let Some(vp) = plans.get_mut(swarm_id) { + vp.participants.remove(session_id); + } +} + +pub(super) async fn remove_session_from_swarm( + session_id: &str, + swarm_id: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>, + swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>, +) { + let started = Instant::now(); + log_swarm_lifecycle( + "member_remove_start", + vec![ + ("session_id", session_id.to_string()), + ("swarm_id", swarm_id.to_string()), + ], + ); + // Capture the departing member's own spawner before any teardown. Some + // callers remove the member from the map before calling us, so this is + // best-effort: when unavailable the orphan-reparenting below falls back to + // the swarm coordinator. + let departing_parent: Option<String> = { + let members = swarm_members.read().await; + members + .get(session_id) + .and_then(|member| member.report_back_to_session_id.clone()) + }; + // A leaving member can no longer drive its plan assignments (crash, stop, + // disconnect, feature-off all funnel through here). Salvage before any + // membership state is torn down so the coordinator notification can still + // resolve names and fan out. + salvage_assignments_of_dead_member( + session_id, + swarm_id, + swarm_members, + swarms_by_id, + swarm_plans, + swarm_coordinators, + ) + .await; + remove_plan_participant(swarm_id, session_id, swarm_plans).await; + + { + let mut swarms = swarms_by_id.write().await; + if let Some(swarm) = swarms.get_mut(swarm_id) { + swarm.remove(session_id); + if swarm.is_empty() { + swarms.remove(swarm_id); + } + } + } + + let was_coordinator = { + let coordinators = swarm_coordinators.read().await; + coordinators + .get(swarm_id) + .map(|id| id == session_id) + .unwrap_or(false) + }; + + let mut elected_coordinator = None; + if was_coordinator { + let new_coordinator = { + let swarms = swarms_by_id.read().await; + let members = swarm_members.read().await; + swarms.get(swarm_id).and_then(|swarm| { + swarm + .iter() + .filter_map(|id| { + members + .get(id) + .filter(|member| !member.is_headless) + .map(|_| id.clone()) + }) + .min() + }) + }; + + { + let mut coordinators = swarm_coordinators.write().await; + coordinators.remove(swarm_id); + if let Some(ref new_id) = new_coordinator { + coordinators.insert(swarm_id.to_string(), new_id.clone()); + } + } + + if let Some(new_id) = new_coordinator { + elected_coordinator = Some(new_id.clone()); + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(&new_id) { + member.role = "coordinator".to_string(); + } + } + let mut plans = swarm_plans.write().await; + if let Some(vp) = plans.get_mut(swarm_id) { + vp.participants.insert(new_id.clone()); + } + let members = swarm_members.read().await; + if let Some(member) = members.get(&new_id) { + let _ = member.event_tx.send(ServerEvent::Notification { + from_session: new_id.clone(), + from_name: member.friendly_name.clone(), + notification_type: NotificationType::Message { + scope: Some("swarm".to_string()), + channel: None, + tldr: None, + }, + message: "You are now the coordinator for this swarm.".to_string(), + }); + } + } + } + + { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(session_id) { + member.role = "agent".to_string(); + } + } + + // Reparent the departing member's direct children so the spawn tree never + // holds dangling report-back edges. Orphaned subtrees would otherwise + // silently change ownership semantics: stop permissions, subtree broadcast + // scope, and completion report-back all walk this chain. Children are + // attached to their grandparent when it is still a live member of this + // swarm, otherwise to the current coordinator, otherwise they become + // roots (report_back_to_session_id = None). + let fallback_parent: Option<String> = { + let grandparent_is_live = if let Some(ref parent) = departing_parent { + parent != session_id && { + let members = swarm_members.read().await; + members + .get(parent) + .is_some_and(|member| member.swarm_id.as_deref() == Some(swarm_id)) + } + } else { + false + }; + if grandparent_is_live { + departing_parent.clone() + } else { + let coordinators = swarm_coordinators.read().await; + coordinators + .get(swarm_id) + .filter(|coordinator| coordinator.as_str() != session_id) + .cloned() + } + }; + let mut reparented: Vec<String> = Vec::new(); + { + let mut members = swarm_members.write().await; + for member in members.values_mut() { + if member.swarm_id.as_deref() == Some(swarm_id) + && member.report_back_to_session_id.as_deref() == Some(session_id) + { + member.report_back_to_session_id = fallback_parent + .clone() + .filter(|parent| parent != &member.session_id); + reparented.push(member.session_id.clone()); + } + } + } + if !reparented.is_empty() { + log_swarm_lifecycle( + "member_remove_reparent", + vec![ + ("session_id", session_id.to_string()), + ("swarm_id", swarm_id.to_string()), + ( + "new_parent", + fallback_parent + .clone() + .unwrap_or_else(|| "none (promoted to root)".to_string()), + ), + ("reparented_children", reparented.join(",")), + ], + ); + } + + if swarm_plans.read().await.contains_key(swarm_id) { + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + persist_swarm_state_for(swarm_id, &swarm_state).await; + } else { + let swarm_state = SwarmState { + members: Arc::clone(swarm_members), + swarms_by_id: Arc::clone(swarms_by_id), + plans: Arc::clone(swarm_plans), + coordinators: Arc::clone(swarm_coordinators), + }; + remove_persisted_swarm_state_for(swarm_id, &swarm_state).await; + } + + let remaining_member_count = swarms_by_id + .read() + .await + .get(swarm_id) + .map(|members| members.len()) + .unwrap_or_default(); + log_swarm_lifecycle( + "member_remove_done", + vec![ + ("session_id", session_id.to_string()), + ("swarm_id", swarm_id.to_string()), + ("was_coordinator", was_coordinator.to_string()), + ( + "new_coordinator_session_id", + elected_coordinator.unwrap_or_else(|| "none".to_string()), + ), + ("remaining_member_count", remaining_member_count.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + broadcast_swarm_status(swarm_id, swarm_members, swarms_by_id).await; +} + +/// Set a member's stable task label, derived from its spawn prompt or task +/// assignment. Unlike `detail` (transient status text), the label survives +/// status churn so UIs can always answer "what was this agent for?". A later +/// assignment overwrites the label: the member is now doing that task. +pub(super) async fn set_member_task_label( + session_id: &str, + task_text: &str, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, +) { + let Some(label) = jcode_swarm_core::derive_swarm_task_label(task_text) else { + return; + }; + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(session_id) { + member.task_label = Some(label); + } +} + +pub(super) async fn record_swarm_event( + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, + session_id: String, + session_name: Option<String>, + swarm_id: Option<String>, + event: SwarmEventType, +) { + let swarm_event = SwarmEvent { + id: event_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst), + session_id, + session_name, + swarm_id, + event, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + }; + let _ = swarm_event_tx.send(swarm_event.clone()); + let mut history = event_history.write().await; + history.push_back(swarm_event); + if history.len() > MAX_EVENT_HISTORY { + history.pop_front(); + } +} + +pub(super) async fn record_swarm_event_for_session( + session_id: &str, + event: SwarmEventType, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>, + event_counter: &Arc<std::sync::atomic::AtomicU64>, + swarm_event_tx: &broadcast::Sender<SwarmEvent>, +) { + let (session_name, swarm_id) = { + let members = swarm_members.read().await; + if let Some(member) = members.get(session_id) { + (member.friendly_name.clone(), member.swarm_id.clone()) + } else { + (None, None) + } + }; + record_swarm_event( + event_history, + event_counter, + swarm_event_tx, + session_id.to_string(), + session_name, + swarm_id, + event, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "member status updates need swarm membership, broadcast state, and optional event history sinks" +)] +pub(super) async fn update_member_status( + session_id: &str, + status: &str, + detail: Option<String>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: Option<&Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>>, + event_counter: Option<&Arc<std::sync::atomic::AtomicU64>>, + swarm_event_tx: Option<&broadcast::Sender<SwarmEvent>>, +) { + update_member_status_with_report( + session_id, + status, + detail, + None, + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ) + .await; +} + +#[expect( + clippy::too_many_arguments, + reason = "member status updates need swarm membership, broadcast state, optional report text, and event history sinks" +)] +pub(super) async fn update_member_status_with_report( + session_id: &str, + status: &str, + detail: Option<String>, + completion_report: Option<String>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: Option<&Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>>, + event_counter: Option<&Arc<std::sync::atomic::AtomicU64>>, + swarm_event_tx: Option<&broadcast::Sender<SwarmEvent>>, +) { + update_member_status_with_report_tldr( + session_id, + status, + detail, + completion_report, + None, + swarm_members, + swarms_by_id, + event_history, + event_counter, + swarm_event_tx, + ) + .await +} + +#[expect( + clippy::too_many_arguments, + reason = "member status updates need swarm membership, broadcast state, optional report text, and event history sinks" +)] +pub(super) async fn update_member_status_with_report_tldr( + session_id: &str, + status: &str, + detail: Option<String>, + completion_report: Option<String>, + report_tldr: Option<String>, + swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>, + swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>, + event_history: Option<&Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>>, + event_counter: Option<&Arc<std::sync::atomic::AtomicU64>>, + swarm_event_tx: Option<&broadcast::Sender<SwarmEvent>>, +) { + let completion_report = normalize_completion_report(completion_report); + let detail_present = detail.is_some(); + let ( + swarm_id, + agent_name, + member_changed, + status_changed, + old_status, + _is_headless, + report_back_to_session_id, + ) = { + let mut members = swarm_members.write().await; + if let Some(member) = members.get_mut(session_id) { + let previous_status = member.status.clone(); + let status_changed = member.status != status; + let detail_changed = member.detail != detail; + let report_changed = + completion_report.is_some() && member.latest_completion_report != completion_report; + let member_changed = status_changed || detail_changed || report_changed; + if status_changed { + member.last_status_change = Instant::now(); + if matches!(status, "running" | "streaming" | "thinking") { + member.runtime.elapsed_secs = None; + } else if matches!( + previous_status.as_str(), + "running" | "streaming" | "thinking" + ) { + member.runtime.elapsed_secs = Some(member.joined_at.elapsed().as_secs()); + } + } + let name = member.friendly_name.clone(); + let is_headless = member.is_headless; + let report_back_to_session_id = member.report_back_to_session_id.clone(); + member.status = status.to_string(); + member.detail = detail; + // Clear any live output tail when the worker reaches a terminal or + // idle state so the inline gallery viewport doesn't keep showing + // stale in-progress text after the turn finishes. + if matches!( + status, + "ready" | "completed" | "done" | "failed" | "crashed" | "stopped" + ) { + member.output_tail = None; + } + if completion_report.is_some() { + member.latest_completion_report = completion_report.clone(); + } + ( + member.swarm_id.clone(), + name, + member_changed, + status_changed, + previous_status, + is_headless, + report_back_to_session_id, + ) + } else { + (None, None, false, false, String::new(), false, None) + } + }; + if let Some(ref id) = swarm_id { + if !member_changed { + return; + } + + log_swarm_lifecycle( + "member_status_updated", + vec![ + ("session_id", session_id.to_string()), + ("swarm_id", id.clone()), + ("old_status", old_status.clone()), + ("new_status", status.to_string()), + ("status_changed", status_changed.to_string()), + ("detail_present", detail_present.to_string()), + ( + "completion_report_present", + completion_report.is_some().to_string(), + ), + ( + "report_back_to_session_id", + report_back_to_session_id + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ], + ); + + if status_changed + && let (Some(history), Some(counter), Some(tx)) = + (event_history, event_counter, swarm_event_tx) + { + record_swarm_event( + history, + counter, + tx, + session_id.to_string(), + agent_name.clone(), + Some(id.clone()), + SwarmEventType::StatusChange { + old_status: old_status.clone(), + new_status: status.to_string(), + }, + ) + .await; + } + + broadcast_swarm_status(id, swarm_members, swarms_by_id).await; + + let should_notify_coordinator = status_changed + && ((status == "completed") + || (report_back_to_session_id.is_some() + && old_status == "running" + && matches!(status, "ready" | "failed" | "stopped")) + // A crash is never routine: notify whoever is responsible + // (owner, else coordinator) whenever a member dies while it + // was doing or holding work, so worker deaths cannot pass + // silently. + || (status == "crashed" + && matches!( + old_status.as_str(), + "running" | "running_stale" | "queued" + ))); + if should_notify_coordinator { + let fallback_coordinator_id = + if report_back_to_session_id.as_deref() == Some(session_id) { + None + } else { + let members = swarm_members.read().await; + members + .values() + .find(|m| { + m.swarm_id.as_deref() == Some(id) + && m.role == "coordinator" + && m.session_id != session_id + }) + .map(|m| m.session_id.clone()) + }; + let recipient_session_id = report_back_to_session_id + .clone() + .filter(|owner_id| owner_id != session_id) + .or(fallback_coordinator_id); + if let Some(recipient_session_id) = recipient_session_id { + let name = agent_name + .as_deref() + .unwrap_or(&session_id[..8.min(session_id.len())]); + let msg = + completion_notification_message(name, status, completion_report.as_deref()); + let _ = fanout_session_event( + swarm_members, + &recipient_session_id, + ServerEvent::Notification { + from_session: session_id.to_string(), + from_name: agent_name.clone(), + notification_type: NotificationType::Message { + scope: Some("swarm".to_string()), + channel: None, + tldr: report_tldr.clone(), + }, + message: msg, + }, + ) + .await; + } + } + } +} + +pub(super) async fn run_swarm_task( + agent: Arc<Mutex<Agent>>, + description: &str, + subagent_type: &str, + prompt: &str, +) -> Result<String> { + let started = Instant::now(); + let (provider, registry, session_id, working_dir, coordinator_model, provider_key, route) = { + let agent = agent.lock().await; + ( + agent.provider_fork(), + agent.registry(), + agent.session_id().to_string(), + agent.working_dir().map(PathBuf::from), + agent.provider_model(), + agent.session_provider_key(), + agent.session_route_api_method(), + ) + }; + let parent_session_id = session_id.clone(); + let mut session = Session::create( + Some(session_id), + Some(format!("{} (@{} swarm)", description, subagent_type)), + ); + let child_session_id = session.id.clone(); + session.model = Some(coordinator_model); + // Inherit the coordinator's exact auth identity so the forked worker keeps + // the same provider/auth route (OAuth vs API, openai-compatible profile) + // instead of silently falling back to the config default on persistence. + session.provider_key = provider_key; + session.route_api_method = route; + if let Some(dir) = working_dir { + session.working_dir = Some(dir.display().to_string()); + } + session.save()?; + + log_swarm_lifecycle( + "task_start", + vec![ + ("parent_session_id", parent_session_id.clone()), + ("child_session_id", child_session_id.clone()), + ("subagent_type", subagent_type.to_string()), + ("description_chars", description.chars().count().to_string()), + ("prompt_chars", prompt.chars().count().to_string()), + ], + ); + + let mut allowed: HashSet<String> = registry.tool_names().await.into_iter().collect(); + for blocked in ["subagent", "task", "todo", "todowrite", "todoread"] { + allowed.remove(blocked); + } + crate::config::config() + .tools + .apply_to_allowed_set(&mut allowed); + + let mut worker = Agent::new_with_session(provider, registry, session, Some(allowed)); + match worker.run_once_capture(prompt).await { + Ok(output) => { + log_swarm_lifecycle( + "task_done", + vec![ + ("parent_session_id", parent_session_id), + ("child_session_id", child_session_id), + ("subagent_type", subagent_type.to_string()), + ("output_chars", output.chars().count().to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + Ok(output) + } + Err(error) => { + crate::logging::event_warn( + "SWARM_LIFECYCLE", + vec![ + ("phase", "task_error".to_string()), + ("parent_session_id", parent_session_id), + ("child_session_id", child_session_id), + ("subagent_type", subagent_type.to_string()), + ("error", error.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + Err(error) + } + } +} + +pub(super) async fn run_swarm_message(agent: Arc<Mutex<Agent>>, message: &str) -> Result<String> { + let started = Instant::now(); + log_swarm_lifecycle( + "message_start", + vec![("message_chars", message.chars().count().to_string())], + ); + let working_dir = { + let agent = agent.lock().await; + agent.working_dir().map(|dir| dir.to_string()) + }; + let working_dir_hint = working_dir + .as_deref() + .map(|dir| format!("Working directory: {}\n", dir)) + .unwrap_or_default(); + + let planner_prompt = format!( + "{working_dir_hint}You are a task planner. Break the request into 2-4 subtasks. \ +Return ONLY a JSON array of objects with keys: description, prompt, subagent_type. \ +No extra text.\n\nRequest:\n{message}" + ); + + let plan_text = { + let mut agent = agent.lock().await; + agent.run_once_capture(&planner_prompt).await? + }; + + let mut tasks = parse_swarm_tasks(&plan_text); + if tasks.is_empty() { + tasks.push(SwarmTaskSpec { + description: "Main task".to_string(), + prompt: message.to_string(), + subagent_type: Some("general".to_string()), + }); + } + log_swarm_lifecycle( + "message_plan_done", + vec![ + ("task_count", tasks.len().to_string()), + ("plan_chars", plan_text.chars().count().to_string()), + ], + ); + + let task_futures = tasks.iter().map(|task| { + let agent = agent.clone(); + let working_dir_hint = working_dir_hint.clone(); + let description = task.description.clone(); + let prompt = format!("{working_dir_hint}{}", task.prompt); + let subagent_type = task + .subagent_type + .clone() + .unwrap_or_else(|| "general".to_string()); + async move { + let output = run_swarm_task(agent, &description, &subagent_type, &prompt).await?; + Ok::<(String, String), anyhow::Error>((description, output)) + } + }); + let task_outputs = try_join_all(task_futures).await?; + + let mut integration_prompt = String::new(); + integration_prompt.push_str( + "You are the coordinator. Complete the original request using the subagent outputs below. ", + ); + integration_prompt.push_str("Do not stop early; run any requested tests and fix failures.\n\n"); + integration_prompt.push_str("Original request:\n"); + integration_prompt.push_str(message); + integration_prompt.push_str("\n\nSubagent outputs:\n"); + for (desc, output) in &task_outputs { + integration_prompt.push_str(&format!("\n--- {} ---\n{}\n", desc, output)); + } + integration_prompt.push_str("\nNow complete the task.\n"); + + let final_output = { + let mut agent = agent.lock().await; + agent.run_once_capture(&integration_prompt).await? + }; + + log_swarm_lifecycle( + "message_done", + vec![ + ("task_count", task_outputs.len().to_string()), + ("output_chars", final_output.chars().count().to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ); + + Ok(final_output) +} + +#[derive(Debug, Deserialize)] +struct SwarmTaskSpec { + description: String, + prompt: String, + #[serde(default)] + subagent_type: Option<String>, +} + +fn parse_swarm_tasks(text: &str) -> Vec<SwarmTaskSpec> { + if let Ok(tasks) = serde_json::from_str::<Vec<SwarmTaskSpec>>(text) { + return tasks; + } + + if let (Some(start), Some(end)) = (text.find('['), text.rfind(']')) + && start < end + && let Ok(tasks) = serde_json::from_str::<Vec<SwarmTaskSpec>>(&text[start..=end]) + { + return tasks; + } + + Vec::new() +} + +#[cfg(test)] +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, + }; + use crate::plan::PlanItem; + use crate::protocol::{NotificationType, ServerEvent}; + use crate::server::{SwarmMember, VersionedPlan}; + use jcode_swarm_core::{ + append_swarm_completion_report_instructions, summarize_plan_items, truncate_detail, + }; + use std::collections::{HashMap, HashSet}; + use std::sync::Arc; + use std::time::{Duration, Instant}; + use tokio::sync::{RwLock, mpsc}; + + fn plan_item(id: &str, content: &str) -> PlanItem { + PlanItem { + content: content.to_string(), + status: "pending".to_string(), + priority: "medium".to_string(), + id: id.to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: None, + } + } + + #[test] + fn truncate_detail_collapses_whitespace_and_ellipsizes() { + assert_eq!(truncate_detail("hello there\nworld", 11), "hello th..."); + } + + #[test] + fn summarize_plan_items_limits_output() { + let items = vec![ + plan_item("1", "inspect"), + plan_item("2", "refactor"), + plan_item("3", "test"), + ]; + + assert_eq!( + summarize_plan_items(&items, 2), + "inspect; refactor (+1 more)" + ); + } + + #[test] + fn parse_swarm_tasks_accepts_wrapped_json() { + let text = + "Plan:\n[{\"description\":\"A\",\"prompt\":\"B\",\"subagent_type\":\"general\"}]"; + let tasks = parse_swarm_tasks(text); + + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].description, "A"); + assert_eq!(tasks[0].prompt, "B"); + assert_eq!(tasks[0].subagent_type.as_deref(), Some("general")); + } + + #[test] + fn append_swarm_completion_report_instructions_is_idempotent() { + let prompt = "Implement the task."; + let with_instructions = append_swarm_completion_report_instructions(prompt); + + assert!(with_instructions.starts_with(prompt)); + assert!(with_instructions.contains("SWARM COMPLETION REPORT REQUIRED")); + assert!(with_instructions.contains("swarm tool with action=\"report\"")); + assert_eq!( + append_swarm_completion_report_instructions(&with_instructions), + with_instructions + ); + } + + fn swarm_member( + session_id: &str, + role: &str, + is_headless: bool, + ) -> (SwarmMember, mpsc::UnboundedReceiver<ServerEvent>) { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + ( + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some("swarm-1".to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + task_label: None, + friendly_name: Some(session_id.to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: role.to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + }, + event_rx, + ) + } + + fn member_with_parent(session_id: &str, parent: Option<&str>) -> SwarmMember { + let (mut member, _rx) = swarm_member(session_id, "agent", false); + member.report_back_to_session_id = parent.map(str::to_string); + member + } + + #[test] + fn swarm_depth_and_ancestry_follow_report_back_chain() { + let mut members: HashMap<String, SwarmMember> = HashMap::new(); + for (id, parent) in [ + ("root", None), + ("a", Some("root")), + ("b", Some("a")), + ("c", Some("b")), + ] { + members.insert(id.to_string(), member_with_parent(id, parent)); + } + + assert_eq!(swarm_spawn_depth(&members, "root"), 0); + assert_eq!(swarm_spawn_depth(&members, "a"), 1); + assert_eq!(swarm_spawn_depth(&members, "c"), 3); + assert_eq!(swarm_ancestors(&members, "c"), vec!["b", "a", "root"]); + + // Ownership: an ancestor (or self) owns the subtree. + assert!(swarm_is_self_or_ancestor(&members, "a", "c")); + assert!(swarm_is_self_or_ancestor(&members, "root", "c")); + assert!(swarm_is_self_or_ancestor(&members, "c", "c")); + // A sibling/descendant is not an ancestor. + assert!(!swarm_is_self_or_ancestor(&members, "c", "a")); + assert!(!swarm_is_self_or_ancestor(&members, "b", "a")); + } + + #[test] + fn swarm_ancestry_guards_against_cycles() { + let mut members: HashMap<String, SwarmMember> = HashMap::new(); + // x -> y -> x is a (pathological) cycle; depth must terminate. + members.insert("x".to_string(), member_with_parent("x", Some("y"))); + members.insert("y".to_string(), member_with_parent("y", Some("x"))); + assert_eq!(swarm_spawn_depth(&members, "x"), 1); + assert_eq!(swarm_ancestors(&members, "x"), vec!["y"]); + } + + #[tokio::test] + async fn broadcast_swarm_plan_with_previous_includes_newly_ready_ids() { + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + VersionedPlan { + items: vec![ + PlanItem { + content: "setup".to_string(), + status: "completed".to_string(), + priority: "high".to_string(), + id: "setup".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: None, + }, + PlanItem { + content: "follow-up".to_string(), + status: "queued".to_string(), + priority: "high".to_string(), + id: "follow-up".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec!["setup".to_string()], + assigned_to: None, + }, + ], + version: 2, + participants: HashSet::from(["worker".to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let (worker, mut worker_rx) = swarm_member("worker", "agent", false); + let swarm_members = Arc::new(RwLock::new(HashMap::from([("worker".to_string(), worker)]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + HashSet::from(["worker".to_string()]), + )]))); + let previous_items = vec![ + PlanItem { + content: "setup".to_string(), + status: "running".to_string(), + priority: "high".to_string(), + id: "setup".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: Some("worker".to_string()), + }, + PlanItem { + content: "follow-up".to_string(), + status: "queued".to_string(), + priority: "high".to_string(), + id: "follow-up".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec!["setup".to_string()], + assigned_to: None, + }, + ]; + + broadcast_swarm_plan_with_previous( + "swarm-1", + Some("task_completed".to_string()), + Some(&previous_items), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + + match worker_rx.recv().await.expect("swarm plan event") { + ServerEvent::SwarmPlan { + reason, + summary: Some(summary), + .. + } => { + assert_eq!(reason.as_deref(), Some("task_completed")); + assert_eq!(summary.newly_ready_ids, vec!["follow-up".to_string()]); + assert_eq!(summary.next_ready_ids, vec!["follow-up".to_string()]); + } + other => panic!("expected SwarmPlan event, got {other:?}"), + } + } + + /// Deterministic demonstration of the mutate->broadcast version-inversion + /// race (wiring-audit.plan-broadcast-ordering). + /// + /// `broadcast_swarm_plan_with_previous` snapshots `(version, items)` under + /// `swarm_plans.read()`, releases the lock, and only later (after further + /// await points on `swarms_by_id.read()` / `swarm_members.read()`) sends + /// on `member.event_tx`. A second mutator can bump the version AND + /// complete its own broadcast inside that window, so a single ordered + /// mpsc channel can deliver v6 before v5. + /// + /// This test parks broadcast A (snapshot v5, empty participants, so it + /// must await `swarms_by_id.read()`) behind a held `swarms_by_id.write()` + /// guard, lets mutator B bump to v6 and broadcast it, then releases A. + /// The worker receives [6, 5]: inverted versions on one channel. + /// + /// If this test starts failing with versions == [6, 6] or [5, 6], the + /// race has been fixed (e.g. by holding the plan lock through send or by + /// stamping a send-order sequence); update the wiring audit and consider + /// whether the TUI-side monotonicity guard (server_events.rs SwarmPlan + /// handler currently overwrites `swarm_plan_version` unconditionally) is + /// still needed. + #[tokio::test] + async fn swarm_plan_broadcast_versions_can_invert_on_one_member_channel() { + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + VersionedPlan { + items: vec![plan_item("t1", "task one")], + version: 5, + // Empty participants: broadcast A takes the swarms_by_id + // fallback path, which is where we deterministically park it. + participants: HashSet::new(), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let (worker, mut worker_rx) = swarm_member("worker", "agent", false); + let swarm_members = Arc::new(RwLock::new(HashMap::from([("worker".to_string(), worker)]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + HashSet::from(["worker".to_string()]), + )]))); + + // Hold a write guard on swarms_by_id so broadcast A parks after it + // has already snapshotted version 5 from swarm_plans. + let gate = swarms_by_id.write().await; + + let a = tokio::spawn({ + let swarm_plans = Arc::clone(&swarm_plans); + let swarm_members = Arc::clone(&swarm_members); + let swarms_by_id = Arc::clone(&swarms_by_id); + async move { + broadcast_swarm_plan( + "swarm-1", + Some("mutator_1".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + } + }); + // Current-thread test runtime: yielding runs A until it parks on the + // contended swarms_by_id.read().await, past its v5 snapshot. + for _ in 0..16 { + tokio::task::yield_now().await; + } + + // Mutator B: bump to v6 and register an explicit participant so B's + // broadcast skips the swarms_by_id fallback and is not blocked by + // the gate. This mirrors real mutators (write, release, broadcast). + { + let mut plans = swarm_plans.write().await; + let vp = plans.get_mut("swarm-1").expect("plan"); + vp.version = 6; + vp.participants.insert("worker".to_string()); + } + broadcast_swarm_plan( + "swarm-1", + Some("mutator_2".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + + // Release A: it resumes with its stale v5 snapshot and sends it + // after v6 on the same ordered channel. + drop(gate); + a.await.expect("broadcast task"); + + let mut versions = Vec::new(); + while let Ok(event) = worker_rx.try_recv() { + if let ServerEvent::SwarmPlan { version, .. } = event { + versions.push(version); + } + } + assert_eq!( + versions, + vec![6, 5], + "expected version inversion on one member channel; if this fails \ + the mutate->broadcast race may have been fixed (update the \ + wiring audit)" + ); + } + + /// Deterministic demonstration of the SwarmStatus immediate-path + /// snapshot-vs-send inversion (wiring-audit.status-proposal-ordering). + /// + /// `broadcast_swarm_status_now` snapshots member statuses under + /// `swarm_members.read()`, drops the guard, then awaits + /// `fanout_session_event` (a `swarm_members.write()` acquisition) before + /// sending. Swarms below `JCODE_SWARM_STATUS_DEBOUNCE_MEMBER_THRESHOLD` + /// (default 2) take this immediate, non-debounced path on every status + /// change, so two concurrent broadcasts can deliver an old snapshot after + /// a newer one on the same ordered mpsc channel. A last-write-wins + /// consumer (the TUI SwarmStatus handler) is then left showing the stale + /// status until the next unrelated broadcast. + /// + /// Unlike the SwarmPlan inversion test above, there is no second lock we + /// can gate on: the status path snapshots from the same `swarm_members` + /// lock it later writes, so holding any guard also blocks the mutator. + /// Instead this test uses tokio's cooperative budget (128 units per task + /// poll on a current-thread runtime; every RwLock acquisition consumes + /// exactly one). Draining 126 units leaves broadcast A exactly enough for + /// `swarms_by_id.read()` and the `swarm_members.read()` snapshot, forcing + /// a yield at the (uncontended) `swarm_members.write()` inside + /// `fanout_session_event`, i.e. precisely inside the race window between + /// snapshot and send. + /// + /// If this test starts failing with `["running", "running"]` or + /// `["ready", "running"]`, the race has been fixed (e.g. by holding the + /// read lock through the send, or by stamping a monotonic sequence on + /// SwarmStatus and dropping stale ones consumer-side); update the wiring + /// audit. If it fails because broadcast A parks somewhere else, the tokio + /// coop budget constants changed: re-derive the `128 - 2` drain count. + #[tokio::test] + async fn swarm_status_immediate_broadcasts_can_invert_on_one_member_channel() { + let (worker, mut worker_rx) = swarm_member("worker", "agent", false); + let swarm_members = Arc::new(RwLock::new(HashMap::from([("worker".to_string(), worker)]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + HashSet::from(["worker".to_string()]), + )]))); + + // Broadcast A: snapshots status "ready", then is forced to yield at + // the fanout write acquisition, before sending. + let a = tokio::spawn({ + let swarm_members = Arc::clone(&swarm_members); + let swarms_by_id = Arc::clone(&swarms_by_id); + async move { + // Initial task budget is 128. Leave exactly 2 units so the two + // read acquisitions (session-id list + status snapshot) + // succeed and the fanout write acquisition forces a yield. + for _ in 0..126 { + tokio::task::coop::consume_budget().await; + } + broadcast_swarm_status("swarm-1", &swarm_members, &swarms_by_id).await; + } + }); + // Single yield on the current-thread runtime: A runs its entire first + // poll (budget drain + both reads) and parks after snapshotting + // "ready". Its coop yield happens *before* joining the lock queue, so + // every acquisition below is uncontended and the mutator finishes + // within one poll, before A is re-polled. + tokio::task::yield_now().await; + + // Concurrent mutator: flips the status and completes its own + // immediate broadcast while A is parked between snapshot and send. + { + let mut members = swarm_members.write().await; + members.get_mut("worker").expect("worker member").status = "running".to_string(); + } + broadcast_swarm_status("swarm-1", &swarm_members, &swarms_by_id).await; + + // Release A: it resumes with a fresh budget and sends its stale + // "ready" snapshot after "running" on the same ordered channel. + a.await.expect("broadcast task"); + + let mut statuses = Vec::new(); + while let Ok(event) = worker_rx.try_recv() { + if let ServerEvent::SwarmStatus { members } = event { + assert_eq!(members.len(), 1); + assert_eq!(members[0].session_id, "worker"); + statuses.push(members[0].status.clone()); + } + } + assert_eq!( + statuses, + vec!["running".to_string(), "ready".to_string()], + "expected status inversion (new-then-old) on one member channel; \ + if this fails with the correct order, the snapshot-vs-send race \ + may have been fixed (update the wiring audit)" + ); + } + + /// Restored (persisted) plan participants with dead channels starve live + /// swarm members of plan broadcasts: the fallback to swarms_by_id only + /// triggers when `participants` is EMPTY, so a participant set that only + /// contains stale sessions (e.g. restored after a server restart, where + /// `from_persisted_member` gives every member a closed event_tx) means + /// nobody receives the snapshot, not even live members of the swarm. + #[tokio::test] + async fn stale_participants_starve_live_members_of_plan_broadcasts() { + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + VersionedPlan { + items: vec![plan_item("t1", "task one")], + version: 7, + // "ghost" is a participant restored from disk whose session + // no longer exists in this server process. + participants: HashSet::from(["ghost".to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + // Ghost member as produced by swarm_persistence restore: present in + // the member map but with a closed event channel. + let (ghost, ghost_rx) = swarm_member("ghost", "agent", true); + drop(ghost_rx); + let (live, mut live_rx) = swarm_member("live", "agent", false); + let swarm_members = Arc::new(RwLock::new(HashMap::from([ + ("ghost".to_string(), ghost), + ("live".to_string(), live), + ]))); + let swarms_by_id = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + HashSet::from(["ghost".to_string(), "live".to_string()]), + )]))); + + broadcast_swarm_plan( + "swarm-1", + Some("test".to_string()), + &swarm_plans, + &swarm_members, + &swarms_by_id, + ) + .await; + + assert!( + live_rx.try_recv().is_err(), + "live member unexpectedly received the plan broadcast; stale \ + participant starvation may have been fixed (update the wiring \ + audit)" + ); + } + + #[tokio::test] + async fn remove_session_from_swarm_reassigns_to_non_headless_member() { + 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([ + "coord".to_string(), + "headless".to_string(), + "worker".to_string(), + ]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "coord".to_string(), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + VersionedPlan { + items: vec![PlanItem { + content: "task".to_string(), + status: "pending".to_string(), + priority: "medium".to_string(), + id: "1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: Some("coord".to_string()), + }], + version: 1, + participants: HashSet::from(["coord".to_string()]), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + + let (coord, _coord_rx) = swarm_member("coord", "coordinator", false); + let (headless, mut headless_rx) = swarm_member("headless", "agent", true); + let (worker, mut worker_rx) = swarm_member("worker", "agent", false); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("headless".to_string(), headless); + members.insert("worker".to_string(), worker); + members.remove("coord"); + } + + remove_session_from_swarm( + "coord", + "swarm-1", + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + + assert_eq!( + swarm_coordinators + .read() + .await + .get("swarm-1") + .map(String::as_str), + Some("worker") + ); + assert!( + swarm_plans + .read() + .await + .get("swarm-1") + .is_some_and(|plan| plan.participants.contains("worker")) + ); + assert_eq!( + swarm_members + .read() + .await + .get("worker") + .map(|member| member.role.as_str()), + Some("coordinator") + ); + assert_eq!( + swarm_members + .read() + .await + .get("headless") + .map(|member| member.role.as_str()), + Some("agent") + ); + + let headless_events: Vec<_> = std::iter::from_fn(|| headless_rx.try_recv().ok()).collect(); + assert!(headless_events.iter().all(|event| { + !matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + } if message == "You are now the coordinator for this swarm." + ) + })); + + let worker_events: Vec<_> = std::iter::from_fn(|| worker_rx.try_recv().ok()).collect(); + assert!(worker_events.iter().any(|event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + } if message == "You are now the coordinator for this swarm." + ) + })); + } + + #[tokio::test] + async fn remove_session_reparents_children_to_live_grandparent() { + 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(["root".to_string(), "mid".to_string(), "leaf".to_string()]), + )]))); + 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 (root, _root_rx) = swarm_member("root", "coordinator", false); + let (mut mid, _mid_rx) = swarm_member("mid", "agent", true); + mid.report_back_to_session_id = Some("root".to_string()); + let (mut leaf, _leaf_rx) = swarm_member("leaf", "agent", true); + leaf.report_back_to_session_id = Some("mid".to_string()); + { + let mut members = swarm_members.write().await; + members.insert("root".to_string(), root); + members.insert("mid".to_string(), mid); + members.insert("leaf".to_string(), leaf); + } + + remove_session_from_swarm( + "mid", + "swarm-1", + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + + // Leaf follows the report-back chain up to its grandparent instead of + // dangling on the removed session. + let members = swarm_members.read().await; + assert_eq!( + members + .get("leaf") + .and_then(|member| member.report_back_to_session_id.as_deref()), + Some("root") + ); + assert!(swarm_is_self_or_ancestor(&members, "root", "leaf")); + } + + #[tokio::test] + async fn remove_session_reparents_children_to_coordinator_when_no_grandparent() { + 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([ + "coord".to_string(), + "peer_root".to_string(), + "child".to_string(), + ]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "coord".to_string(), + )]))); + let swarm_plans = Arc::new(RwLock::new(HashMap::new())); + + // peer_root is itself a root (no parent), so its children have no + // grandparent to inherit; they should fall back to the coordinator. + let (coord, _coord_rx) = swarm_member("coord", "coordinator", false); + let (peer_root, _peer_rx) = swarm_member("peer_root", "agent", false); + let (mut child, _child_rx) = swarm_member("child", "agent", true); + child.report_back_to_session_id = Some("peer_root".to_string()); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("peer_root".to_string(), peer_root); + members.insert("child".to_string(), child); + } + + remove_session_from_swarm( + "peer_root", + "swarm-1", + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + + let members = swarm_members.read().await; + assert_eq!( + members + .get("child") + .and_then(|member| member.report_back_to_session_id.as_deref()), + Some("coord") + ); + } + + #[tokio::test] + async fn update_member_status_notifies_coordinator_when_headless_worker_returns_ready() { + 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(["coord".to_string(), "worker".to_string()]), + )]))); + + let (coord, mut coord_rx) = swarm_member("coord", "coordinator", false); + let (mut worker, _worker_rx) = swarm_member("worker", "agent", true); + worker.status = "running".to_string(); + worker.detail = Some("doing task".to_string()); + worker.report_back_to_session_id = Some("coord".to_string()); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("worker".to_string(), worker); + } + + update_member_status( + "worker", + "ready", + None, + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + let events: Vec<_> = std::iter::from_fn(|| coord_rx.try_recv().ok()).collect(); + assert!(events.iter().any(|event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + } if message.contains("finished their work and is ready for more") + ) + })); + } + + #[tokio::test] + async fn member_elapsed_time_runs_only_while_active_and_freezes_afterward() { + 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(["worker".to_string()]), + )]))); + let (mut worker, _worker_rx) = swarm_member("worker", "agent", true); + worker.runtime.elapsed_secs = Some(12); + swarm_members + .write() + .await + .insert("worker".to_string(), worker); + + update_member_status( + "worker", + "running", + None, + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + assert_eq!( + swarm_members + .read() + .await + .get("worker") + .and_then(|member| member.runtime.elapsed_secs), + None, + "active members should derive elapsed time from joined_at" + ); + + { + let mut members = swarm_members.write().await; + members.get_mut("worker").unwrap().joined_at = Instant::now() - Duration::from_secs(37); + } + update_member_status( + "worker", + "completed", + None, + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + let frozen = swarm_members + .read() + .await + .get("worker") + .and_then(|member| member.runtime.elapsed_secs) + .expect("terminal member should retain frozen elapsed time"); + assert!((37..=38).contains(&frozen), "frozen={frozen}"); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + swarm_members + .read() + .await + .get("worker") + .and_then(|member| member.runtime.elapsed_secs), + Some(frozen) + ); + } + + #[tokio::test] + async fn update_member_status_prefers_explicit_report_back_owner_over_coordinator() { + 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([ + "coord".to_string(), + "owner".to_string(), + "worker".to_string(), + ]), + )]))); + + let (coord, mut coord_rx) = swarm_member("coord", "coordinator", false); + let (owner, mut owner_rx) = swarm_member("owner", "agent", false); + let (mut worker, _worker_rx) = swarm_member("worker", "agent", true); + worker.status = "running".to_string(); + worker.detail = Some("doing task".to_string()); + worker.report_back_to_session_id = Some("owner".to_string()); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("owner".to_string(), owner); + members.insert("worker".to_string(), worker); + } + + update_member_status( + "worker", + "ready", + None, + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + let owner_events: Vec<_> = std::iter::from_fn(|| owner_rx.try_recv().ok()).collect(); + assert!(owner_events.iter().any(|event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + } if message.contains("finished their work and is ready for more") + ) + })); + let coord_events: Vec<_> = std::iter::from_fn(|| coord_rx.try_recv().ok()).collect(); + assert!(coord_events.iter().all(|event| { + !matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + } if message.contains("finished their work and is ready for more") + ) + })); + } + + #[tokio::test] + async fn update_member_status_includes_completion_report_in_owner_notification() { + 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(["coord".to_string(), "worker".to_string()]), + )]))); + + let (coord, mut coord_rx) = swarm_member("coord", "coordinator", false); + let (mut worker, _worker_rx) = swarm_member("worker", "agent", true); + worker.status = "running".to_string(); + worker.report_back_to_session_id = Some("coord".to_string()); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("worker".to_string(), worker); + } + + update_member_status_with_report( + "worker", + "ready", + None, + Some("Validated the parser and all tests passed.".to_string()), + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + let events: Vec<_> = std::iter::from_fn(|| coord_rx.try_recv().ok()).collect(); + assert!(events.iter().any(|event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + message, + .. + } if message.contains("Report:\nValidated the parser") + && !message.contains("No final textual report") + ) + })); + } + + #[tokio::test] + async fn update_member_status_skips_noop_broadcasts() { + 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(["worker".to_string()]), + )]))); + + let (worker, mut worker_rx) = swarm_member("worker", "agent", false); + swarm_members + .write() + .await + .insert("worker".to_string(), worker); + + update_member_status( + "worker", + "ready", + None, + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + assert!(worker_rx.try_recv().is_err()); + + update_member_status( + "worker", + "busy", + Some("working".to_string()), + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + assert!(matches!( + worker_rx.try_recv(), + Ok(ServerEvent::SwarmStatus { members }) if members.len() == 1 + && members[0].session_id == "worker" + && members[0].status == "busy" + && members[0].detail.as_deref() == Some("working") + )); + } + + #[tokio::test] + async fn refresh_swarm_task_staleness_marks_running_tasks_stale_and_heartbeat_revives() { + 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(["worker".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); + let now_ms = now_unix_ms(); + let stale_age_ms = super::swarm_task_stale_after().as_millis() as u64 + 5_000; + let swarm_plans = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + VersionedPlan { + items: vec![PlanItem { + content: "task".to_string(), + status: "running".to_string(), + priority: "medium".to_string(), + id: "task-1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: Some("worker".to_string()), + }], + version: 1, + participants: HashSet::from(["worker".to_string()]), + task_progress: HashMap::from([( + "task-1".to_string(), + crate::server::SwarmTaskProgress { + assigned_session_id: Some("worker".to_string()), + started_at_unix_ms: Some(now_ms.saturating_sub(stale_age_ms)), + last_heartbeat_unix_ms: Some(now_ms.saturating_sub(stale_age_ms)), + ..Default::default() + }, + )]), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))); + let (worker, _worker_rx) = swarm_member("worker", "agent", true); + swarm_members + .write() + .await + .insert("worker".to_string(), worker); + + refresh_swarm_task_staleness( + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + + { + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "running_stale"); + assert!( + plan.task_progress + .get("task-1") + .and_then(|progress| progress.stale_since_unix_ms) + .is_some() + ); + } + + let revived = touch_swarm_task_progress( + "swarm-1", + "task-1", + Some("worker"), + Some("still working".to_string()), + Some("checkpoint saved".to_string()), + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + assert!(revived); + + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "running"); + let progress = plan.task_progress.get("task-1").expect("progress"); + assert_eq!( + progress.checkpoint_summary.as_deref(), + Some("checkpoint saved") + ); + assert!(progress.stale_since_unix_ms.is_none()); + } + + #[test] + fn member_status_is_dead_matches_terminal_non_success_states() { + for status in ["failed", "stopped", "crashed"] { + assert!(member_status_is_dead(status), "{status} should be dead"); + } + for status in ["ready", "running", "running_stale", "queued", "completed"] { + assert!(!member_status_is_dead(status), "{status} should be alive"); + } + } + + fn running_plan_assigned_to( + assignee: &str, + reclaims: Option<u32>, + ) -> Arc<RwLock<HashMap<String, VersionedPlan>>> { + Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + VersionedPlan { + items: vec![PlanItem { + content: "task".to_string(), + status: "running".to_string(), + priority: "medium".to_string(), + id: "task-1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: Some(assignee.to_string()), + }], + version: 1, + participants: HashSet::from([assignee.to_string()]), + task_progress: HashMap::from([( + "task-1".to_string(), + crate::server::SwarmTaskProgress { + assigned_session_id: Some(assignee.to_string()), + dead_assignee_reclaims: reclaims, + ..Default::default() + }, + )]), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]))) + } + + #[tokio::test] + async fn salvage_requeues_dead_members_tasks_and_notifies_coordinator() { + 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(["coord".to_string(), "worker".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "coord".to_string(), + )]))); + let swarm_plans = running_plan_assigned_to("worker", None); + let (coord, mut coord_rx) = swarm_member("coord", "coordinator", false); + let (worker, _worker_rx) = swarm_member("worker", "agent", true); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("worker".to_string(), worker); + } + + let outcome = salvage_assignments_of_dead_member( + "worker", + "swarm-1", + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + + assert_eq!(outcome.requeued_task_ids, vec!["task-1".to_string()]); + assert!(outcome.failed_task_ids.is_empty()); + { + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "queued"); + assert_eq!(plan.items[0].assigned_to, None); + let progress = plan.task_progress.get("task-1").expect("progress"); + assert_eq!(progress.assigned_session_id, None); + assert_eq!(progress.dead_assignee_reclaims, Some(1)); + } + + let coord_events: Vec<_> = std::iter::from_fn(|| coord_rx.try_recv().ok()).collect(); + assert!( + coord_events.iter().any(|event| matches!( + event, + ServerEvent::Notification { message, .. } + if message.contains("died") && message.contains("task-1") + )), + "coordinator should be told about the salvage, got {coord_events:?}" + ); + } + + #[tokio::test] + async fn salvage_fails_task_once_reclaim_cap_is_reached() { + 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(["worker".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); + let swarm_plans = + running_plan_assigned_to("worker", Some(crate::plan::MAX_DEAD_ASSIGNEE_RECLAIMS)); + let (worker, _worker_rx) = swarm_member("worker", "agent", true); + swarm_members + .write() + .await + .insert("worker".to_string(), worker); + + let outcome = salvage_assignments_of_dead_member( + "worker", + "swarm-1", + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + + assert!(outcome.requeued_task_ids.is_empty()); + assert_eq!(outcome.failed_task_ids, vec!["task-1".to_string()]); + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "failed"); + assert_eq!(plan.items[0].assigned_to, None); + } + + #[tokio::test] + async fn remove_session_from_swarm_salvages_running_assignments() { + 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(["coord".to_string(), "worker".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "coord".to_string(), + )]))); + let swarm_plans = running_plan_assigned_to("worker", None); + let (coord, _coord_rx) = swarm_member("coord", "coordinator", false); + let (worker, _worker_rx) = swarm_member("worker", "agent", true); + { + let mut members = swarm_members.write().await; + members.insert("coord".to_string(), coord); + members.insert("worker".to_string(), worker); + } + + remove_session_from_swarm( + "worker", + "swarm-1", + &swarm_members, + &swarms_by_id, + &swarm_coordinators, + &swarm_plans, + ) + .await; + + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "queued"); + assert_eq!(plan.items[0].assigned_to, None); + } + + #[tokio::test] + async fn staleness_sweep_salvages_tasks_of_vanished_assignee() { + // The assignee is not a swarm member at all (zombie left over from a + // previous process): no grace period applies and the sweep must + // requeue its running task. + 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(["coord".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([( + "swarm-1".to_string(), + "coord".to_string(), + )]))); + let swarm_plans = running_plan_assigned_to("ghost", None); + // Give the task a fresh heartbeat so the first sweep phase does not + // interfere; the salvage phase must still fire on the dead assignee. + { + let mut plans = swarm_plans.write().await; + let plan = plans.get_mut("swarm-1").expect("plan"); + let progress = plan.task_progress.get_mut("task-1").expect("progress"); + progress.last_heartbeat_unix_ms = Some(now_unix_ms()); + } + let (coord, _coord_rx) = swarm_member("coord", "coordinator", false); + swarm_members + .write() + .await + .insert("coord".to_string(), coord); + + refresh_swarm_task_staleness( + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "queued"); + assert_eq!(plan.items[0].assigned_to, None); + } + + #[tokio::test] + async fn staleness_sweep_grants_grace_to_recently_crashed_member() { + // A member marked crashed moments ago may be mid reload-recovery; the + // sweep must not reclaim its work inside the grace window. + 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(["worker".to_string()]), + )]))); + let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); + let swarm_plans = running_plan_assigned_to("worker", None); + { + let mut plans = swarm_plans.write().await; + let plan = plans.get_mut("swarm-1").expect("plan"); + let progress = plan.task_progress.get_mut("task-1").expect("progress"); + progress.last_heartbeat_unix_ms = Some(now_unix_ms()); + } + let (mut worker, _worker_rx) = swarm_member("worker", "agent", true); + worker.status = "crashed".to_string(); + worker.last_status_change = Instant::now(); + swarm_members + .write() + .await + .insert("worker".to_string(), worker); + + refresh_swarm_task_staleness( + &swarm_members, + &swarms_by_id, + &swarm_plans, + &swarm_coordinators, + ) + .await; + + let plans = swarm_plans.read().await; + let plan = plans.get("swarm-1").expect("plan"); + assert_eq!(plan.items[0].status, "running"); + assert_eq!(plan.items[0].assigned_to.as_deref(), Some("worker")); + } + + #[tokio::test] + async fn update_member_status_notifies_owner_when_worker_crashes_mid_task() { + 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(["owner".to_string(), "worker".to_string()]), + )]))); + let (owner, mut owner_rx) = swarm_member("owner", "coordinator", false); + let (mut worker, _worker_rx) = swarm_member("worker", "agent", true); + worker.status = "running".to_string(); + worker.report_back_to_session_id = Some("owner".to_string()); + { + let mut members = swarm_members.write().await; + members.insert("owner".to_string(), owner); + members.insert("worker".to_string(), worker); + } + + update_member_status( + "worker", + "crashed", + Some("client disconnected while processing".to_string()), + &swarm_members, + &swarms_by_id, + None, + None, + None, + ) + .await; + + let owner_events: Vec<_> = std::iter::from_fn(|| owner_rx.try_recv().ok()).collect(); + assert!( + owner_events.iter().any(|event| matches!( + event, + ServerEvent::Notification { message, .. } + if message.contains("crashed while working") + )), + "owner should be notified of the crash, got {owner_events:?}" + ); + } +} diff --git a/crates/jcode-app-core/src/server/swarm_channels.rs b/crates/jcode-app-core/src/server/swarm_channels.rs new file mode 100644 index 0000000..89cc62d --- /dev/null +++ b/crates/jcode-app-core/src/server/swarm_channels.rs @@ -0,0 +1,88 @@ +use jcode_swarm_core::ChannelIndex; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; + +type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>; + +async fn with_channel_index_mut( + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, + mutate: impl FnOnce(&mut ChannelIndex), +) { + let mut subs = channel_subscriptions.write().await; + let mut reverse = channel_subscriptions_by_session.write().await; + let mut index = ChannelIndex { + by_swarm_channel: std::mem::take(&mut *subs), + by_session: std::mem::take(&mut *reverse), + }; + mutate(&mut index); + *subs = index.by_swarm_channel; + *reverse = index.by_session; +} + +pub(super) async fn remove_session_channel_subscriptions( + session_id: &str, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, +) { + with_channel_index_mut( + channel_subscriptions, + channel_subscriptions_by_session, + |index| index.remove_session(session_id), + ) + .await; +} + +pub(super) async fn subscribe_session_to_channel( + session_id: &str, + swarm_id: &str, + channel: &str, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, +) { + with_channel_index_mut( + channel_subscriptions, + channel_subscriptions_by_session, + |index| index.subscribe(session_id, swarm_id, channel), + ) + .await; +} + +pub(super) async fn unsubscribe_session_from_channel( + session_id: &str, + swarm_id: &str, + channel: &str, + channel_subscriptions: &ChannelSubscriptions, + channel_subscriptions_by_session: &ChannelSubscriptions, +) { + with_channel_index_mut( + channel_subscriptions, + channel_subscriptions_by_session, + |index| index.unsubscribe(session_id, swarm_id, channel), + ) + .await; +} + +pub(super) async fn list_channels_for_swarm( + swarm_id: &str, + channel_subscriptions: &ChannelSubscriptions, +) -> Vec<(String, usize)> { + let subs = channel_subscriptions.read().await; + let index = ChannelIndex { + by_swarm_channel: subs.clone(), + by_session: HashMap::new(), + }; + let mut channels = index + .by_swarm_channel + .get(swarm_id) + .map(|swarm_channels| { + swarm_channels + .iter() + .map(|(channel, members)| (channel.clone(), members.len())) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + channels.sort_by(|left, right| left.0.cmp(&right.0)); + channels +} diff --git a/crates/jcode-app-core/src/server/swarm_mutation_state.rs b/crates/jcode-app-core/src/server/swarm_mutation_state.rs new file mode 100644 index 0000000..8ab9912 --- /dev/null +++ b/crates/jcode-app-core/src/server/swarm_mutation_state.rs @@ -0,0 +1,281 @@ +use crate::protocol::ServerEvent; +use crate::server::durable_state::{ + elapsed_exceeds, hashed_request_key, load_json_state, now_unix_ms, save_json_state, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{RwLock, mpsc}; + +const SWARM_MUTATION_DIR: &str = "jcode-swarm-mutations"; +const FINAL_STATE_TTL: Duration = Duration::from_secs(30); +const PENDING_STATE_TTL: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) enum PersistedSwarmMutationResponse { + Done, + AssignTask { + task_id: String, + target_session: String, + }, + Error { + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + retry_after_secs: Option<u64>, + }, + Spawn { + new_session_id: String, + }, +} + +impl PersistedSwarmMutationResponse { + fn into_server_event(self, id: u64, session_id: &str) -> ServerEvent { + match self { + Self::Done => ServerEvent::Done { id }, + Self::AssignTask { + task_id, + target_session, + } => ServerEvent::CommAssignTaskResponse { + id, + task_id, + target_session, + }, + Self::Error { + message, + retry_after_secs, + } => ServerEvent::Error { + id, + message, + retry_after_secs, + }, + Self::Spawn { new_session_id } => ServerEvent::CommSpawnResponse { + id, + session_id: session_id.to_string(), + new_session_id, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PersistedSwarmMutationState { + pub key: String, + pub action: String, + pub session_id: String, + pub created_at_unix_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_response: Option<PersistedSwarmMutationResponse>, +} + +#[derive(Clone)] +struct SwarmMutationWaiter { + request_id: u64, + client_event_tx: mpsc::UnboundedSender<ServerEvent>, +} + +/// In-memory coordination for in-flight swarm mutations. +/// +/// A single lock guards both the active-key set and the waiter map so the +/// persisted-final check, waiter registration, and active-claim in +/// [`begin_or_replay`] form one atomic step with respect to +/// [`finish_request`] draining waiters and releasing the claim. Splitting +/// these across separate locks allowed a TOCTOU where a duplicate request +/// could observe "no final state" before the original finished, then +/// register itself after the finisher had already drained waiters and +/// cleared the active claim, re-executing the whole mutation (double +/// assign/spawn). +#[derive(Default)] +struct SwarmMutationSync { + active_keys: HashSet<String>, + waiters: HashMap<String, Vec<SwarmMutationWaiter>>, +} + +#[derive(Clone, Default)] +pub(crate) struct SwarmMutationRuntime { + sync: Arc<RwLock<SwarmMutationSync>>, +} + +fn is_stale(state: &PersistedSwarmMutationState) -> bool { + if state.final_response.is_some() { + elapsed_exceeds(state.created_at_unix_ms, FINAL_STATE_TTL) + } else { + elapsed_exceeds(state.created_at_unix_ms, PENDING_STATE_TTL) + } +} + +pub(super) fn request_key(session_id: &str, action: &str, components: &[String]) -> String { + hashed_request_key(session_id, action, components) +} + +pub(super) fn load_state(key: &str) -> Option<PersistedSwarmMutationState> { + load_json_state(SWARM_MUTATION_DIR, key, is_stale) +} + +pub(super) fn save_state(state: &PersistedSwarmMutationState) { + save_json_state( + SWARM_MUTATION_DIR, + &state.key, + state, + "swarm mutation state", + ) +} + +pub(super) fn ensure_pending_state( + key: &str, + action: &str, + session_id: &str, +) -> PersistedSwarmMutationState { + if let Some(existing) = load_state(key) { + return existing; + } + + let state = PersistedSwarmMutationState { + key: key.to_string(), + action: action.to_string(), + session_id: session_id.to_string(), + created_at_unix_ms: now_unix_ms(), + final_response: None, + }; + save_state(&state); + state +} + +pub(super) fn persist_final_response( + state: &PersistedSwarmMutationState, + response: PersistedSwarmMutationResponse, +) -> PersistedSwarmMutationState { + let mut next = state.clone(); + next.final_response = Some(response); + save_state(&next); + next +} + +pub(super) async fn begin_or_replay( + runtime: &SwarmMutationRuntime, + key: &str, + action: &str, + session_id: &str, + request_id: u64, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) -> Option<PersistedSwarmMutationState> { + begin_with_mode( + runtime, + key, + action, + session_id, + request_id, + client_event_tx, + true, + ) + .await +} + +/// Like [`begin_or_replay`], but never replays a persisted final response. +/// +/// Explicit control-driven mutations (retry/reassign/replace/salvage) must +/// re-dispatch even when an identical mutation finished moments ago: a worker +/// that fails within `FINAL_STATE_TTL` would otherwise turn the coordinator's +/// follow-up retry into a silent no-op replay. Concurrent in-flight duplicates +/// still join the active execution as waiters. +pub(super) async fn begin_or_join_in_flight( + runtime: &SwarmMutationRuntime, + key: &str, + action: &str, + session_id: &str, + request_id: u64, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, +) -> Option<PersistedSwarmMutationState> { + begin_with_mode( + runtime, + key, + action, + session_id, + request_id, + client_event_tx, + false, + ) + .await +} + +async fn begin_with_mode( + runtime: &SwarmMutationRuntime, + key: &str, + action: &str, + session_id: &str, + request_id: u64, + client_event_tx: &mpsc::UnboundedSender<ServerEvent>, + replay_final: bool, +) -> Option<PersistedSwarmMutationState> { + { + // Hold the sync lock across the persisted-final check, waiter + // registration, and active-claim. If the original executor finishes + // concurrently it either persisted the final response before we + // loaded (we replay it here) or still holds the active claim (we + // become a waiter that its finish drains). It can never slip fully + // between the check and the claim, because finish drains/clears + // under this same lock only after persisting the final state. + let mut sync = runtime.sync.write().await; + if replay_final + && let Some(final_response) = load_state(key).and_then(|state| state.final_response) + { + let _ = client_event_tx.send(final_response.into_server_event(request_id, session_id)); + return None; + } + sync.waiters + .entry(key.to_string()) + .or_default() + .push(SwarmMutationWaiter { + request_id, + client_event_tx: client_event_tx.clone(), + }); + if !sync.active_keys.insert(key.to_string()) { + return None; + } + } + + if replay_final { + Some(ensure_pending_state(key, action, session_id)) + } else { + // A control-driven mutation is a fresh attempt: never resurrect a + // stale persisted final response as this attempt's state. + let state = PersistedSwarmMutationState { + key: key.to_string(), + action: action.to_string(), + session_id: session_id.to_string(), + created_at_unix_ms: now_unix_ms(), + final_response: None, + }; + save_state(&state); + Some(state) + } +} + +pub(super) async fn finish_request( + runtime: &SwarmMutationRuntime, + state: &PersistedSwarmMutationState, + response: PersistedSwarmMutationResponse, +) { + // Persist the final response BEFORE draining waiters/releasing the + // active claim: a duplicate request that misses the drain below must be + // able to observe the persisted final state and replay it instead of + // re-executing the mutation. + let persisted = persist_final_response(state, response.clone()); + let waiters = { + let mut sync = runtime.sync.write().await; + let waiters = sync.waiters.remove(&persisted.key).unwrap_or_default(); + sync.active_keys.remove(&persisted.key); + waiters + }; + for waiter in waiters { + let _ = waiter.client_event_tx.send( + response + .clone() + .into_server_event(waiter.request_id, &persisted.session_id), + ); + } +} + +#[cfg(test)] +#[path = "swarm_mutation_state_tests.rs"] +mod swarm_mutation_state_tests; diff --git a/crates/jcode-app-core/src/server/swarm_mutation_state_tests.rs b/crates/jcode-app-core/src/server/swarm_mutation_state_tests.rs new file mode 100644 index 0000000..911fa24 --- /dev/null +++ b/crates/jcode-app-core/src/server/swarm_mutation_state_tests.rs @@ -0,0 +1,206 @@ +use super::{ + PersistedSwarmMutationResponse, SwarmMutationRuntime, begin_or_join_in_flight, begin_or_replay, + finish_request, request_key, +}; +use crate::protocol::ServerEvent; + +struct RuntimeEnvGuard { + _guard: std::sync::MutexGuard<'static, ()>, + prev_runtime: Option<std::ffi::OsString>, +} + +impl RuntimeEnvGuard { + fn new() -> (Self, tempfile::TempDir) { + let guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create runtime dir"); + let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", temp.path()); + ( + Self { + _guard: guard, + prev_runtime, + }, + temp, + ) + } +} + +impl Drop for RuntimeEnvGuard { + fn drop(&mut self) { + if let Some(prev_runtime) = self.prev_runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +#[tokio::test] +async fn swarm_mutation_replays_persisted_spawn_response() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let runtime = SwarmMutationRuntime::default(); + let (client_tx, mut client_rx) = tokio::sync::mpsc::unbounded_channel(); + let key = request_key( + "coord", + "spawn", + &[ + "swarm-1".to_string(), + "/repo".to_string(), + "hello".to_string(), + ], + ); + + let state = begin_or_replay(&runtime, &key, "spawn", "coord", 1, &client_tx) + .await + .expect("first request should start execution"); + finish_request( + &runtime, + &state, + PersistedSwarmMutationResponse::Spawn { + new_session_id: "child-1".to_string(), + }, + ) + .await; + + let (retry_tx, mut retry_rx) = tokio::sync::mpsc::unbounded_channel(); + let replay = begin_or_replay(&runtime, &key, "spawn", "coord", 2, &retry_tx).await; + assert!(replay.is_none(), "retry should replay persisted response"); + + match client_rx.recv().await.expect("initial response") { + ServerEvent::CommSpawnResponse { new_session_id, .. } => { + assert_eq!(new_session_id, "child-1") + } + other => panic!("expected spawn response, got {other:?}"), + } + + match retry_rx.recv().await.expect("replayed response") { + ServerEvent::CommSpawnResponse { + id, new_session_id, .. + } => { + assert_eq!(id, 2); + assert_eq!(new_session_id, "child-1"); + } + other => panic!("expected spawn replay, got {other:?}"), + } +} + +#[tokio::test] +async fn swarm_mutation_concurrent_duplicates_share_final_done_response() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let runtime = SwarmMutationRuntime::default(); + let key = request_key( + "coord", + "assign_task", + &[ + "swarm-1".to_string(), + "worker-1".to_string(), + "task-1".to_string(), + "extra".to_string(), + ], + ); + let (first_tx, mut first_rx) = tokio::sync::mpsc::unbounded_channel(); + let (retry_tx, mut retry_rx) = tokio::sync::mpsc::unbounded_channel(); + + let state = begin_or_replay(&runtime, &key, "assign_task", "coord", 1, &first_tx) + .await + .expect("first request should start execution"); + let replay = begin_or_replay(&runtime, &key, "assign_task", "coord", 2, &retry_tx).await; + assert!( + replay.is_none(), + "second in-flight duplicate should wait for original completion" + ); + + finish_request(&runtime, &state, PersistedSwarmMutationResponse::Done).await; + + match first_rx.recv().await.expect("first response") { + ServerEvent::Done { id } => assert_eq!(id, 1), + other => panic!("expected done, got {other:?}"), + } + match retry_rx.recv().await.expect("retry response") { + ServerEvent::Done { id } => assert_eq!(id, 2), + other => panic!("expected done, got {other:?}"), + } +} + +/// Regression: a control-driven mutation (retry/reassign/replace/salvage) +/// must re-dispatch even when an identical mutation persisted a success +/// within the final-state TTL. Replaying the stale success would turn the +/// coordinator's deliberate retry into a silent no-op whenever the worker +/// failed in under the TTL. +#[tokio::test] +async fn control_driven_begin_re_dispatches_despite_persisted_final_state() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let runtime = SwarmMutationRuntime::default(); + let key = request_key( + "coord", + "assign_task", + &[ + "swarm-1".to_string(), + "worker-1".to_string(), + "task-1".to_string(), + "Retry this assignment.".to_string(), + ], + ); + + let (first_tx, mut first_rx) = tokio::sync::mpsc::unbounded_channel(); + let state = begin_or_join_in_flight(&runtime, &key, "assign_task", "coord", 1, &first_tx) + .await + .expect("first attempt should start execution"); + finish_request( + &runtime, + &state, + PersistedSwarmMutationResponse::AssignTask { + task_id: "task-1".to_string(), + target_session: "worker-1".to_string(), + }, + ) + .await; + match first_rx.recv().await.expect("first response") { + ServerEvent::CommAssignTaskResponse { id, .. } => assert_eq!(id, 1), + other => panic!("expected assign response, got {other:?}"), + } + + // Identical retry within the final-state TTL: must start a fresh attempt. + let (retry_tx, _retry_rx) = tokio::sync::mpsc::unbounded_channel(); + let retry_state = begin_or_join_in_flight(&runtime, &key, "assign_task", "coord", 2, &retry_tx) + .await + .expect("control-driven retry must re-dispatch instead of replaying the stale success"); + assert!( + retry_state.final_response.is_none(), + "fresh attempt must not carry the previous attempt's final response" + ); +} + +/// Concurrent in-flight duplicates of a control-driven mutation still +/// coalesce onto the active execution instead of double-dispatching. +#[tokio::test] +async fn control_driven_begin_coalesces_in_flight_duplicates() { + let (_env, _runtime_dir) = RuntimeEnvGuard::new(); + let runtime = SwarmMutationRuntime::default(); + let key = request_key( + "coord", + "assign_task", + &["swarm-1".to_string(), "worker-1".to_string()], + ); + + let (first_tx, mut first_rx) = tokio::sync::mpsc::unbounded_channel(); + let (dup_tx, mut dup_rx) = tokio::sync::mpsc::unbounded_channel(); + let state = begin_or_join_in_flight(&runtime, &key, "assign_task", "coord", 1, &first_tx) + .await + .expect("first attempt should start execution"); + let dup = begin_or_join_in_flight(&runtime, &key, "assign_task", "coord", 2, &dup_tx).await; + assert!( + dup.is_none(), + "in-flight duplicate should join the active execution as a waiter" + ); + + finish_request(&runtime, &state, PersistedSwarmMutationResponse::Done).await; + match first_rx.recv().await.expect("first response") { + ServerEvent::Done { id } => assert_eq!(id, 1), + other => panic!("expected done, got {other:?}"), + } + match dup_rx.recv().await.expect("duplicate response") { + ServerEvent::Done { id } => assert_eq!(id, 2), + other => panic!("expected done, got {other:?}"), + } +} diff --git a/crates/jcode-app-core/src/server/swarm_persistence.rs b/crates/jcode-app-core/src/server/swarm_persistence.rs new file mode 100644 index 0000000..83d7302 --- /dev/null +++ b/crates/jcode-app-core/src/server/swarm_persistence.rs @@ -0,0 +1,574 @@ +use super::{SwarmMember, SwarmTaskProgress, VersionedPlan}; +use crate::protocol::ServerEvent; +use crate::storage; +use jcode_swarm_core::{SwarmLifecycleStatus, SwarmMemberRecord}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock, Mutex as StdMutex, Weak}; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +/// Directory name under the durable state dir (`~/.jcode/state`). +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"; + +/// Serialize each swarm's complete snapshot/read/write operation. Callers must +/// acquire this before reading the independently locked in-memory maps so an +/// older snapshot cannot finish after a newer one. +static SWARM_OPERATION_LOCKS: LazyLock<StdMutex<HashMap<String, Weak<tokio::sync::Mutex<()>>>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +/// Protect primary/backup comparisons and filesystem updates, including tests +/// and recovery paths that invoke the synchronous persistence helpers directly. +static SWARM_FILE_LOCKS: LazyLock<StdMutex<HashMap<String, Weak<StdMutex<()>>>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct SwarmStateFileVersion(Option<Vec<u8>>); + +pub(super) fn swarm_operation_lock(swarm_id: &str) -> Arc<tokio::sync::Mutex<()>> { + let mut locks = SWARM_OPERATION_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(tokio::sync::Mutex::new(())); + locks.insert(swarm_id.to_string(), Arc::downgrade(&lock)); + lock +} + +fn swarm_file_lock(swarm_id: &str) -> Arc<StdMutex<()>> { + let mut locks = SWARM_FILE_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(StdMutex::new(())); + locks.insert(swarm_id.to_string(), Arc::downgrade(&lock)); + lock +} + +pub(super) struct LoadedSwarmRuntimeState { + pub plans: HashMap<String, VersionedPlan>, + pub coordinators: HashMap<String, String>, + pub members: HashMap<String, SwarmMember>, + pub swarms_by_id: HashMap<String, HashSet<String>>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct PersistedSwarmState { + swarm_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + plan: Option<PersistedVersionedPlan>, + #[serde(default, skip_serializing_if = "Option::is_none")] + coordinator_session_id: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + members: Vec<PersistedSwarmMember>, + updated_at_unix_ms: u64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct PersistedVersionedPlan { + items: Vec<crate::plan::PlanItem>, + version: u64, + participants: Vec<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + task_progress: HashMap<String, SwarmTaskProgress>, + #[serde(default = "default_plan_mode", skip_serializing_if = "is_light_mode")] + mode: String, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + node_meta: HashMap<String, crate::plan::NodeMeta>, +} + +fn default_plan_mode() -> String { + "light".to_string() +} + +fn is_light_mode(mode: &str) -> bool { + mode == "light" +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct PersistedSwarmMember { + #[serde(flatten)] + record: SwarmMemberRecord, + /// Wall-clock time when the member entered its current terminal status. + /// Legacy snapshots omit this; their snapshot timestamp becomes the + /// conservative migration fallback so reports are not discarded eagerly. + #[serde(default, skip_serializing_if = "Option::is_none")] + terminal_since_unix_ms: Option<u64>, +} + +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn state_dir() -> PathBuf { + storage::durable_state_dir().join(SWARM_STATE_DIR) +} + +fn legacy_state_dir() -> PathBuf { + storage::runtime_dir().join(LEGACY_SWARM_STATE_DIR) +} + +/// One-time migration from the legacy runtime-dir location (tmpfs, wiped on +/// reboot) to the durable state dir. Copies legacy snapshots only when the +/// new dir has none, so an already-migrated dir is never clobbered. +fn migrate_legacy_state() { + let new_dir = state_dir(); + let has_new_state = std::fs::read_dir(&new_dir) + .map(|entries| { + entries + .flatten() + .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) + }) + .unwrap_or(false); + if has_new_state { + return; + } + + let legacy_dir = legacy_state_dir(); + let Ok(entries) = std::fs::read_dir(&legacy_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let Some(file_name) = path.file_name() else { + continue; + }; + if let Err(err) = storage::ensure_dir(&new_dir) { + crate::logging::warn(&format!( + "Failed to create swarm state dir {}: {}", + new_dir.display(), + err + )); + return; + } + if let Err(err) = std::fs::copy(&path, new_dir.join(file_name)) { + crate::logging::warn(&format!( + "Failed to migrate legacy swarm state {}: {}", + path.display(), + err + )); + } + } +} + +fn state_path(swarm_id: &str) -> PathBuf { + let sanitized: String = swarm_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + state_dir().join(format!("{}.json", sanitized)) +} + +fn read_primary_version(swarm_id: &str) -> SwarmStateFileVersion { + SwarmStateFileVersion(std::fs::read(state_path(swarm_id)).ok()) +} + +pub(super) fn capture_swarm_state_version(swarm_id: &str) -> SwarmStateFileVersion { + let file_lock = swarm_file_lock(swarm_id); + let _guard = file_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + read_primary_version(swarm_id) +} + +fn remove_snapshot_files(swarm_id: &str) -> bool { + let path = state_path(swarm_id); + // First atomically replace the primary with an empty tombstone. The write + // may rotate the old primary to `.bak`, but load_runtime_state ignores that + // backup while the tombstone exists. Thus every crash point is safe: before + // rename the deletion did not happen, and after rename the old state is + // already logically invalid even if physical cleanup is interrupted. + let tombstone = PersistedSwarmState { + swarm_id: swarm_id.to_string(), + plan: None, + coordinator_session_id: None, + members: Vec::new(), + updated_at_unix_ms: now_unix_ms(), + }; + if let Err(err) = storage::write_json_fast(&path, &tombstone) { + crate::logging::warn(&format!( + "Failed to tombstone swarm state {}: {}", + path.display(), + err + )); + return false; + } + + let mut removed = true; + for candidate in [path.with_extension("bak"), path] { + if let Err(err) = std::fs::remove_file(&candidate) + && err.kind() != std::io::ErrorKind::NotFound + { + removed = false; + crate::logging::warn(&format!( + "Failed to remove swarm state {}: {}", + candidate.display(), + err + )); + } + } + removed +} + +fn from_persisted_plan(mut plan: PersistedVersionedPlan, updated_at_unix_ms: u64) -> VersionedPlan { + for item in &mut plan.items { + if item.status == "running" { + item.status = "running_stale".to_string(); + plan.task_progress + .entry(item.id.clone()) + .or_default() + .stale_since_unix_ms + .get_or_insert(updated_at_unix_ms); + } + } + 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, + } +} + +fn to_persisted_plan(plan: &VersionedPlan) -> PersistedVersionedPlan { + let mut participants: Vec<String> = plan.participants.iter().cloned().collect(); + participants.sort(); + PersistedVersionedPlan { + items: plan.items.clone(), + version: plan.version, + participants, + task_progress: plan.task_progress.clone(), + mode: plan.mode.clone(), + node_meta: plan.node_meta.clone(), + } +} + +fn to_persisted_member(member: &SwarmMember, snapshot_unix_ms: u64) -> PersistedSwarmMember { + let terminal_since_unix_ms = + super::swarm::member_status_is_terminal(&member.status).then(|| { + snapshot_unix_ms.saturating_sub(member.last_status_change.elapsed().as_millis() as u64) + }); + PersistedSwarmMember { + record: member.durable_record(), + terminal_since_unix_ms, + } +} + +fn append_recovery_detail(detail: Option<String>, note: &str) -> Option<String> { + match detail { + Some(existing) if !existing.trim().is_empty() => Some(format!("{} ({})", existing, note)), + _ => Some(note.to_string()), + } +} + +fn recover_member_status( + status: SwarmLifecycleStatus, + detail: Option<String>, + is_headless: bool, +) -> (SwarmLifecycleStatus, Option<String>) { + if status == SwarmLifecycleStatus::Running { + return ( + SwarmLifecycleStatus::Crashed, + append_recovery_detail(detail, "recovered after reload while running"), + ); + } + + // 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 { + return ( + SwarmLifecycleStatus::Stopped, + append_recovery_detail(detail, "idle worker not restored after server restart"), + ); + } + + // Done headless members finished their work before the reload. Nothing + // in-flight was lost and their completion report remains available. + if is_headless + && !matches!( + status, + SwarmLifecycleStatus::Completed + | SwarmLifecycleStatus::Done + | SwarmLifecycleStatus::Failed + | SwarmLifecycleStatus::Stopped + ) + { + return ( + SwarmLifecycleStatus::Crashed, + append_recovery_detail(detail, "headless session did not survive reload"), + ); + } + + (status, detail) +} + +fn recovered_member_event_tx() -> mpsc::UnboundedSender<ServerEvent> { + let (tx, rx) = mpsc::unbounded_channel(); + drop(rx); + tx +} + +fn from_persisted_member( + member: PersistedSwarmMember, + snapshot_updated_at_unix_ms: u64, + loaded_at_unix_ms: u64, + terminal_retention: Duration, +) -> Option<SwarmMember> { + let record = member.record; + let original_status = record.status.as_str(); + let was_terminal_before_recovery = + super::swarm::member_status_is_terminal(original_status.as_ref()); + let (status, detail) = recover_member_status(record.status, record.detail, record.is_headless); + let status_text = status.as_str(); + let terminal_since_unix_ms = super::swarm::member_status_is_terminal(status_text.as_ref()) + .then(|| { + member + .terminal_since_unix_ms + .unwrap_or(if was_terminal_before_recovery { + snapshot_updated_at_unix_ms + } else { + loaded_at_unix_ms + }) + }); + if terminal_since_unix_ms.is_some_and(|terminal_since| { + loaded_at_unix_ms.saturating_sub(terminal_since) >= terminal_retention.as_millis() as u64 + }) { + return None; + } + + let mut recovered = SwarmMember::from_record( + SwarmMemberRecord { + status, + detail, + ..record + }, + recovered_member_event_tx(), + ); + if let Some(terminal_since) = terminal_since_unix_ms { + let terminal_age = Duration::from_millis(loaded_at_unix_ms.saturating_sub(terminal_since)); + recovered.last_status_change = Instant::now() + .checked_sub(terminal_age) + .unwrap_or_else(Instant::now); + } + Some(recovered) +} + +pub(super) fn load_runtime_state() -> LoadedSwarmRuntimeState { + migrate_legacy_state(); + let dir = state_dir(); + let Ok(entries) = std::fs::read_dir(&dir) else { + return LoadedSwarmRuntimeState { + plans: HashMap::new(), + coordinators: HashMap::new(), + members: HashMap::new(), + swarms_by_id: HashMap::new(), + }; + }; + + let mut plans = HashMap::new(); + let mut coordinators = HashMap::new(); + let mut members = HashMap::new(); + 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 mut pruned_terminal_members = 0usize; + let mut pruned_members_by_swarm: HashMap<String, HashSet<String>> = HashMap::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + // `.bak` files are corruption-recovery fallbacks, not co-equal + // snapshots. When the primary `.json` still exists, reading the + // `.bak` alongside it can resurrect state the primary deliberately + // dropped (e.g. a cleared plan: the rotate-on-write keeps the old + // plan-bearing snapshot as `.bak`, and a union-load would re-insert + // that plan forever). `read_json` already falls back to the `.bak` + // internally when the primary is corrupt, so skipping it here loses + // nothing. + if path.extension().and_then(|ext| ext.to_str()) == Some("bak") + && path.with_extension("json").is_file() + { + continue; + } + let Ok(state) = storage::read_json::<PersistedSwarmState>(&path) else { + continue; + }; + 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 let Some(coordinator_session_id) = state.coordinator_session_id { + coordinators.insert(swarm_id, coordinator_session_id); + } + for member in state.members { + let Some(member_swarm_id) = member.record.swarm_id.clone() else { + continue; + }; + let member_session_id = member.record.session_id.clone(); + let Some(member) = from_persisted_member( + member, + state.updated_at_unix_ms, + loaded_at_unix_ms, + terminal_retention, + ) else { + pruned_terminal_members += 1; + pruned_members_by_swarm + .entry(member_swarm_id) + .or_default() + .insert(member_session_id); + continue; + }; + swarms_by_id + .entry(member_swarm_id.clone()) + .or_insert_with(HashSet::new) + .insert(member_session_id.clone()); + members.insert(member_session_id, member); + } + } + coordinators.retain(|swarm_id, session_id| { + !pruned_members_by_swarm + .get(swarm_id) + .is_some_and(|pruned| pruned.contains(session_id)) + }); + for (swarm_id, pruned_session_ids) in &pruned_members_by_swarm { + if let Some(plan) = plans.get_mut(swarm_id) { + plan.participants + .retain(|session_id| !pruned_session_ids.contains(session_id)); + } + } + // 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 retained_members = swarms_by_id + .get(swarm_id) + .into_iter() + .flat_map(|session_ids| session_ids.iter()) + .filter_map(|session_id| members.get(session_id).cloned()) + .collect::<Vec<_>>(); + persist_swarm_state( + swarm_id, + plans.get(swarm_id), + coordinators.get(swarm_id).map(String::as_str), + &retained_members, + ); + } + if pruned_terminal_members > 0 { + crate::logging::info(&format!( + "Pruned {pruned_terminal_members} expired terminal swarm member(s) while loading durable state" + )); + } + LoadedSwarmRuntimeState { + plans, + coordinators, + members, + swarms_by_id, + } +} + +pub(super) fn persist_swarm_state( + swarm_id: &str, + swarm_plan: Option<&VersionedPlan>, + coordinator_session_id: Option<&str>, + swarm_members: &[SwarmMember], +) { + let file_lock = swarm_file_lock(swarm_id); + let _guard = file_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if swarm_plan.is_none() && coordinator_session_id.is_none() && swarm_members.is_empty() { + let _ = remove_snapshot_files(swarm_id); + return; + } + + // A snapshot can be captured before another task advances the plan and + // reach disk afterwards. Never let that stale completion regress the + // durable plan. Full member/coordinator ordering is provided by the + // per-swarm operation lock around load_runtime + this write. + if let Some(candidate_plan) = swarm_plan + && let Ok(current) = storage::read_json::<PersistedSwarmState>(&state_path(swarm_id)) + && current + .plan + .as_ref() + .is_some_and(|plan| plan.version > candidate_plan.version) + { + return; + } + + let snapshot_unix_ms = now_unix_ms(); + let mut members = swarm_members + .iter() + .map(|member| to_persisted_member(member, snapshot_unix_ms)) + .collect::<Vec<_>>(); + members.sort_by(|left, right| left.record.session_id.cmp(&right.record.session_id)); + + let state = PersistedSwarmState { + swarm_id: swarm_id.to_string(), + plan: swarm_plan.map(to_persisted_plan), + coordinator_session_id: coordinator_session_id.map(str::to_string), + members, + updated_at_unix_ms: snapshot_unix_ms, + }; + + if let Err(err) = storage::write_json_fast(&state_path(swarm_id), &state) { + crate::logging::warn(&format!( + "Failed to persist swarm state {}: {}", + swarm_id, err + )); + } +} + +#[cfg(test)] +pub(super) fn remove_swarm_state(swarm_id: &str) { + let file_lock = swarm_file_lock(swarm_id); + let _guard = file_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = remove_snapshot_files(swarm_id); +} + +pub(super) fn remove_swarm_state_if_version( + swarm_id: &str, + expected: &SwarmStateFileVersion, +) -> bool { + let file_lock = swarm_file_lock(swarm_id); + let _guard = file_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if &read_primary_version(swarm_id) != expected { + return false; + } + remove_snapshot_files(swarm_id) +} + +#[cfg(test)] +#[path = "swarm_persistence_tests.rs"] +mod swarm_persistence_tests; diff --git a/crates/jcode-app-core/src/server/swarm_persistence_tests.rs b/crates/jcode-app-core/src/server/swarm_persistence_tests.rs new file mode 100644 index 0000000..278271a --- /dev/null +++ b/crates/jcode-app-core/src/server/swarm_persistence_tests.rs @@ -0,0 +1,1133 @@ +use super::*; +use std::time::{Duration, Instant}; + +struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + runtime: Option<std::ffi::OsString>, +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.runtime.take() { + crate::env::set_var("JCODE_RUNTIME_DIR", value); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + } +} + +fn test_env(dir: &tempfile::TempDir) -> EnvGuard { + let lock = storage::lock_test_env(); + let previous = std::env::var_os("JCODE_RUNTIME_DIR"); + crate::env::set_var("JCODE_RUNTIME_DIR", dir.path()); + EnvGuard { + _lock: lock, + runtime: previous, + } +} + +#[test] +fn persisted_swarm_state_round_trips_and_marks_running_stale() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let mut plans = HashMap::new(); + plans.insert( + "swarm-alpha".to_string(), + VersionedPlan { + items: vec![crate::plan::PlanItem { + content: "do thing".to_string(), + status: "running".to_string(), + priority: "high".to_string(), + id: "task-1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: Some("session-1".to_string()), + }], + version: 3, + participants: ["session-1".to_string(), "session-2".to_string()] + .into_iter() + .collect(), + task_progress: HashMap::from([( + "task-1".to_string(), + SwarmTaskProgress { + assigned_session_id: Some("session-1".to_string()), + assignment_summary: Some("do thing".to_string()), + assigned_at_unix_ms: Some(10), + started_at_unix_ms: Some(20), + last_heartbeat_unix_ms: Some(30), + last_detail: Some("tool start: read".to_string()), + last_checkpoint_unix_ms: Some(40), + checkpoint_summary: Some("tool done: read".to_string()), + completed_at_unix_ms: None, + stale_since_unix_ms: None, + heartbeat_count: Some(2), + checkpoint_count: Some(1), + no_artifact_requeues: None, + dead_assignee_reclaims: None, + }, + )]), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + ); + let coordinators = HashMap::from([("swarm-alpha".to_string(), "session-2".to_string())]); + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let members = vec![SwarmMember { + session_id: "session-1".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: Some(PathBuf::from("/tmp/swarm-alpha")), + swarm_id: Some("swarm-alpha".to_string()), + swarm_enabled: true, + status: "running".to_string(), + detail: Some("writing tests".to_string()), + friendly_name: Some("fox".to_string()), + report_back_to_session_id: Some("session-2".to_string()), + latest_completion_report: None, + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }]; + + persist_swarm_state( + "swarm-alpha", + plans.get("swarm-alpha"), + coordinators.get("swarm-alpha").map(String::as_str), + &members, + ); + let loaded = load_runtime_state(); + + let loaded_plan = loaded.plans.get("swarm-alpha").expect("loaded plan"); + assert_eq!(loaded_plan.version, 3); + assert_eq!(loaded_plan.items.len(), 1); + assert_eq!(loaded_plan.items[0].status, "running_stale"); + let progress = loaded_plan + .task_progress + .get("task-1") + .expect("task progress"); + assert_eq!(progress.assigned_session_id.as_deref(), Some("session-1")); + assert_eq!( + progress.checkpoint_summary.as_deref(), + Some("tool done: read") + ); + assert!(progress.stale_since_unix_ms.is_some()); + assert_eq!( + loaded.coordinators.get("swarm-alpha"), + Some(&"session-2".to_string()) + ); + let recovered_member = loaded.members.get("session-1").expect("recovered member"); + assert_eq!(recovered_member.role, "agent"); + assert_eq!( + recovered_member.report_back_to_session_id.as_deref(), + Some("session-2") + ); + assert_eq!(recovered_member.status, "crashed"); + assert_eq!( + recovered_member.detail.as_deref(), + Some("writing tests (recovered after reload while running)") + ); + assert_eq!( + loaded.swarms_by_id.get("swarm-alpha"), + Some(&HashSet::from(["session-1".to_string()])) + ); +} + +#[test] +fn ready_headless_member_with_report_stops_without_losing_report() { + // A headless worker that finished its task has no process after restart. + // Preserve its report, but do not eagerly reconstruct the full Agent just + // to keep an idle worker reusable indefinitely. + 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-ready".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: Some(PathBuf::from("/tmp/swarm-gamma")), + swarm_id: Some("swarm-gamma".to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("pig".to_string()), + report_back_to_session_id: Some("session-coordinator".to_string()), + latest_completion_report: Some("Done. Built the worker; all tests pass.".to_string()), + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }]; + + persist_swarm_state("swarm-gamma", None, None, &members); + let loaded = load_runtime_state(); + + let recovered = loaded.members.get("session-ready").expect("member"); + assert_eq!(recovered.status, "stopped"); + assert_eq!( + recovered.detail.as_deref(), + Some("idle worker not restored after server restart") + ); + assert_eq!( + recovered.latest_completion_report.as_deref(), + Some("Done. Built the worker; all tests pass.") + ); +} + +#[test] +fn terminal_member_retention_preserves_recent_reports_and_prunes_expired_records() { + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let member = SwarmMember { + session_id: "session-terminal".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: Some(PathBuf::from("/tmp/swarm-terminal")), + swarm_id: Some("swarm-terminal".to_string()), + swarm_enabled: true, + status: "completed".to_string(), + detail: Some("done".to_string()), + friendly_name: Some("otter".to_string()), + report_back_to_session_id: Some("session-coordinator".to_string()), + latest_completion_report: Some("All targeted tests passed.".to_string()), + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: Some("retention test".to_string()), + }; + let loaded_at = 10_000_000; + let mut persisted = to_persisted_member(&member, loaded_at); + persisted.terminal_since_unix_ms = Some(loaded_at - 30_000); + + let recent = from_persisted_member( + persisted.clone(), + loaded_at, + loaded_at, + Duration::from_secs(60), + ) + .expect("recent terminal member remains inspectable"); + assert_eq!( + recent.latest_completion_report.as_deref(), + Some("All targeted tests passed.") + ); + assert!(recent.last_status_change.elapsed() >= Duration::from_secs(30)); + + assert!( + from_persisted_member(persisted, loaded_at, loaded_at, Duration::from_secs(10),).is_none(), + "expired terminal member should be pruned" + ); +} + +#[test] +fn legacy_terminal_member_uses_snapshot_time_as_retention_fallback() { + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let member = SwarmMember { + session_id: "session-legacy-terminal".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some("swarm-legacy".to_string()), + swarm_enabled: true, + status: "failed".to_string(), + detail: Some("old failure".to_string()), + friendly_name: Some("badger".to_string()), + report_back_to_session_id: None, + latest_completion_report: Some("legacy report".to_string()), + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }; + let loaded_at = 20_000_000; + let mut persisted = to_persisted_member(&member, loaded_at); + persisted.terminal_since_unix_ms = None; + + assert!( + from_persisted_member( + persisted, + loaded_at - 20_000, + loaded_at, + Duration::from_secs(10), + ) + .is_none(), + "legacy records should age from their containing snapshot" + ); +} + +#[test] +fn recovery_induced_terminal_status_starts_retention_at_load_time() { + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let member = SwarmMember { + session_id: "session-ready-recovery".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some("swarm-recovery".to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("hare".to_string()), + report_back_to_session_id: None, + latest_completion_report: Some("finished just before restart".to_string()), + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }; + let loaded_at = 300_000_000; + let mut persisted = to_persisted_member(&member, loaded_at); + persisted.terminal_since_unix_ms = None; + + let recovered = from_persisted_member( + persisted, + loaded_at - Duration::from_secs(48 * 60 * 60).as_millis() as u64, + loaded_at, + Duration::from_secs(24 * 60 * 60), + ) + .expect("recovery-induced terminal status should receive a fresh retention window"); + assert_eq!(recovered.status, "stopped"); + assert!(recovered.last_status_change.elapsed() < Duration::from_secs(1)); +} + +#[test] +fn startup_gc_removes_expired_terminal_members_from_durable_snapshot() { + 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-expired".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some("swarm-expired".to_string()), + swarm_enabled: true, + status: "completed".to_string(), + detail: None, + friendly_name: Some("fox".to_string()), + report_back_to_session_id: None, + latest_completion_report: Some("report retained until expiry".to_string()), + role: "agent".to_string(), + joined_at: Instant::now(), + last_status_change: Instant::now(), + is_headless: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + }]; + persist_swarm_state("swarm-expired", None, None, &members); + + let path = state_path("swarm-expired"); + let mut persisted = storage::read_json::<PersistedSwarmState>(&path).expect("snapshot"); + persisted.members[0].terminal_since_unix_ms = + Some(now_unix_ms().saturating_sub(Duration::from_secs(48 * 60 * 60).as_millis() as u64)); + storage::write_json_fast(&path, &persisted).expect("age terminal member"); + + let loaded = load_runtime_state(); + assert!(!loaded.members.contains_key("session-expired")); + assert!( + !path.exists(), + "empty snapshot should be deleted after startup collection" + ); +} + +#[test] +fn remove_swarm_state_deletes_persisted_snapshot() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let plans = HashMap::from([( + "swarm-beta".to_string(), + VersionedPlan { + items: Vec::new(), + version: 1, + participants: Default::default(), + task_progress: HashMap::new(), + mode: "light".to_string(), + node_meta: HashMap::new(), + }, + )]); + persist_swarm_state("swarm-beta", plans.get("swarm-beta"), None, &[]); + assert!(state_path("swarm-beta").exists()); + + remove_swarm_state("swarm-beta"); + assert!(!state_path("swarm-beta").exists()); +} + +#[test] +fn deep_plan_mode_and_node_meta_round_trip() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let mut node_meta = HashMap::new(); + node_meta.insert( + "root".to_string(), + crate::plan::NodeMeta { + kind: Some("explore".to_string()), + parent: None, + expanded: true, + is_gate: false, + planner: Some("session-1".to_string()), + artifact_json: Some(r#"{"findings":"found it","confidence":"high"}"#.to_string()), + origin: Some("seed".to_string()), + }, + ); + node_meta.insert( + "root.gate".to_string(), + crate::plan::NodeMeta { + kind: Some("critique".to_string()), + parent: Some("root".to_string()), + expanded: false, + is_gate: true, + planner: None, + artifact_json: None, + origin: Some("gate".to_string()), + }, + ); + + let plan = VersionedPlan { + items: vec![ + crate::plan::PlanItem { + content: "explore X".to_string(), + status: "completed".to_string(), + priority: "high".to_string(), + id: "root".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: Some("session-1".to_string()), + }, + crate::plan::PlanItem { + content: "gate".to_string(), + status: "queued".to_string(), + priority: "medium".to_string(), + id: "root.gate".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: vec!["root".to_string()], + assigned_to: None, + }, + ], + version: 7, + participants: ["session-1".to_string()].into_iter().collect(), + task_progress: HashMap::new(), + mode: "deep".to_string(), + node_meta, + }; + + persist_swarm_state("swarm-deep", Some(&plan), None, &[]); + let loaded = load_runtime_state(); + + let loaded_plan = loaded.plans.get("swarm-deep").expect("loaded plan"); + assert_eq!(loaded_plan.mode, "deep"); + assert_eq!(loaded_plan.version, 7); + + // Edges survive on the item itself. + let gate_item = loaded_plan + .items + .iter() + .find(|item| item.id == "root.gate") + .expect("gate item"); + assert_eq!(gate_item.blocked_by, vec!["root".to_string()]); + + // Node kinds, gate flags, expansion, planner, and artifacts survive in node_meta. + let root_meta = loaded_plan.node_meta.get("root").expect("root meta"); + assert_eq!(root_meta.kind.as_deref(), Some("explore")); + assert!(root_meta.expanded); + assert!(!root_meta.is_gate); + assert_eq!(root_meta.planner.as_deref(), Some("session-1")); + assert!( + root_meta + .artifact_json + .as_deref() + .is_some_and(|json| json.contains("found it")) + ); + let gate_meta = loaded_plan.node_meta.get("root.gate").expect("gate meta"); + assert_eq!(gate_meta.kind.as_deref(), Some("critique")); + assert!(gate_meta.is_gate); + assert_eq!(gate_meta.parent.as_deref(), Some("root")); +} + +/// The behavioral counterpart of `deep_plan_mode_and_node_meta_round_trip`: +/// after a persist -> load cycle (server restart), the reloaded plan must still +/// drive the deep-mode machinery that reads `node_meta`: +/// +/// 1. `low_confidence_completed_ids` still reports completed nodes whose stored +/// artifact self-reported low confidence (gate confidence-debt tracking). +/// 2. `hydrate_assignment` still injects completed upstream artifacts +/// (forward dataflow) into assignment content. +/// 3. Lifting the reloaded plan into the DAG engine still enforces the gate +/// debt rule: a gate cannot rubber-stamp past an unaddressed low-confidence +/// sibling, but passes once it addresses that sibling by id. +#[test] +fn gate_debt_and_artifact_hydration_survive_reload() { + use crate::plan::dag::{DagError, HandoffArtifact, complete_node, dispatch}; + + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let solid_artifact = serde_json::to_string(&HandoffArtifact { + findings: "solid scope fully mapped".to_string(), + evidence: vec!["crates/foo/api.rs:12".to_string()], + confidence: Some("high".to_string()), + what_i_did_not_check: vec!["nothing, fully covered".to_string()], + ..HandoffArtifact::default() + }) + .unwrap(); + let shaky_artifact = serde_json::to_string(&HandoffArtifact { + findings: "unsure about the edge cases here".to_string(), + confidence: Some("low".to_string()), + what_i_did_not_check: vec!["error paths".to_string()], + ..HandoffArtifact::default() + }) + .unwrap(); + + let item = |id: &str, status: &str, blocked_by: Vec<String>| crate::plan::PlanItem { + content: format!("work on {id}"), + status: status.to_string(), + priority: "medium".to_string(), + id: id.to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by, + assigned_to: None, + }; + let meta = |kind: &str, parent: Option<&str>, is_gate: bool, artifact: Option<&str>| { + crate::plan::NodeMeta { + kind: Some(kind.to_string()), + parent: parent.map(str::to_string), + expanded: false, + is_gate, + planner: None, + artifact_json: artifact.map(str::to_string), + origin: None, + } + }; + + let mut plan = VersionedPlan::new(); + plan.mode = "deep".to_string(); + plan.version = 4; + plan.items = vec![ + { + let mut root = item("root", "running", Vec::new()); + root.assigned_to = Some("planner-1".to_string()); + root + }, + item("root.solid", "completed", Vec::new()), + item("root.shaky", "completed", Vec::new()), + item( + "root.gate", + "queued", + vec!["root.solid".to_string(), "root.shaky".to_string()], + ), + ]; + plan.node_meta = HashMap::from([ + ("root".to_string(), { + let mut m = meta("explore", None, false, None); + m.expanded = true; + m.planner = Some("planner-1".to_string()); + m + }), + ( + "root.solid".to_string(), + meta("explore", Some("root"), false, Some(&solid_artifact)), + ), + ( + "root.shaky".to_string(), + meta("explore", Some("root"), false, Some(&shaky_artifact)), + ), + ( + "root.gate".to_string(), + meta("critique", Some("root"), true, None), + ), + ]); + + persist_swarm_state("swarm-debt", Some(&plan), None, &[]); + let loaded = load_runtime_state(); + let loaded_plan = loaded.plans.get("swarm-debt").expect("loaded plan"); + + // 1. Confidence-debt tracking: the reloaded plan still flags the shaky node. + assert_eq!( + crate::plan::bridge::low_confidence_completed_ids(loaded_plan), + vec!["root.shaky".to_string()] + ); + + // 2. Upstream artifact hydration: the gate's assignment content still gets + // both completed dependency artifacts, including what_i_did_not_check. + let hydrated = crate::plan::bridge::hydrate_assignment(loaded_plan, "root.gate", "gate prompt"); + assert!(hydrated.contains("gate prompt")); + assert!(hydrated.contains("Inputs from completed dependencies")); + assert!(hydrated.contains("solid scope fully mapped")); + assert!(hydrated.contains("crates/foo/api.rs:12")); + assert!(hydrated.contains("unsure about the edge cases here")); + assert!(hydrated.contains("error paths")); + + // 3. The DAG engine, lifted from the reloaded plan, still enforces the gate + // debt rule end to end. + let mut graph = crate::plan::bridge::to_task_graph(loaded_plan); + assert!(dispatch(&mut graph, "root.gate", "gate-worker")); + let err = complete_node( + &mut graph, + "root.gate", + "gate-worker", + HandoffArtifact::brief("all good, no gaps"), + ) + .unwrap_err(); + match &err { + DagError::UnaddressedLowConfidence { gate, nodes } => { + assert_eq!(gate, "root.gate"); + assert_eq!(nodes, &vec!["root.shaky".to_string()]); + } + other => panic!("expected UnaddressedLowConfidence after reload, got {other:?}"), + } + complete_node( + &mut graph, + "root.gate", + "gate-worker", + HandoffArtifact::brief( + "root.shaky's low confidence is acceptable: its scope was re-derived and \ + cross-checked; root.solid audited clean", + ), + ) + .expect("gate passes once every audited node is addressed by id"); +} + +#[test] +fn load_migrates_legacy_runtime_dir_state() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let legacy = serde_json::json!({ + "swarm_id": "swarm-migrate", + "coordinator_session_id": "coord-legacy", + "updated_at_unix_ms": 1u64 + }); + std::fs::create_dir_all(legacy_state_dir()).expect("legacy state dir"); + std::fs::write( + legacy_state_dir().join("swarm-migrate.json"), + serde_json::to_vec(&legacy).unwrap(), + ) + .expect("write legacy snapshot"); + + let loaded = load_runtime_state(); + assert_eq!( + loaded.coordinators.get("swarm-migrate"), + Some(&"coord-legacy".to_string()) + ); + // Migrated copy lives in the durable dir now. + assert!(state_path("swarm-migrate").exists()); +} + +#[test] +fn migration_does_not_clobber_existing_durable_state() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + // Durable dir already has state for this swarm. + persist_swarm_state("swarm-both", None, Some("coord-new"), &[]); + + // Legacy dir has a stale snapshot for the same swarm. + let legacy = serde_json::json!({ + "swarm_id": "swarm-both", + "coordinator_session_id": "coord-old", + "updated_at_unix_ms": 1u64 + }); + std::fs::create_dir_all(legacy_state_dir()).expect("legacy state dir"); + std::fs::write( + legacy_state_dir().join("swarm-both.json"), + serde_json::to_vec(&legacy).unwrap(), + ) + .expect("write legacy snapshot"); + + let loaded = load_runtime_state(); + assert_eq!( + loaded.coordinators.get("swarm-both"), + Some(&"coord-new".to_string()) + ); +} + +#[test] +fn state_dir_is_durable_not_runtime() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + // With JCODE_RUNTIME_DIR pinned, the state dir stays sandboxed but must + // not be the legacy runtime-dir location. + assert_ne!(state_dir(), legacy_state_dir()); + assert!(state_dir().starts_with(dir.path())); +} + +#[test] +fn legacy_snapshot_without_mode_defaults_to_light() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + // Simulate a pre-deep-mode snapshot on disk: no `mode`, no `node_meta`. + let legacy = serde_json::json!({ + "swarm_id": "swarm-legacy", + "plan": { + "items": [{ + "content": "old task", + "status": "queued", + "priority": "medium", + "id": "t1" + }], + "version": 2, + "participants": ["session-1"] + }, + "updated_at_unix_ms": 1u64 + }); + std::fs::create_dir_all(state_dir()).expect("state dir"); + std::fs::write( + state_path("swarm-legacy"), + serde_json::to_vec(&legacy).unwrap(), + ) + .expect("write legacy snapshot"); + + let loaded = load_runtime_state(); + let plan = loaded.plans.get("swarm-legacy").expect("legacy plan"); + assert_eq!(plan.mode, "light"); + assert!(plan.node_meta.is_empty()); + assert_eq!(plan.version, 2); +} + +/// A persist that captured an older plan must not overwrite a newer durable +/// plan when the calls complete out of order. +/// +/// This test parks persist A inside `load_runtime` at `members.read()` +/// (after A has already cloned the v5 plan) behind a held `members.write()` +/// gate, then performs mutator B's work while A is parked: bump the +/// in-memory plan to v6 and run B's persist half (`persist_swarm_state` with +/// the v6 runtime, exactly what B's unblocked `persist_swarm_state_for` does +/// on another worker thread, where its uncontended lock reads resolve +/// without suspending). v6 is then durably on disk. Releasing A regresses +/// the durable snapshot back to v5. +/// +/// Post-restart impact: `Server::new` seeds `SwarmState` from +/// `load_runtime_state()` and `recover_headless_sessions_on_startup` +/// (server.rs:584-918) drives recovery from that state, so a regressed +/// snapshot silently restores the older plan: work completed between v5 and +/// v6 flips back to queued/running_stale and newer node_meta artifacts are +/// lost. +/// +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn stale_persist_cannot_regress_newer_plan_version() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let mut plan = VersionedPlan::new(); + plan.version = 5; + plan.items = vec![crate::plan::PlanItem { + content: "task one".to_string(), + status: "queued".to_string(), + priority: "medium".to_string(), + id: "t1".to_string(), + subsystem: None, + file_scope: Vec::new(), + blocked_by: Vec::new(), + assigned_to: None, + }]; + let swarm_state = crate::server::SwarmState::new( + HashMap::new(), + HashMap::new(), + HashMap::from([("swarm-race".to_string(), plan)]), + HashMap::new(), + ); + + // Gate: hold members.write() so persist A parks inside load_runtime at + // the final members.read(), AFTER it has already cloned the v5 plan + // under plans.read(). + let gate = swarm_state.members.write().await; + + let a = tokio::spawn({ + let swarm_state = swarm_state.clone(); + async move { + crate::server::persist_swarm_state_for("swarm-race", &swarm_state).await; + } + }); + // Current-thread test runtime: yielding runs A until it parks on the + // contended members.read().await, past its v5 plan clone. + for _ in 0..16 { + tokio::task::yield_now().await; + } + + // Mutator B: bump the in-memory plan to v6 ... + let v6_plan = { + let mut plans = swarm_state.plans.write().await; + let plan = plans.get_mut("swarm-race").expect("plan"); + plan.version = 6; + plan.clone() + }; + // ... and B's persist half runs to completion while A is parked. In + // production this is B's own persist_swarm_state_for on another worker + // thread: nothing gates B on A (there is no per-swarm persist lock), so + // B's load_runtime observes v6 and its synchronous persist_swarm_state + // lands v6 on disk before A's task is polled again. + persist_swarm_state("swarm-race", Some(&v6_plan), None, &[]); + assert_eq!( + load_runtime_state() + .plans + .get("swarm-race") + .expect("v6 snapshot") + .version, + 6, + "v6 must be durably on disk before A resumes" + ); + + // Release A: it resumes with its stale v5 runtime snapshot. The durable + // version guard must reject that write. + drop(gate); + a.await.expect("persist task"); + + let primary = storage::read_json::<PersistedSwarmState>(&state_path("swarm-race")) + .expect("primary snapshot"); + assert_eq!( + primary.plan.expect("plan").version, + 6, + "a stale persist must not regress the durable plan version" + ); +} + +#[tokio::test] +async fn persistence_operations_serialize_per_swarm_but_not_globally() { + let alpha = swarm_operation_lock("swarm-lock-alpha"); + let same_alpha = swarm_operation_lock("swarm-lock-alpha"); + let beta = swarm_operation_lock("swarm-lock-beta"); + assert!( + Arc::ptr_eq(&alpha, &same_alpha), + "the same swarm must share one operation lock" + ); + assert!( + !Arc::ptr_eq(&alpha, &beta), + "unrelated swarms must not share a global serialization lock" + ); + + let alpha_guard = alpha.lock().await; + assert!( + tokio::time::timeout(Duration::from_millis(20), same_alpha.lock()) + .await + .is_err(), + "a second operation for the same swarm must wait" + ); + let _beta_guard = tokio::time::timeout(Duration::from_millis(100), beta.lock()) + .await + .expect("an unrelated swarm operation was unnecessarily blocked"); + drop(alpha_guard); +} + +/// Companion finding discovered while writing the regression test above: +/// `load_runtime_state` filters entries only with `path.is_file()`, with no +/// `.json` extension check (unlike `migrate_legacy_state`, which does check). +/// Since `storage::write_json_fast` leaves a `<swarm>.bak` hard link of the +/// PREVIOUS snapshot next to the primary, startup restore parses both files +/// and inserts them into the same maps keyed by `state.swarm_id`, so +/// whichever the directory iterator yields last wins. After a regressed +/// primary (v5) with a newer backup (v6), restart restore is therefore +/// nondeterministic between the two. This test pins the underlying behavior +/// deterministically: a `.bak` file with no primary at all is still loaded +/// as a live snapshot. +#[test] +fn load_runtime_state_reads_bak_files_as_snapshots() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + let snapshot = serde_json::json!({ + "swarm_id": "swarm-bak-only", + "coordinator_session_id": "coord-from-bak", + "updated_at_unix_ms": 1u64 + }); + std::fs::create_dir_all(state_dir()).expect("state dir"); + std::fs::write( + state_dir().join("swarm-bak-only.bak"), + serde_json::to_vec(&snapshot).unwrap(), + ) + .expect("write bak snapshot"); + + let loaded = load_runtime_state(); + assert_eq!( + loaded.coordinators.get("swarm-bak-only"), + Some(&"coord-from-bak".to_string()), + "load_runtime_state currently ingests .bak files as snapshots; if \ + this fails the loader gained a .json extension filter (update the \ + wiring audit and the primary-file assertions in \ + stale_persist_cannot_regress_newer_plan_version)" + ); +} + +/// A `.bak` sibling must NOT be loaded when the primary `.json` exists: +/// the write path rotates the previous snapshot to `.bak`, so after an +/// intentional state drop (e.g. `swarm:clear_plan`) the `.bak` still holds +/// the dropped plan. Union-loading both would resurrect the cleared plan on +/// every server restart. +#[test] +fn load_runtime_state_ignores_bak_when_primary_json_exists() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + std::fs::create_dir_all(state_dir()).expect("state dir"); + let stale_with_plan = serde_json::json!({ + "swarm_id": "swarm-cleared", + "plan": { + "items": [{ + "id": "stale-task", + "content": "stale", + "status": "queued", + "assigned_to": null + }], + "version": 42u64, + "participants": [], + "task_progress": {}, + "mode": "light", + "node_meta": {} + }, + "coordinator_session_id": "coord-stale", + "updated_at_unix_ms": 1u64 + }); + let current_without_plan = serde_json::json!({ + "swarm_id": "swarm-cleared", + "coordinator_session_id": "coord-current", + "updated_at_unix_ms": 2u64 + }); + std::fs::write( + state_dir().join("swarm-cleared.bak"), + serde_json::to_vec(&stale_with_plan).unwrap(), + ) + .expect("write bak snapshot"); + std::fs::write( + state_dir().join("swarm-cleared.json"), + serde_json::to_vec(¤t_without_plan).unwrap(), + ) + .expect("write primary snapshot"); + + let loaded = load_runtime_state(); + assert!( + !loaded.plans.contains_key("swarm-cleared"), + "plan cleared from the primary snapshot must not be resurrected from .bak" + ); + assert_eq!( + loaded.coordinators.get("swarm-cleared"), + Some(&"coord-current".to_string()), + "primary snapshot must win over its .bak sibling" + ); +} + +#[test] +fn persisted_swarm_state_without_plan_still_restores_coordinator_and_members() { + 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: "coord-1".to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: Some(PathBuf::from("/tmp/swarm-gamma")), + swarm_id: Some("swarm-gamma".to_string()), + swarm_enabled: true, + status: "ready".to_string(), + detail: None, + friendly_name: Some("owl".to_string()), + report_back_to_session_id: None, + latest_completion_report: None, + role: "coordinator".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-gamma", None, Some("coord-1"), &members); + + let loaded = load_runtime_state(); + assert!(!loaded.plans.contains_key("swarm-gamma")); + assert_eq!( + loaded.coordinators.get("swarm-gamma"), + Some(&"coord-1".to_string()) + ); + assert_eq!( + loaded + .members + .get("coord-1") + .and_then(|member| member.friendly_name.as_deref()), + Some("owl") + ); + assert_eq!( + loaded.swarms_by_id.get("swarm-gamma"), + Some(&HashSet::from(["coord-1".to_string()])) + ); +} + +#[test] +fn remove_swarm_state_removes_backup_and_cannot_resurrect() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + // First persist creates the primary; the second overwrite makes + // write_json_fast hard-link the previous (coord-v1) snapshot to `.bak`. + persist_swarm_state("swarm-zombie", None, Some("coord-v1"), &[]); + persist_swarm_state("swarm-zombie", None, Some("coord-v2"), &[]); + let bak_path = state_path("swarm-zombie").with_extension("bak"); + assert!(bak_path.exists(), "write_json_fast leaves a .bak hard link"); + + remove_swarm_state("swarm-zombie"); + assert!(!state_path("swarm-zombie").exists()); + assert!( + !bak_path.exists(), + "logical deletion must remove the recovery backup too" + ); + + let loaded = load_runtime_state(); + assert!( + !loaded.coordinators.contains_key("swarm-zombie"), + "a deleted swarm must not be restored on the next load" + ); +} + +#[test] +fn empty_persist_dissolution_removes_backup_and_cannot_resurrect() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + persist_swarm_state("swarm-dissolve", None, Some("coord-v1"), &[]); + persist_swarm_state("swarm-dissolve", None, Some("coord-v2"), &[]); + let bak_path = state_path("swarm-dissolve").with_extension("bak"); + assert!(bak_path.exists(), "write_json_fast leaves a .bak hard link"); + + // Dissolution: no plan, no coordinator, no members hits the + // remove_file branch instead of writing a snapshot. + persist_swarm_state("swarm-dissolve", None, None, &[]); + assert!(!state_path("swarm-dissolve").exists()); + assert!( + !bak_path.exists(), + "empty-state persistence must remove the recovery backup too" + ); + + let loaded = load_runtime_state(); + assert!( + !loaded.coordinators.contains_key("swarm-dissolve"), + "a dissolved swarm must not be restored on the next load" + ); +} + +/// Delete-vs-write interleaving between `remove_persisted_swarm_state_for` +/// and a concurrent persist (wiring-audit.bak-resurrection, part b). +/// +/// `remove_persisted_swarm_state_for` (server.rs:120) is `load_runtime() +/// .await` followed by an unserialized `remove_swarm_state`. Like the +/// persist inversion race above, `load_runtime` observes the four state +/// maps across multiple await points, so a remover that saw an all-empty +/// (dissolved) runtime can park, lose the race to a swarm re-creation plus +/// persist, then resume and delete the FRESH snapshot the re-creation just +/// wrote. Two failures compound: +/// 1. Orphaned live swarm: the recreated swarm (coordinator registered +/// in memory) has no primary snapshot, so a clean restart loses it. +/// 2. Zombie resurrection: the persist that the remover clobbered +/// hard-linked the PRE-dissolution snapshot to `.bak`, and +/// `load_runtime_state` reads `.bak` files, so restart restores the +/// stale pre-dissolution state instead. +/// +/// Same gate technique as +/// `stale_persist_cannot_regress_newer_plan_version`: +/// park A inside `load_runtime` at the contended `members.read()`, run +/// mutator B's re-creation and persist while A is parked, release A. +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn stale_remove_cannot_delete_fresh_snapshot_or_restore_backup() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let _env = test_env(&dir); + + // The previous incarnation's snapshot is on disk; the swarm has since + // been dissolved, so the in-memory runtime is empty. + persist_swarm_state("swarm-del-race", None, Some("coord-stale"), &[]); + let swarm_state = crate::server::SwarmState::new( + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + ); + + // Gate: hold members.write() so remover A parks inside load_runtime at + // the final members.read(), AFTER it has already observed the + // dissolved (all-empty) plans/coordinators/swarms_by_id state. + let gate = swarm_state.members.write().await; + + let a = tokio::spawn({ + let swarm_state = swarm_state.clone(); + async move { + crate::server::remove_persisted_swarm_state_for("swarm-del-race", &swarm_state).await; + } + }); + // Current-thread test runtime: yielding runs A until it parks on the + // contended members.read().await. + for _ in 0..16 { + tokio::task::yield_now().await; + } + + // Mutator B: the swarm is recreated while A is parked. B registers a + // new coordinator in memory ... + { + let mut coordinators = swarm_state.coordinators.write().await; + coordinators.insert("swarm-del-race".to_string(), "coord-new".to_string()); + } + // ... and B's persist half runs to completion (in production this is + // B's own persist_swarm_state_for on another worker thread, whose + // uncontended lock reads resolve without suspending). This overwrite + // also hard-links the stale pre-dissolution snapshot to `.bak`. + persist_swarm_state("swarm-del-race", None, Some("coord-new"), &[]); + let on_disk = storage::read_json::<PersistedSwarmState>(&state_path("swarm-del-race")) + .expect("fresh snapshot"); + assert_eq!( + on_disk.coordinator_session_id.as_deref(), + Some("coord-new"), + "fresh snapshot must be durably on disk before A resumes" + ); + + // Release A: its stale all-empty runtime passes has_any_state(), but the + // compare-and-delete guard must notice that the durable snapshot changed. + drop(gate); + a.await.expect("remove task"); + + assert!( + state_path("swarm-del-race").exists(), + "a stale remove must not delete a freshly persisted snapshot" + ); + let loaded = load_runtime_state(); + assert_eq!( + loaded.coordinators.get("swarm-del-race"), + Some(&"coord-new".to_string()), + "restart must restore the fresh incarnation, not its stale backup" + ); +} diff --git a/crates/jcode-app-core/src/server/tests.rs b/crates/jcode-app-core/src/server/tests.rs new file mode 100644 index 0000000..ed6be1e --- /dev/null +++ b/crates/jcode-app-core/src/server/tests.rs @@ -0,0 +1,939 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::{ + FileAccess, Server, SessionInterruptQueues, SwarmMember, dispatch_background_task_completion, + file_activity_scope_label, persist_swarm_state_snapshot, +}; +use crate::agent::Agent; +use crate::bus::{ + BackgroundTaskCompleted, BackgroundTaskProgress, BackgroundTaskProgressEvent, + BackgroundTaskProgressKind, BackgroundTaskProgressSource, BackgroundTaskStatus, FileOp, + FileTouch, +}; +use crate::message::{Message, Role, StreamEvent, ToolDefinition}; +use crate::protocol::{NotificationType, ServerEvent}; +use crate::provider::{EventStream, Provider}; +use crate::tool::Registry; +use crate::tool::selfdev::ReloadContext; +use anyhow::Result; +use async_trait::async_trait; +use std::collections::HashMap; +use std::ffi::OsString; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; +use tokio::time::timeout; + +struct EnvGuard { + prev_home: Option<OsString>, + prev_runtime_dir: Option<OsString>, + prev_socket: Option<OsString>, +} + +struct ScopedEnvVar { + key: &'static str, + prev: Option<OsString>, +} + +fn file_access_with_summary(summary: Option<&str>) -> FileAccess { + FileAccess { + session_id: "session-peer".to_string(), + op: FileOp::Edit, + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + intent: None, + summary: summary.map(str::to_string), + detail: None, + } +} + +fn file_touch_with_summary(summary: Option<&str>) -> FileTouch { + FileTouch { + session_id: "session-current".to_string(), + path: std::path::PathBuf::from("src/lib.rs"), + op: FileOp::Edit, + intent: None, + summary: summary.map(str::to_string), + detail: None, + } +} + +#[test] +fn file_activity_scope_label_classifies_overlap() { + let previous = file_access_with_summary(Some("edited lines 10-20")); + let current = file_touch_with_summary(Some("edited lines 18-25")); + assert_eq!( + file_activity_scope_label(&previous, ¤t), + "overlapping lines" + ); + + let current = file_touch_with_summary(Some("edited lines 30-40")); + assert_eq!( + file_activity_scope_label(&previous, ¤t), + "same file, non-overlapping lines" + ); + + let current = file_touch_with_summary(None); + assert_eq!(file_activity_scope_label(&previous, ¤t), "same file"); +} + +#[test] +fn configured_server_name_normalizes_operator_labels() { + assert_eq!( + super::normalize_configured_server_name(" Mount Cloud/Fabian ").as_deref(), + Some("mount-cloud-fabian") + ); + assert_eq!( + super::normalize_configured_server_name("john@example.com").as_deref(), + Some("john-example.com") + ); + assert_eq!(super::normalize_configured_server_name(" 🫥 "), None); +} + +#[test] +fn server_identity_uses_configured_name() { + let _guard = crate::storage::lock_test_env(); + let _server_name_guard = ScopedEnvVar::set("JCODE_SERVER_NAME", "env-name"); + let _server_display_name_guard = ScopedEnvVar::set("JCODE_SERVER_DISPLAY_NAME", "display-name"); + + let server = Server::new_with_name( + Arc::new(StreamingMockProvider::default()), + Some("Mount Cloud/Fabian".to_string()), + ); + + assert_eq!(server.identity().name, "mount-cloud-fabian"); + assert!( + server + .identity() + .id + .starts_with("server_mount-cloud-fabian_") + ); +} + +#[test] +fn server_identity_reads_name_from_env() { + let _guard = crate::storage::lock_test_env(); + let _server_name_guard = ScopedEnvVar::set("JCODE_SERVER_NAME", "mount-cloud/john"); + let _server_display_name_guard = ScopedEnvVar::set("JCODE_SERVER_DISPLAY_NAME", "ignored"); + + let server = Server::new_with_name(Arc::new(StreamingMockProvider::default()), None); + + assert_eq!(server.identity().name, "mount-cloud-john"); +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.prev_home { + crate::env::set_var("JCODE_HOME", value); + } else { + crate::env::remove_var("JCODE_HOME"); + } + if let Some(value) = &self.prev_runtime_dir { + crate::env::set_var("JCODE_RUNTIME_DIR", value); + } else { + crate::env::remove_var("JCODE_RUNTIME_DIR"); + } + if let Some(value) = &self.prev_socket { + crate::env::set_var("JCODE_SOCKET", value); + } else { + crate::env::remove_var("JCODE_SOCKET"); + } + } +} + +impl ScopedEnvVar { + fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self { + let prev = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, prev } + } +} + +impl Drop for ScopedEnvVar { + fn drop(&mut self) { + if let Some(value) = &self.prev { + crate::env::set_var(self.key, value); + } else { + crate::env::remove_var(self.key); + } + } +} + +fn configure_test_env(root: &tempfile::TempDir) -> EnvGuard { + let prev_home = std::env::var_os("JCODE_HOME"); + let prev_runtime_dir = std::env::var_os("JCODE_RUNTIME_DIR"); + let prev_socket = std::env::var_os("JCODE_SOCKET"); + let home_dir = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + std::fs::create_dir_all(&home_dir).expect("create home dir"); + std::fs::create_dir_all(&runtime_dir).expect("create runtime dir"); + crate::env::set_var("JCODE_HOME", &home_dir); + crate::env::set_var("JCODE_RUNTIME_DIR", &runtime_dir); + crate::env::remove_var("JCODE_SOCKET"); + EnvGuard { + prev_home, + prev_runtime_dir, + prev_socket, + } +} + +#[derive(Default, Clone)] +struct StreamingMockProvider { + responses: Arc<StdMutex<Vec<Vec<StreamEvent>>>>, +} + +impl StreamingMockProvider { + fn queue_response(&self, response: Vec<StreamEvent>) { + self.responses + .lock() + .expect("streaming mock response queue lock") + .push(response); + } +} + +#[async_trait] +impl Provider for StreamingMockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + let response = self + .responses + .lock() + .expect("streaming mock response queue lock") + .remove(0); + Ok(Box::pin(tokio_stream::iter(response.into_iter().map(Ok)))) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(self.clone()) + } +} + +async fn test_agent(provider: Arc<dyn Provider>) -> Arc<Mutex<Agent>> { + let registry = Registry::new(provider.clone()).await; + Arc::new(Mutex::new(Agent::new(provider, registry))) +} + +#[allow(clippy::type_complexity)] +fn empty_swarm_status_state() -> ( + Arc<RwLock<HashMap<String, std::collections::HashSet<String>>>>, + Arc<RwLock<std::collections::VecDeque<super::SwarmEvent>>>, + Arc<std::sync::atomic::AtomicU64>, + broadcast::Sender<super::SwarmEvent>, +) { + let (swarm_event_tx, _) = broadcast::channel(16); + ( + Arc::new(RwLock::new(HashMap::new())), + Arc::new(RwLock::new(std::collections::VecDeque::new())), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + swarm_event_tx, + ) +} + +fn attached_swarm_member( + session_id: &str, + event_tx: mpsc::UnboundedSender<ServerEvent>, +) -> SwarmMember { + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: None, + swarm_enabled: false, + status: "ready".to_string(), + detail: None, + friendly_name: Some("otter".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, + } +} + +fn persisted_headless_member( + session_id: &str, + swarm_id: &str, + status: &str, + detail: &str, +) -> SwarmMember { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + SwarmMember { + session_id: session_id.to_string(), + event_tx, + event_txs: HashMap::new(), + working_dir: None, + swarm_id: Some(swarm_id.to_string()), + swarm_enabled: true, + status: status.to_string(), + detail: Some(detail.to_string()), + friendly_name: Some(session_id.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: true, + output_tail: None, + todo_progress: None, + todo_items: Vec::new(), + runtime: crate::protocol::SwarmMemberRuntime::default(), + task_label: None, + } +} + +#[tokio::test] +async fn background_task_wake_runs_live_session_immediately_when_idle() { + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("Build result processed.".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider_dyn: Arc<dyn Provider> = 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 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: "bgwake".to_string(), + tool_name: "selfdev-build".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("bgwake.output"), + duration_secs: 1.4, + notify: true, + 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 notification = timeout(Duration::from_secs(2), async { + loop { + match member_event_rx.recv().await { + Some(ServerEvent::Notification { + notification_type, + message, + .. + }) => return (notification_type, message), + Some(_) => continue, + None => panic!("member stream closed before notification"), + } + } + }) + .await + .expect("background task notification should arrive promptly"); + + match notification.0 { + NotificationType::Message { scope, channel, .. } => { + assert_eq!(scope.as_deref(), Some("background_task")); + assert!(channel.is_none()); + } + other => panic!("unexpected notification type: {other:?}"), + } + assert!(notification.1.contains("**Background task** `bgwake`")); + + let streamed = timeout(Duration::from_secs(2), async { + loop { + match member_event_rx.recv().await { + Some(ServerEvent::TextDelta { text }) + if text.contains("Build result processed.") => + { + return text; + } + Some(_) => continue, + None => panic!("member stream closed before wake ran"), + } + } + }) + .await + .expect("wake delivery should start streaming promptly"); + assert!(streamed.contains("Build result processed.")); + + let guard = agent.lock().await; + assert!(guard.messages().iter().any(|message| { + message.role == Role::User + && message + .content_preview() + .contains("**Background task** `bgwake`") + })); +} + +#[tokio::test] +async fn wake_turn_tracks_member_status_and_emits_terminal_done() { + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("Wake turn finished.".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + let provider_dyn: Arc<dyn Provider> = 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 mut member = attached_swarm_member(&session_id, member_event_tx); + member.swarm_id = Some("test-swarm".to_string()); + 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 mut swarms = swarms_by_id.write().await; + swarms.insert( + "test-swarm".to_string(), + std::collections::HashSet::from([session_id.clone()]), + ); + } + + let started = super::live_turn::run_live_turn_if_idle( + &session_id, + "DM from coordinator: please respond", + Some("You received a direct swarm message.".to_string()), + &sessions, + super::live_turn::LiveTurnSwarmContext::new( + &swarm_members, + &swarms_by_id, + &event_history, + &event_counter, + &swarm_event_tx, + ), + ) + .await; + assert!(started, "idle live session should accept the wake turn"); + + // Member status must flip to running while the wake turn streams. + let observed_running = timeout(Duration::from_secs(2), async { + loop { + { + let members = swarm_members.read().await; + if members + .get(&session_id) + .is_some_and(|member| member.status == "running") + { + return true; + } + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap_or(false); + + // Attached clients must see a terminal Done event so the UI can settle. + let saw_done = timeout(Duration::from_secs(2), async { + loop { + match member_event_rx.recv().await { + Some(ServerEvent::Done { .. }) => return true, + Some(_) => continue, + None => return false, + } + } + }) + .await + .expect("wake turn should emit a terminal event promptly"); + assert!(saw_done, "wake turn must emit Done to attached clients"); + assert!( + observed_running, + "member status should be running while the wake turn streams" + ); + + // After completion the member returns to ready with a completion report. + let (final_status, report) = timeout(Duration::from_secs(2), async { + loop { + { + let members = swarm_members.read().await; + if let Some(member) = members.get(&session_id) + && member.status == "ready" + { + return ( + member.status.clone(), + member.latest_completion_report.clone(), + ); + } + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("member should return to ready after the wake turn"); + assert_eq!(final_status, "ready"); + assert!( + report.is_some_and(|report| report.contains("Wake turn finished.")), + "completion report should capture the wake turn's assistant text" + ); +} + +#[tokio::test] +async fn background_task_notify_without_wake_does_not_queue_soft_interrupt() { + let provider: Arc<dyn Provider> = Arc::new(StreamingMockProvider::default()); + let agent = test_agent(provider).await; + let session_id = agent.lock().await.session_id().to_string(); + let queue = agent.lock().await.soft_interrupt_queue(); + let sessions = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + agent.clone(), + )]))); + let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::from([( + session_id.clone(), + queue.clone(), + )]))); + 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: "bgnotify".to_string(), + tool_name: "bash".to_string(), + display_name: None, + session_id: session_id.clone(), + status: BackgroundTaskStatus::Completed, + exit_code: Some(0), + output_preview: "ok\n".to_string(), + output_file: std::env::temp_dir().join("bgnotify.output"), + duration_secs: 0.7, + notify: true, + wake: false, + }; + + 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 notification = timeout(Duration::from_secs(2), member_event_rx.recv()) + .await + .expect("background task notification should arrive promptly") + .expect("member stream should stay open"); + match notification { + ServerEvent::Notification { message, .. } => { + assert!(message.contains("**Background task** `bgnotify`")); + } + other => panic!("expected notification, got {other:?}"), + } + + let pending = queue.lock().expect("queue lock"); + assert!( + pending.is_empty(), + "notify-only delivery should not wake the session" + ); +} + +#[tokio::test] +async fn background_task_progress_notifies_attached_clients() { + let provider: Arc<dyn Provider> = Arc::new(StreamingMockProvider::default()); + let agent = test_agent(provider).await; + let session_id = agent.lock().await.session_id().to_string(); + 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 = BackgroundTaskProgressEvent { + task_id: "bgprogress".to_string(), + tool_name: "bash".to_string(), + display_name: None, + session_id: session_id.clone(), + progress: BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(42.0), + message: Some("Running tests".to_string()), + current: Some(21), + total: Some(50), + unit: Some("tests".to_string()), + eta_seconds: None, + updated_at: chrono::Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + }, + }; + + super::dispatch_background_task_progress(&task, &swarm_members).await; + + let notification = timeout(Duration::from_secs(2), member_event_rx.recv()) + .await + .expect("background task progress notification should arrive promptly") + .expect("member stream should stay open"); + match notification { + ServerEvent::Notification { + notification_type, + message, + .. + } => { + match notification_type { + NotificationType::Message { scope, channel, .. } => { + assert_eq!(scope.as_deref(), Some("background_task")); + assert!(channel.is_none()); + } + other => panic!("unexpected notification type: {other:?}"), + } + assert!(message.contains("**Background task progress** `bgprogress`")); + assert!(message.contains("42%"), "message was: {message}"); + } + other => panic!("expected notification, got {other:?}"), + } +} + +#[tokio::test] +#[allow( + clippy::await_holding_lock, + reason = "test intentionally serializes process-wide JCODE_HOME/env state across async recovery assertions" +)] +async fn startup_recovery_resumes_interrupted_headless_sessions_after_reload() -> Result<()> { + let _storage_guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new()?; + let _env = configure_test_env(&temp); + + let provider = Arc::new(StreamingMockProvider::default()); + for _ in 0..2 { + provider.queue_response(vec![ + StreamEvent::TextDelta("continued after reload".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + } + + let mut initiator = crate::session::Session::create(None, Some("initiator".to_string())); + initiator.set_canary("self-dev"); + initiator.add_message( + Role::User, + vec![crate::message::ContentBlock::ToolResult { + tool_use_id: "tool_reload".to_string(), + content: "Reload initiated. Process restarting...".to_string(), + is_error: Some(false), + }], + ); + initiator.save()?; + + ReloadContext { + task_context: Some("Verify multi-session reload recovery".to_string()), + version_before: "old-build".to_string(), + version_after: "new-build".to_string(), + session_id: initiator.id.clone(), + timestamp: "2026-04-19T00:00:00Z".to_string(), + } + .save()?; + + let mut peer = crate::session::Session::create(None, Some("peer".to_string())); + peer.add_message( + Role::User, + vec![crate::message::ContentBlock::ToolResult { + tool_use_id: "tool_bash".to_string(), + content: "[Tool 'bash' interrupted by server reload after 0.2s]".to_string(), + is_error: Some(true), + }], + ); + peer.save()?; + + let swarm_id = "swarm-reload-recovery"; + persist_swarm_state_snapshot( + swarm_id, + None, + None, + &[ + persisted_headless_member(&initiator.id, swarm_id, "running", "selfdev reload"), + persisted_headless_member(&peer.id, swarm_id, "running", "bash tool"), + ], + ); + + let server = Server::new(provider.clone()); + { + let members = server.swarm_state.members.read().await; + assert_eq!( + members + .get(&initiator.id) + .map(|member| member.status.as_str()), + Some("crashed"), + "persisted running headless sessions should load as crashed before recovery" + ); + assert_eq!( + members.get(&peer.id).map(|member| member.status.as_str()), + Some("crashed") + ); + } + + server.recover_headless_sessions_on_startup().await; + + timeout(Duration::from_secs(5), async { + loop { + let sessions = server.sessions.read().await; + let Some(initiator_agent) = sessions.get(&initiator.id).cloned() else { + drop(sessions); + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + let Some(peer_agent) = sessions.get(&peer.id).cloned() else { + drop(sessions); + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + drop(sessions); + + let initiator_done = { + let guard = initiator_agent.lock().await; + guard.messages().iter().any(|message| { + message.role == Role::Assistant + && message.content_preview().contains("continued after reload") + }) + }; + let peer_done = { + let guard = peer_agent.lock().await; + guard.messages().iter().any(|message| { + message.role == Role::Assistant + && message.content_preview().contains("continued after reload") + }) + }; + let statuses_ready = { + let members = server.swarm_state.members.read().await; + members + .get(&initiator.id) + .map(|member| member.status.as_str()) + == Some("ready") + && members.get(&peer.id).map(|member| member.status.as_str()) == Some("ready") + }; + + if initiator_done && peer_done && statuses_ready { + break; + } + + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("headless reload recovery should resume both sessions"); + + assert!( + ReloadContext::peek_for_session(&initiator.id)?.is_none(), + "initiator reload context should be consumed by headless recovery" + ); + + Ok(()) +} + +#[tokio::test] +#[allow( + clippy::await_holding_lock, + reason = "test intentionally serializes process-wide JCODE_HOME/env state across async recovery assertions" +)] +async fn startup_recovery_preserves_headed_session_reload_context_for_later_reconnect() -> Result<()> +{ + let _storage_guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new()?; + let _env = configure_test_env(&temp); + + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![ + StreamEvent::TextDelta("continued after reload".to_string()), + StreamEvent::MessageEnd { stop_reason: None }, + ]); + + let mut headless = crate::session::Session::create(None, Some("headless".to_string())); + headless.add_message( + Role::User, + vec![crate::message::ContentBlock::ToolResult { + tool_use_id: "tool_bash".to_string(), + content: "[Tool 'bash' interrupted by server reload after 0.2s]".to_string(), + is_error: Some(true), + }], + ); + headless.save()?; + + ReloadContext { + task_context: Some("resume headless worker".to_string()), + version_before: "old-headless".to_string(), + version_after: "new-headless".to_string(), + session_id: headless.id.clone(), + timestamp: "2026-04-19T00:00:00Z".to_string(), + } + .save()?; + + let headed_session_id = crate::id::new_id("headed-reconnect"); + ReloadContext { + task_context: Some("resume headed reconnecting session".to_string()), + version_before: "old-headed".to_string(), + version_after: "new-headed".to_string(), + session_id: headed_session_id.clone(), + timestamp: "2026-04-19T00:00:01Z".to_string(), + } + .save()?; + + let swarm_id = "swarm-reload-headed-mixed"; + persist_swarm_state_snapshot( + swarm_id, + None, + None, + &[persisted_headless_member( + &headless.id, + swarm_id, + "running", + "bash tool", + )], + ); + + let server = Server::new(provider.clone()); + server.recover_headless_sessions_on_startup().await; + + timeout(Duration::from_secs(5), async { + loop { + let sessions = server.sessions.read().await; + let Some(headless_agent) = sessions.get(&headless.id).cloned() else { + drop(sessions); + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + drop(sessions); + + let headless_done = { + let guard = headless_agent.lock().await; + guard.messages().iter().any(|message| { + message.role == Role::Assistant + && message.content_preview().contains("continued after reload") + }) + }; + if headless_done { + break; + } + + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("headless reload recovery should complete"); + + assert!( + ReloadContext::peek_for_session(&headless.id)?.is_none(), + "headless session reload context should be consumed by startup recovery" + ); + assert!( + ReloadContext::peek_for_session(&headed_session_id)?.is_some(), + "headed reconnecting session reload context should remain available for later reconnect" + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +#[allow( + clippy::await_holding_lock, + reason = "test intentionally serializes process-wide JCODE_HOME/env state across async startup assertions" +)] +async fn startup_ready_signal_is_not_blocked_by_headless_recovery_delay() -> Result<()> { + use std::os::unix::io::FromRawFd; + use tokio::io::AsyncReadExt; + + let _storage_guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new()?; + let _env = configure_test_env(&temp); + let _delay_guard = ScopedEnvVar::set("JCODE_TEST_HEADLESS_STARTUP_RECOVERY_DELAY_MS", "500"); + + let mut headless = + crate::session::Session::create(None, Some("headless-ready-delay".to_string())); + headless.save()?; + + let swarm_id = "swarm-ready-before-recovery"; + persist_swarm_state_snapshot( + swarm_id, + None, + None, + &[persisted_headless_member( + &headless.id, + swarm_id, + "running", + "delay startup recovery", + )], + ); + + let provider = Arc::new(StreamingMockProvider::default()); + provider.queue_response(vec![StreamEvent::MessageEnd { stop_reason: None }]); + let server = Server::new(provider); + + let mut ready_fds = [0; 2]; + let pipe_rc = unsafe { libc::pipe(ready_fds.as_mut_ptr()) }; + assert_eq!(pipe_rc, 0, "pipe() should succeed"); + let read_fd = ready_fds[0]; + let write_fd = ready_fds[1]; + let _ready_fd_guard = ScopedEnvVar::set("JCODE_READY_FD", write_fd.to_string()); + + let main_listener = crate::transport::Listener::bind(&server.socket_path)?; + let debug_listener = crate::transport::Listener::bind(&server.debug_socket_path)?; + + let startup = tokio::spawn(async move { + server + .finish_startup_after_bind(main_listener, debug_listener, Instant::now()) + .await + }); + + let read_file = unsafe { std::fs::File::from_raw_fd(read_fd) }; + let mut async_read = tokio::fs::File::from_std(read_file); + let mut ready = [0u8; 1]; + timeout(Duration::from_millis(200), async { + async_read.read_exact(&mut ready).await + }) + .await + .expect("ready signal should arrive before delayed startup recovery completes")?; + assert_eq!(ready, [b'R']); + assert!( + !startup.is_finished(), + "startup task should still be blocked on delayed recovery even though ready was already signaled" + ); + + let (runtime, main_handle, debug_handle) = timeout(Duration::from_secs(2), startup) + .await + .expect("startup should finish after delayed recovery") + .expect("startup task should succeed"); + timeout(Duration::from_secs(1), runtime.shutdown()) + .await + .expect("runtime should shut down"); + timeout(Duration::from_secs(1), async { + main_handle.await.expect("main accept loop"); + debug_handle.await.expect("debug accept loop"); + }) + .await + .expect("accept loops should observe runtime cancellation"); + + Ok(()) +} diff --git a/crates/jcode-app-core/src/server/util.rs b/crates/jcode-app-core/src/server/util.rs new file mode 100644 index 0000000..9d17bfa --- /dev/null +++ b/crates/jcode-app-core/src/server/util.rs @@ -0,0 +1,1070 @@ +use crate::build; +use std::collections::HashSet; +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; + +pub(crate) fn debug_control_allowed() -> bool { + // Check config file setting + if crate::config::config().display.debug_socket { + return true; + } + if std::env::var("JCODE_DEBUG_CONTROL") + .ok() + .map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false) + { + return true; + } + // Check for file-based toggle (allows enabling without restart) + if let Ok(jcode_dir) = crate::storage::jcode_dir() + && jcode_dir.join("debug_control").exists() + { + return true; + } + false +} + +pub(crate) fn embedding_idle_unload_secs() -> u64 { + std::env::var("JCODE_EMBEDDING_IDLE_UNLOAD_SECS") + .ok() + .and_then(|v| v.parse::<u64>().ok()) + .filter(|v| *v > 0) + .unwrap_or(EMBEDDING_IDLE_UNLOAD_DEFAULT_SECS) +} + +pub(crate) async fn get_shared_mcp_pool( + cell: &OnceCell<Arc<crate::mcp::SharedMcpPool>>, +) -> Arc<crate::mcp::SharedMcpPool> { + cell.get_or_init(|| async { Arc::new(crate::mcp::SharedMcpPool::from_default_config()) }) + .await + .clone() +} + +pub(crate) fn server_update_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'static str)> { + build::shared_server_update_candidate(is_selfdev_session) +} + +/// Resolve the binary the reload should actually exec into, with a hard +/// no-downgrade guard. +/// +/// `server_update_candidate` can legitimately return an *older* binary (e.g. a +/// `shared-server` channel that an update never advanced, or a leftover self-dev +/// promotion synced from another machine). A forced reload bypasses +/// `server_has_newer_binary`, so without this guard it would silently exec into +/// that older binary and downgrade every connected client. +/// +/// We never block a same-or-newer candidate (so self-dev builds, which are +/// freshly written and therefore newer by mtime, still apply). When the +/// candidate is *strictly older* than the running executable we refuse it and +/// re-exec into the current executable instead: same code, fresh process and +/// socket handoff, but no downgrade. Any mtime uncertainty is treated as "do +/// not downgrade". +/// +/// Crucially, the candidate is the *newest* reload candidate across BOTH +/// self-dev flavors, not just the one matching `is_selfdev_session`. This keeps +/// the reload target consistent with `server_has_newer_binary`, which also scans +/// both flavors. Without this, a self-dev/canary daemon whose `shared-server` +/// channel is pinned to an *old* self-dev build would advertise +/// `server_has_update = true` (the normal-flavor probe self-heals to the freshly +/// installed release) yet reload into that same old pinned build -> the server +/// reports an update it can never apply, so the client upgrades while the server +/// stays stale and the auto-reload loops until it is suppressed. Selecting the +/// newest candidate across flavors still preserves a deliberately-pinned self-dev +/// build whenever that build is the freshest one on disk (the case the pin is +/// meant to protect). +pub(crate) fn reload_exec_target(is_selfdev_session: bool) -> Option<(PathBuf, &'static str)> { + let candidate = newest_reload_candidate(is_selfdev_session)?; + // On Linux a self-dev rebuild rewrites the running binary in place (a dirty + // build reuses the same `versions/<hash>` path), which unlinks the running + // inode. `current_exe()` then resolves `/proc/self/exe` to a path with a + // trailing " (deleted)" marker that is NOT a real file. If we keep that + // marker we (a) fail the "same binary" fast-path below, (b) read no mtime so + // the freshly-built candidate looks like a downgrade, and (c) fall back to + // re-execing the bogus " (deleted)" path, which does not exist -> the server + // exits without a replacement and strands every connected client. Strip the + // marker so we compare against (and can re-exec) the real on-disk path. + let current_exe = std::env::current_exe().ok().map(strip_deleted_suffix); + + // Identity/mtime comparisons must look through release wrapper scripts to + // the payload that actually runs (see `build::resolve_binary_payload`): + // the running exe is the `.bin` payload while channel candidates are tiny + // wrapper scripts, and comparing wrapper-vs-payload mtimes turned every + // release install into a phantom "downgrade"/"update". The exec target + // stays the original candidate path (the wrapper), which is what sets up + // `LD_LIBRARY_PATH` correctly. + let candidate_canonical = build::resolve_binary_payload(&candidate.0); + let current_canonical = current_exe + .as_ref() + .map(|p| build::resolve_binary_payload(p)); + + let current_mtime = current_canonical.as_deref().and_then(binary_mtime); + let candidate_mtime = binary_mtime(candidate_canonical.as_path()); + + match guarded_reload_target( + candidate.clone(), + candidate_canonical.as_path(), + current_exe.as_deref(), + current_canonical.as_deref(), + current_mtime, + candidate_mtime, + ) { + ReloadTargetDecision::UseCandidate(target) => Some(target), + ReloadTargetDecision::DowngradeBlockedUseCurrent(target) => { + // Never strand clients by re-execing a binary that is gone from disk. + // If the running exe was unlinked (e.g. an in-place rebuild) but the + // candidate still exists, prefer the candidate over refusing to + // reload. The candidate may be older, but a live downgrade beats a + // dead server with no replacement. + if !target.0.exists() && candidate_canonical.exists() { + crate::logging::warn(&format!( + "reload downgrade guard: current binary {:?} is missing on disk; falling back to candidate {:?} to avoid stranding clients", + target.0, candidate.0, + )); + return Some(candidate); + } + crate::logging::warn(&format!( + "reload downgrade guard: refusing to exec into older candidate; re-execing current binary {:?} instead", + target.0, + )); + Some(target) + } + ReloadTargetDecision::DowngradeUnverifiable(target) => { + crate::logging::warn(&format!( + "reload downgrade guard: older candidate {:?} detected but current exe is unavailable; proceeding with candidate", + target.0, + )); + Some(target) + } + } +} + +fn binary_mtime(path: &Path) -> Option<std::time::SystemTime> { + std::fs::metadata(path).ok().and_then(|m| m.modified().ok()) +} + +/// Pick the newest reload candidate across BOTH self-dev flavors. +/// +/// The session's own flavor (`is_selfdev_session`) is evaluated first so it wins +/// any exact-mtime tie, preserving self-dev semantics: a deliberately-pinned +/// self-dev `shared-server` build is honored whenever it is at least as fresh as +/// the other flavor's candidate. The other flavor only wins when it is +/// *strictly newer*, which is exactly the situation that makes +/// `server_has_newer_binary` report an update (e.g. `/update` installed a newer +/// release while the self-dev pin stayed on an older build). +fn newest_reload_candidate(is_selfdev_session: bool) -> Option<(PathBuf, &'static str)> { + let ordered = [ + server_update_candidate(is_selfdev_session), + server_update_candidate(!is_selfdev_session), + ]; + let with_mtimes = ordered.into_iter().flatten().map(|candidate| { + // Compare payloads, not release wrapper scripts (whose mtimes carry no + // version information). Dedup also happens on the payload so a wrapper + // and its payload never count as two distinct candidates. + let canonical = build::resolve_binary_payload(&candidate.0); + let mtime = binary_mtime(canonical.as_path()); + (candidate, canonical, mtime) + }); + pick_newest_candidate(with_mtimes) +} + +/// Pure, order-sensitive "newest candidate" selection used by +/// [`newest_reload_candidate`]. Candidates are provided in *preference order* +/// (the session's own flavor first). A later candidate only displaces an earlier +/// one when it is provably, strictly newer by mtime, so equal/unknown mtimes +/// never demote the higher-preference flavor (protecting a self-dev pin on a +/// tie). Canonical-path duplicates are collapsed to the first occurrence. +fn pick_newest_candidate( + candidates: impl IntoIterator< + Item = ( + (PathBuf, &'static str), + PathBuf, + Option<std::time::SystemTime>, + ), + >, +) -> Option<(PathBuf, &'static str)> { + let mut best: Option<((PathBuf, &'static str), Option<std::time::SystemTime>)> = None; + let mut seen: HashSet<PathBuf> = HashSet::new(); + for (candidate, canonical, mtime) in candidates { + if !seen.insert(canonical) { + continue; + } + let replace = match (&best, mtime) { + (None, _) => true, + (Some((_, Some(best_mtime))), Some(new_mtime)) => new_mtime > *best_mtime, + (Some((_, None)), Some(_)) => true, + (Some(_), None) => false, + }; + if replace { + best = Some((candidate, mtime)); + } + } + best.map(|(candidate, _)| candidate) +} + +#[derive(Debug)] +enum ReloadTargetDecision { + UseCandidate((PathBuf, &'static str)), + DowngradeBlockedUseCurrent((PathBuf, &'static str)), + DowngradeUnverifiable((PathBuf, &'static str)), +} + +/// Pure no-downgrade decision used by [`reload_exec_target`]. A candidate is +/// accepted unless it is strictly older than (or not provably as new as) the +/// running executable, in which case we prefer re-execing the current binary. +fn guarded_reload_target( + candidate: (PathBuf, &'static str), + candidate_canonical: &Path, + current_exe: Option<&Path>, + current_canonical: Option<&Path>, + current_mtime: Option<std::time::SystemTime>, + candidate_mtime: Option<std::time::SystemTime>, +) -> ReloadTargetDecision { + // Reloading into the same binary is always fine; no version question. + if current_canonical == Some(candidate_canonical) { + return ReloadTargetDecision::UseCandidate(candidate); + } + + let candidate_is_strictly_older = match (current_mtime, candidate_mtime) { + (Some(current), Some(cand)) => cand < current, + // Unknown mtimes: be conservative and treat as a potential downgrade so + // we never silently swap to an unverifiable binary on a forced reload. + _ => true, + }; + + if !candidate_is_strictly_older { + return ReloadTargetDecision::UseCandidate(candidate); + } + + match current_exe { + Some(current_exe) => ReloadTargetDecision::DowngradeBlockedUseCurrent(( + current_exe.to_path_buf(), + "current-exe (downgrade-guard)", + )), + None => ReloadTargetDecision::DowngradeUnverifiable(candidate), + } +} + +fn canonicalize_or(path: PathBuf) -> PathBuf { + std::fs::canonicalize(&path).unwrap_or(path) +} + +/// Strip the Linux `/proc/self/exe` " (deleted)" marker that appears when the +/// running binary has been unlinked or replaced in place. The marker is part of +/// the readlink target, not the real filename, so removing it recovers the path +/// that may now point at the freshly written replacement binary. +fn strip_deleted_suffix(path: PathBuf) -> PathBuf { + const DELETED_MARKER: &str = " (deleted)"; + if let Some(stripped) = path.to_str().and_then(|s| s.strip_suffix(DELETED_MARKER)) { + return PathBuf::from(stripped); + } + path +} + +pub(crate) fn git_common_dir_for(path: &Path) -> Option<PathBuf> { + let mut current = Some(path); + while let Some(dir) = current { + let dotgit = dir.join(".git"); + if dotgit.is_dir() { + return Some(canonicalize_or(dotgit)); + } + if dotgit.is_file() { + let content = std::fs::read_to_string(&dotgit).ok()?; + let gitdir_line = content + .lines() + .find(|line| line.trim_start().starts_with("gitdir:"))?; + let raw = gitdir_line + .trim_start() + .trim_start_matches("gitdir:") + .trim(); + if raw.is_empty() { + return None; + } + let gitdir = if Path::new(raw).is_absolute() { + PathBuf::from(raw) + } else { + dir.join(raw) + }; + let gitdir = canonicalize_or(gitdir); + // Worktree gitdir looks like: <repo>/.git/worktrees/<name> + if let Some(parent) = gitdir.parent() + && parent.file_name().and_then(|s| s.to_str()) == Some("worktrees") + && let Some(common) = parent.parent() + { + return Some(canonicalize_or(common.to_path_buf())); + } + return Some(gitdir); + } + current = dir.parent(); + } + None +} + +pub(crate) fn swarm_id_for_dir(dir: Option<PathBuf>) -> Option<String> { + 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()); + } + } + let dir = dir?; + if let Some(git_common) = git_common_dir_for(&dir) { + return Some(git_common.to_string_lossy().to_string()); + } + Some(dir.to_string_lossy().to_string()) +} + +/// Decide whether any reload candidate is *provably* newer than the running +/// server binary. +/// +/// This is intentionally conservative. An earlier version reported "update +/// available" whenever the mtime comparison was inconclusive (e.g. a metadata +/// read failed) as long as the candidate path differed from the running exe. +/// On some systems that fallback fired permanently, so the client would +/// auto-reload the server, the server would exec into the candidate, and the +/// freshly-exec'd server would again report an update -> an infinite reload +/// loop that flickers the terminal (see issue #277). +/// +/// We now only report an update when we can read both mtimes and the candidate +/// is strictly newer than the running binary. Any uncertainty suppresses the +/// auto-reload signal so it can never wedge the client into a loop. +fn newer_binary_available( + current_mtime: Option<std::time::SystemTime>, + current_canonical: Option<&Path>, + candidates: impl IntoIterator<Item = (PathBuf, Option<std::time::SystemTime>)>, +) -> bool { + let Some(current_time) = current_mtime else { + crate::logging::warn( + "server_has_newer_binary: current executable mtime unavailable; suppressing auto-reload update signal", + ); + return false; + }; + + candidates.into_iter().any(|(candidate, candidate_mtime)| { + // Reloading into ourselves is never an "update". + if current_canonical == Some(candidate.as_path()) { + return false; + } + + match candidate_mtime { + Some(candidate_time) => candidate_time > current_time, + None => { + crate::logging::warn(&format!( + "server_has_newer_binary: candidate mtime unavailable for {}; suppressing auto-reload update signal", + candidate.display() + )); + false + } + } + }) +} + +pub(crate) fn server_has_newer_binary() -> bool { + // Directional check only: report an update solely when a reload *candidate* + // binary is strictly newer than the binary we are running. + // + // We deliberately do NOT treat "my version differs from the installed + // channel markers" as "I am outdated". That conflated *different* with + // *older* and caused a real regression (issue #291): a newer self-dev / + // shared-server daemon (e.g. v0.17.23-dev) running alongside an older + // release client would be told to "reload" and downgrade itself, because + // its git hash no longer matched the `current`/`stable` channel markers + // after a release build moved them. It also fed the reload-loop family from + // issue #277, since a server that merely "differs" can never make the + // difference go away by reloading. + // + // `UPDATE_SEMVER` is the base Cargo version for every dev build, so it + // cannot order two dev builds; binary mtime is the only robust, directional + // signal we have. `newer_binary_available` compares candidate mtimes against + // the running binary, excludes reloading into ourselves, and treats any + // uncertainty (unreadable mtime) as "no update". + // + // Strip the Linux " (deleted)" marker (see `strip_deleted_suffix`) so an + // in-place rebuild does not make the running binary's mtime unreadable and + // suppress a legitimate update signal. + // + // All paths are resolved through `build::resolve_binary_payload` so release + // installs (channel symlink -> wrapper script -> `.bin` payload) compare the + // payload that actually runs. Comparing the wrapper script against the + // running payload compared two different files with unrelated mtimes, which + // could report a phantom update forever and wedge clients into an infinite + // reload loop right after `/update`. + let current_exe = std::env::current_exe().ok().map(strip_deleted_suffix); + let current_canonical = current_exe + .as_ref() + .map(|path| build::resolve_binary_payload(path)); + let current_mtime = current_canonical + .as_ref() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| m.modified().ok()); + + let mut candidates = HashSet::new(); + for is_selfdev_session in [false, true] { + if let Some((candidate, _label)) = server_update_candidate(is_selfdev_session) { + candidates.insert(build::resolve_binary_payload(&candidate)); + } + } + + let candidates_with_mtimes = candidates.into_iter().map(|candidate| { + let candidate_mtime = std::fs::metadata(&candidate) + .ok() + .and_then(|m| m.modified().ok()); + (candidate, candidate_mtime) + }); + + newer_binary_available( + current_mtime, + current_canonical.as_deref(), + candidates_with_mtimes, + ) +} + +/// Server identity for multi-server support +#[derive(Debug, Clone)] +pub struct ServerIdentity { + /// Full server ID (e.g., "server_blazing_1705012345678") + pub id: String, + /// Short name (e.g., "blazing") + pub name: String, + /// Icon for display (e.g., "🔥") + pub icon: String, + /// Git hash of the binary + pub git_hash: String, + /// Version string (e.g., "v0.1.123") + pub version: String, +} + +impl ServerIdentity { + /// Display name with icon (e.g., "🔥 blazing") + pub fn display_name(&self) -> String { + format!("{} {}", self.icon, self.name) + } +} + +pub(crate) fn startup_headless_recovery_test_delay() -> Option<std::time::Duration> { + let raw = std::env::var("JCODE_TEST_HEADLESS_STARTUP_RECOVERY_DELAY_MS").ok()?; + let delay_ms = raw.trim().parse::<u64>().ok()?; + (delay_ms > 0).then(|| std::time::Duration::from_millis(delay_ms)) +} + +#[cfg(test)] +mod newer_binary_tests { + use super::newer_binary_available; + use std::path::PathBuf; + use std::time::{Duration, SystemTime}; + + fn t(secs: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + } + + #[test] + fn reports_update_when_candidate_is_strictly_newer() { + let candidates = vec![(PathBuf::from("/x/stable/jcode"), Some(t(200)))]; + assert!(newer_binary_available( + Some(t(100)), + Some(std::path::Path::new("/x/current/jcode")), + candidates, + )); + } + + #[test] + fn ignores_candidate_that_is_not_newer() { + let candidates = vec![(PathBuf::from("/x/stable/jcode"), Some(t(100)))]; + assert!(!newer_binary_available( + Some(t(100)), + Some(std::path::Path::new("/x/current/jcode")), + candidates, + )); + } + + #[test] + fn never_reloads_into_self_even_if_paths_were_equal() { + // Same canonical path must never count as an update, regardless of mtime. + let candidates = vec![(PathBuf::from("/x/current/jcode"), Some(t(999)))]; + assert!(!newer_binary_available( + Some(t(100)), + Some(std::path::Path::new("/x/current/jcode")), + candidates, + )); + } + + #[test] + fn suppresses_update_when_current_mtime_unavailable() { + // Regression for issue #277: an unreadable current mtime previously fell + // through to a path-difference heuristic that could loop forever. + let candidates = vec![(PathBuf::from("/x/stable/jcode"), Some(t(200)))]; + assert!(!newer_binary_available( + None, + Some(std::path::Path::new("/x/current/jcode")), + candidates, + )); + } + + #[test] + fn suppresses_update_when_candidate_mtime_unavailable() { + // The dangerous case from issue #277: candidate path differs but its + // mtime cannot be read. Must NOT report an update. + let candidates = vec![(PathBuf::from("/x/stable/jcode"), None)]; + assert!(!newer_binary_available( + Some(t(100)), + Some(std::path::Path::new("/x/current/jcode")), + candidates, + )); + } + + #[test] + fn reports_update_if_any_candidate_is_newer() { + let candidates = vec![ + (PathBuf::from("/x/stable/jcode"), None), + (PathBuf::from("/x/shared/jcode"), Some(t(300))), + ]; + assert!(newer_binary_available( + Some(t(100)), + Some(std::path::Path::new("/x/current/jcode")), + candidates, + )); + } + + #[test] + fn newer_server_is_not_outdated_by_older_channel_binary() { + // Issue #291: a newer self-dev / shared-server daemon must NOT report an + // update just because an *older* channel binary exists. Here the running + // server (t=300) is newer than the only candidate (stable at t=100), so + // there is no update. Previously a channel-version *mismatch* short-circuit + // reported `true` here and told the newer server to downgrade itself. + let candidates = vec![(PathBuf::from("/x/stable/jcode"), Some(t(100)))]; + assert!(!newer_binary_available( + Some(t(300)), + Some(std::path::Path::new("/x/builds/versions/dev/jcode")), + candidates, + )); + } + + #[test] + fn equal_mtime_channel_binary_is_not_an_update() { + // A candidate with the same mtime is not strictly newer, so it must not + // trigger a reload (avoids the differ-but-not-newer reload loop, #277). + let candidates = vec![(PathBuf::from("/x/stable/jcode"), Some(t(100)))]; + assert!(!newer_binary_available( + Some(t(100)), + Some(std::path::Path::new("/x/builds/versions/dev/jcode")), + candidates, + )); + } +} + +#[cfg(test)] +mod reload_target_tests { + use super::{ReloadTargetDecision, guarded_reload_target}; + use std::path::{Path, PathBuf}; + use std::time::{Duration, SystemTime}; + + fn t(secs: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + } + + fn candidate(path: &str) -> (PathBuf, &'static str) { + (PathBuf::from(path), "shared-server") + } + + #[test] + fn same_binary_is_always_used() { + // Reloading into ourselves never raises a version question, even with an + // older mtime reading. + let decision = guarded_reload_target( + candidate("/x/current/jcode"), + Path::new("/x/current/jcode"), + Some(Path::new("/x/current/jcode")), + Some(Path::new("/x/current/jcode")), + Some(t(200)), + Some(t(100)), + ); + assert!(matches!(decision, ReloadTargetDecision::UseCandidate(_))); + } + + #[test] + fn newer_candidate_is_used() { + // The self-dev case: a freshly written candidate is newer, so apply it. + let decision = guarded_reload_target( + candidate("/x/shared-server/jcode"), + Path::new("/x/builds/versions/new/jcode"), + Some(Path::new("/x/builds/versions/old/jcode")), + Some(Path::new("/x/builds/versions/old/jcode")), + Some(t(100)), + Some(t(200)), + ); + match decision { + ReloadTargetDecision::UseCandidate((path, _)) => { + assert_eq!(path, PathBuf::from("/x/shared-server/jcode")); + } + other => panic!("expected candidate to be used, got {other:?}"), + } + } + + #[test] + fn equal_mtime_candidate_is_used() { + // Same mtime is not a downgrade. + let decision = guarded_reload_target( + candidate("/x/shared-server/jcode"), + Path::new("/x/builds/versions/same/jcode"), + Some(Path::new("/x/builds/versions/current/jcode")), + Some(Path::new("/x/builds/versions/current/jcode")), + Some(t(100)), + Some(t(100)), + ); + assert!(matches!(decision, ReloadTargetDecision::UseCandidate(_))); + } + + #[test] + fn strictly_older_candidate_is_blocked_and_uses_current_exe() { + // The reported bug: shared-server channel points at an older build than + // the running client. Force reload must NOT downgrade; it re-execs the + // current binary instead. + let decision = guarded_reload_target( + candidate("/x/shared-server/jcode"), + Path::new("/x/builds/versions/old-0.14.3/jcode"), + Some(Path::new("/x/builds/versions/new/jcode")), + Some(Path::new("/x/builds/versions/new/jcode")), + Some(t(300)), + Some(t(100)), + ); + match decision { + ReloadTargetDecision::DowngradeBlockedUseCurrent((path, _)) => { + assert_eq!(path, PathBuf::from("/x/builds/versions/new/jcode")); + } + other => panic!("expected downgrade to be blocked, got {other:?}"), + } + } + + #[test] + fn unreadable_candidate_mtime_is_treated_as_downgrade() { + let decision = guarded_reload_target( + candidate("/x/shared-server/jcode"), + Path::new("/x/builds/versions/unknown/jcode"), + Some(Path::new("/x/builds/versions/new/jcode")), + Some(Path::new("/x/builds/versions/new/jcode")), + Some(t(300)), + None, + ); + assert!(matches!( + decision, + ReloadTargetDecision::DowngradeBlockedUseCurrent(_) + )); + } + + #[test] + fn downgrade_without_current_exe_falls_back_to_candidate() { + // If we cannot identify the running exe we cannot re-exec it, so we have + // to proceed with the candidate rather than refuse to reload entirely. + let decision = guarded_reload_target( + candidate("/x/shared-server/jcode"), + Path::new("/x/builds/versions/old/jcode"), + None, + None, + None, + Some(t(100)), + ); + assert!(matches!( + decision, + ReloadTargetDecision::DowngradeUnverifiable(_) + )); + } +} + +#[cfg(test)] +mod pick_newest_candidate_tests { + use super::pick_newest_candidate; + use std::path::PathBuf; + use std::time::{Duration, SystemTime}; + + fn t(secs: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + } + + fn entry( + path: &str, + label: &'static str, + mtime: Option<SystemTime>, + ) -> ((PathBuf, &'static str), PathBuf, Option<SystemTime>) { + let p = PathBuf::from(path); + ((p.clone(), label), p, mtime) + } + + #[test] + fn other_flavor_wins_when_strictly_newer() { + // The /update bug: the session's own (self-dev) flavor is pinned to an + // OLD build, but the other (normal) flavor self-healed to a NEWER + // release. The reload target must follow the newer release so the daemon + // can actually apply the update it advertises. + let chosen = pick_newest_candidate([ + entry( + "/x/versions/old-selfdev/jcode", + "shared-server", + Some(t(100)), + ), + entry("/x/versions/new-release/jcode", "stable", Some(t(200))), + ]) + .expect("a candidate"); + assert_eq!(chosen.0, PathBuf::from("/x/versions/new-release/jcode")); + } + + #[test] + fn own_flavor_wins_on_tie() { + // A deliberately-pinned self-dev build that is at least as fresh as the + // other flavor must be preserved (self-dev pin protection). + let chosen = pick_newest_candidate([ + entry("/x/versions/selfdev/jcode", "shared-server", Some(t(200))), + entry("/x/versions/release/jcode", "stable", Some(t(200))), + ]) + .expect("a candidate"); + assert_eq!(chosen.0, PathBuf::from("/x/versions/selfdev/jcode")); + } + + #[test] + fn own_flavor_wins_when_strictly_newer() { + let chosen = pick_newest_candidate([ + entry( + "/x/versions/fresh-selfdev/jcode", + "shared-server", + Some(t(300)), + ), + entry("/x/versions/release/jcode", "stable", Some(t(200))), + ]) + .expect("a candidate"); + assert_eq!(chosen.0, PathBuf::from("/x/versions/fresh-selfdev/jcode")); + } + + #[test] + fn unknown_other_mtime_never_displaces_preferred() { + // An unreadable mtime on the other flavor must not let it win, so we + // never swap to an unverifiable binary. + let chosen = pick_newest_candidate([ + entry("/x/versions/selfdev/jcode", "shared-server", Some(t(100))), + entry("/x/versions/release/jcode", "stable", None), + ]) + .expect("a candidate"); + assert_eq!(chosen.0, PathBuf::from("/x/versions/selfdev/jcode")); + } + + #[test] + fn duplicate_canonical_paths_collapse() { + // Both flavors resolving to the same binary must not double-count; the + // first (preferred) occurrence wins. + let chosen = pick_newest_candidate([ + entry("/x/versions/same/jcode", "shared-server", Some(t(100))), + entry("/x/versions/same/jcode", "stable", Some(t(999))), + ]) + .expect("a candidate"); + assert_eq!(chosen.1, "shared-server"); + } + + #[test] + fn empty_is_none() { + assert!(pick_newest_candidate(std::iter::empty()).is_none()); + } +} + +#[cfg(test)] +mod newest_reload_candidate_integration_tests { + //! End-to-end-ish coverage that drives `newest_reload_candidate` through the + //! REAL channel resolution (`build::shared_server_update_candidate`) against + //! a temp `JCODE_HOME`. This reproduces the field "/update -> new client, + //! stale server" state and proves the fix: a self-dev daemon now reloads into + //! the freshly installed release instead of its old pinned binary. + use super::{newer_binary_available, newest_reload_candidate}; + use crate::build; + use std::path::Path; + use std::time::{Duration, SystemTime}; + + fn install_versioned_binary(version: &str, mtime: SystemTime) -> std::path::PathBuf { + // A real, distinct file per version so mtimes are independently settable + // (install hard-links the source, which would share an inode/mtime). + let dir = build::builds_dir() + .expect("builds dir") + .join("versions") + .join(version); + std::fs::create_dir_all(&dir).expect("create version dir"); + let path = dir.join(build::binary_name()); + std::fs::write(&path, format!("binary for {version}")).expect("write binary"); + std::fs::File::open(&path) + .expect("open binary") + .set_modified(mtime) + .expect("set mtime"); + path + } + + fn candidate_version_for(is_selfdev: bool) -> Option<String> { + let (path, _label) = newest_reload_candidate(is_selfdev)?; + let canonical = std::fs::canonicalize(&path).unwrap_or(path); + canonical + .parent() + .and_then(Path::file_name) + .map(|n| n.to_string_lossy().into_owned()) + } + + #[test] + fn selfdev_daemon_reloads_into_fresh_release_after_update() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + // Field state: shared-server pinned to an OLD self-dev build; stable + // lags. Then `/update` installs a NEWER release and advances + // stable/current (but NOT the pinned shared-server channel). + let old_selfdev = "3f160da1-dirty-e756d52efca9"; + let new_release = "0.15.0"; + install_versioned_binary(old_selfdev, base); + install_versioned_binary(new_release, base + Duration::from_secs(60)); + + build::update_shared_server_symlink(old_selfdev).expect("pin shared-server"); + build::update_stable_symlink(new_release).expect("stable advanced by update"); + build::update_current_symlink(new_release).expect("current advanced by update"); + + // The self-dev session's reload target must now be the fresh release, not + // the stale pinned build. This is the fix. + assert_eq!( + candidate_version_for(true).as_deref(), + Some(new_release), + "self-dev daemon should reload into the freshly installed release" + ); + // The normal session is unaffected (already healed to stable/release). + assert_eq!( + candidate_version_for(false).as_deref(), + Some(new_release), + "normal daemon should also target the fresh release" + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + #[test] + fn selfdev_pin_is_preserved_when_it_is_the_freshest_build() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + // A deliberately-promoted self-dev build that is NEWER than stable must + // still be honored: the whole point of pinning shared-server. + let stable_old = "0.14.3"; + let selfdev_new = "56f43c3d-dirty-deadbeef"; + install_versioned_binary(stable_old, base); + install_versioned_binary(selfdev_new, base + Duration::from_secs(120)); + + build::update_stable_symlink(stable_old).expect("stable"); + build::update_shared_server_symlink(selfdev_new).expect("pin newer self-dev"); + + assert_eq!( + candidate_version_for(true).as_deref(), + Some(selfdev_new), + "a fresher self-dev pin must be preserved for self-dev sessions" + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + /// Re-implements `server_has_newer_binary`'s decision against an *injected* + /// running-daemon path + mtime, so a test can model "the daemon is still the + /// OLD binary" without spawning a real process. It scans the exact same + /// candidate set (both flavors) and uses the same `newer_binary_available` + /// core the production function uses, including the wrapper->payload + /// resolution. + fn daemon_reports_update(running: &Path, running_mtime: SystemTime) -> bool { + let running_canonical = build::resolve_binary_payload(running); + let mut candidates = std::collections::HashSet::new(); + for is_selfdev in [false, true] { + if let Some((candidate, _label)) = super::server_update_candidate(is_selfdev) { + candidates.insert(build::resolve_binary_payload(&candidate)); + } + } + let with_mtimes = candidates.into_iter().map(|candidate| { + let m = std::fs::metadata(&candidate) + .ok() + .and_then(|m| m.modified().ok()); + (candidate, m) + }); + newer_binary_available( + Some(running_mtime), + Some(running_canonical.as_path()), + with_mtimes, + ) + } + + /// The question that matters for shipped users: after a NORMAL (non-self-dev) + /// `/update`, does the long-lived daemon actually advertise + apply the + /// upgrade on reconnect? + /// + /// Models a normal install: `shared-server` was tracking `stable`, the daemon + /// is running the old release, and `/update` installs a newer release and + /// advances stable/current/shared-server. We then drive the REAL + /// update-detection core and reload-target resolver and assert both: + /// (1) the daemon reports `server_has_update = true`, and + /// (2) the binary it reloads into is the freshly installed release. + #[test] + fn normal_user_daemon_detects_and_targets_update_after_update() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let old_release = "0.14.3"; + let new_release = "0.15.0"; + let old_path = install_versioned_binary(old_release, base); + install_versioned_binary(new_release, base + Duration::from_secs(60)); + + // Pre-update state: every channel on the old release (shared-server + // tracking stable). This is the steady state for a normal user. + build::update_stable_symlink(old_release).expect("stable old"); + build::update_current_symlink(old_release).expect("current old"); + build::update_shared_server_symlink(old_release).expect("shared old"); + + // `/update` installs the new release and advances the channels. Because + // shared-server was tracking stable, it advances too. + build::advance_shared_server_if_tracking_stable(new_release).expect("advance shared"); + build::update_stable_symlink(new_release).expect("stable new"); + build::update_current_symlink(new_release).expect("current new"); + + // (1) The daemon (still the OLD binary) must now SEE the update so it + // reports server_has_update = true to reconnecting clients. + assert!( + daemon_reports_update(&old_path, base), + "normal-user daemon should report a server update after /update advanced the channels" + ); + + // (2) The binary it reloads into must be the freshly installed release. + assert_eq!( + candidate_version_for(false).as_deref(), + Some(new_release), + "normal-user daemon should reload into the freshly installed release" + ); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } + } + + /// Install a release-archive-style version dir: a tiny `jcode` wrapper + /// script plus the real `jcode-linux-x86_64.bin` payload, with independently + /// settable mtimes. This is exactly what `/update`'s tar.gz install path + /// produces on disk. + fn install_release_style_binary( + version: &str, + wrapper_mtime: SystemTime, + payload_mtime: SystemTime, + ) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = build::builds_dir() + .expect("builds dir") + .join("versions") + .join(version); + std::fs::create_dir_all(&dir).expect("create version dir"); + let payload = dir.join("jcode-linux-x86_64.bin"); + std::fs::write(&payload, format!("payload for {version}")).expect("write payload"); + std::fs::File::open(&payload) + .expect("open payload") + .set_modified(payload_mtime) + .expect("set payload mtime"); + let wrapper = dir.join(build::binary_name()); + std::fs::write( + &wrapper, + "#!/usr/bin/env sh\nexec ./jcode-linux-x86_64.bin \"$@\"\n", + ) + .expect("write wrapper"); + std::fs::File::open(&wrapper) + .expect("open wrapper") + .set_modified(wrapper_mtime) + .expect("set wrapper mtime"); + (wrapper, payload) + } + + /// Regression test for the post-`/update` infinite reload loop: release + /// archives install a wrapper script + `.bin` payload, and the install copy + /// loop can write the wrapper AFTER the payload. The running daemon's + /// `current_exe()` is the payload, while the channel candidate resolves to + /// the wrapper. Comparing wrapper-vs-payload mtimes made the freshly + /// updated daemon report "newer binary available" against ITS OWN install + /// forever -> the client force-reloaded the server in a loop and the + /// session never attached. + #[test] + fn freshly_updated_release_daemon_reports_no_phantom_update() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + // Wrapper written strictly AFTER the payload (the bad copy order). + let (wrapper, payload) = + install_release_style_binary("0.25.1", base + Duration::from_secs(5), base); + build::update_stable_symlink("0.25.1").expect("stable"); + build::update_current_symlink("0.25.1").expect("current"); + build::update_shared_server_symlink("0.25.1").expect("shared"); + + // The daemon runs the payload; the candidate is the wrapper. Same + // logical install -> no update must be reported. + let payload_mtime = std::fs::metadata(&payload) + .expect("payload metadata") + .modified() + .expect("payload mtime"); + assert!( + !daemon_reports_update(&payload, payload_mtime), + "a freshly updated daemon must not report an update against its own install" + ); + // Sanity: the wrapper IS strictly newer than the payload on disk, so a + // naive wrapper-vs-payload comparison would have reported a phantom + // update (the bug this guards against). + let wrapper_mtime = std::fs::metadata(&wrapper) + .expect("wrapper metadata") + .modified() + .expect("wrapper mtime"); + assert!(wrapper_mtime > payload_mtime); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } +} + +#[cfg(test)] +mod deleted_suffix_tests { + use super::strip_deleted_suffix; + use std::path::PathBuf; + + #[test] + fn strips_linux_deleted_marker() { + let p = PathBuf::from("/home/u/.jcode/builds/versions/abc/jcode (deleted)"); + assert_eq!( + strip_deleted_suffix(p), + PathBuf::from("/home/u/.jcode/builds/versions/abc/jcode") + ); + } + + #[test] + fn leaves_normal_paths_untouched() { + let p = PathBuf::from("/home/u/.jcode/builds/versions/abc/jcode"); + assert_eq!(strip_deleted_suffix(p.clone()), p); + } + + #[test] + fn only_strips_trailing_marker() { + // A path that merely contains the substring must not be altered. + let p = PathBuf::from("/home/u/jcode (deleted)/jcode"); + assert_eq!(strip_deleted_suffix(p.clone()), p); + } +} diff --git a/crates/jcode-app-core/src/server_spawn.rs b/crates/jcode-app-core/src/server_spawn.rs new file mode 100644 index 0000000..e02b492 --- /dev/null +++ b/crates/jcode-app-core/src/server_spawn.rs @@ -0,0 +1,47 @@ +//! Shared-server lifecycle hooks usable from lower layers. +//! +//! The actual "spawn a shared jcode server" logic lives in the CLI command +//! layer (`cli::dispatch::spawn_server`) because it depends on CLI types like +//! `ProviderChoice` and on argument-driven bootstrap. Lower layers such as the +//! TUI reconnect loop still need to (a) check whether a shared server is +//! reachable and (b) request a replacement server when a reload stalls. +//! +//! To avoid a `tui -> cli` dependency, the CLI registers a default spawner here +//! at startup (mirroring the `register_permission_notifier` / +//! `register_api_key_fallback_resolver` inversion pattern). Consumers call +//! [`is_running`] and [`spawn_default_server`] without knowing about `cli`. + +use anyhow::Result; +use std::future::Future; +use std::pin::Pin; +use std::sync::OnceLock; + +type ServerSpawner = + Box<dyn Fn() -> Pin<Box<dyn Future<Output = Result<()>> + Send>> + Send + Sync>; + +static DEFAULT_SERVER_SPAWNER: OnceLock<ServerSpawner> = OnceLock::new(); + +/// Register the default shared-server spawner. +/// +/// Called once at startup by the CLI layer, which owns the provider-bootstrap +/// logic. Subsequent calls are ignored. +pub fn register_default_server_spawner(spawner: ServerSpawner) { + let _ = DEFAULT_SERVER_SPAWNER.set(spawner); +} + +/// Returns true if a shared server is currently reachable on the default socket. +pub async fn is_running() -> bool { + let socket = crate::server::socket_path(); + crate::server::is_server_ready(&socket).await || crate::server::has_live_listener(&socket).await +} + +/// Spawn a replacement shared server using the registered default spawner. +/// +/// Returns an error if no spawner has been registered (e.g. in a context that +/// never initialized the CLI startup hooks). +pub async fn spawn_default_server() -> Result<()> { + match DEFAULT_SERVER_SPAWNER.get() { + Some(spawner) => spawner().await, + None => anyhow::bail!("no default server spawner registered"), + } +} diff --git a/crates/jcode-app-core/src/session_active_pids.rs b/crates/jcode-app-core/src/session_active_pids.rs new file mode 100644 index 0000000..2c7db23 --- /dev/null +++ b/crates/jcode-app-core/src/session_active_pids.rs @@ -0,0 +1,47 @@ +use super::*; + +pub(super) fn active_pids_dir() -> Option<std::path::PathBuf> { + storage::jcode_dir().ok().map(|d| d.join("active_pids")) +} + +pub(super) fn register_active_pid(session_id: &str, pid: u32) { + if let Some(dir) = active_pids_dir() { + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join(session_id), pid.to_string()); + } +} + +pub(super) fn unregister_active_pid(session_id: &str) { + if let Some(dir) = active_pids_dir() { + let _ = std::fs::remove_file(dir.join(session_id)); + } +} + +/// Find the active session ID currently owned by the given process ID. +pub fn find_active_session_id_by_pid(pid: u32) -> Option<String> { + let dir = active_pids_dir()?; + for entry in std::fs::read_dir(dir).ok()? { + let entry = entry.ok()?; + let session_id = entry.file_name().to_string_lossy().to_string(); + let stored = std::fs::read_to_string(entry.path()).ok()?; + if stored.trim().parse::<u32>().ok()? == pid { + return Some(session_id); + } + } + None +} + +/// List active session IDs currently tracked in ~/.jcode/active_pids. +pub fn active_session_ids() -> Vec<String> { + let Some(dir) = active_pids_dir() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect() +} diff --git a/crates/jcode-app-core/src/session_effort.rs b/crates/jcode-app-core/src/session_effort.rs new file mode 100644 index 0000000..6b48a1d --- /dev/null +++ b/crates/jcode-app-core/src/session_effort.rs @@ -0,0 +1,70 @@ +//! Process-global, deadlock-free registry of each session's current reasoning +//! effort. +//! +//! The swarm task-graph seed handler runs on the server socket thread while the +//! *seeding* agent is blocked inside its `swarm` tool call holding its own agent +//! lock. That means the handler cannot read the seeder's effort via the agent +//! mutex without deadlocking. This tiny side-table is updated whenever an agent's +//! effort changes (cheap string writes) so server handlers can learn a session's +//! effort by id without taking any agent lock. +//! +//! This is what lets `swarm-deep` *reliably* engage deep mode: if a deep-effort +//! agent seeds a graph but forgets to pass `mode:"deep"`, the server can still +//! default the plan to deep instead of silently downgrading to light. + +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +static SESSION_EFFORTS: LazyLock<RwLock<HashMap<String, String>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Record (or clear) a session's current reasoning effort. Pass `None` to forget +/// it (e.g. when the effort is unset). +pub fn record_session_effort(session_id: &str, effort: Option<&str>) { + let Ok(mut map) = SESSION_EFFORTS.write() else { + return; + }; + match effort { + Some(effort) => { + map.insert(session_id.to_string(), effort.to_string()); + } + None => { + map.remove(session_id); + } + } +} + +/// Look up a session's last-recorded reasoning effort, if any. +pub fn session_effort(session_id: &str) -> Option<String> { + SESSION_EFFORTS.read().ok()?.get(session_id).cloned() +} + +/// Drop a session's entry entirely (called on session teardown to bound growth). +pub fn forget_session_effort(session_id: &str) { + if let Ok(mut map) = SESSION_EFFORTS.write() { + map.remove(session_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_and_reads_back_effort() { + let sid = "session-effort-roundtrip"; + forget_session_effort(sid); + assert_eq!(session_effort(sid), None); + + record_session_effort(sid, Some("swarm-deep")); + assert_eq!(session_effort(sid).as_deref(), Some("swarm-deep")); + + // Overwrite with a new value. + record_session_effort(sid, Some("high")); + assert_eq!(session_effort(sid).as_deref(), Some("high")); + + // Clearing forgets it. + record_session_effort(sid, None); + assert_eq!(session_effort(sid), None); + } +} diff --git a/crates/jcode-app-core/src/session_launch.rs b/crates/jcode-app-core/src/session_launch.rs new file mode 100644 index 0000000..421a91c --- /dev/null +++ b/crates/jcode-app-core/src/session_launch.rs @@ -0,0 +1,641 @@ +//! Launching jcode sessions in new terminal windows. +//! +//! These helpers spawn a fresh `jcode` process (resume or self-dev) inside a +//! new terminal window. They are pure process/terminal orchestration built on +//! the low-level `terminal_launch` facade and depend only on core modules +//! (`id`, `process_title`, `platform`), so +//! they live in the core layer rather than the CLI command layer. This lets +//! lower layers like `server`, `restart_snapshot`, and `tool` relaunch +//! sessions without depending on `cli`. + +use anyhow::Result; + +use crate::{id, server}; + +/// Map a persisted session/runtime provider key (e.g. `anthropic-api-key`, +/// `claude-oauth`) to the value the resumed process accepts for `--provider` +/// (the CLI `ProviderChoice` vocabulary, e.g. `anthropic-api`, `claude`). +/// +/// The two vocabularies are not identical, so passing the raw runtime key +/// straight through makes clap reject it (`invalid value 'anthropic-api-key'`) +/// and the freshly spawned window exits immediately before the TUI starts. +/// Returns `None` when the key has no clean standalone CLI provider value; the +/// flag is then omitted and the persisted session reconstructs the route on +/// resume. +fn resume_provider_arg(provider_key: Option<&str>) -> Option<&'static str> { + provider_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(crate::provider::cli_provider_arg_for_session_key) +} + +/// Metadata describing why a session window is being spawned, exported to +/// spawn hooks and spawned terminals as `JCODE_SPAWN_*` env vars so external +/// programs (tmux, kitty remote, herd, window managers) can reroute or place +/// the window. See `[terminal] spawn_hook` in config. +#[derive(Debug, Clone, Default)] +pub struct SessionSpawnContext { + /// Spawn kind override (e.g. "swarm-agent", "restart"). Defaults to + /// "resume" or "selfdev" based on the launch helper used. + pub kind: Option<String>, + /// Extra `JCODE_SPAWN_*` env entries (e.g. swarm/coordinator ids). + pub extra_env: Vec<(String, String)>, + /// Terminal-identifying env vars captured from the client that requested + /// the spawn (tmux/zellij/kitty/DISPLAY/...). Re-exported to spawn/focus + /// hooks so the new window lands in the client's terminal instead of the + /// server's stale startup env (#405). + pub client_terminal_env: Vec<(String, String)>, +} + +impl SessionSpawnContext { + pub fn kind(kind: impl Into<String>) -> Self { + Self { + kind: Some(kind.into()), + extra_env: Vec::new(), + client_terminal_env: Vec::new(), + } + } + + pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self { + self.extra_env.push((key.into(), value.into())); + self + } + + /// Attach the requesting client's terminal env snapshot (#405). + pub fn with_client_terminal_env(mut self, env: Vec<(String, String)>) -> Self { + self.client_terminal_env = env; + self + } + + fn apply( + &self, + mut command: crate::terminal_launch::TerminalCommand, + default_kind: &str, + session_id: &str, + ) -> crate::terminal_launch::TerminalCommand { + command = command + .kind(self.kind.as_deref().unwrap_or(default_kind)) + .session_id(session_id); + for (key, value) in &self.extra_env { + command = command.spawn_env(key.clone(), value.clone()); + } + if !self.client_terminal_env.is_empty() { + command = command.client_terminal_env(self.client_terminal_env.clone()); + } + command + } +} + +/// Compute the window/terminal title used when (re)launching a session. +pub fn resumed_window_title(session_id: &str) -> String { + let session_name = crate::process_title::session_name(session_id); + let icon = id::session_icon(&session_name); + let display_title = crate::process_title::terminal_display_title_for_id(session_id); + let session_label = crate::process_title::terminal_session_label(&session_name, None); + let fallback_label = if let Some(server_info) = + crate::registry::find_server_by_socket_sync(&server::socket_path()) + { + format!("jcode/{} {}", server_info.name, session_label) + } else { + format!("jcode {}", session_label) + }; + crate::process_title::terminal_window_title( + &icon, + display_title.as_deref(), + Some(&fallback_label), + false, + ) +} + +/// Focus/raise the window for `session_id` via the configured focus hook. +/// +/// Returns `true` when a hook was configured and its process started (the +/// built-in wmctrl/xdotool fallback should then be skipped). The hook receives +/// `JCODE_FOCUS_SESSION_ID` and `JCODE_FOCUS_TITLE` env vars. +pub fn focus_session_via_hook(session_id: &str, title: &str) -> bool { + focus_session_via_hook_with_env(session_id, title, &[]) +} + +/// Like [`focus_session_via_hook`] but also re-exports the requesting client's +/// terminal env (#405) so focus hooks (e.g. `zellij action go-to-tab-name`) +/// target the client's terminal session instead of the server's stale env. Each +/// var is exported natively and under a `JCODE_CLIENT_<NAME>` alias. +pub fn focus_session_via_hook_with_env( + session_id: &str, + title: &str, + client_terminal_env: &[(String, String)], +) -> bool { + let hook = { + let config = &crate::config::config().terminal; + config + .focus_hook + .as_deref() + .map(str::trim) + .filter(|hook| !hook.is_empty()) + .map(str::to_string) + }; + let Some(hook) = hook else { + return false; + }; + + let parts = match crate::terminal_launch::parse_hook_command(&hook) { + Ok(parts) => parts, + Err(error) => { + crate::logging::warn(&format!("Focus hook '{hook}' failed to parse: {error}")); + return false; + } + }; + let (program, args) = parts + .split_first() + .expect("parse_hook_command guarantees at least one part"); + + let mut cmd = std::process::Command::new(crate::terminal_launch::expand_home(program)); + cmd.args(args) + .env("JCODE_FOCUS_SESSION_ID", session_id) + .env("JCODE_FOCUS_TITLE", title) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + for (key, value) in client_terminal_env { + cmd.env(key, value); + cmd.env(format!("JCODE_CLIENT_{key}"), value); + } + match crate::platform::spawn_detached(&mut cmd) { + Ok(_) => true, + Err(error) => { + crate::logging::warn(&format!( + "Focus hook '{hook}' failed to start ({error}); falling back to built-in focus" + )); + false + } + } +} + +/// Focus a session window: configured focus hook first, then the built-in +/// wmctrl/xdotool title search (Linux only) as a best-effort fallback. +pub fn focus_session_window_best_effort(session_id: &str, title: &str) { + focus_session_window_best_effort_with_env(session_id, title, &[]); +} + +/// Like [`focus_session_window_best_effort`] but forwards the requesting +/// client's terminal env to the focus hook (#405). +pub fn focus_session_window_best_effort_with_env( + session_id: &str, + title: &str, + client_terminal_env: &[(String, String)], +) { + if focus_session_via_hook_with_env(session_id, title, client_terminal_env) { + return; + } + focus_title_best_effort(title); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn focus_title_best_effort(title: &str) { + use std::process::{Command, Stdio}; + + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg( + "sleep 0.4; \ + if command -v wmctrl >/dev/null 2>&1; then wmctrl -a \"$JCODE_WINDOW_TITLE\" >/dev/null 2>&1 && exit 0; fi; \ + if command -v xdotool >/dev/null 2>&1; then xdotool search --name \"$JCODE_WINDOW_TITLE\" windowactivate >/dev/null 2>&1 && exit 0; fi; \ + exit 0", + ) + .env("JCODE_WINDOW_TITLE", title) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let _ = crate::platform::spawn_detached(&mut cmd); +} + +#[cfg(any(not(unix), target_os = "macos"))] +fn focus_title_best_effort(_title: &str) {} + +#[cfg(unix)] +pub fn spawn_resume_in_new_terminal( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, +) -> Result<bool> { + spawn_resume_in_new_terminal_with_provider(exe, session_id, cwd, None) +} + +#[cfg(unix)] +pub fn spawn_resume_in_new_terminal_with_provider( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, +) -> Result<bool> { + spawn_resume_in_new_terminal_with_context( + exe, + session_id, + cwd, + provider_key, + &SessionSpawnContext::default(), + ) +} + +#[cfg(unix)] +pub fn spawn_resume_in_new_terminal_with_context( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, + context: &SessionSpawnContext, +) -> Result<bool> { + let title = resumed_window_title(session_id); + let mut args = vec!["--fresh-spawn".to_string()]; + if let Some(provider_arg) = resume_provider_arg(provider_key) { + args.push("--provider".to_string()); + args.push(provider_arg.to_string()); + } + args.extend(["--resume".to_string(), session_id.to_string()]); + let command = crate::terminal_launch::TerminalCommand::new(exe, args) + .title(title) + .fresh_spawn(); + let command = context.apply(command, "resume", session_id); + crate::terminal_launch::spawn_command_in_new_terminal(&command, cwd) +} + +#[cfg(unix)] +pub fn spawn_selfdev_in_new_terminal( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, +) -> Result<bool> { + spawn_selfdev_in_new_terminal_with_provider(exe, session_id, cwd, None) +} + +#[cfg(unix)] +pub fn spawn_selfdev_in_new_terminal_with_provider( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, +) -> Result<bool> { + spawn_selfdev_in_new_terminal_with_context( + exe, + session_id, + cwd, + provider_key, + &SessionSpawnContext::default(), + ) +} + +#[cfg(unix)] +pub fn spawn_selfdev_in_new_terminal_with_context( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, + context: &SessionSpawnContext, +) -> Result<bool> { + let selfdev_title = format!("{} [self-dev]", resumed_window_title(session_id)); + let mut args = vec!["--fresh-spawn".to_string()]; + if let Some(provider_arg) = resume_provider_arg(provider_key) { + args.push("--provider".to_string()); + args.push(provider_arg.to_string()); + } + args.extend([ + "--resume".to_string(), + session_id.to_string(), + "self-dev".to_string(), + ]); + let command = crate::terminal_launch::TerminalCommand::new(exe, args) + .title(selfdev_title.clone()) + .fresh_spawn(); + let command = context.apply(command, "selfdev", session_id); + let spawned = crate::terminal_launch::spawn_command_in_new_terminal(&command, cwd)?; + if spawned { + focus_session_window_best_effort_with_env( + session_id, + &selfdev_title, + &context.client_terminal_env, + ); + } + Ok(spawned) +} + +#[cfg(not(unix))] +fn find_wezterm_gui_binary() -> Option<String> { + use std::process::{Command, Stdio}; + + if let Ok(exe) = std::env::var("WEZTERM_EXECUTABLE") { + let p = std::path::Path::new(&exe); + let gui = p.with_file_name("wezterm-gui.exe"); + if gui.exists() { + return Some(gui.to_string_lossy().into_owned()); + } + return Some(exe); + } + + let candidates = [ + r"C:\Program Files\WezTerm\wezterm-gui.exe", + r"C:\Program Files (x86)\WezTerm\wezterm-gui.exe", + ]; + for c in &candidates { + if std::path::Path::new(c).exists() { + return Some(c.to_string()); + } + } + + for bin in &["wezterm-gui", "wezterm"] { + if let Ok(output) = Command::new("where") + .arg(bin) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(line) = stdout.lines().next() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + if *bin == "wezterm" { + let p = std::path::Path::new(trimmed); + let gui = p.with_file_name("wezterm-gui.exe"); + if gui.exists() { + return Some(gui.to_string_lossy().into_owned()); + } + } + return Some(trimmed.to_string()); + } + } + } + } + } + + None +} + +#[cfg(not(unix))] +fn resume_terminal_candidates_windows() -> Vec<String> { + std::env::var("JCODE_RESUME_TERMINAL") + .ok() + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::<Vec<_>>() + }) + .filter(|candidates| !candidates.is_empty()) + .unwrap_or_else(|| { + vec![ + "wezterm".to_string(), + "wt".to_string(), + "alacritty".to_string(), + ] + }) +} + +#[cfg(not(unix))] +pub fn spawn_resume_in_new_terminal( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, +) -> Result<bool> { + spawn_resume_in_new_terminal_with_provider(exe, session_id, cwd, None) +} + +#[cfg(not(unix))] +pub fn spawn_resume_in_new_terminal_with_provider( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, +) -> Result<bool> { + spawn_resume_in_new_terminal_with_context( + exe, + session_id, + cwd, + provider_key, + &SessionSpawnContext::default(), + ) +} + +#[cfg(not(unix))] +pub fn spawn_resume_in_new_terminal_with_context( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, + context: &SessionSpawnContext, +) -> Result<bool> { + use std::process::{Command, Stdio}; + + let mut jcode_args: Vec<String> = Vec::new(); + if let Some(provider_arg) = resume_provider_arg(provider_key) { + jcode_args.push("--provider".to_string()); + jcode_args.push(provider_arg.to_string()); + } + jcode_args.push("--resume".to_string()); + jcode_args.push(session_id.to_string()); + + let hook_command = crate::terminal_launch::TerminalCommand::new(exe, jcode_args.clone()) + .title(resumed_window_title(session_id)); + let hook_command = context.apply(hook_command, "resume", session_id); + if crate::terminal_launch::try_spawn_via_configured_hook(&hook_command, cwd) { + return Ok(true); + } + + let wezterm_gui = find_wezterm_gui_binary(); + let alacritty_available = Command::new("where") + .arg("alacritty") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let wt_available = std::env::var("WT_SESSION").is_ok() + || Command::new("where") + .arg("wt") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + for term in resume_terminal_candidates_windows() { + let status = match term.as_str() { + "wezterm" => { + let Some(ref wezterm_bin) = wezterm_gui else { + continue; + }; + let mut cmd = Command::new(wezterm_bin); + cmd.args(["start", "--always-new-process", "--"]) + .arg(exe) + .args(&jcode_args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::spawn_detached(&mut cmd) + } + "wt" | "windows-terminal" => { + if !wt_available { + continue; + } + let mut cmd = Command::new("wt.exe"); + cmd.args(["-p", "Command Prompt"]) + .arg(exe) + .args(&jcode_args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::spawn_detached(&mut cmd) + } + "alacritty" => { + if !alacritty_available { + continue; + } + let mut cmd = Command::new("alacritty"); + cmd.args(["-e"]) + .arg(exe) + .args(&jcode_args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::spawn_detached(&mut cmd) + } + _ => continue, + }; + + if status.is_ok() { + return Ok(true); + } + } + + Ok(false) +} + +#[cfg(not(unix))] +pub fn spawn_selfdev_in_new_terminal( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, +) -> Result<bool> { + spawn_selfdev_in_new_terminal_with_provider(exe, session_id, cwd, None) +} + +#[cfg(not(unix))] +pub fn spawn_selfdev_in_new_terminal_with_provider( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, +) -> Result<bool> { + spawn_selfdev_in_new_terminal_with_context( + exe, + session_id, + cwd, + provider_key, + &SessionSpawnContext::default(), + ) +} + +#[cfg(not(unix))] +pub fn spawn_selfdev_in_new_terminal_with_context( + exe: &std::path::Path, + session_id: &str, + cwd: &std::path::Path, + provider_key: Option<&str>, + context: &SessionSpawnContext, +) -> Result<bool> { + use std::process::{Command, Stdio}; + + let mut jcode_args: Vec<String> = Vec::new(); + if let Some(provider_arg) = resume_provider_arg(provider_key) { + jcode_args.push("--provider".to_string()); + jcode_args.push(provider_arg.to_string()); + } + jcode_args.extend([ + "--resume".to_string(), + session_id.to_string(), + "self-dev".to_string(), + ]); + + let hook_command = crate::terminal_launch::TerminalCommand::new(exe, jcode_args.clone()) + .title(format!("{} [self-dev]", resumed_window_title(session_id))); + let hook_command = context.apply(hook_command, "selfdev", session_id); + if crate::terminal_launch::try_spawn_via_configured_hook(&hook_command, cwd) { + return Ok(true); + } + + let wezterm_gui = find_wezterm_gui_binary(); + let alacritty_available = Command::new("where") + .arg("alacritty") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let wt_available = std::env::var("WT_SESSION").is_ok() + || Command::new("where") + .arg("wt") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + for term in resume_terminal_candidates_windows() { + let status = match term.as_str() { + "wezterm" => { + let Some(ref wezterm_bin) = wezterm_gui else { + continue; + }; + let mut cmd = Command::new(wezterm_bin); + cmd.args(["start", "--always-new-process", "--"]) + .arg(exe) + .args(&jcode_args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::spawn_detached(&mut cmd) + } + "wt" | "windows-terminal" => { + if !wt_available { + continue; + } + let mut cmd = Command::new("wt.exe"); + cmd.args(["-p", "Command Prompt"]) + .arg(exe) + .args(&jcode_args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::spawn_detached(&mut cmd) + } + "alacritty" => { + if !alacritty_available { + continue; + } + let mut cmd = Command::new("alacritty"); + cmd.args(["-e"]) + .arg(exe) + .args(&jcode_args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::platform::spawn_detached(&mut cmd) + } + _ => continue, + }; + + if status.is_ok() { + return Ok(true); + } + } + + Ok(false) +} diff --git a/crates/jcode-app-core/src/session_rebuild.rs b/crates/jcode-app-core/src/session_rebuild.rs new file mode 100644 index 0000000..37e4ea7 --- /dev/null +++ b/crates/jcode-app-core/src/session_rebuild.rs @@ -0,0 +1,242 @@ +use anyhow::Result; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, ExitStatus}; + +use crate::bus::{Bus, BusEvent, ClientMaintenanceAction, SessionUpdateStatus}; +use crate::{build, update}; + +pub fn hot_rebuild(session_id: &str) -> Result<()> { + let cwd = std::env::current_dir()?; + let repo_dir = + build::get_repo_dir().ok_or_else(|| anyhow::anyhow!("Could not find jcode repository"))?; + + eprintln!("Rebuilding jcode with session {}...", session_id); + pull_latest_changes_for_rebuild(&repo_dir); + run_release_build(&repo_dir)?; + run_release_tests(&repo_dir)?; + install_local_release_with_warning(&repo_dir); + + let is_selfdev = jcode_selfdev_types::client_selfdev_requested(); + let exe = rebuild_reload_candidate(&repo_dir, is_selfdev); + if !exe.exists() { + anyhow::bail!("Binary not found at {:?}", exe); + } + + update::print_centered(&format!("Restarting with session {}...", session_id)); + exec_rebuilt_session(&exe, session_id, &cwd, is_selfdev) +} + +pub fn spawn_background_session_rebuild(session_id: String) { + std::thread::spawn(move || run_background_session_rebuild(session_id)); +} + +fn pull_latest_changes_for_rebuild(repo_dir: &Path) { + eprintln!("Pulling latest changes..."); + if let Err(e) = update::run_git_pull_ff_only(repo_dir, true) { + eprintln!("Warning: {}. Continuing with current version.", e); + } +} + +fn run_release_build(repo_dir: &Path) -> Result<()> { + eprintln!("Building..."); + let status = run_cargo_release_step(repo_dir, &["build", "--release"])?; + if !status.success() { + anyhow::bail!("Build failed - staying on current version"); + } + Ok(()) +} + +fn run_release_tests(repo_dir: &Path) -> Result<()> { + eprintln!("Running tests..."); + 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."); + eprintln!("Fix the failing tests and try /rebuild again."); + anyhow::bail!("Tests failed - staying on current version"); + } + eprintln!("✓ All tests passed"); + Ok(()) +} + +fn run_cargo_release_step(repo_dir: &Path, args: &[&str]) -> Result<ExitStatus> { + Ok(ProcessCommand::new("cargo") + .args(args) + .current_dir(repo_dir) + .status()?) +} + +fn install_local_release_with_warning(repo_dir: &Path) { + if let Err(e) = build::install_local_release(repo_dir) { + eprintln!("Warning: install failed: {}", e); + } +} + +fn rebuild_reload_candidate(repo_dir: &Path, is_selfdev: bool) -> PathBuf { + build::client_update_candidate(is_selfdev) + .map(|(path, _)| path) + .unwrap_or_else(|| build::release_binary_path(repo_dir)) +} + +fn exec_rebuilt_session(exe: &Path, session_id: &str, cwd: &Path, is_selfdev: bool) -> Result<()> { + crate::env::set_var("JCODE_RESUMING", "1"); + + let mut cmd = ProcessCommand::new(exe); + if is_selfdev { + cmd.arg("self-dev"); + } + cmd.arg("--resume").arg(session_id).current_dir(cwd); + let err = crate::platform::replace_process(&mut cmd); + + Err(anyhow::anyhow!("Failed to exec {:?}: {}", exe, err)) +} + +fn run_background_session_rebuild(session_id: String) { + let publisher = BackgroundRebuildPublisher::new(session_id); + let Some(repo_dir) = build::get_repo_dir() else { + publisher.error("Rebuild failed: could not find the jcode repository."); + return; + }; + + background_pull_latest_changes(&publisher, &repo_dir); + if !background_release_build(&publisher, &repo_dir) { + return; + } + if !background_release_tests(&publisher, &repo_dir) { + return; + } + background_install_local_release(&publisher, &repo_dir); + publish_rebuild_ready_or_error(publisher, &repo_dir); +} + +#[derive(Clone)] +struct BackgroundRebuildPublisher { + session_id: String, + action: ClientMaintenanceAction, +} + +impl BackgroundRebuildPublisher { + fn new(session_id: String) -> Self { + Self { + session_id, + action: ClientMaintenanceAction::Rebuild, + } + } + + fn status(&self, message: impl Into<String>) { + self.publish(SessionUpdateStatus::Status { + session_id: self.session_id.clone(), + action: self.action, + message: message.into(), + }); + } + + fn error(&self, message: impl Into<String>) { + self.publish(SessionUpdateStatus::Error { + session_id: self.session_id.clone(), + action: self.action, + message: message.into(), + }); + } + + fn ready(self, repo_dir: &Path) { + Bus::global().publish(BusEvent::SessionUpdateStatus( + SessionUpdateStatus::ReadyToReload { + session_id: self.session_id, + action: self.action, + version: rebuild_version_label(repo_dir), + }, + )); + } + + fn publish(&self, status: SessionUpdateStatus) { + Bus::global().publish(BusEvent::SessionUpdateStatus(status)); + } +} + +fn background_pull_latest_changes(publisher: &BackgroundRebuildPublisher, repo_dir: &Path) { + publisher.status("Pulling latest changes in the background..."); + if let Err(error) = update::run_git_pull_ff_only(repo_dir, true) { + publisher.status(format!( + "Git pull skipped: {}. Continuing with the current checkout.", + error + )); + } +} + +fn background_release_build(publisher: &BackgroundRebuildPublisher, repo_dir: &Path) -> bool { + publisher.status("Building release binary in the background..."); + let status = match run_cargo_release_step(repo_dir, &["build", "--release"]) { + Ok(status) => status, + Err(error) => { + publisher.error(format!( + "Rebuild failed while starting cargo build: {}", + error + )); + return false; + } + }; + + if !status.success() { + publisher.error("Build failed — staying on the current binary."); + return false; + } + true +} + +fn background_release_tests(publisher: &BackgroundRebuildPublisher, repo_dir: &Path) -> bool { + publisher.status("Running release tests in the background..."); + let status = + match run_cargo_release_step(repo_dir, &["test", "--release", "--", "--test-threads=1"]) { + Ok(status) => status, + Err(error) => { + publisher.error(format!("Rebuild failed while starting tests: {}", error)); + return false; + } + }; + + if !status.success() { + publisher.error( + "Tests failed — staying on the current binary. Fix the failing tests and try /rebuild again.", + ); + return false; + } + true +} + +fn background_install_local_release(publisher: &BackgroundRebuildPublisher, repo_dir: &Path) { + if let Err(error) = build::install_local_release(repo_dir) { + publisher.status(format!( + "Install warning: {}. Will reload from the repo build if needed.", + error + )); + } +} + +fn publish_rebuild_ready_or_error(publisher: BackgroundRebuildPublisher, repo_dir: &Path) { + let is_selfdev = jcode_selfdev_types::client_selfdev_requested(); + let exe = build::preferred_reload_candidate(is_selfdev) + .map(|(path, _)| path) + .unwrap_or_else(|| build::release_binary_path(repo_dir)); + if !exe.exists() { + publisher.error(format!( + "Rebuild finished but no reloadable binary was found at {:?}.", + exe + )); + return; + } + + publisher.ready(repo_dir); +} + +fn rebuild_version_label(repo_dir: &Path) -> String { + build::current_build_info(repo_dir) + .map(|info| { + if info.dirty { + format!("{}-dirty", info.hash) + } else { + info.hash + } + }) + .unwrap_or_else(|_| "local source build".to_string()) +} diff --git a/crates/jcode-app-core/src/setup_hints.rs b/crates/jcode-app-core/src/setup_hints.rs new file mode 100644 index 0000000..9d09737 --- /dev/null +++ b/crates/jcode-app-core/src/setup_hints.rs @@ -0,0 +1 @@ +pub use jcode_setup_hints::*; diff --git a/crates/jcode-app-core/src/ssh_remote.rs b/crates/jcode-app-core/src/ssh_remote.rs new file mode 100644 index 0000000..7a38ec7 --- /dev/null +++ b/crates/jcode-app-core/src/ssh_remote.rs @@ -0,0 +1,309 @@ +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SshRemoteConfig { + #[serde(default)] + pub hosts: Vec<SshRemoteProfile>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SshRemoteProfile { + pub name: String, + pub ssh_target: String, + #[serde(default = "default_workspace")] + pub workspace: String, +} + +fn default_workspace() -> String { + "~".to_string() +} + +pub fn config_path() -> Result<PathBuf> { + Ok(crate::storage::jcode_dir()?.join("ssh_remotes.json")) +} + +pub fn control_dir() -> Result<PathBuf> { + Ok(crate::storage::jcode_dir()?.join("ssh-control")) +} + +pub fn control_socket_path(name: &str) -> Result<PathBuf> { + Ok(control_dir()?.join(format!("{}.sock", sanitize_profile_name(name)))) +} + +pub fn load_config() -> Result<SshRemoteConfig> { + let path = config_path()?; + if !path.exists() { + return Ok(SshRemoteConfig::default()); + } + crate::storage::read_json(&path).with_context(|| format!("failed to read {}", path.display())) +} + +pub fn save_config(config: &SshRemoteConfig) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + let _ = crate::platform::set_directory_permissions_owner_only(parent); + } + let bytes = serde_json::to_vec_pretty(config)?; + std::fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +pub fn upsert_profile(name: &str, ssh_target: &str) -> Result<SshRemoteProfile> { + let mut config = load_config()?; + let ssh_target = normalize_ssh_target(ssh_target)?; + let profile = SshRemoteProfile { + name: name.to_string(), + ssh_target, + workspace: default_workspace(), + }; + if let Some(existing) = config.hosts.iter_mut().find(|p| p.name == name) { + *existing = profile.clone(); + } else { + config.hosts.push(profile.clone()); + config.hosts.sort_by(|a, b| a.name.cmp(&b.name)); + } + save_config(&config)?; + Ok(profile) +} + +pub fn find_profile(name: &str) -> Result<Option<SshRemoteProfile>> { + let Some(mut profile) = load_config()?.hosts.into_iter().find(|p| p.name == name) else { + return Ok(None); + }; + // Older MVP builds could save a pasted command like `ssh user@host`. Normalize on load so + // users do not have to manually repair ~/.jcode/ssh_remotes.json. + profile.ssh_target = normalize_ssh_target(&profile.ssh_target)?; + Ok(Some(profile)) +} + +pub fn normalize_ssh_target(input: &str) -> Result<String> { + let trimmed = input.trim(); + let without_ssh = trimmed + .strip_prefix("ssh ") + .or_else(|| trimmed.strip_prefix("ssh\t")) + .unwrap_or(trimmed) + .trim(); + + if without_ssh.is_empty() { + bail!("SSH target cannot be empty. Example: alice@login.school.edu"); + } + if without_ssh.starts_with('-') || without_ssh.split_whitespace().count() != 1 { + bail!( + "SSH target should be just the host alias or user@host, not a full command. Example: alice@login.school.edu" + ); + } + if without_ssh.chars().any(|c| c.is_control()) { + bail!("SSH target contains invalid control characters"); + } + Ok(without_ssh.to_string()) +} + +pub fn sanitize_profile_name(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('_'); + if trimmed.is_empty() { + "remote".to_string() + } else { + trimmed.to_string() + } +} + +pub fn is_control_master_alive(profile: &SshRemoteProfile) -> bool { + let Ok(socket) = control_socket_path(&profile.name) else { + return false; + }; + Command::new("ssh") + .arg("-S") + .arg(socket) + .arg("-O") + .arg("check") + .arg(&profile.ssh_target) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +pub fn can_connect_batch_mode(profile: &SshRemoteProfile) -> bool { + Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=5") + .arg(&profile.ssh_target) + .arg("true") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +pub fn disconnect(profile: &SshRemoteProfile) -> Result<bool> { + let socket = control_socket_path(&profile.name)?; + let status = Command::new("ssh") + .arg("-S") + .arg(socket) + .arg("-O") + .arg("exit") + .arg(&profile.ssh_target) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .context("failed to run ssh disconnect")?; + Ok(status.success()) +} + +pub fn build_control_master_script(profile: &SshRemoteProfile) -> Result<String> { + std::fs::create_dir_all(control_dir()?)?; + let socket = control_socket_path(&profile.name)?; + let target = &profile.ssh_target; + Ok(format!( + r#"printf '%s\n' '========================================' +printf '%s\n' 'Jcode SSH login for {name}' +printf '%s\n' '========================================' +printf '%s\n' '' +printf '%s\n' 'Step 2/4: Authenticate with your system SSH client' +printf '%s\n' '' +printf '%s\n' 'What is happening:' +printf '%s\n' ' - This terminal is running OpenSSH, not a Jcode password form.' +printf '%s\n' ' - If a password or two-factor prompt appears, type it here.' +printf '%s\n' ' - Jcode cannot read or store what you type in this terminal.' +printf '%s\n' '' +printf '%s\n' 'After authentication:' +printf '%s\n' ' - SSH will create a temporary background control socket.' +printf '%s\n' ' - Jcode will verify that socket before this terminal closes.' +printf '%s\n' ' - If anything fails, this terminal stays open with the reason.' +printf '%s\n' '' +ssh -f -M -S {socket} -N {target} +status=$? +if [ $status -ne 0 ]; then + printf '%s\n' '' + printf '%s\n' 'Step 2/4 failed: SSH did not complete authentication.' + printf '%s\n' '' + printf '%s\n' 'Common fixes:' + printf '%s\n' ' - Check that the SSH target is only user@host or an SSH alias, not a full command.' + printf '%s\n' ' - Check username, hostname, password, VPN, and two-factor prompt.' + printf '%s\n' ' - Try the same target manually with: ssh {target}' + printf '%s' 'Press Enter to close this terminal... ' + read _ + exit $status +fi + +printf '%s\n' '' +printf '%s\n' 'Step 3/4: SSH accepted the login. Verifying background control socket...' +for i in 1 2 3 4 5 6 7 8 9 10; do + if ssh -S {socket} -O check {target} >/dev/null 2>&1; then + printf '%s\n' '' + printf '%s\n' 'Step 4/4: Connected and verified.' + printf '%s\n' 'Jcode can now use this SSH connection headlessly.' + printf '%s\n' 'This terminal will close automatically.' + sleep 1 + exit 0 + fi + sleep 1 +done + +printf '%s\n' '' +printf '%s\n' 'Step 3/4 failed: Jcode could not verify the background control socket.' +printf '%s\n' '' +printf '%s\n' 'What this means:' +printf '%s\n' ' - SSH login may have succeeded, but multiplexing did not stay available.' +printf '%s\n' ' - The server may disallow SSH ControlMaster, or the connection closed immediately.' +printf '%s\n' ' - Jcode is keeping this terminal open so you can read the reason.' +printf '%s' 'Press Enter to close this terminal... ' +read _ +exit 1 +"#, + name = shell_single_quote(&profile.name), + socket = shell_single_quote(&socket.to_string_lossy()), + target = shell_single_quote(target), + )) +} + +pub fn spawn_control_master_terminal(profile: &SshRemoteProfile) -> Result<bool> { + let script = build_control_master_script(profile)?; + let command = crate::terminal_launch::TerminalCommand::new( + "sh".to_string(), + vec!["-c".to_string(), script], + ) + .title(format!("jcode ssh · {}", profile.name)); + crate::terminal_launch::spawn_command_in_new_terminal(&command, Path::new(".")) +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_profile_name_keeps_safe_chars() { + assert_eq!(sanitize_profile_name("school"), "school"); + assert_eq!( + sanitize_profile_name("alice@login.school.edu"), + "alice_login.school.edu" + ); + assert_eq!(sanitize_profile_name("!!!"), "remote"); + } + + #[test] + fn normalize_ssh_target_accepts_alias_and_user_host() { + assert_eq!(normalize_ssh_target("school").unwrap(), "school"); + assert_eq!( + normalize_ssh_target(" alice@login.school.edu ").unwrap(), + "alice@login.school.edu" + ); + } + + #[test] + fn normalize_ssh_target_strips_pasted_ssh_prefix() { + assert_eq!( + normalize_ssh_target("ssh alice@login.school.edu").unwrap(), + "alice@login.school.edu" + ); + } + + #[test] + fn normalize_ssh_target_rejects_full_commands_with_options() { + assert!(normalize_ssh_target("ssh -p 2222 alice@login.school.edu").is_err()); + assert!(normalize_ssh_target("alice@login.school.edu true").is_err()); + } + + #[test] + fn control_master_script_waits_for_verified_socket_before_closing() { + let profile = SshRemoteProfile { + name: "school".to_string(), + ssh_target: "alice@login.school.edu".to_string(), + workspace: "~".to_string(), + }; + + let script = build_control_master_script(&profile).unwrap(); + assert!(script.contains("Step 3/4: SSH accepted the login")); + assert!(script.contains("Step 4/4: Connected and verified")); + assert!(script.contains("ssh -S")); + assert!(script.contains("-O check")); + assert!(script.contains("Press Enter to close this terminal")); + assert!(script.contains("Jcode cannot read or store")); + } +} diff --git a/crates/jcode-app-core/src/startup_profile.rs b/crates/jcode-app-core/src/startup_profile.rs new file mode 100644 index 0000000..a745041 --- /dev/null +++ b/crates/jcode-app-core/src/startup_profile.rs @@ -0,0 +1,84 @@ +use std::sync::Mutex; +use std::time::Instant; + +static PROFILE: Mutex<Option<StartupProfile>> = Mutex::new(None); + +pub struct StartupProfile { + start: Instant, + marks: Vec<(String, Instant)>, +} + +impl StartupProfile { + fn new() -> Self { + let now = Instant::now(); + Self { + start: now, + marks: vec![("process_start".to_string(), now)], + } + } +} + +pub fn init() { + let mut guard = match PROFILE.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + *guard = Some(StartupProfile::new()); +} + +pub fn mark(name: &str) { + if let Ok(mut guard) = PROFILE.lock() + && let Some(ref mut profile) = *guard + { + profile.marks.push((name.to_string(), Instant::now())); + } +} + +pub fn report() -> String { + let guard = match PROFILE.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let profile = match guard.as_ref() { + Some(p) => p, + None => return "No startup profile recorded".to_string(), + }; + + let total = profile + .marks + .last() + .map(|(_, instant)| instant.duration_since(profile.start)) + .unwrap_or_default(); + let mut lines = vec![format!( + "=== Startup Profile ({:.1}ms total) ===", + total.as_secs_f64() * 1000.0 + )]; + + for i in 1..profile.marks.len() { + let delta = profile.marks[i].1.duration_since(profile.marks[i - 1].1); + let from_start = profile.marks[i].1.duration_since(profile.start); + let pct = if total.as_nanos() > 0 { + (delta.as_nanos() as f64 / total.as_nanos() as f64) * 100.0 + } else { + 0.0 + }; + let bar = "█".repeat((pct / 2.0).ceil() as usize); + lines.push(format!( + " {:>7.1}ms {:>7.1}ms {:>5.1}% {:<30} {}", + from_start.as_secs_f64() * 1000.0, + delta.as_secs_f64() * 1000.0, + pct, + profile.marks[i].0, + bar, + )); + } + + lines.join("\n") +} + +pub fn report_to_log() { + let report = report(); + for line in report.lines() { + crate::logging::info(line); + } +} diff --git a/crates/jcode-app-core/src/stdin_detect_tests.rs b/crates/jcode-app-core/src/stdin_detect_tests.rs new file mode 100644 index 0000000..76c7e16 --- /dev/null +++ b/crates/jcode-app-core/src/stdin_detect_tests.rs @@ -0,0 +1,207 @@ +use super::*; +use std::process::{Command, Stdio}; + +#[test] +fn test_own_process_not_reading_stdin() { + let pid = std::process::id(); + let state = is_waiting_for_stdin(pid); + assert_ne!(state, StdinState::Reading); +} + +#[test] +fn test_nonexistent_pid() { + let state = is_waiting_for_stdin(u32::MAX); + assert_ne!(state, StdinState::Reading); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_blocked_process_detected() { + let mut child = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("failed to spawn cat"); + + let pid = child.id(); + std::thread::sleep(std::time::Duration::from_millis(200)); + + let state = linux::check_process_tree(pid); + + child.kill().ok(); + child.wait().ok(); + + assert_eq!( + state, + StdinState::Reading, + "cat should be waiting for stdin" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_running_process_not_reading() { + let mut child = Command::new("sleep") + .arg("10") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .expect("failed to spawn sleep"); + + let pid = child.id(); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let state = linux::check(pid); + + child.kill().ok(); + child.wait().ok(); + + assert_eq!( + state, + StdinState::NotReading, + "sleep should not be reading stdin" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_child_process_tree_detection() { + // bash -c "cat" spawns bash which spawns cat - cat is the one reading stdin + let mut child = Command::new("bash") + .arg("-c") + .arg("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("failed to spawn bash"); + + let pid = child.id(); + std::thread::sleep(std::time::Duration::from_millis(300)); + + // The bash process itself may not be reading, but its child (cat) should be + let state = linux::check_process_tree(pid); + + child.kill().ok(); + child.wait().ok(); + + assert_eq!( + state, + StdinState::Reading, + "child cat should be detected via process tree" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_process_that_reads_then_exits() { + use std::io::Write; + + let mut child = Command::new("head") + .arg("-n1") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("failed to spawn head"); + + let pid = child.id(); + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Should be reading initially + let state_before = linux::check(pid); + + // Write a line - head should read it and exit + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(b"hello\n").ok(); + stdin.flush().ok(); + } + + // Wait for exit + let status = child.wait().expect("failed to wait"); + + // After exit, checking the pid should not report Reading + let state_after = is_waiting_for_stdin(pid); + + assert_eq!( + state_before, + StdinState::Reading, + "head should be reading before input" + ); + assert_ne!( + state_after, + StdinState::Reading, + "head should not be reading after exit" + ); + assert!(status.success(), "head should exit successfully"); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_process_with_closed_stdin_not_reading() { + // Spawn a process with stdin completely closed (null) + let mut child = Command::new("cat") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .expect("failed to spawn cat"); + + let pid = child.id(); + // cat with /dev/null as stdin should read EOF immediately and exit + std::thread::sleep(std::time::Duration::from_millis(200)); + + let state = is_waiting_for_stdin(pid); + + child.kill().ok(); + child.wait().ok(); + + // cat with /dev/null gets EOF immediately, should not be stuck reading + assert_ne!(state, StdinState::Reading); +} + +#[cfg(target_os = "linux")] +#[test] +fn test_multiple_sequential_reads() { + use std::io::Write; + + // Use a program that reads multiple lines + let mut child = Command::new("head") + .arg("-n2") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("failed to spawn head"); + + let pid = child.id(); + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Should be reading first line + let state1 = linux::check(pid); + assert_eq!( + state1, + StdinState::Reading, + "should be waiting for first line" + ); + + // Send first line + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(b"line1\n").ok(); + stdin.flush().ok(); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Should be reading second line + let state2 = linux::check(pid); + assert_eq!( + state2, + StdinState::Reading, + "should be waiting for second line" + ); + + // Send second line + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(b"line2\n").ok(); + stdin.flush().ok(); + } + + let status = child.wait().expect("failed to wait"); + assert!(status.success()); +} diff --git a/crates/jcode-app-core/src/telemetry_state.rs b/crates/jcode-app-core/src/telemetry_state.rs new file mode 100644 index 0000000..dd725f7 --- /dev/null +++ b/crates/jcode-app-core/src/telemetry_state.rs @@ -0,0 +1,348 @@ +use super::{SESSION_STATE, sanitize_telemetry_label}; +use crate::storage; +use chrono::{DateTime, Datelike, Timelike, Utc}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +pub(super) fn telemetry_id_path() -> Option<PathBuf> { + storage::jcode_dir().ok().map(|d| d.join("telemetry_id")) +} + +pub(super) fn install_recorded_path() -> Option<PathBuf> { + storage::jcode_dir() + .ok() + .map(|d| d.join("telemetry_install_sent")) +} + +pub(super) fn version_recorded_path() -> Option<PathBuf> { + storage::jcode_dir() + .ok() + .map(|d| d.join("telemetry_version_sent")) +} + +pub(super) fn telemetry_state_path(name: &str) -> Option<PathBuf> { + storage::jcode_dir().ok().map(|d| d.join(name)) +} + +pub(super) fn milestone_recorded_path(id: &str, key: &str) -> Option<PathBuf> { + telemetry_state_path(&format!( + "telemetry_milestone_{}_{}", + sanitize_telemetry_label(key), + id + )) +} + +pub(super) fn onboarding_step_milestone_key( + step: &str, + auth_provider: Option<&str>, + auth_method: Option<&str>, +) -> String { + fn normalize_part(value: &str) -> String { + let sanitized = sanitize_telemetry_label(value); + let collapsed = sanitized + .split_whitespace() + .filter(|part| !part.is_empty()) + .collect::<Vec<_>>() + .join("_"); + collapsed.to_ascii_lowercase() + } + + let mut parts = vec![normalize_part(step)]; + if let Some(provider) = auth_provider { + let provider = normalize_part(provider); + if !provider.is_empty() { + parts.push(provider); + } + } + if let Some(method) = auth_method { + let method = normalize_part(method); + if !method.is_empty() { + parts.push(method); + } + } + parts.join("_") +} + +pub(super) fn active_days_path(id: &str) -> Option<PathBuf> { + telemetry_state_path(&format!("telemetry_active_days_{}.txt", id)) +} + +pub(super) fn session_starts_path(id: &str) -> Option<PathBuf> { + telemetry_state_path(&format!("telemetry_session_starts_{}.txt", id)) +} + +pub(super) fn active_sessions_dir() -> Option<PathBuf> { + telemetry_state_path("telemetry_active_sessions") +} + +pub(super) fn active_session_file(session_id: &str) -> Option<PathBuf> { + active_sessions_dir().map(|dir| dir.join(format!("{}.active", session_id))) +} + +pub(super) fn write_private_file(path: &PathBuf, value: &str) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, value); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } +} + +pub(super) fn utc_hour(timestamp: DateTime<Utc>) -> u32 { + timestamp.hour() +} + +pub(super) fn utc_weekday(timestamp: DateTime<Utc>) -> u32 { + timestamp.weekday().num_days_from_monday() +} + +pub(super) fn write_private_dir_file(path: &PathBuf, value: &str) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + write_private_file(path, value); +} + +pub(super) fn read_epoch_lines(path: &PathBuf) -> Vec<i64> { + std::fs::read_to_string(path) + .ok() + .into_iter() + .flat_map(|text| { + text.lines() + .map(str::trim) + .map(str::to_string) + .collect::<Vec<_>>() + }) + .filter_map(|line| line.parse::<i64>().ok()) + .collect() +} + +pub(super) fn update_session_start_history( + id: &str, + started_at_utc: DateTime<Utc>, +) -> (Option<u64>, u32, u32) { + let Some(path) = session_starts_path(id) else { + return (None, 0, 0); + }; + let now = started_at_utc.timestamp(); + let cutoff_30d = now - 30 * 24 * 60 * 60; + let mut starts = read_epoch_lines(&path) + .into_iter() + .filter(|value| *value >= cutoff_30d) + .collect::<Vec<_>>(); + starts.sort_unstable(); + let previous = starts.last().copied(); + starts.push(now); + let rendered = starts + .iter() + .map(i64::to_string) + .collect::<Vec<_>>() + .join("\n"); + write_private_dir_file(&path, &rendered); + let sessions_started_24h = starts + .iter() + .filter(|value| now.saturating_sub(**value) < 24 * 60 * 60) + .count() + .min(u32::MAX as usize) as u32; + let sessions_started_7d = starts + .iter() + .filter(|value| now.saturating_sub(**value) < 7 * 24 * 60 * 60) + .count() + .min(u32::MAX as usize) as u32; + let previous_session_gap_secs = previous + .and_then(|value| now.checked_sub(value)) + .map(|value| value.min(u64::MAX as i64) as u64); + ( + previous_session_gap_secs, + sessions_started_24h, + sessions_started_7d, + ) +} + +pub(super) fn prune_active_session_files(dir: &PathBuf) -> u32 { + let _ = std::fs::create_dir_all(dir); + let now = SystemTime::now(); + let max_age = Duration::from_secs(24 * 60 * 60); + let mut count = 0u32; + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return 0, + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let fresh = entry + .metadata() + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|modified| now.duration_since(modified).ok()) + .map(|age| age <= max_age) + .unwrap_or(false); + if fresh { + count = count.saturating_add(1); + } else { + let _ = std::fs::remove_file(path); + } + } + count +} + +pub(super) fn register_active_session(session_id: &str) -> (u32, u32) { + let Some(dir) = active_sessions_dir() else { + return (0, 0); + }; + let existing = prune_active_session_files(&dir); + if let Some(path) = active_session_file(session_id) { + write_private_dir_file(&path, "1"); + } + (existing.saturating_add(1), existing) +} + +pub(super) fn observe_active_sessions() -> u32 { + active_sessions_dir() + .map(|dir| prune_active_session_files(&dir)) + .unwrap_or(0) +} + +pub(super) fn unregister_active_session(session_id: &str) { + if let Some(path) = active_session_file(session_id) { + let _ = std::fs::remove_file(path); + } +} + +pub(super) fn get_or_create_id() -> Option<String> { + let path = telemetry_id_path()?; + if let Ok(id) = std::fs::read_to_string(&path) { + let id = id.trim().to_string(); + if !id.is_empty() { + return Some(id); + } + } + let id = uuid::Uuid::new_v4().to_string(); + write_private_file(&path, &id); + Some(id) +} + +pub(super) fn is_first_run() -> bool { + telemetry_id_path().map(|p| !p.exists()).unwrap_or(false) +} + +pub(super) fn version() -> String { + jcode_build_meta::PKG_VERSION.to_string() +} + +pub(super) fn install_recorded_for_id(id: &str) -> bool { + install_recorded_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .map(|stored| stored.trim() == id) + .unwrap_or(false) +} + +pub(super) fn mark_install_recorded(id: &str) { + if let Some(path) = install_recorded_path() { + write_private_file(&path, id); + } +} + +pub(super) fn previously_recorded_version() -> Option<String> { + version_recorded_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(super) fn mark_current_version_recorded() { + if let Some(path) = version_recorded_path() { + write_private_file(&path, &version()); + } +} + +pub(super) fn new_event_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub(super) fn build_channel() -> String { + if std::env::var(jcode_selfdev_types::CLIENT_SELFDEV_ENV).is_ok() { + return "selfdev".to_string(); + } + if let Ok(exe) = std::env::current_exe() { + let path = exe.to_string_lossy(); + if path.contains("/target/debug/") || path.contains("\\target\\debug\\") { + return "debug".to_string(); + } + if path.contains("/target/release/") || path.contains("\\target\\release\\") { + return "local_build".to_string(); + } + } + if crate::build::get_repo_dir().is_some() { + return "git_checkout".to_string(); + } + "release".to_string() +} + +pub(super) fn is_git_checkout() -> bool { + crate::build::get_repo_dir().is_some() +} + +pub(super) fn is_ci() -> bool { + [ + "CI", + "GITHUB_ACTIONS", + "BUILDKITE", + "JENKINS_URL", + "GITLAB_CI", + "CIRCLECI", + ] + .iter() + .any(|key| std::env::var(key).is_ok()) +} + +pub(super) fn ran_from_cargo() -> bool { + std::env::var("CARGO").is_ok() || std::env::var("CARGO_MANIFEST_DIR").is_ok() +} + +pub(super) fn install_anchor_time(id: &str) -> Option<SystemTime> { + install_recorded_path() + .filter(|path| install_recorded_for_id(id) && path.exists()) + .and_then(|path| std::fs::metadata(path).ok()) + .and_then(|meta| meta.modified().ok()) + .or_else(|| { + telemetry_id_path() + .and_then(|path| std::fs::metadata(path).ok()) + .and_then(|meta| meta.modified().ok()) + }) +} + +pub(super) fn elapsed_since_install_ms(id: &str) -> Option<u64> { + let anchor = install_anchor_time(id)?; + let elapsed = SystemTime::now().duration_since(anchor).ok()?; + Some(elapsed.as_millis().min(u128::from(u64::MAX)) as u64) +} + +pub(super) fn days_since_install(id: &str) -> Option<u32> { + let anchor = install_anchor_time(id)?; + let elapsed = SystemTime::now().duration_since(anchor).ok()?; + Some((elapsed.as_secs() / 86_400).min(u64::from(u32::MAX)) as u32) +} + +pub(super) fn milestone_recorded(id: &str, step: &str) -> bool { + milestone_recorded_path(id, step) + .map(|path| path.exists()) + .unwrap_or(false) +} + +pub(super) fn mark_milestone_recorded(id: &str, step: &str) { + if let Some(path) = milestone_recorded_path(id, step) { + write_private_file(&path, "1"); + } +} + +pub(super) fn current_session_id() -> Option<String> { + SESSION_STATE + .lock() + .map(|state| state.as_ref().map(|s| s.session_id.clone())) + .ok() + .flatten() +} diff --git a/crates/jcode-app-core/src/telemetry_tests.rs b/crates/jcode-app-core/src/telemetry_tests.rs new file mode 100644 index 0000000..ba38a1f --- /dev/null +++ b/crates/jcode-app-core/src/telemetry_tests.rs @@ -0,0 +1,317 @@ +use super::*; +use crate::storage::lock_test_env; +use std::sync::{Mutex, OnceLock}; + +fn lock_telemetry_test_state() -> std::sync::MutexGuard<'static, ()> { + static TELEMETRY_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + TELEMETRY_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[test] +fn test_opt_out_env_var() { + let _guard = lock_test_env(); + crate::env::set_var("JCODE_NO_TELEMETRY", "1"); + assert!(!is_enabled()); + crate::env::remove_var("JCODE_NO_TELEMETRY"); +} + +#[test] +fn test_do_not_track() { + let _guard = lock_test_env(); + crate::env::set_var("DO_NOT_TRACK", "1"); + assert!(!is_enabled()); + crate::env::remove_var("DO_NOT_TRACK"); +} + +#[test] +fn test_error_counters() { + let _guard = lock_telemetry_test_state(); + reset_counters(); + record_error(ErrorCategory::ProviderTimeout); + record_error(ErrorCategory::ProviderTimeout); + record_error(ErrorCategory::ToolError); + assert_eq!(ERROR_PROVIDER_TIMEOUT.load(Ordering::Relaxed), 2); + assert_eq!(ERROR_TOOL_ERROR.load(Ordering::Relaxed), 1); + reset_counters(); +} + +#[test] +fn test_session_reason_labels() { + assert_eq!(SessionEndReason::NormalExit.as_str(), "normal_exit"); + assert_eq!(SessionEndReason::Disconnect.as_str(), "disconnect"); +} + +#[test] +fn test_session_start_event_serialization() { + let event = SessionStartEvent { + event_id: "event-1".to_string(), + id: "test-uuid".to_string(), + session_id: "session-1".to_string(), + event: "session_start", + version: "0.6.1".to_string(), + os: "linux", + arch: "x86_64", + provider_start: "claude".to_string(), + model_start: "claude-sonnet-4".to_string(), + resumed_session: true, + session_start_hour_utc: 13, + session_start_weekday_utc: 2, + previous_session_gap_secs: Some(3600), + sessions_started_24h: 3, + sessions_started_7d: 8, + active_sessions_at_start: 2, + other_active_sessions_at_start: 1, + schema_version: TELEMETRY_SCHEMA_VERSION, + build_channel: "release".to_string(), + is_git_checkout: false, + is_ci: false, + ran_from_cargo: false, + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["event"], "session_start"); + assert_eq!(json["resumed_session"], true); + assert_eq!(json["session_id"], "session-1"); + assert_eq!(json["sessions_started_24h"], 3); +} + +#[test] +fn test_session_end_event_serialization() { + let event = SessionLifecycleEvent { + event_id: "event-2".to_string(), + id: "test-uuid".to_string(), + session_id: "session-2".to_string(), + event: "session_end", + version: "0.6.1".to_string(), + os: "linux", + arch: "x86_64", + provider_start: "claude".to_string(), + provider_end: "openrouter".to_string(), + model_start: "claude-sonnet-4-20250514".to_string(), + model_end: "anthropic/claude-sonnet-4".to_string(), + provider_switches: 1, + model_switches: 2, + duration_mins: 45, + duration_secs: 2700, + turns: 23, + had_user_prompt: true, + had_assistant_response: true, + assistant_responses: 3, + first_assistant_response_ms: Some(1200), + first_tool_call_ms: Some(900), + first_tool_success_ms: Some(1500), + first_file_edit_ms: Some(2200), + first_test_pass_ms: Some(4100), + tool_calls: 4, + tool_failures: 1, + executed_tool_calls: 5, + executed_tool_successes: 4, + executed_tool_failures: 1, + tool_latency_total_ms: 3200, + tool_latency_max_ms: 1400, + file_write_calls: 2, + tests_run: 1, + tests_passed: 1, + input_tokens: 1234, + output_tokens: 567, + cache_read_input_tokens: 890, + cache_creation_input_tokens: 12, + total_tokens: 2703, + feature_memory_used: true, + feature_swarm_used: false, + feature_web_used: true, + feature_email_used: false, + feature_mcp_used: true, + feature_side_panel_used: true, + feature_goal_used: false, + feature_selfdev_used: false, + feature_background_used: false, + feature_subagent_used: true, + unique_mcp_servers: 2, + session_success: true, + abandoned_before_response: false, + session_stop_reason: "completed_successfully", + agent_role: "foreground", + parent_session_id: None, + agent_active_ms_total: 180_000, + agent_model_ms_total: 120_000, + agent_tool_ms_total: 60_000, + session_idle_ms_total: 30_000, + agent_blocked_ms_total: 0, + time_to_first_agent_action_ms: Some(900), + time_to_first_useful_action_ms: Some(1500), + spawned_agent_count: 3, + background_task_count: 1, + background_task_completed_count: 1, + subagent_task_count: 1, + subagent_success_count: 1, + swarm_task_count: 1, + swarm_success_count: 0, + user_cancelled_count: 1, + transport_https: 2, + transport_persistent_ws_fresh: 1, + transport_persistent_ws_reuse: 5, + transport_cli_subprocess: 0, + transport_native_http2: 0, + transport_other: 0, + tool_cat_read_search: 2, + tool_cat_write: 2, + tool_cat_shell: 1, + tool_cat_web: 1, + tool_cat_memory: 1, + tool_cat_subagent: 1, + tool_cat_swarm: 0, + tool_cat_email: 0, + tool_cat_side_panel: 1, + tool_cat_goal: 0, + tool_cat_mcp: 1, + tool_cat_other: 0, + command_login_used: false, + command_model_used: true, + command_usage_used: false, + command_resume_used: false, + command_memory_used: true, + command_swarm_used: false, + command_goal_used: false, + command_selfdev_used: false, + command_feedback_used: false, + command_other_used: false, + workflow_chat_only: false, + workflow_coding_used: true, + workflow_research_used: true, + workflow_tests_used: true, + workflow_background_used: false, + workflow_subagent_used: true, + workflow_swarm_used: false, + project_repo_present: true, + project_lang_rust: true, + project_lang_js_ts: false, + project_lang_python: false, + project_lang_go: false, + project_lang_markdown: true, + project_lang_mixed: true, + days_since_install: Some(12), + active_days_7d: 4, + active_days_30d: 9, + session_start_hour_utc: 13, + session_start_weekday_utc: 2, + session_end_hour_utc: 14, + session_end_weekday_utc: 2, + previous_session_gap_secs: Some(1800), + sessions_started_24h: 5, + sessions_started_7d: 12, + active_sessions_at_start: 2, + other_active_sessions_at_start: 1, + max_concurrent_sessions: 3, + multi_sessioned: true, + resumed_session: false, + end_reason: "normal_exit", + schema_version: TELEMETRY_SCHEMA_VERSION, + build_channel: "release".to_string(), + is_git_checkout: false, + is_ci: false, + ran_from_cargo: false, + errors: ErrorCounts { + provider_timeout: 2, + auth_failed: 0, + tool_error: 1, + mcp_error: 0, + rate_limited: 0, + }, + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["event"], "session_end"); + assert_eq!(json["assistant_responses"], 3); + assert_eq!(json["duration_secs"], 2700); + assert_eq!(json["executed_tool_calls"], 5); + assert_eq!(json["transport_https"], 2); + assert_eq!(json["tool_cat_write"], 2); + assert_eq!(json["workflow_coding_used"], true); + assert_eq!(json["active_days_30d"], 9); + assert_eq!(json["transport_persistent_ws_reuse"], 5); + assert_eq!(json["multi_sessioned"], true); + assert_eq!(json["end_reason"], "normal_exit"); + assert_eq!(json["input_tokens"], 1234); + assert_eq!(json["output_tokens"], 567); + assert_eq!(json["cache_read_input_tokens"], 890); + assert_eq!(json["cache_creation_input_tokens"], 12); + assert_eq!(json["total_tokens"], 2703); + assert_eq!(json["errors"]["provider_timeout"], 2); + assert_eq!(json["session_stop_reason"], "completed_successfully"); + assert_eq!(json["agent_active_ms_total"], 180_000); + assert_eq!(json["time_to_first_useful_action_ms"], 1500); + assert_eq!(json["subagent_task_count"], 1); + assert_eq!(json["user_cancelled_count"], 1); +} + +#[test] +fn test_record_connection_type_buckets_transport() { + let _guard = lock_telemetry_test_state(); + reset_counters(); + if let Ok(mut session) = SESSION_STATE.lock() { + *session = None; + } + begin_session_with_mode("openai", "gpt-5.4", None, false); + record_connection_type("websocket/persistent-fresh"); + record_connection_type("websocket/persistent-reuse"); + record_connection_type("https/sse"); + record_connection_type("native http2"); + record_connection_type("cli subprocess"); + record_connection_type("weird-transport"); + + { + let guard = SESSION_STATE.lock().unwrap(); + let state = guard.as_ref().expect("session telemetry state"); + assert_eq!(state.transport_persistent_ws_fresh, 1); + assert_eq!(state.transport_persistent_ws_reuse, 1); + assert_eq!(state.transport_https, 1); + assert_eq!(state.transport_native_http2, 1); + assert_eq!(state.transport_cli_subprocess, 1); + assert_eq!(state.transport_other, 1); + } + if let Ok(mut session) = SESSION_STATE.lock() { + *session = None; + } + reset_counters(); +} + +#[test] +fn test_sanitize_telemetry_label_strips_ansi_and_controls() { + assert_eq!( + sanitize_telemetry_label("\u{1b}[1mclaude-opus-4-6\u{1b}[0m\n"), + "claude-opus-4-6" + ); +} + +#[test] +fn test_onboarding_step_milestone_key_includes_provider_and_method() { + assert_eq!( + onboarding_step_milestone_key("auth_success", Some("jcode"), Some("API key")), + "auth_success_jcode_api_key" + ); + assert_eq!( + onboarding_step_milestone_key("login_picker_opened", None, None), + "login_picker_opened" + ); +} + +#[test] +fn test_install_marker_tracks_current_telemetry_id() { + let _guard = lock_test_env(); + let prev_home = std::env::var_os("JCODE_HOME"); + let temp = tempfile::TempDir::new().expect("create temp dir"); + crate::env::set_var("JCODE_HOME", temp.path()); + + assert!(!install_recorded_for_id("id-a")); + mark_install_recorded("id-a"); + assert!(install_recorded_for_id("id-a")); + assert!(!install_recorded_for_id("id-b")); + + 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/agentgrep.rs b/crates/jcode-app-core/src/tool/agentgrep.rs new file mode 100644 index 0000000..4100ddf --- /dev/null +++ b/crates/jcode-app-core/src/tool/agentgrep.rs @@ -0,0 +1,429 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::message::{ContentBlock, ToolCall}; +use crate::session::Session; +use crate::storage; +use crate::{logging, util}; +use ::agentgrep::cli::{FindArgs, FullRegionMode, GrepArgs, OutlineArgs, SmartArgs}; +use ::agentgrep::find::{FindResult, run_find}; +use ::agentgrep::outline::run_outline; +use ::agentgrep::search::{GrepResult, run_grep}; +use ::agentgrep::smart_dsl::{SmartQuery, parse_smart_query}; +use ::agentgrep::smart_engine::{SmartResult, run_smart}; +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +mod args; +mod context; + +#[cfg(test)] +use self::args::trace_or_smart_terms_owned; +use self::args::{ + build_find_args, build_grep_args, build_outline_args, build_smart_args_and_query, + resolve_search_root, summarize_agentgrep_request, +}; +use self::context::maybe_write_context_json; +#[cfg(test)] +use self::context::{ + collect_bash_exposure, collect_trace_exposure, tune_known_file, tune_known_region, +}; +use ::agentgrep::render::{ + render_find_output, render_grep_output, render_outline_output, render_smart_output, +}; + +#[derive(Debug, Deserialize)] +struct AgentGrepInput { + #[serde(default = "default_agentgrep_mode")] + mode: String, + // `pattern` accepted for legacy grep-tool calls aliased to agentgrep. + #[serde(default, alias = "pattern")] + query: Option<String>, + // `file_path` accepted because agents frequently pass it instead of `file`. + #[serde(default, alias = "file_path")] + file: Option<String>, + #[serde(default)] + terms: Option<Vec<String>>, + #[serde(default)] + regex: Option<bool>, + #[serde(default)] + path: Option<String>, + // `include` accepted for legacy grep-tool calls aliased to agentgrep. + #[serde(default, alias = "include")] + glob: Option<String>, + #[serde(rename = "type", default)] + file_type: Option<String>, + #[serde(default)] + hidden: Option<bool>, + #[serde(default)] + no_ignore: Option<bool>, + #[serde(default)] + max_files: Option<usize>, + #[serde(default)] + max_regions: Option<usize>, + #[serde(default)] + full_region: Option<String>, + #[serde(default)] + debug_plan: Option<bool>, + #[serde(default)] + debug_score: Option<bool>, + #[serde(default)] + paths_only: Option<bool>, +} + +fn default_agentgrep_mode() -> String { + "grep".to_string() +} + +#[derive(Debug, Serialize, Default)] +struct AgentGrepHarnessContext { + version: u32, + #[serde(skip_serializing_if = "Vec::is_empty")] + known_regions: Vec<AgentGrepKnownRegion>, + #[serde(skip_serializing_if = "Vec::is_empty")] + known_files: Vec<AgentGrepKnownFile>, + #[serde(skip_serializing_if = "Vec::is_empty")] + known_symbols: Vec<AgentGrepKnownSymbol>, + #[serde(skip_serializing_if = "Vec::is_empty")] + focus_files: Vec<String>, +} + +#[derive(Debug, Serialize)] +struct AgentGrepKnownRegion { + path: String, + start_line: usize, + end_line: usize, + body_confidence: f32, + current_version_confidence: f32, + prune_confidence: f32, + source_strength: &'static str, + reasons: Vec<&'static str>, +} + +#[derive(Debug, Serialize)] +struct AgentGrepKnownFile { + path: String, + structure_confidence: f32, + body_confidence: f32, + current_version_confidence: f32, + prune_confidence: f32, + source_strength: &'static str, + reasons: Vec<&'static str>, +} + +#[derive(Debug, Serialize)] +struct AgentGrepKnownSymbol { + path: String, + symbol: String, + #[serde(skip_serializing_if = "Option::is_none")] + kind: Option<&'static str>, + structure_confidence: f32, + body_confidence: f32, + current_version_confidence: f32, + prune_confidence: f32, + source_strength: &'static str, + reasons: Vec<&'static str>, +} + +#[derive(Debug, Clone, Copy)] +struct RegionConfidenceProfile { + body_confidence: f32, + current_version_confidence: f32, + prune_confidence: f32, + source_strength: &'static str, +} + +#[derive(Debug, Clone)] +struct PendingTraceRegion { + path: String, + kind: Option<&'static str>, + start_line: usize, + end_line: usize, +} + +#[derive(Debug, Clone)] +struct ToolExposureObservation { + tool: ToolCall, + content: String, + timestamp: Option<DateTime<Utc>>, + message_index: usize, +} + +#[derive(Debug, Clone, Copy)] +struct ExposureDescriptor { + timestamp: Option<DateTime<Utc>>, + message_index: usize, + total_messages: usize, + compaction_cutoff: Option<usize>, +} + +pub struct AgentGrepTool; + +impl AgentGrepTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for AgentGrepTool { + fn name(&self) -> &str { + "agentgrep" + } + + fn description(&self) -> &str { + "Search code and file names. Defaults to grep mode when mode is omitted." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "regex": { + "type": "boolean", + "description": "When true in grep mode, interpret query as a regular expression. Defaults to false, which is safer for literal searches." + }, + "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." + }, + "glob": { + "type": "string", + "description": "Optional file glob filter such as **/*.rs. Do not set glob to **/* just to search everything; omit it instead." + }, + "type": { + "type": "string", + "description": "Optional ripgrep file type filter, such as rs, py, js, ts, or md." + }, + "max_files": { + "type": "integer", + "description": "Maximum number of files to return for find/trace-style modes." + }, + "max_regions": { + "type": "integer", + "description": "Maximum number of matching regions to return." + }, + "paths_only": { + "type": "boolean", + "description": "Return only matching paths instead of match excerpts where supported." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: AgentGrepInput = serde_json::from_value(input)?; + // 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 + // to the blocking pool so we never stall a tokio worker thread. When it + // ran inline, a single poll of this future executed the whole search to + // completion, freezing the TUI's select! render/input loop, which made + // 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}"))? + } +} + +fn run_agentgrep_blocking(params: &AgentGrepInput, ctx: &ToolContext) -> Result<ToolOutput> { + if ctx.working_dir.is_none() { + let explicit_path = params.path.as_deref().or(params.file.as_deref()); + if explicit_path.is_none_or(|path| !Path::new(path).is_absolute()) { + anyhow::bail!( + "agentgrep requires a session working directory unless an absolute path is provided" + ); + } + } + let context_path = maybe_write_context_json(params, ctx)?; + let request = summarize_agentgrep_request(params, ctx, context_path.as_deref()); + let started_at = std::time::Instant::now(); + let outcome = execute_linked_agentgrep(params, ctx, context_path.as_deref()); + let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + + if let Some(path) = context_path { + let _ = std::fs::remove_file(path); + } + + match outcome { + Ok(output) => { + if elapsed_ms >= 2_000 { + logging::warn(&format!( + "agentgrep slow mode={} elapsed_ms={} request={}", + params.mode, elapsed_ms, request + )); + } + Ok(output) + } + Err(err) => { + let detail = err.to_string(); + let detail = util::truncate_str(detail.trim(), 600); + logging::warn(&format!( + "agentgrep failure mode={} elapsed_ms={} request={} error={}", + params.mode, elapsed_ms, request, detail + )); + Err(anyhow::anyhow!( + "agentgrep {} failed after {}ms: {}", + params.mode, + elapsed_ms, + err + )) + } + } +} + +fn execute_linked_agentgrep( + params: &AgentGrepInput, + ctx: &ToolContext, + context_json_path: Option<&Path>, +) -> Result<ToolOutput> { + let exact_file = exact_search_file_path(ctx, params.path.as_deref()); + match params.mode.as_str() { + "grep" => { + let args = build_grep_args(params, ctx)?; + let root = resolve_search_root(ctx, args.path.as_deref())?; + let result = filter_grep_result_to_exact_file( + run_grep(&root, &args).map_err(anyhow::Error::msg)?, + exact_file.as_deref(), + ); + Ok( + ToolOutput::new(render_grep_output(&result, &args, params.max_regions)) + .with_title("agentgrep grep"), + ) + } + "find" => { + let args = build_find_args(params, ctx)?; + let root = resolve_search_root(ctx, args.path.as_deref())?; + let result = + filter_find_result_to_exact_file(run_find(&root, &args), exact_file.as_deref()); + Ok(ToolOutput::new(render_find_output(&result, &args)).with_title("agentgrep find")) + } + "outline" => { + let args = build_outline_args(params, ctx, context_json_path)?; + let root = resolve_search_root(ctx, args.path.as_deref())?; + let result = run_outline(&root, &args).map_err(anyhow::Error::msg)?; + Ok(ToolOutput::new(render_outline_output(&result)).with_title("agentgrep outline")) + } + "trace" | "smart" => { + let (args, query) = build_smart_args_and_query(params, ctx, context_json_path)?; + let root = resolve_search_root(ctx, args.path.as_deref())?; + let result = filter_smart_result_to_exact_file( + run_smart(&root, &query, &args).map_err(anyhow::Error::msg)?, + exact_file.as_deref(), + ); + Ok(ToolOutput::new(render_smart_output(&result, &args)) + .with_title(format!("agentgrep {}", params.mode))) + } + _ => Err(anyhow::anyhow!( + "Unsupported agentgrep mode: {}. Use grep, find, outline, or trace.", + params.mode + )), + } +} + +fn resolve_path_arg(ctx: &ToolContext, path: &str) -> PathBuf { + ctx.resolve_path(Path::new(path)) +} + +fn exact_search_file_path(ctx: &ToolContext, path: Option<&str>) -> Option<String> { + let path = path?; + let resolved = resolve_path_arg(ctx, path); + if !resolved.is_file() { + return None; + } + resolved + .file_name() + .map(|name| name.to_string_lossy().into_owned()) +} + +fn filter_grep_result_to_exact_file( + mut result: GrepResult, + exact_file: Option<&str>, +) -> GrepResult { + let Some(exact_file) = exact_file else { + return result; + }; + + result.files.retain(|file| file.path == exact_file); + result.total_files = result.files.len(); + result.total_matches = result.files.iter().map(|file| file.matches.len()).sum(); + result +} + +fn filter_find_result_to_exact_file( + mut result: FindResult, + exact_file: Option<&str>, +) -> FindResult { + let Some(exact_file) = exact_file else { + return result; + }; + + result.files.retain(|file| file.path == exact_file); + result +} + +fn filter_smart_result_to_exact_file( + mut result: SmartResult, + exact_file: Option<&str>, +) -> SmartResult { + let Some(exact_file) = exact_file else { + return result; + }; + + result.files.retain(|file| file.path == exact_file); + result.summary.total_files = result.files.len(); + result.summary.total_regions = result.files.iter().map(|file| file.regions.len()).sum(); + result.summary.best_file = result.files.first().map(|file| file.path.clone()); + result +} + +fn normalized_agentgrep_glob(glob: Option<&str>) -> Option<&str> { + let glob = glob?.trim(); + if glob.is_empty() { + return None; + } + + if is_match_all_glob(glob) { + return None; + } + + Some(glob) +} + +fn normalized_agentgrep_glob_owned(glob: Option<&str>) -> Option<String> { + normalized_agentgrep_glob(glob).map(ToOwned::to_owned) +} + +fn is_match_all_glob(glob: &str) -> bool { + matches!(glob, "*" | "**" | "**/*" | "./*" | "./**" | "./**/*") +} + +#[cfg(test)] +#[path = "agentgrep_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/tool/agentgrep/args.rs b/crates/jcode-app-core/src/tool/agentgrep/args.rs new file mode 100644 index 0000000..b80ffa5 --- /dev/null +++ b/crates/jcode-app-core/src/tool/agentgrep/args.rs @@ -0,0 +1,255 @@ +use super::*; + +struct ResolvedSearchScope { + root: Option<String>, + glob: Option<String>, +} + +fn resolved_search_scope( + ctx: &ToolContext, + path: Option<&str>, + glob: Option<&str>, +) -> ResolvedSearchScope { + let Some(path) = path else { + return ResolvedSearchScope { + root: None, + glob: normalized_agentgrep_glob_owned(glob), + }; + }; + + let resolved = resolve_path_arg(ctx, path); + if resolved.is_file() { + let root = resolved + .parent() + .unwrap_or_else(|| Path::new(".")) + .display() + .to_string(); + let glob = resolved + .file_name() + .map(|name| name.to_string_lossy().into_owned()); + return ResolvedSearchScope { + root: Some(root), + glob, + }; + } + + ResolvedSearchScope { + root: Some(resolved.display().to_string()), + glob: normalized_agentgrep_glob_owned(glob), + } +} + +pub(super) fn build_grep_args(params: &AgentGrepInput, ctx: &ToolContext) -> Result<GrepArgs> { + let query = params + .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()); + Ok(GrepArgs { + query, + regex: params.regex.unwrap_or(false), + file_type: params.file_type.clone(), + json: false, + paths_only: params.paths_only.unwrap_or(false), + hidden: params.hidden.unwrap_or(false), + no_ignore: params.no_ignore.unwrap_or(false), + path: scope.root, + glob: scope.glob, + }) +} + +pub(super) fn build_find_args(params: &AgentGrepInput, ctx: &ToolContext) -> Result<FindArgs> { + let query = params.query.as_deref().unwrap_or_default(); + if query.trim().is_empty() + && params.path.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) + { + return Err(anyhow::anyhow!( + "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()); + Ok(FindArgs { + query_parts: query.split_whitespace().map(ToOwned::to_owned).collect(), + file_type: params.file_type.clone(), + json: false, + paths_only: params.paths_only.unwrap_or(false), + debug_score: params.debug_score.unwrap_or(false), + max_files: params.max_files.unwrap_or(10), + hidden: params.hidden.unwrap_or(false), + no_ignore: params.no_ignore.unwrap_or(false), + path: scope.root, + glob: scope.glob, + }) +} + +pub(super) fn build_outline_args( + params: &AgentGrepInput, + ctx: &ToolContext, + context_json_path: Option<&Path>, +) -> Result<OutlineArgs> { + // Agents sometimes point `path` at the file itself, either instead of or + // in addition to `file`. Treat a file-valued `path` as the outline target + // so the file argument is not joined onto it (for example, + // ".../todo.rs/.../todo.rs"). + if let Some(path) = params.path.as_deref() { + let resolved = resolve_path_arg(ctx, path); + if resolved.is_file() { + return Ok(OutlineArgs { + file: resolved.display().to_string(), + json: false, + max_items: None, + path: None, + context_json: context_json_path.map(|path| path.display().to_string()), + }); + } + } + + let file = outline_file_arg(params)?; + Ok(OutlineArgs { + file, + json: false, + max_items: None, + path: resolved_root_string(ctx, params.path.as_deref()), + context_json: context_json_path.map(|path| path.display().to_string()), + }) +} + +pub(super) fn build_smart_args_and_query( + params: &AgentGrepInput, + ctx: &ToolContext, + context_json_path: Option<&Path>, +) -> Result<(SmartArgs, SmartQuery)> { + let terms = trace_or_smart_terms_owned(params)?; + let query = parse_smart_query(&terms).map_err(|err| { + anyhow::anyhow!( + "{}\n\ntrace queries use a small DSL. Example:\n agentgrep trace subject:auth_status relation:rendered support:ui", + err + ) + })?; + let scope = resolved_search_scope(ctx, params.path.as_deref(), params.glob.as_deref()); + + let args = SmartArgs { + terms, + json: false, + max_files: params.max_files.unwrap_or(5), + max_regions: params.max_regions.unwrap_or(6), + full_region: parse_full_region_mode(params.full_region.as_deref())?, + debug_plan: params.debug_plan.unwrap_or(false), + debug_score: params.debug_score.unwrap_or(false), + paths_only: params.paths_only.unwrap_or(false), + path: scope.root, + file_type: params.file_type.clone(), + glob: scope.glob, + hidden: params.hidden.unwrap_or(false), + no_ignore: params.no_ignore.unwrap_or(false), + context_json: context_json_path.map(|path| path.display().to_string()), + }; + + Ok((args, query)) +} + +pub(super) fn trace_or_smart_terms_owned(params: &AgentGrepInput) -> Result<Vec<String>> { + if let Some(terms) = params.terms.as_ref().filter(|terms| !terms.is_empty()) { + return Ok(terms.clone()); + } + + if params.mode == "smart" + && let Some(query) = params.query.as_deref() + { + let split_terms: Vec<String> = query + .split_whitespace() + .filter(|term| !term.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if !split_terms.is_empty() { + return Ok(split_terms); + } + } + + let field_hint = if params.mode == "smart" { + "non-empty 'terms' or 'query'" + } else { + "non-empty 'terms'" + }; + + Err(anyhow::anyhow!( + "agentgrep {} requires {}", + params.mode, + field_hint + )) +} + +fn outline_file_arg(params: &AgentGrepInput) -> Result<String> { + params + .file + .clone() + .or_else(|| params.query.clone()) + .or_else(|| { + params + .terms + .as_ref() + .and_then(|terms| terms.first().cloned()) + }) + .ok_or_else(|| { + anyhow::anyhow!("agentgrep outline requires 'file' (or legacy 'query' / first term)") + }) +} + +fn parse_full_region_mode(value: Option<&str>) -> Result<FullRegionMode> { + match value.unwrap_or("auto").trim().to_ascii_lowercase().as_str() { + "auto" => Ok(FullRegionMode::Auto), + "always" => Ok(FullRegionMode::Always), + "never" => Ok(FullRegionMode::Never), + other => Err(anyhow::anyhow!( + "agentgrep trace full_region must be one of: auto, always, never; got {other}" + )), + } +} + +fn resolved_root_string(ctx: &ToolContext, path: Option<&str>) -> Option<String> { + path.map(|path| resolve_path_arg(ctx, path).display().to_string()) +} + +pub(super) fn resolve_search_root(ctx: &ToolContext, path: Option<&str>) -> Result<PathBuf> { + path.map(PathBuf::from) + .or_else(|| ctx.working_dir.clone()) + .ok_or_else(|| anyhow::anyhow!("agentgrep requires a session working directory")) +} + +pub(super) fn summarize_agentgrep_request( + params: &AgentGrepInput, + ctx: &ToolContext, + context_json_path: Option<&Path>, +) -> String { + let mut parts = vec![format!("mode={}", params.mode)]; + if let Some(query) = params.query.as_deref() { + parts.push(format!("query={}", util::truncate_str(query, 80))); + } + if let Some(file) = params.file.as_deref() { + parts.push(format!("file={file}")); + } + if let Some(terms) = params.terms.as_ref() { + parts.push(format!( + "terms={}", + util::truncate_str(&terms.join(" "), 80) + )); + } + if let Some(path) = resolved_root_string(ctx, params.path.as_deref()) { + parts.push(format!("root={path}")); + } + if let Some(glob) = normalized_agentgrep_glob(params.glob.as_deref()) { + parts.push(format!("glob={glob}")); + } + if let Some(file_type) = params.file_type.as_deref() { + parts.push(format!("type={file_type}")); + } + if params.paths_only.unwrap_or(false) { + parts.push("paths_only=true".to_string()); + } + if context_json_path.is_some() { + parts.push("context_json=true".to_string()); + } + parts.join(" ") +} diff --git a/crates/jcode-app-core/src/tool/agentgrep/context.rs b/crates/jcode-app-core/src/tool/agentgrep/context.rs new file mode 100644 index 0000000..5f5b5b9 --- /dev/null +++ b/crates/jcode-app-core/src/tool/agentgrep/context.rs @@ -0,0 +1,1044 @@ +use super::*; + +pub(super) fn maybe_write_context_json( + params: &AgentGrepInput, + ctx: &ToolContext, +) -> Result<Option<PathBuf>> { + if !matches!(params.mode.as_str(), "trace" | "smart" | "outline") { + return Ok(None); + } + + let context = build_harness_context(params, ctx); + let Some(context) = context else { + return Ok(None); + }; + + let mut path = storage::runtime_dir(); + path.push(format!( + "jcode-agentgrep-context-{}-{}.json", + ctx.session_id, ctx.tool_call_id + )); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&path, serde_json::to_vec(&context)?)?; + Ok(Some(path)) +} + +fn build_harness_context( + params: &AgentGrepInput, + ctx: &ToolContext, +) -> Option<AgentGrepHarnessContext> { + let session = Session::load(&ctx.session_id).ok()?; + let observations = collect_tool_exposures(&session); + let search_root = params + .path + .as_deref() + .map(|path| resolve_path_arg(ctx, path)) + .or_else(|| ctx.working_dir.clone())?; + let total_messages = session.messages.len().max(1); + let compaction_cutoff = session + .compaction + .as_ref() + .map(|state| state.covers_up_to_turn.min(total_messages)); + let mut file_mtime_cache = HashMap::new(); + + let mut context = AgentGrepHarnessContext { + version: 1, + ..Default::default() + }; + let mut focus = HashSet::new(); + + for observation in observations { + let exposure = ExposureDescriptor { + timestamp: observation.timestamp, + message_index: observation.message_index, + total_messages, + compaction_cutoff, + }; + match observation.tool.name.as_str() { + "read" => collect_read_exposure( + &observation.tool, + &search_root, + ctx, + &mut context, + &mut focus, + exposure, + &mut file_mtime_cache, + ), + "agentgrep" => collect_agentgrep_exposure( + &observation.tool, + &observation.content, + &search_root, + ctx, + &mut context, + &mut focus, + exposure, + &mut file_mtime_cache, + ), + "bash" => collect_bash_exposure( + &observation.tool, + &observation.content, + &search_root, + ctx, + &mut context, + &mut focus, + exposure, + &mut file_mtime_cache, + ), + _ => {} + } + } + + let mut focus_files = focus.into_iter().collect::<Vec<_>>(); + focus_files.sort(); + context.focus_files = focus_files; + if context.known_regions.is_empty() + && context.known_files.is_empty() + && context.known_symbols.is_empty() + && context.focus_files.is_empty() + { + None + } else { + Some(context) + } +} + +fn collect_tool_exposures(session: &Session) -> Vec<ToolExposureObservation> { + let mut observations = Vec::new(); + let mut tool_map: HashMap<String, ToolCall> = HashMap::new(); + + for (message_index, msg) in session.messages.iter().enumerate() { + for block in &msg.content { + match block { + ContentBlock::ToolUse { + id, name, input, .. + } => { + tool_map.insert( + id.clone(), + ToolCall { + id: id.clone(), + name: name.clone(), + input: input.clone(), + intent: None, + thought_signature: None, + }, + ); + } + ContentBlock::ToolResult { + tool_use_id, + content, + .. + } => { + let tool = tool_map + .get(tool_use_id) + .cloned() + .unwrap_or_else(|| ToolCall { + id: tool_use_id.clone(), + name: "tool".to_string(), + input: Value::Null, + intent: None, + thought_signature: None, + }); + observations.push(ToolExposureObservation { + tool, + content: content.clone(), + timestamp: msg.timestamp, + message_index, + }); + } + _ => {} + } + } + } + + observations +} + +fn collect_read_exposure( + tool: &ToolCall, + search_root: &Path, + ctx: &ToolContext, + context: &mut AgentGrepHarnessContext, + focus: &mut HashSet<String>, + exposure: ExposureDescriptor, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + let Some(file_path) = tool.input.get("file_path").and_then(|value| value.as_str()) else { + return; + }; + let Some(path) = normalize_context_path(file_path, search_root, ctx) else { + return; + }; + let (start_line, end_line) = normalize_read_range_from_tool_input(&tool.input); + focus.insert(path.clone()); + let region = tune_known_region( + AgentGrepKnownRegion { + path: path.clone(), + start_line, + end_line, + body_confidence: 0.85, + current_version_confidence: 0.88, + prune_confidence: 0.78, + source_strength: "full_region", + reasons: vec!["read_tool_exposure", "session_local_history"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_region(context, region); + let file = tune_known_file( + AgentGrepKnownFile { + path, + structure_confidence: 0.55, + body_confidence: 0.45, + current_version_confidence: 0.88, + prune_confidence: 0.4, + source_strength: "snippet", + reasons: vec!["read_tool_exposure"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_file(context, file); +} + +#[expect( + clippy::too_many_arguments, + reason = "agentgrep exposure collection needs tool payload, content, search root, context, focus set, exposure metadata, and mtime cache" +)] +fn collect_agentgrep_exposure( + tool: &ToolCall, + content: &str, + search_root: &Path, + ctx: &ToolContext, + context: &mut AgentGrepHarnessContext, + focus: &mut HashSet<String>, + exposure: ExposureDescriptor, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + let Some(mode) = tool.input.get("mode").and_then(|value| value.as_str()) else { + return; + }; + match mode { + "outline" => { + let file = tool + .input + .get("file") + .and_then(|value| value.as_str()) + .or_else(|| tool.input.get("query").and_then(|value| value.as_str())); + let Some(file) = file else { + return; + }; + let Some(path) = normalize_context_path(file, search_root, ctx) else { + return; + }; + focus.insert(path.clone()); + let known = tune_known_file( + AgentGrepKnownFile { + path: path.clone(), + structure_confidence: 0.95, + body_confidence: 0.15, + current_version_confidence: 0.82, + prune_confidence: 0.86, + source_strength: "outline_only", + reasons: vec!["agentgrep_outline_result"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_file(context, known); + collect_outline_symbols( + content, + &path, + context, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + } + "trace" | "smart" => { + if let Some(path_hint) = tool.input.get("path").and_then(|value| value.as_str()) + && let Some(path) = normalize_context_path(path_hint, search_root, ctx) + { + focus.insert(path); + } + collect_trace_exposure( + content, + search_root, + ctx, + context, + focus, + exposure, + file_mtime_cache, + ); + } + _ => {} + } +} + +#[expect( + clippy::too_many_arguments, + reason = "bash exposure collection needs tool payload, output content, search root, context, focus set, exposure metadata, and mtime cache" +)] +pub(super) fn collect_bash_exposure( + tool: &ToolCall, + content: &str, + search_root: &Path, + ctx: &ToolContext, + context: &mut AgentGrepHarnessContext, + focus: &mut HashSet<String>, + exposure: ExposureDescriptor, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + let Some(command) = tool.input.get("command").and_then(|value| value.as_str()) else { + return; + }; + + if let Some(path) = parse_sed_file_range(command).and_then(|(path, start_line, end_line)| { + normalize_context_path(&path, search_root, ctx) + .map(|normalized| (normalized, start_line, end_line)) + }) { + let (path, start_line, end_line) = path; + focus.insert(path.clone()); + let region = tune_known_region( + AgentGrepKnownRegion { + path, + start_line, + end_line, + body_confidence: 0.78, + current_version_confidence: 0.7, + prune_confidence: 0.7, + source_strength: "snippet", + reasons: vec!["bash_sed_exposure"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_region(context, region); + } + + for candidate in parse_cat_files(command) + .into_iter() + .chain(parse_git_show_files(command).into_iter()) + .chain(parse_git_diff_files(command).into_iter()) + { + let Some(path) = normalize_context_path(&candidate, search_root, ctx) else { + continue; + }; + focus.insert(path.clone()); + let known = tune_known_file( + AgentGrepKnownFile { + path, + structure_confidence: 0.5, + body_confidence: 0.72, + current_version_confidence: 0.72, + prune_confidence: 0.55, + source_strength: "full_file", + reasons: vec!["bash_file_exposure"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_file(context, known); + } + + collect_shell_output_path_exposure( + content, + search_root, + ctx, + context, + focus, + exposure, + file_mtime_cache, + ); +} + +fn normalize_context_path(path: &str, search_root: &Path, ctx: &ToolContext) -> Option<String> { + let path = path.trim().trim_matches('"').trim_matches('\''); + let path = path.strip_prefix("./").unwrap_or(path); + let resolved = ctx.resolve_path(Path::new(path)); + if let Ok(relative) = resolved.strip_prefix(search_root) { + return Some(relative.display().to_string()); + } + if Path::new(path).is_relative() { + return Some(path.to_string()); + } + None +} + +fn normalize_read_range_from_tool_input(input: &Value) -> (usize, usize) { + if let Some(start_line) = input.get("start_line").and_then(|value| value.as_u64()) { + let start_line = start_line as usize; + let end_line = input + .get("end_line") + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or( + start_line + .saturating_add( + input + .get("limit") + .and_then(|value| value.as_u64()) + .unwrap_or(200) as usize, + ) + .saturating_sub(1), + ); + return (start_line.max(1), end_line.max(start_line.max(1))); + } + let offset = input + .get("offset") + .and_then(|value| value.as_u64()) + .unwrap_or(0) as usize; + let limit = input + .get("limit") + .and_then(|value| value.as_u64()) + .unwrap_or(200) as usize; + let start_line = offset + 1; + let end_line = start_line + limit.saturating_sub(1); + (start_line, end_line) +} + +fn collect_outline_symbols( + content: &str, + path: &str, + context: &mut AgentGrepHarnessContext, + exposure: ExposureDescriptor, + search_root: &Path, + ctx: &ToolContext, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + for (kind, label, _start_line, _end_line) in parse_structure_items(content) { + let symbol = tune_known_symbol( + AgentGrepKnownSymbol { + path: path.to_string(), + symbol: label, + kind: Some(kind), + structure_confidence: 0.92, + body_confidence: 0.1, + current_version_confidence: 0.82, + prune_confidence: 0.8, + source_strength: "outline_only", + reasons: vec!["agentgrep_outline_structure"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_symbol(context, symbol); + } +} + +pub(super) fn collect_trace_exposure( + content: &str, + search_root: &Path, + ctx: &ToolContext, + context: &mut AgentGrepHarnessContext, + focus: &mut HashSet<String>, + exposure: ExposureDescriptor, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + let mut current_file: Option<String> = None; + let mut section: Option<&str> = None; + let mut pending_region: Option<PendingTraceRegion> = None; + + for raw_line in content.lines() { + let line = raw_line.trim_end(); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(path) = parse_ranked_file_header(trimmed) { + current_file = Some(path.clone()); + focus.insert(path.clone()); + let known = tune_known_file( + AgentGrepKnownFile { + path, + structure_confidence: 0.72, + body_confidence: 0.2, + current_version_confidence: 0.78, + prune_confidence: 0.62, + source_strength: "trace_summary", + reasons: vec!["agentgrep_trace_file"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_file(context, known); + section = None; + pending_region = None; + continue; + } + if let Some(best_file) = trimmed.strip_prefix("best answer likely in ") { + if let Some(path) = normalize_context_path(best_file.trim(), search_root, ctx) { + focus.insert(path); + } + continue; + } + match trimmed { + "structure:" => { + section = Some("structure"); + pending_region = None; + continue; + } + "regions:" => { + section = Some("regions"); + pending_region = None; + continue; + } + _ => {} + } + + let Some(file_path) = current_file.clone() else { + continue; + }; + + if section == Some("structure") { + if let Some((kind, label, _start_line, _end_line)) = parse_structure_item_line(trimmed) + { + let symbol = tune_known_symbol( + AgentGrepKnownSymbol { + path: file_path, + symbol: label, + kind: Some(kind), + structure_confidence: 0.82, + body_confidence: 0.12, + current_version_confidence: 0.78, + prune_confidence: 0.66, + source_strength: "trace_structure", + reasons: vec!["agentgrep_trace_structure"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_symbol(context, symbol); + } + continue; + } + + if section == Some("regions") { + if let Some((label, start_line, end_line)) = parse_region_header_line(trimmed) { + pending_region = Some(PendingTraceRegion { + path: file_path.clone(), + kind: None, + start_line, + end_line, + }); + let symbol = tune_known_symbol( + AgentGrepKnownSymbol { + path: file_path, + symbol: label, + kind: None, + structure_confidence: 0.86, + body_confidence: 0.28, + current_version_confidence: 0.8, + prune_confidence: 0.68, + source_strength: "trace_region", + reasons: vec!["agentgrep_trace_region_header"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_symbol(context, symbol); + continue; + } + if let Some(kind) = trimmed.strip_prefix("kind: ") { + if let Some(region) = pending_region.as_mut() { + region.kind = Some(leak_str(kind.trim().to_string())); + } + continue; + } + if (trimmed == "full region:" || trimmed == "snippet:") + && let Some(region) = pending_region.take() + { + let profile = if trimmed == "full region:" { + RegionConfidenceProfile { + body_confidence: 0.9, + current_version_confidence: 0.72, + prune_confidence: 0.82, + source_strength: "full_region", + } + } else { + RegionConfidenceProfile { + body_confidence: 0.48, + current_version_confidence: 0.72, + prune_confidence: 0.52, + source_strength: "snippet", + } + }; + let region = tune_known_region( + AgentGrepKnownRegion { + path: region.path, + start_line: region.start_line, + end_line: region.end_line, + body_confidence: profile.body_confidence, + current_version_confidence: profile.current_version_confidence, + prune_confidence: profile.prune_confidence, + source_strength: profile.source_strength, + reasons: vec!["agentgrep_trace_region_body"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_region(context, region); + } + } + } +} + +fn collect_shell_output_path_exposure( + content: &str, + search_root: &Path, + ctx: &ToolContext, + context: &mut AgentGrepHarnessContext, + focus: &mut HashSet<String>, + exposure: ExposureDescriptor, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + for (path, line_number) in parse_path_line_hits(content) { + let Some(path) = normalize_context_path(&path, search_root, ctx) else { + continue; + }; + focus.insert(path.clone()); + let file = tune_known_file( + AgentGrepKnownFile { + path: path.clone(), + structure_confidence: 0.28, + body_confidence: 0.22, + current_version_confidence: 0.68, + prune_confidence: 0.18, + source_strength: "match_line_only", + reasons: vec!["bash_output_file_hit"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_file(context, file); + let region = tune_known_region( + AgentGrepKnownRegion { + path, + start_line: line_number, + end_line: line_number, + body_confidence: 0.26, + current_version_confidence: 0.68, + prune_confidence: 0.2, + source_strength: "match_line_only", + reasons: vec!["bash_output_line_hit"], + }, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + push_known_region(context, region); + } +} + +pub(super) fn tune_known_file( + mut known: AgentGrepKnownFile, + exposure: ExposureDescriptor, + search_root: &Path, + ctx: &ToolContext, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) -> AgentGrepKnownFile { + apply_exposure_tuning( + Some(&mut known.structure_confidence), + &mut known.body_confidence, + &mut known.current_version_confidence, + &mut known.prune_confidence, + &mut known.reasons, + &known.path, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + known +} + +pub(super) fn tune_known_region( + mut known: AgentGrepKnownRegion, + exposure: ExposureDescriptor, + search_root: &Path, + ctx: &ToolContext, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) -> AgentGrepKnownRegion { + apply_exposure_tuning( + None, + &mut known.body_confidence, + &mut known.current_version_confidence, + &mut known.prune_confidence, + &mut known.reasons, + &known.path, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + known +} + +fn tune_known_symbol( + mut known: AgentGrepKnownSymbol, + exposure: ExposureDescriptor, + search_root: &Path, + ctx: &ToolContext, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) -> AgentGrepKnownSymbol { + apply_exposure_tuning( + Some(&mut known.structure_confidence), + &mut known.body_confidence, + &mut known.current_version_confidence, + &mut known.prune_confidence, + &mut known.reasons, + &known.path, + exposure, + search_root, + ctx, + file_mtime_cache, + ); + known +} + +#[expect( + clippy::too_many_arguments, + reason = "exposure tuning uses several confidence outputs plus exposure metadata and file freshness cache" +)] +fn apply_exposure_tuning( + structure_confidence: Option<&mut f32>, + body_confidence: &mut f32, + current_version_confidence: &mut f32, + prune_confidence: &mut f32, + reasons: &mut Vec<&'static str>, + path: &str, + exposure: ExposureDescriptor, + search_root: &Path, + ctx: &ToolContext, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) { + let position_ratio = if exposure.total_messages <= 1 { + 1.0 + } else { + (exposure.message_index + 1) as f32 / exposure.total_messages as f32 + }; + let memory_multiplier = if exposure + .compaction_cutoff + .is_some_and(|cutoff| exposure.message_index < cutoff) + { + merge_reasons(reasons, vec!["compacted_history"]); + 0.42 + } else if position_ratio >= 0.85 { + merge_reasons(reasons, vec!["active_context_tail"]); + 1.0 + } else if position_ratio >= 0.6 { + merge_reasons(reasons, vec!["recent_context"]); + 0.88 + } else { + merge_reasons(reasons, vec!["older_context"]); + 0.72 + }; + + if let Some(structure_confidence) = structure_confidence { + *structure_confidence = + (*structure_confidence * (0.75 + 0.25 * memory_multiplier)).clamp(0.0, 1.0); + } + *body_confidence = (*body_confidence * memory_multiplier).clamp(0.0, 1.0); + *prune_confidence = (*prune_confidence * memory_multiplier).clamp(0.0, 1.0); + + let freshness_multiplier = + file_freshness_multiplier(path, exposure.timestamp, search_root, ctx, file_mtime_cache); + if freshness_multiplier < 0.999 { + merge_reasons(reasons, vec!["file_changed_since_seen"]); + } else if exposure.timestamp.is_some() { + merge_reasons(reasons, vec!["file_unchanged_since_seen"]); + } + *current_version_confidence = + (*current_version_confidence * freshness_multiplier).clamp(0.0, 1.0); +} + +fn file_freshness_multiplier( + path: &str, + exposure_time: Option<DateTime<Utc>>, + search_root: &Path, + ctx: &ToolContext, + file_mtime_cache: &mut HashMap<String, Option<DateTime<Utc>>>, +) -> f32 { + let Some(exposure_time) = exposure_time else { + return 0.7; + }; + + let modified_at = file_mtime_cache + .entry(path.to_string()) + .or_insert_with(|| file_modified_at(path, search_root, ctx)) + .to_owned(); + let Some(modified_at) = modified_at else { + return 0.72; + }; + if modified_at <= exposure_time { + return 1.0; + } + + let delta = modified_at.signed_duration_since(exposure_time); + if delta.num_seconds() <= 5 { + 0.92 + } else if delta.num_minutes() <= 10 { + 0.68 + } else if delta.num_hours() <= 6 { + 0.45 + } else { + 0.25 + } +} + +fn file_modified_at(path: &str, search_root: &Path, ctx: &ToolContext) -> Option<DateTime<Utc>> { + let candidate = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + let resolved = ctx.resolve_path(Path::new(path)); + if resolved.starts_with(search_root) { + resolved + } else { + search_root.join(path) + } + }; + let modified = std::fs::metadata(candidate).ok()?.modified().ok()?; + Some(DateTime::<Utc>::from(modified)) +} + +fn push_known_file(context: &mut AgentGrepHarnessContext, known: AgentGrepKnownFile) { + if let Some(existing) = context + .known_files + .iter_mut() + .find(|entry| entry.path == known.path) + { + existing.structure_confidence = existing + .structure_confidence + .max(known.structure_confidence); + existing.body_confidence = existing.body_confidence.max(known.body_confidence); + existing.current_version_confidence = existing + .current_version_confidence + .max(known.current_version_confidence); + existing.prune_confidence = existing.prune_confidence.max(known.prune_confidence); + merge_reasons(&mut existing.reasons, known.reasons); + return; + } + context.known_files.push(known); +} + +fn push_known_region(context: &mut AgentGrepHarnessContext, known: AgentGrepKnownRegion) { + if let Some(existing) = context.known_regions.iter_mut().find(|entry| { + entry.path == known.path + && entry.start_line == known.start_line + && entry.end_line == known.end_line + }) { + existing.body_confidence = existing.body_confidence.max(known.body_confidence); + existing.current_version_confidence = existing + .current_version_confidence + .max(known.current_version_confidence); + existing.prune_confidence = existing.prune_confidence.max(known.prune_confidence); + merge_reasons(&mut existing.reasons, known.reasons); + return; + } + context.known_regions.push(known); +} + +fn push_known_symbol(context: &mut AgentGrepHarnessContext, known: AgentGrepKnownSymbol) { + if let Some(existing) = context.known_symbols.iter_mut().find(|entry| { + entry.path == known.path && entry.symbol == known.symbol && entry.kind == known.kind + }) { + existing.structure_confidence = existing + .structure_confidence + .max(known.structure_confidence); + existing.body_confidence = existing.body_confidence.max(known.body_confidence); + existing.current_version_confidence = existing + .current_version_confidence + .max(known.current_version_confidence); + existing.prune_confidence = existing.prune_confidence.max(known.prune_confidence); + merge_reasons(&mut existing.reasons, known.reasons); + return; + } + context.known_symbols.push(known); +} + +fn merge_reasons(existing: &mut Vec<&'static str>, new_reasons: Vec<&'static str>) { + for reason in new_reasons { + if !existing.contains(&reason) { + existing.push(reason); + } + } +} + +fn parse_structure_items(content: &str) -> Vec<(&'static str, String, usize, usize)> { + content + .lines() + .filter_map(|line| parse_structure_item_line(line.trim())) + .collect() +} + +fn parse_structure_item_line(line: &str) -> Option<(&'static str, String, usize, usize)> { + static STRUCTURE_ITEM_RE: OnceLock<Option<Regex>> = OnceLock::new(); + let captures = STRUCTURE_ITEM_RE + .get_or_init(|| Regex::new(r"^-\s+([A-Za-z0-9_-]+)\s+(.+?)\s+@\s*(\d+)-(\d+)").ok()) + .as_ref()? + .captures(line)?; + let kind = captures.get(1)?.as_str(); + let label = captures.get(2)?.as_str().trim().to_string(); + let start_line = captures.get(3)?.as_str().parse().ok()?; + let end_line = captures.get(4)?.as_str().parse().ok()?; + Some((leak_str(kind.to_string()), label, start_line, end_line)) +} + +fn parse_ranked_file_header(line: &str) -> Option<String> { + static FILE_HEADER_RE: OnceLock<Option<Regex>> = OnceLock::new(); + FILE_HEADER_RE + .get_or_init(|| Regex::new(r"^\d+\.\s+(.+)$").ok()) + .as_ref()? + .captures(line) + .and_then(|captures| { + captures + .get(1) + .map(|value| value.as_str().trim().to_string()) + }) +} + +fn parse_region_header_line(line: &str) -> Option<(String, usize, usize)> { + static REGION_HEADER_RE: OnceLock<Option<Regex>> = OnceLock::new(); + let captures = REGION_HEADER_RE + .get_or_init(|| Regex::new(r"^-\s+(.+?)\s+@\s*(\d+)-(\d+)").ok()) + .as_ref()? + .captures(line)?; + let label = captures.get(1)?.as_str().trim().to_string(); + let start_line = captures.get(2)?.as_str().parse().ok()?; + let end_line = captures.get(3)?.as_str().parse().ok()?; + Some((label, start_line, end_line)) +} + +fn parse_sed_file_range(command: &str) -> Option<(String, usize, usize)> { + static SED_RE: OnceLock<Option<Regex>> = OnceLock::new(); + let captures = SED_RE + .get_or_init(|| { + Regex::new(r#"sed\s+-n\s+['"]?(\d+),(\d+)p['"]?\s+(?:"([^"]+)"|'([^']+)'|([^\s|;]+))"#) + .ok() + }) + .as_ref()? + .captures(command)?; + let start_line = captures.get(1)?.as_str().parse().ok()?; + let end_line = captures.get(2)?.as_str().parse().ok()?; + let path = captures + .get(3) + .or_else(|| captures.get(4)) + .or_else(|| captures.get(5))? + .as_str() + .to_string(); + Some((path, start_line, end_line)) +} + +fn parse_cat_files(command: &str) -> Vec<String> { + static CAT_RE: OnceLock<Option<Regex>> = OnceLock::new(); + CAT_RE + .get_or_init(|| { + Regex::new(r#"(?:^|[;&|]\s*)cat\s+(?:"([^"]+)"|'([^']+)'|([^\s|;]+))"#).ok() + }) + .as_ref() + .map(|regex| { + regex + .captures_iter(command) + .filter_map(|captures| { + captures + .get(1) + .or_else(|| captures.get(2)) + .or_else(|| captures.get(3)) + .map(|value| value.as_str().to_string()) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_git_show_files(command: &str) -> Vec<String> { + static GIT_SHOW_RE: OnceLock<Option<Regex>> = OnceLock::new(); + GIT_SHOW_RE + .get_or_init(|| { + Regex::new(r#"git\s+show\s+[^:\s]+:(?:"([^"]+)"|'([^']+)'|([^\s|;]+))"#).ok() + }) + .as_ref() + .map(|regex| { + regex + .captures_iter(command) + .filter_map(|captures| { + captures + .get(1) + .or_else(|| captures.get(2)) + .or_else(|| captures.get(3)) + .map(|value| value.as_str().to_string()) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_git_diff_files(command: &str) -> Vec<String> { + static GIT_DIFF_RE: OnceLock<Option<Regex>> = OnceLock::new(); + GIT_DIFF_RE + .get_or_init(|| { + Regex::new(r#"git\s+diff(?:\s+[^\n]*)?\s+--\s+(?:"([^"]+)"|'([^']+)'|([^\s|;]+))"#).ok() + }) + .as_ref() + .map(|regex| { + regex + .captures_iter(command) + .filter_map(|captures| { + captures + .get(1) + .or_else(|| captures.get(2)) + .or_else(|| captures.get(3)) + .map(|value| value.as_str().to_string()) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_path_line_hits(content: &str) -> Vec<(String, usize)> { + static PATH_LINE_RE: OnceLock<Option<Regex>> = OnceLock::new(); + PATH_LINE_RE + .get_or_init(|| Regex::new(r"(?m)^([^:\n]+):(\d+):").ok()) + .as_ref() + .map(|regex| { + regex + .captures_iter(content) + .filter_map(|captures| { + let path = captures.get(1)?.as_str().trim().to_string(); + let line_number = captures.get(2)?.as_str().parse().ok()?; + Some((path, line_number)) + }) + .collect() + }) + .unwrap_or_default() +} + +fn leak_str(value: String) -> &'static str { + Box::leak(value.into_boxed_str()) +} diff --git a/crates/jcode-app-core/src/tool/agentgrep_tests.rs b/crates/jcode-app-core/src/tool/agentgrep_tests.rs new file mode 100644 index 0000000..12d87f1 --- /dev/null +++ b/crates/jcode-app-core/src/tool/agentgrep_tests.rs @@ -0,0 +1,876 @@ +use super::*; +use chrono::Duration; +use std::fs; + +fn test_ctx(root: &Path) -> ToolContext { + ToolContext { + session_id: "test".to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(root.to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: super::super::ToolExecutionMode::Direct, + } +} + +fn test_exposure(message_index: usize, total_messages: usize) -> ExposureDescriptor { + ExposureDescriptor { + timestamp: Some(Utc::now()), + message_index, + total_messages, + compaction_cutoff: None, + } +} + +fn grep_input(query: &str, max_regions: Option<usize>) -> AgentGrepInput { + AgentGrepInput { + mode: "grep".to_string(), + query: Some(query.to_string()), + file: None, + terms: None, + regex: Some(false), + path: None, + glob: None, + file_type: None, + hidden: None, + no_ignore: None, + max_files: None, + max_regions, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + } +} + +#[test] +fn agentgrep_rejects_missing_session_cwd_instead_of_using_process_cwd() { + let mut ctx = test_ctx(Path::new("/unused")); + ctx.working_dir = None; + + let error = run_agentgrep_blocking(&grep_input("needle", None), &ctx) + .expect_err("workspace search without a session cwd must fail"); + + assert!(error.to_string().contains("session working directory")); +} + +#[test] +fn render_compacts_huge_grep_match_lines() { + let args = GrepArgs { + query: "set_status_notice".to_string(), + regex: false, + file_type: None, + json: false, + paths_only: false, + hidden: false, + no_ignore: false, + path: None, + glob: None, + }; + let line = format!( + "{{\"output\":\"{}set_status_notice{}\"}}", + "a".repeat(800), + "b".repeat(800) + ); + + let compact = ::agentgrep::render::compact_rendered_match_line(&line, &args); + + assert!(compact.contains("set_status_notice")); + assert!(compact.contains("[truncated:"), "{compact}"); + assert!( + compact.chars().count() < 340, + "compact output should be bounded, got {} chars: {compact}", + compact.chars().count() + ); +} + +#[test] +fn render_compacts_huge_trace_region_body_lines() { + let line = format!("function handleAuth(){{{}}}", "var x=1;".repeat(2000)); + + let compact = ::agentgrep::render::compact_region_body_line(&line); + + assert!(compact.contains("[truncated:"), "{compact}"); + assert!( + compact.chars().count() < 340, + "compact region body line should be bounded, got {} chars", + compact.chars().count() + ); + + let short = "fn small() {}"; + assert_eq!(::agentgrep::render::compact_region_body_line(short), short); +} + +#[test] +fn grep_max_regions_limits_rendered_match_excerpts() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write( + temp.path().join("a.rs"), + "fn one() { status_notice(); }\nfn two() { status_notice(); }\nfn three() { status_notice(); }\n", + ) + .expect("write file"); + + let output = execute_linked_agentgrep( + &grep_input("status_notice", Some(2)), + &test_ctx(temp.path()), + None, + ) + .expect("agentgrep execute") + .output; + + assert_eq!(output.matches(" - @ ").count(), 2, "{output}"); + assert!( + output.contains("1 more matches omitted (max_regions=2)"), + "{output}" + ); +} + +#[test] +fn grep_caps_non_code_file_match_excerpts_by_default() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write( + temp.path().join("timeline.json"), + (0..5) + .map(|idx| format!("{{\"event\":\"status_notice {idx}\"}}\n")) + .collect::<String>(), + ) + .expect("write file"); + + let output = execute_linked_agentgrep( + &grep_input("status_notice", None), + &test_ctx(temp.path()), + None, + ) + .expect("agentgrep execute") + .output; + + assert_eq!(output.matches(" - @ ").count(), 3, "{output}"); + assert!( + output.contains("2 more non-code matches omitted"), + "{output}" + ); +} + +#[test] +fn build_grep_args_includes_scope_flags() { + let ctx = test_ctx(Path::new("/tmp/root")); + let params = AgentGrepInput { + mode: "grep".to_string(), + query: Some("auth_status".to_string()), + file: None, + terms: None, + regex: Some(true), + path: Some("src".to_string()), + glob: Some("src/**/*.rs".to_string()), + file_type: Some("rs".to_string()), + hidden: Some(true), + no_ignore: Some(true), + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: Some(true), + }; + + let args = build_grep_args(¶ms, &ctx).unwrap(); + assert_eq!(args.query, "auth_status"); + assert!(args.regex); + assert_eq!(args.file_type.as_deref(), Some("rs")); + assert!(args.paths_only); + assert!(args.hidden); + assert!(args.no_ignore); + assert_eq!(args.path.as_deref(), Some("/tmp/root/src")); + assert_eq!(args.glob.as_deref(), Some("src/**/*.rs")); +} + +#[test] +fn build_grep_args_drops_match_all_glob() { + let ctx = test_ctx(Path::new("/tmp/root")); + let params = AgentGrepInput { + mode: "grep".to_string(), + query: Some("agentgrep".to_string()), + file: None, + terms: None, + regex: Some(false), + path: Some(".".to_string()), + glob: Some("**/*".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 args = build_grep_args(¶ms, &ctx).unwrap(); + assert_eq!(args.query, "agentgrep"); + assert_eq!(args.file_type.as_deref(), Some("rs")); + assert_eq!(args.path.as_deref(), Some("/tmp/root/.")); + assert_eq!(args.glob, None); +} + +#[test] +fn build_grep_args_scopes_file_path_to_parent_and_exact_glob() { + 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: None, + terms: None, + regex: Some(false), + path: Some("src/app.rs".to_string()), + 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 args = build_grep_args(¶ms, &ctx).unwrap(); + assert_eq!( + args.path.as_deref(), + Some(temp.path().join("src").to_string_lossy().as_ref()) + ); + assert_eq!(args.glob.as_deref(), Some("app.rs")); +} + +#[test] +fn build_find_args_allows_glob_only_search() { + let ctx = test_ctx(Path::new("/tmp/root")); + let params = AgentGrepInput { + mode: "find".to_string(), + query: None, + file: None, + terms: None, + regex: None, + path: Some(".".to_string()), + glob: Some("**/*release*".to_string()), + file_type: None, + hidden: None, + no_ignore: None, + max_files: Some(25), + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: Some(true), + }; + + let args = build_find_args(¶ms, &ctx).expect("glob-only find should be valid"); + assert!(args.query_parts.is_empty()); + assert_eq!(args.path.as_deref(), Some("/tmp/root/.")); + assert_eq!(args.glob.as_deref(), Some("**/*release*")); + assert_eq!(args.max_files, 25); + assert!(args.paths_only); +} + +#[test] +fn build_find_args_still_rejects_unscoped_empty_query() { + let ctx = test_ctx(Path::new("/tmp/root")); + let params = AgentGrepInput { + mode: "find".to_string(), + query: None, + file: None, + terms: None, + regex: None, + path: None, + glob: None, + file_type: None, + hidden: None, + no_ignore: None, + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + }; + + let error = build_find_args(¶ms, &ctx).unwrap_err(); + assert_eq!( + error.to_string(), + "agentgrep find requires 'query' unless path, glob, or type narrows the search" + ); +} + +#[test] +fn build_smart_args_uses_terms() { + let ctx = test_ctx(Path::new("/workspace")); + let params = AgentGrepInput { + mode: "smart".to_string(), + query: None, + file: None, + terms: Some(vec![ + "subject:auth_status".to_string(), + "relation:rendered".to_string(), + "path:src/tui".to_string(), + ]), + regex: None, + path: Some("repo".to_string()), + glob: None, + file_type: Some("rs".to_string()), + hidden: None, + no_ignore: None, + max_files: Some(3), + max_regions: Some(4), + full_region: Some("auto".to_string()), + debug_plan: Some(true), + debug_score: Some(true), + paths_only: None, + }; + + let (args, query) = build_smart_args_and_query(¶ms, &ctx, None).unwrap(); + assert_eq!( + args.terms, + vec!["subject:auth_status", "relation:rendered", "path:src/tui"] + ); + assert_eq!(args.max_files, 3); + assert_eq!(args.max_regions, 4); + assert!(matches!(args.full_region, FullRegionMode::Auto)); + assert!(args.debug_plan); + assert!(args.debug_score); + assert_eq!(args.file_type.as_deref(), Some("rs")); + assert_eq!(args.path.as_deref(), Some("/workspace/repo")); + assert_eq!(query.subject, "auth_status"); + assert_eq!(query.relation.as_str(), "rendered"); + assert_eq!(query.path_hint.as_deref(), Some("src/tui")); +} + +#[test] +fn build_smart_args_falls_back_to_query_terms() { + let ctx = test_ctx(Path::new("/workspace")); + let params = AgentGrepInput { + mode: "smart".to_string(), + query: Some( + "subject:auth_status relation:rendered path:src/tui support:current".to_string(), + ), + file: None, + terms: None, + regex: None, + path: Some("repo".to_string()), + glob: None, + file_type: Some("rs".to_string()), + hidden: None, + no_ignore: None, + max_files: Some(3), + max_regions: Some(4), + full_region: Some("auto".to_string()), + debug_plan: Some(true), + debug_score: Some(true), + paths_only: None, + }; + + let (args, _query) = build_smart_args_and_query(¶ms, &ctx, None).unwrap(); + assert_eq!( + args.terms, + vec![ + "subject:auth_status", + "relation:rendered", + "path:src/tui", + "support:current" + ] + ); +} + +#[test] +fn build_args_for_trace_still_requires_terms() { + let params = AgentGrepInput { + mode: "trace".to_string(), + query: Some("subject:auth_status relation:rendered".to_string()), + file: None, + terms: None, + regex: None, + path: None, + glob: None, + file_type: None, + hidden: None, + no_ignore: None, + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + }; + + let error = trace_or_smart_terms_owned(¶ms).unwrap_err(); + assert_eq!( + error.to_string(), + "agentgrep trace requires non-empty 'terms'" + ); +} + +#[test] +fn schema_only_advertises_common_public_fields() { + let schema = AgentGrepTool::new().parameters_schema(); + let props = schema["properties"] + .as_object() + .expect("agentgrep schema should have properties"); + let required = schema["required"].as_array().cloned().unwrap_or_default(); + let mode_enum = props["mode"]["enum"] + .as_array() + .expect("agentgrep mode should expose enum values"); + + assert!( + !required.contains(&json!("mode")), + "agentgrep mode should be optional because omitted mode defaults to grep" + ); + assert!(props.contains_key("mode")); + assert!(props.contains_key("query")); + assert!(props.contains_key("file")); + assert!(props.contains_key("terms")); + assert!(props.contains_key("regex")); + assert!(props.contains_key("path")); + assert!(props.contains_key("glob")); + assert!(props.contains_key("type")); + assert!(props.contains_key("max_files")); + assert!(props.contains_key("max_regions")); + assert!(props.contains_key("paths_only")); + assert_eq!( + mode_enum, + &vec![ + json!("grep"), + json!("find"), + json!("outline"), + json!("trace") + ] + ); + assert!(!props.contains_key("hidden")); + assert!(!props.contains_key("no_ignore")); + assert!(!props.contains_key("full_region")); + assert!(!props.contains_key("debug_plan")); + assert!(!props.contains_key("debug_score")); +} + +#[test] +fn input_defaults_missing_mode_to_grep() { + let params: AgentGrepInput = serde_json::from_value(json!({ + "query": "auth_status", + "path": "src" + })) + .expect("agentgrep input without mode should deserialize"); + + assert_eq!(params.mode, "grep"); + assert_eq!(params.query.as_deref(), Some("auth_status")); +} + +#[test] +fn build_outline_args_accepts_file_field() { + let ctx = test_ctx(Path::new("/workspace")); + let params = AgentGrepInput { + mode: "outline".to_string(), + query: None, + file: Some("src/tool/agentgrep.rs".to_string()), + terms: None, + regex: None, + path: Some("repo".to_string()), + glob: None, + file_type: None, + hidden: None, + no_ignore: None, + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + }; + + let args = build_outline_args(¶ms, &ctx, None).unwrap(); + assert_eq!(args.file, "src/tool/agentgrep.rs"); + assert_eq!(args.path.as_deref(), Some("/workspace/repo")); +} + +#[test] +fn input_accepts_file_path_alias_for_file() { + let params: AgentGrepInput = serde_json::from_value(json!({ + "mode": "outline", + "file_path": "src/app.rs" + })) + .expect("agentgrep input with file_path should deserialize"); + + assert_eq!(params.file.as_deref(), Some("src/app.rs")); +} + +#[test] +fn build_outline_args_treats_file_valued_path_as_outline_target() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write(temp.path().join("app.rs"), "fn main() {}\n").expect("write file"); + let ctx = test_ctx(temp.path()); + + let params = AgentGrepInput { + mode: "outline".to_string(), + query: Some("fn".to_string()), + file: None, + terms: None, + regex: None, + path: Some("app.rs".to_string()), + glob: None, + file_type: None, + hidden: None, + no_ignore: None, + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + }; + + let args = build_outline_args(¶ms, &ctx, None).unwrap(); + assert_eq!( + args.file, + temp.path().join("app.rs").display().to_string(), + "file-valued path should become the outline target instead of joining query onto it" + ); + assert_eq!(args.path, None); +} + +#[test] +fn build_outline_args_does_not_duplicate_file_valued_path_when_file_is_also_set() { + let temp = tempfile::tempdir().expect("tempdir"); + let relative_file = "src/tool/todo.rs"; + let absolute_file = temp.path().join(relative_file); + fs::create_dir_all(absolute_file.parent().expect("file parent")).expect("mkdir"); + fs::write(&absolute_file, "pub fn save_todos() {}\n").expect("write file"); + let ctx = test_ctx(temp.path()); + + let params = AgentGrepInput { + mode: "outline".to_string(), + query: None, + file: Some(relative_file.to_string()), + terms: None, + regex: None, + path: Some(relative_file.to_string()), + glob: None, + file_type: None, + hidden: None, + no_ignore: None, + max_files: None, + max_regions: None, + full_region: None, + debug_plan: None, + debug_score: None, + paths_only: None, + }; + + let args = build_outline_args(¶ms, &ctx, None).unwrap(); + assert_eq!(args.file, absolute_file.display().to_string()); + assert_eq!(args.path, None); +} + +#[tokio::test] +async fn execute_runs_linked_grep() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("mkdir"); + fs::write( + temp.path().join("src/app.rs"), + "pub fn auth_status() {}\nfn render_status_bar() {}\n", + ) + .expect("write file"); + + let tool = AgentGrepTool::new(); + let ctx = test_ctx(temp.path()); + let output = tool + .execute( + json!({"mode": "grep", "query": "auth_status", "path": ".", "type": "rs"}), + ctx, + ) + .await + .expect("tool output"); + assert!(output.output.contains("query: auth_status")); + assert!(output.output.contains("src/app.rs")); + assert!(output.output.contains("@ 1 pub fn auth_status() {}")); +} + +#[tokio::test] +async fn execute_runs_linked_grep_when_mode_is_omitted() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src")).expect("mkdir"); + fs::write(temp.path().join("src/app.rs"), "pub fn auth_status() {}\n").expect("write file"); + + let tool = AgentGrepTool::new(); + let ctx = test_ctx(temp.path()); + let output = tool + .execute(json!({"query": "auth_status", "path": "src"}), ctx) + .await + .expect("tool output"); + + assert!(output.output.contains("query: auth_status")); + assert!(output.output.contains("app.rs")); +} + +#[tokio::test] +async fn execute_runs_linked_grep_when_path_points_to_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"), + "pub fn auth_status() {}\nfn render_status_bar() {}\n", + ) + .expect("write target file"); + fs::write( + temp.path().join("src/other.rs"), + "pub fn auth_status() {}\nfn render_other() {}\n", + ) + .expect("write sibling file"); + + let tool = AgentGrepTool::new(); + let ctx = test_ctx(temp.path()); + let output = tool + .execute( + json!({ + "mode": "grep", + "query": "auth_status", + "path": "src/app.rs", + "glob": "**/*.rs", + "type": "rs" + }), + ctx, + ) + .await + .expect("tool output for exact-file path"); + assert!(output.output.contains("app.rs")); + assert!(!output.output.contains("src/other.rs")); + assert!(!output.output.contains("other.rs")); +} + +#[tokio::test] +async fn execute_smart_accepts_query_fallback() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::create_dir_all(temp.path().join("src/tool")).expect("mkdir"); + fs::write( + temp.path().join("src/tool/lsp.rs"), + r#"pub struct LspTool; +impl LspTool {} +fn execute() { println!("implementation"); } +"#, + ) + .expect("write file"); + + let tool = AgentGrepTool::new(); + let ctx = test_ctx(temp.path()); + let output = tool + .execute( + json!({ + "mode": "smart", + "query": "subject:lsp relation:implementation path:src/tool", + "path": ".", + "max_files": 2, + "max_regions": 3, + "debug_plan": true + }), + ctx, + ) + .await + .expect("agentgrep execution"); + assert!(output.output.contains("debug plan:")); + assert!(output.output.contains("subject: lsp")); + assert!(output.output.contains("relation: implementation")); +} + +#[test] +fn trace_output_collects_symbols_regions_and_focus() { + let ctx = test_ctx(Path::new("/repo")); + let mut context = AgentGrepHarnessContext { + version: 1, + ..Default::default() + }; + let mut focus = HashSet::new(); + let mut file_mtime_cache = HashMap::new(); + let content = r#" +query parameters: + subject: auth_status + relation: rendered + +top results: 1 files, 1 regions +best answer likely in src/tui/app.rs + +1. src/tui/app.rs + role: ui + structure: + - function render_status_bar @ 9002-9017 (16 lines) + - function draw_header @ 9035-9056 (22 lines) + regions: + - render_status_bar @ 9002-9017 (16 lines) + kind: render-site + full region: + fn render_status_bar(&self, ui: &mut Ui) { + let status = auth_status(); + } + why: + - exact subject match +"#; + + collect_trace_exposure( + content, + Path::new("/repo"), + &ctx, + &mut context, + &mut focus, + test_exposure(8, 10), + &mut file_mtime_cache, + ); + + assert!(focus.contains("src/tui/app.rs")); + assert!( + context + .known_files + .iter() + .any(|entry| entry.path == "src/tui/app.rs") + ); + assert!( + context + .known_symbols + .iter() + .any(|entry| { entry.path == "src/tui/app.rs" && entry.symbol == "render_status_bar" }) + ); + assert!(context.known_regions.iter().any(|entry| { + entry.path == "src/tui/app.rs" && entry.start_line == 9002 && entry.end_line == 9017 + })); +} + +#[test] +fn bash_exposure_collects_file_and_line_hits() { + let ctx = test_ctx(Path::new("/repo")); + let mut context = AgentGrepHarnessContext { + version: 1, + ..Default::default() + }; + let mut focus = HashSet::new(); + let mut file_mtime_cache = HashMap::new(); + let tool = ToolCall { + id: "tool-1".to_string(), + name: "bash".to_string(), + input: json!({ + "command": "cat src/tool/lsp.rs && rg -n auth_status src/tool/lsp.rs" + }), + intent: None, + thought_signature: None, + }; + let content = "src/tool/lsp.rs:42:let status = auth_status();\n"; + + collect_bash_exposure( + &tool, + content, + Path::new("/repo"), + &ctx, + &mut context, + &mut focus, + test_exposure(9, 10), + &mut file_mtime_cache, + ); + + assert!(focus.contains("src/tool/lsp.rs")); + assert!( + context + .known_files + .iter() + .any(|entry| entry.path == "src/tool/lsp.rs") + ); + assert!(context.known_regions.iter().any(|entry| { + entry.path == "src/tool/lsp.rs" && entry.start_line == 42 && entry.end_line == 42 + })); +} + +#[test] +fn tuning_penalizes_compacted_history() { + let temp = tempfile::tempdir().expect("tempdir"); + let ctx = test_ctx(temp.path()); + let file_path = temp.path().join("src/foo.rs"); + fs::create_dir_all(file_path.parent().expect("parent")).expect("mkdir"); + fs::write(&file_path, "fn foo() {}\n").expect("write file"); + + let known = AgentGrepKnownFile { + path: "src/foo.rs".to_string(), + structure_confidence: 0.9, + body_confidence: 0.8, + current_version_confidence: 0.9, + prune_confidence: 0.8, + source_strength: "full_file", + reasons: vec!["test"], + }; + let mut cache = HashMap::new(); + let tuned = tune_known_file( + known, + ExposureDescriptor { + timestamp: Some(Utc::now()), + message_index: 1, + total_messages: 10, + compaction_cutoff: Some(8), + }, + temp.path(), + &ctx, + &mut cache, + ); + + assert!(tuned.body_confidence < 0.5); + assert!(tuned.prune_confidence < 0.5); + assert!(tuned.reasons.contains(&"compacted_history")); +} + +#[test] +fn tuning_detects_file_changed_since_seen() { + let temp = tempfile::tempdir().expect("tempdir"); + let ctx = test_ctx(temp.path()); + let file_path = temp.path().join("src/bar.rs"); + fs::create_dir_all(file_path.parent().expect("parent")).expect("mkdir"); + fs::write(&file_path, "fn bar() {}\n").expect("write file"); + + let mut cache = HashMap::new(); + let tuned = tune_known_region( + AgentGrepKnownRegion { + path: "src/bar.rs".to_string(), + start_line: 1, + end_line: 1, + body_confidence: 0.9, + current_version_confidence: 0.9, + prune_confidence: 0.8, + source_strength: "full_region", + reasons: vec!["test"], + }, + ExposureDescriptor { + timestamp: Some(Utc::now() - Duration::hours(1)), + message_index: 9, + total_messages: 10, + compaction_cutoff: None, + }, + temp.path(), + &ctx, + &mut cache, + ); + + assert!(tuned.current_version_confidence < 0.6); + assert!(tuned.reasons.contains(&"file_changed_since_seen")); +} + +#[test] +fn input_accepts_legacy_grep_param_aliases() { + // Models sometimes call the removed native `grep` tool, which is now + // aliased to agentgrep. Its `pattern`/`include` params must map to + // agentgrep's `query`/`glob`. + let input: AgentGrepInput = serde_json::from_value(serde_json::json!({ + "pattern": "fn main", + "include": "*.rs", + "path": "src" + })) + .expect("legacy grep params should deserialize"); + assert_eq!(input.query.as_deref(), Some("fn main")); + assert_eq!(input.glob.as_deref(), Some("*.rs")); + assert_eq!(input.path.as_deref(), Some("src")); + assert_eq!(input.mode, "grep"); +} diff --git a/crates/jcode-app-core/src/tool/ambient.rs b/crates/jcode-app-core/src/tool/ambient.rs new file mode 100644 index 0000000..24d1eb4 --- /dev/null +++ b/crates/jcode-app-core/src/tool/ambient.rs @@ -0,0 +1,1132 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::ambient::{ + AmbientCycleResult, AmbientManager, AmbientState, CycleStatus, Priority, ScheduleRequest, + ScheduleTarget, ScheduledItem, +}; +use crate::ambient_runner::AmbientRunnerHandle; +use crate::safety::{self, PermissionRequest, PermissionResult, SafetySystem, Urgency}; +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::collections::HashSet; +use std::sync::{Arc, Mutex, OnceLock}; + +// --------------------------------------------------------------------------- +// Global state for ambient tools +// --------------------------------------------------------------------------- + +/// Global ambient cycle result, set by EndAmbientCycleTool for the ambient +/// runner to collect after the cycle completes. +static AMBIENT_CYCLE_RESULT: OnceLock<Mutex<Option<AmbientCycleResult>>> = OnceLock::new(); + +fn cycle_result_slot() -> &'static Mutex<Option<AmbientCycleResult>> { + AMBIENT_CYCLE_RESULT.get_or_init(|| Mutex::new(None)) +} + +/// Store a cycle result for the ambient runner to pick up. +pub fn store_cycle_result(result: AmbientCycleResult) { + if let Ok(mut slot) = cycle_result_slot().lock() { + *slot = Some(result); + } +} + +/// Take the stored cycle result (returns None if not set or already taken). +pub fn take_cycle_result() -> Option<AmbientCycleResult> { + cycle_result_slot() + .lock() + .ok() + .and_then(|mut slot| slot.take()) +} + +/// Global SafetySystem instance shared with ambient tools. +static SAFETY_SYSTEM: OnceLock<Arc<SafetySystem>> = OnceLock::new(); +/// Shared schedule/ambient runner handle used to wake the background loop after +/// queue changes. +static SCHEDULE_RUNNER: OnceLock<Mutex<Option<AmbientRunnerHandle>>> = OnceLock::new(); +/// Session IDs currently allowed to use ambient-only permission workflows. +static AMBIENT_SESSION_IDS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new(); + +pub fn init_safety_system(system: Arc<SafetySystem>) { + let _ = SAFETY_SYSTEM.set(system); +} + +pub fn init_schedule_runner(handle: AmbientRunnerHandle) { + if let Ok(mut slot) = SCHEDULE_RUNNER.get_or_init(|| Mutex::new(None)).lock() { + *slot = Some(handle); + } +} + +fn get_safety_system() -> Arc<SafetySystem> { + SAFETY_SYSTEM + .get() + .cloned() + .unwrap_or_else(|| Arc::new(SafetySystem::new())) +} + +fn ambient_session_ids() -> &'static Mutex<HashSet<String>> { + AMBIENT_SESSION_IDS.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Mark a session ID as ambient-enabled for ambient-only tooling. +pub fn register_ambient_session(session_id: impl Into<String>) { + if let Ok(mut ids) = ambient_session_ids().lock() { + ids.insert(session_id.into()); + } +} + +/// Remove a session ID from the ambient-enabled set. +pub fn unregister_ambient_session(session_id: &str) { + if let Ok(mut ids) = ambient_session_ids().lock() { + ids.remove(session_id); + } +} + +fn is_ambient_session_registered(session_id: &str) -> bool { + ambient_session_ids() + .lock() + .map(|ids| ids.contains(session_id)) + .unwrap_or(false) +} + +fn ensure_ambient_session(ctx: &ToolContext) -> Result<()> { + if is_ambient_session_registered(&ctx.session_id) { + Ok(()) + } else { + anyhow::bail!( + "request_permission is only available to ambient sessions (session '{}')", + ctx.session_id + ) + } +} + +// =========================================================================== +// EndAmbientCycleTool +// =========================================================================== + +pub struct EndAmbientCycleTool; + +impl Default for EndAmbientCycleTool { + fn default() -> Self { + Self::new() + } +} + +impl EndAmbientCycleTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct EndCycleInput { + summary: String, + #[serde(deserialize_with = "super::serde_coerce::u32_from_string_or_number")] + memories_modified: u32, + #[serde(deserialize_with = "super::serde_coerce::u32_from_string_or_number")] + compactions: u32, + #[serde(default)] + proactive_work: Option<String>, + #[serde(default)] + next_schedule: Option<NextScheduleInput>, +} + +#[derive(Deserialize)] +struct NextScheduleInput { + #[serde( + default, + deserialize_with = "super::serde_coerce::opt_u32_from_string_or_number" + )] + wake_in_minutes: Option<u32>, + #[serde(default)] + context: Option<String>, + #[serde(default)] + priority: Option<String>, +} + +#[async_trait] +impl Tool for EndAmbientCycleTool { + fn name(&self) -> &str { + "end_ambient_cycle" + } + + fn description(&self) -> &str { + "End the current ambient cycle." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["summary", "memories_modified", "compactions"], + "properties": { + "intent": super::intent_schema_property(), + "summary": { + "type": "string", + "description": "Human-readable summary of what was done this cycle" + }, + "memories_modified": { + "type": "integer", + "description": "Count of memories created, merged, pruned, or updated" + }, + "compactions": { + "type": "integer", + "description": "Number of context compactions during this cycle" + }, + "proactive_work": { + "type": "string", + "description": "Description of proactive code changes, if any" + }, + "next_schedule": { + "type": "object", + "description": "When to wake next and what to do", + "properties": { + "wake_in_minutes": { + "type": "integer", + "description": "Minutes until next wake" + }, + "context": { + "type": "string", + "description": "What to do next cycle" + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high"], + "description": "Priority for next cycle" + } + } + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: EndCycleInput = serde_json::from_value(input)?; + + let next_schedule = params.next_schedule.map(|ns| ScheduleRequest { + wake_in_minutes: ns.wake_in_minutes, + wake_at: None, + context: ns.context.unwrap_or_default(), + priority: parse_priority(ns.priority.as_deref()), + target: ScheduleTarget::Ambient, + created_by_session: ctx.session_id.clone(), + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }); + + let now = Utc::now(); + let result = AmbientCycleResult { + summary: params.summary.clone(), + memories_modified: params.memories_modified, + compactions: params.compactions, + proactive_work: params.proactive_work, + next_schedule: next_schedule.clone(), + started_at: now, // approximate; the runner will override if it tracks start time + ended_at: now, + status: CycleStatus::Complete, + conversation: None, // populated by the runner after cycle completes + }; + + // Store for the ambient runner to pick up + store_cycle_result(result); + + // Also persist state immediately so a crash after this tool but before + // the runner collects won't lose the cycle. + if let Ok(mut state) = AmbientState::load() { + let next_desc = if let Some(ref sched) = next_schedule { + let mins = sched.wake_in_minutes.unwrap_or(30); + format!("~{}", crate::ambient::format_minutes_human(mins)) + } else { + "system default".to_string() + }; + + state.last_run = Some(now); + state.last_summary = Some(params.summary.clone()); + state.last_compactions = Some(params.compactions); + state.last_memories_modified = Some(params.memories_modified); + state.total_cycles += 1; + let _ = state.save(); + + Ok(ToolOutput::new(format!( + "Ambient cycle ended. Memories modified: {}, compactions: {}. Next wake: {}", + params.memories_modified, params.compactions, next_desc + )) + .with_title("ambient cycle ended".to_string())) + } else { + Ok(ToolOutput::new(format!( + "Ambient cycle ended (state save failed). Summary: {}", + params.summary + )) + .with_title("ambient cycle ended".to_string())) + } + } +} + +// =========================================================================== +// ScheduleAmbientTool +// =========================================================================== + +pub struct ScheduleAmbientTool; + +impl Default for ScheduleAmbientTool { + fn default() -> Self { + Self::new() + } +} + +impl ScheduleAmbientTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct ScheduleInput { + #[serde( + default, + deserialize_with = "super::serde_coerce::opt_u32_from_string_or_number" + )] + wake_in_minutes: Option<u32>, + #[serde(default)] + wake_at: Option<String>, + context: String, + #[serde(default)] + priority: Option<String>, +} + +#[async_trait] +impl Tool for ScheduleAmbientTool { + fn name(&self) -> &str { + "schedule_ambient" + } + + fn description(&self) -> &str { + "Schedule an ambient task." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["context"], + "properties": { + "intent": super::intent_schema_property(), + "wake_in_minutes": { + "type": "integer", + "description": "Minutes from now to wake" + }, + "wake_at": { + "type": "string", + "description": "ISO 8601 timestamp for when to wake (alternative to wake_in_minutes)" + }, + "context": { + "type": "string", + "description": "What to do when waking — stored in the scheduled queue" + }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high"], + "description": "Priority for this scheduled task (default: normal)" + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: ScheduleInput = serde_json::from_value(input)?; + + let wake_at = if let Some(ref ts) = params.wake_at { + Some( + ts.parse::<chrono::DateTime<Utc>>() + .map_err(|e| anyhow::anyhow!("Invalid wake_at timestamp: {}", e))?, + ) + } else { + None + }; + + let request = ScheduleRequest { + wake_in_minutes: params.wake_in_minutes, + wake_at, + context: params.context.clone(), + priority: parse_priority(params.priority.as_deref()), + target: ScheduleTarget::Ambient, + created_by_session: ctx.session_id, + working_dir: None, + task_description: None, + relevant_files: Vec::new(), + git_branch: None, + additional_context: None, + }; + + let mut manager = AmbientManager::new()?; + let id = manager.schedule(request)?; + nudge_schedule_runner(); + + let when = if let Some(ref ts) = params.wake_at { + ts.clone() + } else if let Some(mins) = params.wake_in_minutes { + format!("in {}", crate::ambient::format_minutes_human(mins)) + } else { + "in 30m (default)".to_string() + }; + + Ok( + ToolOutput::new(format!("Scheduled ambient task {} for {}", id, when)) + .with_title(format!("scheduled: {}", params.context)), + ) + } +} + +// =========================================================================== +// RequestPermissionTool +// =========================================================================== + +pub struct RequestPermissionTool; + +impl Default for RequestPermissionTool { + fn default() -> Self { + Self::new() + } +} + +impl RequestPermissionTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct RequestPermissionInput { + action: String, + description: String, + rationale: String, + #[serde(default)] + urgency: Option<String>, + #[serde( + default = "default_false", + deserialize_with = "super::serde_coerce::bool_from_string_or_bool" + )] + wait: bool, + #[serde(default)] + context: Option<Value>, +} + +fn default_false() -> bool { + false +} + +fn extract_context_string(map: &Map<String, Value>, keys: &[&str]) -> Option<String> { + keys.iter().find_map(|key| { + map.get(*key).and_then(|value| { + value.as_str().and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + }) + }) +} + +fn extract_context_list(map: &Map<String, Value>, keys: &[&str]) -> Vec<String> { + for key in keys { + let Some(value) = map.get(*key) else { + continue; + }; + + if let Some(items) = value.as_array() { + let list: Vec<String> = items + .iter() + .filter_map(|item| item.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(); + if !list.is_empty() { + return list; + } + } else if let Some(single) = value.as_str() { + let trimmed = single.trim(); + if !trimmed.is_empty() { + return vec![trimmed.to_string()]; + } + } + } + Vec::new() +} + +fn build_permission_review_context( + action: &str, + description: &str, + rationale: &str, + context: Option<&Value>, +) -> Value { + let context_obj = context.and_then(Value::as_object); + + let summary = context_obj + .and_then(|m| extract_context_string(m, &["summary", "what", "activity_summary"])) + .unwrap_or_else(|| description.to_string()); + + let why_permission_needed = context_obj + .and_then(|m| { + extract_context_string( + m, + &[ + "why_permission_needed", + "why", + "reason", + "rationale", + "justification", + ], + ) + }) + .unwrap_or_else(|| rationale.to_string()); + + let mut review = Map::new(); + review.insert("summary".to_string(), Value::String(summary)); + review.insert( + "why_permission_needed".to_string(), + Value::String(why_permission_needed), + ); + review.insert( + "requested_action".to_string(), + Value::String(action.to_string()), + ); + + let string_fields: [(&str, &[&str]); 4] = [ + ( + "current_activity", + &["current_activity", "activity", "task", "current_task"], + ), + ( + "expected_outcome", + &["expected_outcome", "outcome", "success_criteria", "success"], + ), + ("impact", &["impact", "user_impact"]), + ("rollback_plan", &["rollback_plan", "rollback"]), + ]; + + if let Some(map) = context_obj { + for (field_name, keys) in string_fields { + if let Some(value) = extract_context_string(map, keys) { + review.insert(field_name.to_string(), Value::String(value)); + } + } + + let list_fields: [(&str, &[&str]); 4] = [ + ( + "planned_steps", + &["planned_steps", "steps", "plan", "checklist"], + ), + ("files", &["files", "file_paths", "planned_files"]), + ("commands", &["commands", "planned_commands"]), + ("risks", &["risks", "risk", "safety_risks"]), + ]; + + for (field_name, keys) in list_fields { + let items = extract_context_list(map, keys); + if !items.is_empty() { + review.insert( + field_name.to_string(), + Value::Array(items.into_iter().map(Value::String).collect()), + ); + } + } + } + + if let Some(raw) = context + && !raw.is_object() + { + review.insert("notes".to_string(), raw.clone()); + } + + Value::Object(review) +} + +#[async_trait] +impl Tool for RequestPermissionTool { + fn name(&self) -> &str { + "request_permission" + } + + fn description(&self) -> &str { + "Request user permission." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["action", "description", "rationale"], + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "description": "The action requiring permission (e.g., 'create_pull_request', 'push', 'edit')" + }, + "description": { + "type": "string", + "description": "What the action will do" + }, + "rationale": { + "type": "string", + "description": "Why this action is beneficial" + }, + "urgency": { + "type": "string", + "enum": ["low", "normal", "high"], + "description": "How urgent the permission request is (default: normal)" + }, + "wait": { + "type": "boolean", + "description": "If true, block until user decides (with timeout). If false, queue and continue." + }, + "context": { + "type": "object", + "description": "Structured reviewer context. Include summary of current work and why permission is needed.", + "properties": { + "summary": { + "type": "string", + "description": "One-paragraph summary of what you are currently doing" + }, + "why_permission_needed": { + "type": "string", + "description": "Why this action needs user approval right now" + }, + "current_activity": { + "type": "string", + "description": "Current task or ambient objective" + }, + "planned_steps": { + "type": "array", + "items": { "type": "string" }, + "description": "Short ordered plan of intended steps" + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "Files expected to be created/modified" + }, + "commands": { + "type": "array", + "items": { "type": "string" }, + "description": "Commands expected to be executed" + }, + "risks": { + "type": "array", + "items": { "type": "string" }, + "description": "Known risks or side effects" + }, + "rollback_plan": { + "type": "string", + "description": "How to back out changes if needed" + }, + "expected_outcome": { + "type": "string", + "description": "What successful completion should look like" + } + }, + "additionalProperties": true + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + ensure_ambient_session(&ctx)?; + + let params: RequestPermissionInput = serde_json::from_value(input)?; + + let urgency = match params.urgency.as_deref() { + Some("low") => Urgency::Low, + Some("high") => Urgency::High, + _ => Urgency::Normal, + }; + + let request_id = safety::new_request_id(); + let now = Utc::now(); + let review = build_permission_review_context( + ¶ms.action, + ¶ms.description, + ¶ms.rationale, + params.context.as_ref(), + ); + let mut request_context = json!({ + "session_id": ctx.session_id, + "message_id": ctx.message_id, + "tool_call_id": ctx.tool_call_id, + "working_dir": ctx.working_dir.as_ref().map(|p| p.display().to_string()), + "requested_at": now.to_rfc3339(), + }); + if let Some(obj) = request_context.as_object_mut() { + obj.insert("review".to_string(), review); + if let Some(user_context) = params.context { + obj.insert("details".to_string(), user_context); + } + } + + let request = PermissionRequest { + id: request_id.clone(), + action: params.action.clone(), + description: params.description.clone(), + rationale: params.rationale.clone(), + urgency, + wait: params.wait, + created_at: now, + context: Some(request_context), + }; + + let system = get_safety_system(); + let result = system.request_permission(request); + + let output = match result { + PermissionResult::Approved { ref message } => { + let msg = message.as_deref().unwrap_or("no message"); + format!("Permission approved: {}", msg) + } + PermissionResult::Denied { ref reason } => { + let reason = reason.as_deref().unwrap_or("no reason given"); + format!("Permission denied: {}", reason) + } + PermissionResult::Queued { ref request_id } => { + format!( + "Permission request queued (id: {}). \ + Action '{}' is pending user review.", + request_id, params.action + ) + } + PermissionResult::Timeout => { + "Permission request timed out. The user did not respond in time.".to_string() + } + }; + + Ok(ToolOutput::new(output).with_title(format!("permission: {}", params.action))) + } +} + +// =========================================================================== +// ScheduleTool — available to normal sessions to queue future ambient tasks +// =========================================================================== + +pub struct ScheduleTool; + +impl Default for ScheduleTool { + fn default() -> Self { + Self::new() + } +} + +impl ScheduleTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct ScheduleToolInput { + #[serde(default)] + action: Option<String>, + #[serde(default)] + schedule_id: Option<String>, + #[serde(default)] + task: Option<String>, + #[serde( + default, + deserialize_with = "super::serde_coerce::opt_u32_from_string_or_number" + )] + wake_in_minutes: Option<u32>, + #[serde(default)] + wake_at: Option<String>, + #[serde(default)] + priority: Option<String>, + #[serde(default)] + relevant_files: Vec<String>, + #[serde(default)] + background_context: Option<String>, + #[serde(default)] + success_criteria: Option<String>, + #[serde(default)] + target: Option<String>, +} + +#[async_trait] +impl Tool for ScheduleTool { + fn name(&self) -> &str { + "schedule" + } + + fn description(&self) -> &str { + "Schedule, list, or cancel future tasks." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["create", "list", "cancel"], + "description": "Action to perform. Defaults to create for backwards compatibility." + }, + "schedule_id": { + "type": "string", + "description": "Scheduled task ID. Required for action=cancel." + }, + "task": { + "type": "string", + "description": "Task. Required for action=create." + }, + "wake_in_minutes": { "type": "integer" }, + "wake_at": { "type": "string" }, + "priority": { + "type": "string", + "enum": ["low", "normal", "high"] + }, + "relevant_files": { + "type": "array", + "items": { "type": "string" } + }, + "background_context": { + "type": "string", + "description": "Optional background context for the scheduled task." + }, + "success_criteria": { "type": "string" }, + "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." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: ScheduleToolInput = serde_json::from_value(input)?; + + match params.action.as_deref().unwrap_or("create") { + "create" => self.execute_create(params, ctx).await, + "list" => self.execute_list().await, + "cancel" => self.execute_cancel(params).await, + other => anyhow::bail!( + "Invalid action '{}'. Expected one of: create, list, cancel", + other + ), + } + } +} + +impl ScheduleTool { + async fn execute_create( + &self, + params: ScheduleToolInput, + ctx: ToolContext, + ) -> Result<ToolOutput> { + let task = params + .task + .clone() + .ok_or_else(|| anyhow::anyhow!("task is required for action=create"))?; + + if params.wake_in_minutes.is_none() && params.wake_at.is_none() { + anyhow::bail!( + "Either wake_in_minutes or wake_at is required. \ + This tool is for scheduling future tasks." + ); + } + + let wake_at = if let Some(ref ts) = params.wake_at { + Some( + ts.parse::<chrono::DateTime<Utc>>() + .map_err(|e| anyhow::anyhow!("Invalid wake_at timestamp: {}", e))?, + ) + } else { + None + }; + + let working_dir = ctx.working_dir.as_ref().map(|p| p.display().to_string()); + + let git_branch = ctx + .working_dir + .as_ref() + .and_then(|wd| { + std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(wd) + .output() + .ok() + }) + .and_then(|out| { + if out.status.success() { + String::from_utf8(out.stdout) + .ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }); + + let target = parse_schedule_target(params.target.as_deref(), &ctx.session_id)?; + let target_summary = format_schedule_target(&target); + + let request = ScheduleRequest { + wake_in_minutes: params.wake_in_minutes, + wake_at, + context: task.clone(), + priority: parse_priority(params.priority.as_deref()), + target, + created_by_session: ctx.session_id.clone(), + working_dir: working_dir.clone(), + task_description: Some(task.clone()), + relevant_files: params.relevant_files.clone(), + git_branch, + additional_context: { + let mut parts = Vec::new(); + if let Some(ref bg) = params.background_context { + parts.push(format!("Background: {}", bg)); + } + if let Some(ref sc) = params.success_criteria { + parts.push(format!("Success criteria: {}", sc)); + } + parts.push(format!("Scheduled by session: {}", ctx.session_id)); + Some(parts.join("\n")) + }, + }; + + let mut manager = AmbientManager::new()?; + let id = manager.schedule(request)?; + nudge_schedule_runner(); + + let when = if let Some(ref ts) = params.wake_at { + ts.clone() + } else if let Some(mins) = params.wake_in_minutes { + format!("in {}", crate::ambient::format_minutes_human(mins)) + } else { + "unspecified".to_string() + }; + + let mut summary = format!("Scheduled task '{}' for {} (id: {})", task, when, id); + if let Some(ref wd) = working_dir { + summary.push_str(&format!("\nWorking directory: {}", wd)); + } + if !params.relevant_files.is_empty() { + summary.push_str(&format!( + "\nRelevant files: {}", + params.relevant_files.join(", ") + )); + } + summary.push_str(&format!("\nTarget: {}", target_summary)); + + Ok(ToolOutput::new(summary).with_title(format!("scheduled: {}", task))) + } + + async fn execute_list(&self) -> Result<ToolOutput> { + let manager = AmbientManager::new()?; + let mut items: Vec<&ScheduledItem> = manager.queue().items().iter().collect(); + items.sort_by_key(|item| item.scheduled_for); + + if items.is_empty() { + return Ok(ToolOutput::new("No scheduled tasks.")); + } + + let mut summary = format!("{} scheduled task(s):", items.len()); + for item in items { + summary.push('\n'); + summary.push_str(&format_scheduled_item(item)); + } + + Ok(ToolOutput::new(summary).with_title("scheduled tasks")) + } + + async fn execute_cancel(&self, params: ScheduleToolInput) -> Result<ToolOutput> { + let id = params + .schedule_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("schedule_id is required for action=cancel"))?; + + let mut manager = AmbientManager::new()?; + let Some(item) = manager.cancel_schedule(id)? else { + anyhow::bail!("No scheduled task found with id '{}'", id); + }; + nudge_schedule_runner(); + + Ok(ToolOutput::new(format!( + "Cancelled scheduled task '{}' for {} (id: {})", + item.task_description.as_deref().unwrap_or(&item.context), + item.scheduled_for, + item.id + )) + .with_title(format!("cancelled: {}", item.id))) + } +} + +// =========================================================================== +// Helpers +// =========================================================================== + +fn parse_priority(s: Option<&str>) -> Priority { + match s { + Some("low") => Priority::Low, + Some("high") => Priority::High, + _ => Priority::Normal, + } +} + +fn parse_schedule_target(s: Option<&str>, session_id: &str) -> Result<ScheduleTarget> { + Ok(match s { + Some("ambient") => ScheduleTarget::Ambient, + Some("spawn") => ScheduleTarget::Spawn { + parent_session_id: session_id.to_string(), + }, + Some("resume") | None => ScheduleTarget::Session { + session_id: session_id.to_string(), + }, + Some(other) => anyhow::bail!( + "Invalid target '{}'. Expected one of: resume, spawn, ambient", + other + ), + }) +} + +fn format_schedule_target(target: &ScheduleTarget) -> String { + match target { + ScheduleTarget::Ambient => "ambient agent".to_string(), + ScheduleTarget::Session { session_id } => format!("resume session {}", session_id), + ScheduleTarget::Spawn { parent_session_id } => { + format!("spawn one child session from {}", parent_session_id) + } + } +} + +fn format_scheduled_item(item: &ScheduledItem) -> String { + format!( + "- {} | {} | {:?} | {} | {}", + item.id, + item.scheduled_for, + item.priority, + format_schedule_target(&item.target), + item.task_description.as_deref().unwrap_or(&item.context) + ) +} + +fn nudge_schedule_runner() { + let runner = SCHEDULE_RUNNER + .get_or_init(|| Mutex::new(None)) + .lock() + .ok() + .and_then(|slot| slot.clone()); + if let Some(runner) = runner { + runner.nudge(); + } +} + +// --------------------------------------------------------------------------- +// SendChannelMessageTool — send messages via any configured channel +// --------------------------------------------------------------------------- + +pub struct SendChannelMessageTool; + +impl Default for SendChannelMessageTool { + fn default() -> Self { + Self::new() + } +} + +impl SendChannelMessageTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for SendChannelMessageTool { + fn name(&self) -> &str { + "send_message" + } + + fn description(&self) -> &str { + "Send a user message." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "message": { + "type": "string", + "description": "The message text to send" + }, + "channel": { + "type": "string", + "description": "Optional: specific channel to send to (e.g. 'telegram', 'discord'). Omit to send to all." + } + }, + "required": ["message"] + }) + } + + async fn execute(&self, args: Value, _context: ToolContext) -> Result<ToolOutput> { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("missing required parameter: message"))?; + + let channel_name = args.get("channel").and_then(|v| v.as_str()); + + let config = crate::config::config(); + let registry = crate::channel::ChannelRegistry::from_config(&config.safety); + + if let Some(name) = channel_name { + match registry.find_by_name(name) { + Some(ch) => match ch.send(message).await { + Ok(()) => Ok(ToolOutput::new(format!("Message sent via {}.", name))), + Err(e) => Ok(ToolOutput::new(format!( + "Failed to send via {}: {}", + name, e + ))), + }, + None => { + let available = registry.channel_names(); + Ok(ToolOutput::new(format!( + "Channel '{}' not found. Available: {}", + name, + if available.is_empty() { + "none configured".to_string() + } else { + available.join(", ") + } + ))) + } + } + } else { + let channels = registry.send_enabled(); + if channels.is_empty() { + return Ok(ToolOutput::new( + "No messaging channels configured. Enable telegram or discord in config.", + )); + } + let mut results = Vec::new(); + for ch in &channels { + match ch.send(message).await { + Ok(()) => results.push(format!("✓ {}", ch.name())), + Err(e) => results.push(format!("✗ {}: {}", ch.name(), e)), + } + } + Ok(ToolOutput::new(format!( + "Message sent: {}", + results.join(", ") + ))) + } + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +#[path = "ambient/tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/tool/ambient/tests.rs b/crates/jcode-app-core/src/tool/ambient/tests.rs new file mode 100644 index 0000000..56fd420 --- /dev/null +++ b/crates/jcode-app-core/src/tool/ambient/tests.rs @@ -0,0 +1,498 @@ +use super::*; + +#[test] +fn test_parse_priority() { + assert_eq!(parse_priority(Some("low")), Priority::Low); + assert_eq!(parse_priority(Some("normal")), Priority::Normal); + assert_eq!(parse_priority(Some("high")), Priority::High); + assert_eq!(parse_priority(None), Priority::Normal); + assert_eq!(parse_priority(Some("unknown")), Priority::Normal); +} + +#[test] +fn test_cycle_result_store_and_take() { + let result = AmbientCycleResult { + summary: "test".to_string(), + memories_modified: 1, + compactions: 0, + proactive_work: None, + next_schedule: None, + started_at: Utc::now(), + ended_at: Utc::now(), + status: CycleStatus::Complete, + conversation: None, + }; + + store_cycle_result(result); + let taken = take_cycle_result(); + assert!(taken.is_some()); + assert_eq!(taken.unwrap().summary, "test"); + + // Second take should be None + assert!(take_cycle_result().is_none()); +} + +#[test] +fn test_end_cycle_input_deserialization() { + let input = json!({ + "summary": "Merged 3 duplicates", + "memories_modified": 5, + "compactions": 1, + "proactive_work": "Fixed typo in README", + "next_schedule": { + "wake_in_minutes": 20, + "context": "Verify stale facts", + "priority": "high" + } + }); + + let parsed: EndCycleInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.summary, "Merged 3 duplicates"); + assert_eq!(parsed.memories_modified, 5); + assert_eq!(parsed.compactions, 1); + assert_eq!( + parsed.proactive_work.as_deref(), + Some("Fixed typo in README") + ); + let ns = parsed.next_schedule.unwrap(); + assert_eq!(ns.wake_in_minutes, Some(20)); + assert_eq!(ns.context.as_deref(), Some("Verify stale facts")); + assert_eq!(ns.priority.as_deref(), Some("high")); +} + +#[test] +fn test_end_cycle_input_minimal() { + let input = json!({ + "summary": "Nothing to do", + "memories_modified": 0, + "compactions": 0 + }); + + let parsed: EndCycleInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.summary, "Nothing to do"); + assert!(parsed.proactive_work.is_none()); + assert!(parsed.next_schedule.is_none()); +} + +/// Regression for #106: Claude's tool calling emits numeric arguments as JSON +/// *strings* (e.g. `{"compactions": "0"}`). Before the fix, this failed with +/// `invalid type: string "0", expected u32`, breaking every ambient cycle. +#[test] +fn test_end_cycle_input_accepts_string_numbers() { + let input = json!({ + "summary": "Stringified counts", + "memories_modified": "5", + "compactions": "0", + "next_schedule": { + "wake_in_minutes": "20", + "context": "later", + "priority": "high" + } + }); + + let parsed: EndCycleInput = serde_json::from_value(input) + .expect("string-encoded numbers must deserialize (regression #106)"); + assert_eq!(parsed.memories_modified, 5); + assert_eq!(parsed.compactions, 0); + assert_eq!(parsed.next_schedule.unwrap().wake_in_minutes, Some(20)); +} + +/// The `schedule_ambient` and `schedule` tools and the permission `wait` flag +/// must survive the same stringified-argument quirk. +#[test] +fn test_ambient_inputs_accept_string_numbers_and_bools() { + let sched: ScheduleInput = serde_json::from_value(json!({ + "wake_in_minutes": "15", + "context": "ctx" + })) + .expect("schedule_ambient must accept string wake_in_minutes (#106)"); + assert_eq!(sched.wake_in_minutes, Some(15)); + + let perm: RequestPermissionInput = serde_json::from_value(json!({ + "action": "delete", + "description": "remove file", + "rationale": "cleanup", + "wait": "true" + })) + .expect("request_permission must accept string wait flag (#106)"); + assert!(perm.wait); + + let tool: ScheduleToolInput = serde_json::from_value(json!({ + "task": "do thing", + "wake_in_minutes": "30" + })) + .expect("schedule tool must accept string wake_in_minutes (#106)"); + assert_eq!(tool.wake_in_minutes, Some(30)); +} + +#[test] +fn test_schedule_input_deserialization() { + let input = json!({ + "wake_in_minutes": 15, + "context": "Check CI results", + "priority": "normal" + }); + + let parsed: ScheduleInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.wake_in_minutes, Some(15)); + assert!(parsed.wake_at.is_none()); + assert_eq!(parsed.context, "Check CI results"); + assert_eq!(parsed.priority.as_deref(), Some("normal")); +} + +#[test] +fn test_permission_input_deserialization() { + let input = json!({ + "action": "create_pull_request", + "description": "Create PR for test fixes", + "rationale": "Found failing tests that need attention", + "urgency": "high", + "wait": true + }); + + let parsed: RequestPermissionInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.action, "create_pull_request"); + assert_eq!(parsed.description, "Create PR for test fixes"); + assert_eq!(parsed.rationale, "Found failing tests that need attention"); + assert_eq!(parsed.urgency.as_deref(), Some("high")); + assert!(parsed.wait); +} + +#[test] +fn test_permission_input_defaults() { + let input = json!({ + "action": "edit", + "description": "Fix typo", + "rationale": "Obvious error" + }); + + let parsed: RequestPermissionInput = serde_json::from_value(input).unwrap(); + assert!(parsed.urgency.is_none()); + assert!(!parsed.wait); +} + +#[test] +fn test_build_permission_review_context_defaults() { + let review = + build_permission_review_context("edit", "Fix typo in docs", "Needs write permission", None); + + assert_eq!( + review + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "Fix typo in docs" + ); + assert_eq!( + review + .get("why_permission_needed") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "Needs write permission" + ); + assert_eq!( + review + .get("requested_action") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "edit" + ); +} + +#[test] +fn test_build_permission_review_context_uses_structured_fields() { + let context = json!({ + "summary": "Preparing a focused refactor", + "why_permission_needed": "Need to modify tracked files", + "planned_steps": ["Update parser", "Run tests"], + "files": ["src/parser.rs", "src/tests.rs"], + "commands": ["cargo test"], + "risks": ["Could regress parsing edge cases"], + "rollback_plan": "Revert commit if tests fail", + "expected_outcome": "Parser handles edge-case input", + }); + let review = + build_permission_review_context("edit", "fallback summary", "fallback why", Some(&context)); + + assert_eq!( + review + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "Preparing a focused refactor" + ); + assert_eq!( + review + .get("why_permission_needed") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "Need to modify tracked files" + ); + assert_eq!( + review + .get("rollback_plan") + .and_then(|v| v.as_str()) + .unwrap_or_default(), + "Revert commit if tests fail" + ); + assert_eq!( + review + .get("planned_steps") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or_default(), + 2 + ); +} + +#[test] +fn test_register_unregister_ambient_session() { + let session_id = "ambient_tool_test_session"; + unregister_ambient_session(session_id); + assert!(!is_ambient_session_registered(session_id)); + + register_ambient_session(session_id.to_string()); + assert!(is_ambient_session_registered(session_id)); + + unregister_ambient_session(session_id); + assert!(!is_ambient_session_registered(session_id)); +} + +#[tokio::test] +async fn test_request_permission_rejects_non_ambient_session() { + let tool = RequestPermissionTool::new(); + let input = json!({ + "action": "edit", + "description": "Update docs", + "rationale": "Fix typo" + }); + let ctx = ToolContext { + session_id: "normal_session_test".to_string(), + message_id: "msg_1".to_string(), + tool_call_id: "call_1".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + let err = tool + .execute(input, ctx) + .await + .expect_err("non-ambient session should be rejected"); + assert!( + err.to_string() + .contains("request_permission is only available to ambient sessions") + ); +} + +#[test] +fn test_schedule_tool_input_deserialization() { + let input = json!({ + "task": "Run the full test suite and report results", + "wake_in_minutes": 120, + "priority": "high", + "relevant_files": ["src/main.rs", "tests/e2e/main.rs"], + "background_context": "We just merged PR #42 which changed the parser", + "success_criteria": "All tests pass, or a summary of failures is stored" + }); + + let parsed: ScheduleToolInput = serde_json::from_value(input).unwrap(); + assert_eq!( + parsed.task.as_deref(), + Some("Run the full test suite and report results") + ); + assert!(parsed.action.is_none()); + assert!(parsed.schedule_id.is_none()); + assert_eq!(parsed.wake_in_minutes, Some(120)); + assert!(parsed.wake_at.is_none()); + assert_eq!(parsed.priority.as_deref(), Some("high")); + assert_eq!(parsed.relevant_files.len(), 2); + assert_eq!( + parsed.background_context.as_deref(), + Some("We just merged PR #42 which changed the parser") + ); + assert_eq!( + parsed.success_criteria.as_deref(), + Some("All tests pass, or a summary of failures is stored") + ); +} + +#[test] +fn test_schedule_tool_input_resume_target() { + let input = json!({ + "task": "Follow up in this chat", + "wake_in_minutes": 10, + "target": "resume" + }); + + let parsed: ScheduleToolInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.target.as_deref(), Some("resume")); +} + +#[test] +fn test_schedule_tool_input_spawn_target() { + let input = json!({ + "task": "Follow up in a new child session", + "wake_in_minutes": 10, + "target": "spawn" + }); + + let parsed: ScheduleToolInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.target.as_deref(), Some("spawn")); +} + +#[test] +fn test_schedule_tool_input_minimal() { + let input = json!({ + "task": "Check CI", + "wake_in_minutes": 30 + }); + + let parsed: ScheduleToolInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.task.as_deref(), Some("Check CI")); + assert_eq!(parsed.wake_in_minutes, Some(30)); + assert!(parsed.relevant_files.is_empty()); + assert!(parsed.background_context.is_none()); + assert!(parsed.success_criteria.is_none()); +} + +#[test] +fn test_schedule_tool_input_cancel_action() { + let input = json!({ + "action": "cancel", + "schedule_id": "sched_abc123" + }); + + let parsed: ScheduleToolInput = serde_json::from_value(input).unwrap(); + assert_eq!(parsed.action.as_deref(), Some("cancel")); + assert_eq!(parsed.schedule_id.as_deref(), Some("sched_abc123")); + assert!(parsed.task.is_none()); +} + +#[test] +fn test_parse_schedule_target_defaults_to_resume_originating_session() { + assert_eq!( + parse_schedule_target(None, "session_123").unwrap(), + ScheduleTarget::Session { + session_id: "session_123".to_string() + } + ); + assert_eq!( + parse_schedule_target(Some("resume"), "session_123").unwrap(), + ScheduleTarget::Session { + session_id: "session_123".to_string() + } + ); +} + +#[test] +fn test_parse_schedule_target_supports_spawn_and_ambient() { + assert_eq!( + parse_schedule_target(Some("spawn"), "session_123").unwrap(), + ScheduleTarget::Spawn { + parent_session_id: "session_123".to_string() + } + ); + assert_eq!( + parse_schedule_target(Some("ambient"), "session_123").unwrap(), + ScheduleTarget::Ambient + ); +} + +#[test] +fn test_parse_schedule_target_rejects_removed_session_alias() { + let err = parse_schedule_target(Some("session"), "session_123") + .expect_err("removed session alias should be rejected"); + assert!(err.to_string().contains("resume, spawn, ambient")); +} + +#[tokio::test] +#[allow( + clippy::await_holding_lock, + reason = "test intentionally serializes process-wide JCODE_HOME/env state across async tool execution" +)] +async fn test_schedule_tool_defaults_to_resuming_originating_session() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let tool = ScheduleTool::new(); + let input = json!({ + "task": "Follow up on this work", + "wake_in_minutes": 5 + }); + let ctx = ToolContext { + session_id: "origin_session".to_string(), + message_id: "msg_1".to_string(), + tool_call_id: "call_1".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + let output = tool + .execute(input, ctx) + .await + .expect("schedule should succeed"); + assert!( + output + .output + .contains("Target: resume session origin_session") + ); + + let manager = AmbientManager::new().expect("ambient manager"); + let scheduled = manager + .queue() + .items() + .first() + .expect("scheduled item should exist"); + assert_eq!( + scheduled.target, + ScheduleTarget::Session { + session_id: "origin_session".to_string() + } + ); + + if let Some(prev) = prev_home { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[test] +fn test_schedule_tool_schema_avoids_top_level_combinators() { + let tool = ScheduleTool::new(); + let schema = tool.parameters_schema(); + + assert_eq!(schema.get("type"), Some(&json!("object"))); + assert!(schema.get("anyOf").is_none()); + assert!(schema.get("oneOf").is_none()); + assert!(schema.get("allOf").is_none()); +} + +#[tokio::test] +async fn test_schedule_tool_requires_time() { + let tool = ScheduleTool::new(); + let input = json!({ + "task": "Do something eventually" + }); + let ctx = ToolContext { + session_id: "test_session".to_string(), + message_id: "msg_1".to_string(), + tool_call_id: "call_1".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + let err = tool + .execute(input, ctx) + .await + .expect_err("should require wake_in_minutes or wake_at"); + assert!(err.to_string().contains("wake_in_minutes")); +} diff --git a/crates/jcode-app-core/src/tool/apply_patch.rs b/crates/jcode-app-core/src/tool/apply_patch.rs new file mode 100644 index 0000000..1f89a9d --- /dev/null +++ b/crates/jcode-app-core/src/tool/apply_patch.rs @@ -0,0 +1,654 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use similar::{ChangeTag, TextDiff}; +use std::path::Path; + +const FILE_TOUCH_PREVIEW_MAX_LINES: usize = 6; +const FILE_TOUCH_PREVIEW_MAX_BYTES: usize = 240; + +pub struct ApplyPatchTool; + +impl ApplyPatchTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct ApplyPatchInput { + #[serde(default)] + intent: Option<String>, + patch_text: String, +} + +#[derive(Debug, Clone)] +struct UpdateFileChunk { + change_context: Option<String>, + old_lines: Vec<String>, + new_lines: Vec<String>, + is_end_of_file: bool, +} + +#[derive(Debug)] +#[expect( + clippy::enum_variant_names, + reason = "patch variants intentionally mirror unified diff file-level operations for readability" +)] +enum PatchHunk { + AddFile { + path: String, + contents: String, + }, + DeleteFile { + path: String, + }, + UpdateFile { + path: String, + move_to: Option<String>, + chunks: Vec<UpdateFileChunk>, + }, +} + +#[async_trait] +impl Tool for ApplyPatchTool { + fn name(&self) -> &str { + "apply_patch" + } + + fn description(&self) -> &str { + "Apply a Codex-style patch using *** Begin Patch / *** End Patch blocks. Prefer this over patch for Jcode/Codex patches." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["patch_text"], + "properties": { + "intent": super::intent_schema_property(), + "patch_text": { + "type": "string", + "description": "Patch text." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: ApplyPatchInput = serde_json::from_value(input)?; + let hunks = parse_apply_patch(¶ms.patch_text)?; + + let mut results = Vec::new(); + let mut touched_paths = Vec::new(); + + for hunk in &hunks { + match hunk { + PatchHunk::AddFile { path, contents } => { + let resolved = ctx.resolve_path(Path::new(path)); + if let Some(parent) = resolved.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&resolved, contents).await?; + let diff = generate_diff_summary("", contents); + publish_file_touch( + &ctx, + &resolved, + path, + "created", + &diff, + params.intent.as_deref(), + ); + touched_paths.push(path.clone()); + if diff.is_empty() { + results.push(format!("✓ {}: created", path)); + } else { + results.push(format!("✓ {}: created\n{}", path, diff)); + } + } + PatchHunk::DeleteFile { path } => { + let resolved = ctx.resolve_path(Path::new(path)); + let old_contents = tokio::fs::read_to_string(&resolved) + .await + .unwrap_or_default(); + if tokio::fs::remove_file(&resolved).await.is_ok() { + let diff = generate_diff_summary(&old_contents, ""); + publish_file_touch( + &ctx, + &resolved, + path, + "deleted", + &diff, + params.intent.as_deref(), + ); + touched_paths.push(path.clone()); + if diff.is_empty() { + results.push(format!("✓ {}: deleted", path)); + } else { + results.push(format!("✓ {}: deleted\n{}", path, diff)); + } + } else { + results.push(format!("✗ {}: failed to delete", path)); + } + } + PatchHunk::UpdateFile { + path, + move_to, + chunks, + } => { + let resolved = ctx.resolve_path(Path::new(path)); + match apply_update_chunks(&resolved, chunks).await { + Ok((old_contents, new_contents)) => { + let diff = generate_diff_summary(&old_contents, &new_contents); + if let Some(dest) = move_to { + let dest_resolved = ctx.resolve_path(Path::new(dest)); + if let Some(parent) = dest_resolved.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&dest_resolved, &new_contents).await?; + let _ = tokio::fs::remove_file(&resolved).await; + publish_file_touch( + &ctx, + &resolved, + path, + "modified", + &diff, + params.intent.as_deref(), + ); + publish_file_touch( + &ctx, + &dest_resolved, + dest, + "modified", + &diff, + params.intent.as_deref(), + ); + touched_paths.push(path.clone()); + touched_paths.push(dest.clone()); + if diff.is_empty() { + results.push(format!( + "✓ {}: modified ({} hunks), moved to {}", + path, + chunks.len(), + dest + )); + } else { + results.push(format!( + "✓ {}: modified ({} hunks), moved to {}\n{}", + path, + chunks.len(), + dest, + diff + )); + } + } else { + tokio::fs::write(&resolved, &new_contents).await?; + publish_file_touch( + &ctx, + &resolved, + path, + "modified", + &diff, + params.intent.as_deref(), + ); + touched_paths.push(path.clone()); + if diff.is_empty() { + results.push(format!( + "✓ {}: modified ({} hunks)", + path, + chunks.len() + )); + } else { + results.push(format!( + "✓ {}: modified ({} hunks)\n{}", + path, + chunks.len(), + diff + )); + } + } + } + Err(e) => { + results.push(format!("✗ {}: {}", path, e)); + } + } + } + } + } + + if results.is_empty() { + Ok(ToolOutput::new("No changes applied")) + } else { + let output = ToolOutput::new(results.join("\n")); + if touched_paths.len() == 1 { + Ok(output.with_title(touched_paths[0].clone())) + } else { + Ok(output.with_title(format!("{} files", touched_paths.len()))) + } + } + } +} + +fn publish_file_touch( + ctx: &ToolContext, + resolved: &Path, + display_path: &str, + verb: &str, + diff: &str, + intent: Option<&str>, +) { + let detail = build_file_touch_preview(diff); + Bus::global().publish(BusEvent::FileTouch(FileTouch { + session_id: ctx.session_id.clone(), + path: resolved.to_path_buf(), + op: FileOp::Edit, + intent: intent + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + summary: Some(format!("{} via apply_patch", verb)), + detail, + })); + let _ = display_path; +} + +fn build_file_touch_preview(diff: &str) -> Option<String> { + let trimmed = diff.trim(); + if trimmed.is_empty() { + return None; + } + + let mut lines = trimmed.lines(); + let mut preview = lines + .by_ref() + .take(FILE_TOUCH_PREVIEW_MAX_LINES) + .collect::<Vec<_>>() + .join("\n"); + let mut truncated = lines.next().is_some(); + + if preview.len() > FILE_TOUCH_PREVIEW_MAX_BYTES { + preview = crate::util::truncate_str(&preview, FILE_TOUCH_PREVIEW_MAX_BYTES) + .trim_end() + .to_string(); + truncated = true; + } + + if truncated { + preview.push_str("\n…"); + } + + Some(preview) +} + +async fn apply_update_chunks(path: &Path, chunks: &[UpdateFileChunk]) -> Result<(String, String)> { + let original_contents = tokio::fs::read_to_string(path).await?; + let mut original_lines: Vec<String> = original_contents.split('\n').map(String::from).collect(); + + if original_lines.last().is_some_and(String::is_empty) { + original_lines.pop(); + } + + let replacements = compute_replacements(&original_lines, path, chunks)?; + let mut new_lines = apply_replacements(original_lines, &replacements); + + if !new_lines.last().is_some_and(String::is_empty) { + new_lines.push(String::new()); + } + Ok((original_contents, new_lines.join("\n"))) +} + +/// Generate a compact diff with line numbers (max 30 lines). +fn generate_diff_summary(old: &str, new: &str) -> String { + let diff = TextDiff::from_lines(old, new); + let mut output = String::new(); + let mut line_count = 0; + const MAX_LINES: usize = 30; + + let mut old_line = 1usize; + let mut new_line = 1usize; + + for change in diff.iter_all_changes() { + if line_count >= MAX_LINES { + output.push_str("... (diff truncated)\n"); + break; + } + + let content = change.value().trim_end_matches('\n'); + let (prefix, line_num) = match change.tag() { + ChangeTag::Delete => { + let num = old_line; + old_line += 1; + if content.trim().is_empty() { + continue; + } + ("-", num) + } + ChangeTag::Insert => { + let num = new_line; + new_line += 1; + if content.trim().is_empty() { + continue; + } + ("+", num) + } + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + continue; + } + }; + + output.push_str(&format!("{}{} {}\n", line_num, prefix, content)); + line_count += 1; + } + + output.trim_end().to_string() +} + +fn compute_replacements( + original_lines: &[String], + path: &Path, + chunks: &[UpdateFileChunk], +) -> Result<Vec<(usize, usize, Vec<String>)>> { + let mut replacements: Vec<(usize, usize, Vec<String>)> = Vec::new(); + let mut line_index: usize = 0; + + for chunk in chunks { + if let Some(ctx_line) = &chunk.change_context { + if let Some(idx) = seek_sequence( + original_lines, + std::slice::from_ref(ctx_line), + line_index, + false, + ) { + line_index = idx + 1; + } else { + anyhow::bail!( + "Failed to find context '{}' in {}", + ctx_line, + path.display() + ); + } + } + + if chunk.old_lines.is_empty() { + let insertion_idx = if original_lines.last().is_some_and(String::is_empty) { + original_lines.len() - 1 + } else { + original_lines.len() + }; + replacements.push((insertion_idx, 0, chunk.new_lines.clone())); + continue; + } + + let mut pattern: &[String] = &chunk.old_lines; + let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file); + + let mut new_slice: &[String] = &chunk.new_lines; + + if found.is_none() && pattern.last().is_some_and(String::is_empty) { + pattern = &pattern[..pattern.len() - 1]; + if new_slice.last().is_some_and(String::is_empty) { + new_slice = &new_slice[..new_slice.len() - 1]; + } + found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file); + } + + if let Some(start_idx) = found { + replacements.push((start_idx, pattern.len(), new_slice.to_vec())); + line_index = start_idx + pattern.len(); + } else { + anyhow::bail!( + "Failed to find expected lines in {}:\n{}", + path.display(), + chunk.old_lines.join("\n"), + ); + } + } + + replacements.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); + Ok(replacements) +} + +fn apply_replacements( + mut lines: Vec<String>, + replacements: &[(usize, usize, Vec<String>)], +) -> Vec<String> { + for (start_idx, old_len, new_segment) in replacements.iter().rev() { + let start_idx = *start_idx; + let old_len = *old_len; + + for _ in 0..old_len { + if start_idx < lines.len() { + lines.remove(start_idx); + } + } + + for (offset, new_line) in new_segment.iter().enumerate() { + lines.insert(start_idx + offset, new_line.clone()); + } + } + + lines +} + +fn seek_sequence(lines: &[String], pattern: &[String], start: usize, eof: bool) -> Option<usize> { + if pattern.is_empty() { + return Some(start); + } + + if pattern.len() > lines.len() { + return None; + } + + let search_start = if eof && lines.len() >= pattern.len() { + lines.len() - pattern.len() + } else { + start + }; + + for i in search_start..=lines.len().saturating_sub(pattern.len()) { + if lines[i..i + pattern.len()] == *pattern { + return Some(i); + } + } + + for i in search_start..=lines.len().saturating_sub(pattern.len()) { + let mut ok = true; + for (p_idx, pat) in pattern.iter().enumerate() { + if lines[i + p_idx].trim_end() != pat.trim_end() { + ok = false; + break; + } + } + if ok { + return Some(i); + } + } + + for i in search_start..=lines.len().saturating_sub(pattern.len()) { + let mut ok = true; + for (p_idx, pat) in pattern.iter().enumerate() { + if lines[i + p_idx].trim() != pat.trim() { + ok = false; + break; + } + } + if ok { + return Some(i); + } + } + + None +} + +fn parse_apply_patch(input: &str) -> Result<Vec<PatchHunk>> { + let lines: Vec<&str> = input.lines().collect(); + + let start = lines + .iter() + .position(|l| l.trim() == "*** Begin Patch") + .ok_or_else(|| anyhow::anyhow!("Patch must contain *** Begin Patch"))?; + + let mut hunks = Vec::new(); + let mut i = start + 1; + + while i < lines.len() { + let line = lines[i].trim_end(); + if line.trim() == "*** End Patch" { + break; + } + + if let Some(path) = line.strip_prefix("*** Add File: ") { + let path = path.trim().to_string(); + i += 1; + let mut contents = String::new(); + while i < lines.len() { + let current = lines[i]; + if current.starts_with("*** ") { + break; + } + if let Some(added) = current.strip_prefix('+') { + contents.push_str(added); + contents.push('\n'); + } + i += 1; + } + hunks.push(PatchHunk::AddFile { path, contents }); + continue; + } + + if let Some(path) = line.strip_prefix("*** Delete File: ") { + hunks.push(PatchHunk::DeleteFile { + path: path.trim().to_string(), + }); + i += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Update File: ") { + let path = path.trim().to_string(); + i += 1; + + let mut move_to = None; + if i < lines.len() + && let Some(target) = lines[i].trim_end().strip_prefix("*** Move to: ") + { + move_to = Some(target.trim().to_string()); + i += 1; + } + + let mut chunks = Vec::new(); + let mut is_first_chunk = true; + + while i < lines.len() { + let current = lines[i].trim_end(); + + if current.starts_with("*** ") && current != "*** End of File" { + break; + } + + if current.trim().is_empty() + && !current.starts_with(' ') + && !current.starts_with('+') + && !current.starts_with('-') + { + i += 1; + continue; + } + + let change_context; + if current == "@@" { + change_context = None; + i += 1; + } else if let Some(ctx) = current.strip_prefix("@@ ") { + change_context = Some(ctx.to_string()); + i += 1; + } else if is_first_chunk { + change_context = None; + } else { + break; + } + + let mut old_lines = Vec::new(); + let mut new_lines = Vec::new(); + let mut is_end_of_file = false; + let mut had_diff_lines = false; + + while i < lines.len() { + let cl = lines[i]; + + if cl == "*** End of File" { + is_end_of_file = true; + i += 1; + break; + } + + if cl.starts_with("*** ") || cl.starts_with("@@") { + break; + } + + if let Some(content) = cl.strip_prefix(' ') { + old_lines.push(content.to_string()); + new_lines.push(content.to_string()); + had_diff_lines = true; + } else if let Some(content) = cl.strip_prefix('+') { + new_lines.push(content.to_string()); + had_diff_lines = true; + } else if let Some(content) = cl.strip_prefix('-') { + old_lines.push(content.to_string()); + had_diff_lines = true; + } else if cl.is_empty() { + old_lines.push(String::new()); + new_lines.push(String::new()); + had_diff_lines = true; + } else { + if had_diff_lines { + break; + } + i += 1; + continue; + } + + i += 1; + } + + if had_diff_lines || change_context.is_some() { + chunks.push(UpdateFileChunk { + change_context, + old_lines, + new_lines, + is_end_of_file, + }); + } + + is_first_chunk = false; + } + + if chunks.is_empty() { + anyhow::bail!("Update file hunk for '{}' has no changes", path); + } + + hunks.push(PatchHunk::UpdateFile { + path, + move_to, + chunks, + }); + continue; + } + + i += 1; + } + + if hunks.is_empty() { + anyhow::bail!("No valid patch directives found"); + } + + Ok(hunks) +} + +#[cfg(test)] +#[path = "apply_patch_tests.rs"] +mod apply_patch_tests; diff --git a/crates/jcode-app-core/src/tool/apply_patch_tests.rs b/crates/jcode-app-core/src/tool/apply_patch_tests.rs new file mode 100644 index 0000000..9fbb68b --- /dev/null +++ b/crates/jcode-app-core/src/tool/apply_patch_tests.rs @@ -0,0 +1,247 @@ +use super::*; +use std::io::Write; +use tempfile::NamedTempFile; + +fn write_temp(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f +} + +#[test] +fn test_parse_add_file() { + let patch = + "*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n+Second line\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + assert_eq!(hunks.len(), 1); + match &hunks[0] { + PatchHunk::AddFile { path, contents } => { + assert_eq!(path, "hello.txt"); + assert_eq!(contents, "Hello world\nSecond line\n"); + } + _ => panic!("Expected AddFile"), + } +} + +#[test] +fn test_parse_delete_file() { + let patch = "*** Begin Patch\n*** Delete File: old.txt\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + assert_eq!(hunks.len(), 1); + match &hunks[0] { + PatchHunk::DeleteFile { path } => { + assert_eq!(path, "old.txt"); + } + _ => panic!("Expected DeleteFile"), + } +} + +#[test] +fn test_parse_update_file_simple() { + let patch = "*** Begin Patch\n*** Update File: test.py\n@@\n foo\n-bar\n+baz\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + assert_eq!(hunks.len(), 1); + match &hunks[0] { + PatchHunk::UpdateFile { path, chunks, .. } => { + assert_eq!(path, "test.py"); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].old_lines, vec!["foo", "bar"]); + assert_eq!(chunks[0].new_lines, vec!["foo", "baz"]); + } + _ => panic!("Expected UpdateFile"), + } +} + +#[test] +fn test_parse_update_with_context() { + let patch = "*** Begin Patch\n*** Update File: test.py\n@@ def my_func():\n- pass\n+ return 42\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + match &hunks[0] { + PatchHunk::UpdateFile { chunks, .. } => { + assert_eq!(chunks[0].change_context, Some("def my_func():".to_string())); + assert_eq!(chunks[0].old_lines, vec![" pass"]); + assert_eq!(chunks[0].new_lines, vec![" return 42"]); + } + _ => panic!("Expected UpdateFile"), + } +} + +#[test] +fn test_parse_update_with_move() { + let patch = "*** Begin Patch\n*** Update File: old.py\n*** Move to: new.py\n@@\n-old_line\n+new_line\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + match &hunks[0] { + PatchHunk::UpdateFile { + path, + move_to, + chunks, + } => { + assert_eq!(path, "old.py"); + assert_eq!(move_to, &Some("new.py".to_string())); + assert_eq!(chunks.len(), 1); + } + _ => panic!("Expected UpdateFile"), + } +} + +#[test] +fn test_parse_multiple_chunks() { + let patch = "*** Begin Patch\n*** Update File: test.py\n@@\n foo\n-bar\n+BAR\n@@\n baz\n-qux\n+QUX\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + match &hunks[0] { + PatchHunk::UpdateFile { chunks, .. } => { + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].old_lines, vec!["foo", "bar"]); + assert_eq!(chunks[0].new_lines, vec!["foo", "BAR"]); + assert_eq!(chunks[1].old_lines, vec!["baz", "qux"]); + assert_eq!(chunks[1].new_lines, vec!["baz", "QUX"]); + } + _ => panic!("Expected UpdateFile"), + } +} + +#[test] +fn test_parse_end_of_file() { + let patch = "*** Begin Patch\n*** Update File: test.py\n@@\n last_line\n+new_last_line\n*** End of File\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + match &hunks[0] { + PatchHunk::UpdateFile { chunks, .. } => { + assert!(chunks[0].is_end_of_file); + } + _ => panic!("Expected UpdateFile"), + } +} + +#[tokio::test] +async fn test_apply_update_simple() { + let f = write_temp("foo\nbar\n"); + let chunks = vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["foo".to_string(), "bar".to_string()], + new_lines: vec!["foo".to_string(), "baz".to_string()], + is_end_of_file: false, + }]; + let (old_result, new_result) = apply_update_chunks(f.path(), &chunks).await.unwrap(); + assert_eq!(old_result, "foo\nbar\n"); + assert_eq!(new_result, "foo\nbaz\n"); +} + +#[tokio::test] +async fn test_apply_update_multiple_chunks() { + let f = write_temp("foo\nbar\nbaz\nqux\n"); + let chunks = vec![ + UpdateFileChunk { + change_context: None, + old_lines: vec!["foo".to_string(), "bar".to_string()], + new_lines: vec!["foo".to_string(), "BAR".to_string()], + is_end_of_file: false, + }, + UpdateFileChunk { + change_context: None, + old_lines: vec!["baz".to_string(), "qux".to_string()], + new_lines: vec!["baz".to_string(), "QUX".to_string()], + is_end_of_file: false, + }, + ]; + let (old_result, new_result) = apply_update_chunks(f.path(), &chunks).await.unwrap(); + assert_eq!(old_result, "foo\nbar\nbaz\nqux\n"); + assert_eq!(new_result, "foo\nBAR\nbaz\nQUX\n"); +} + +#[tokio::test] +async fn test_apply_update_with_context_header() { + let f = write_temp( + "class Foo:\n def bar(self):\n pass\n def baz(self):\n pass\n", + ); + let chunks = vec![UpdateFileChunk { + change_context: Some("def baz(self):".to_string()), + old_lines: vec![" pass".to_string()], + new_lines: vec![" return 42".to_string()], + is_end_of_file: false, + }]; + let (_old_result, new_result) = apply_update_chunks(f.path(), &chunks).await.unwrap(); + assert_eq!( + new_result, + "class Foo:\n def bar(self):\n pass\n def baz(self):\n return 42\n" + ); +} + +#[tokio::test] +async fn test_apply_update_append_at_eof() { + let f = write_temp("foo\nbar\nbaz\n"); + let chunks = vec![UpdateFileChunk { + change_context: None, + old_lines: vec![], + new_lines: vec!["quux".to_string()], + is_end_of_file: false, + }]; + let (_old_result, new_result) = apply_update_chunks(f.path(), &chunks).await.unwrap(); + assert_eq!(new_result, "foo\nbar\nbaz\nquux\n"); +} + +#[test] +fn test_generate_diff_summary_compact_format() { + let old = "line one\nline two\nline three\n"; + let new = "line one\nchanged two\nline three\n"; + let diff = generate_diff_summary(old, new); + + assert!(diff.contains("2- line two")); + assert!(diff.contains("2+ changed two")); + assert!(!diff.contains("line one")); +} + +#[test] +fn test_seek_sequence_exact() { + let lines: Vec<String> = vec!["foo", "bar", "baz"] + .into_iter() + .map(String::from) + .collect(); + let pattern: Vec<String> = vec!["bar", "baz"].into_iter().map(String::from).collect(); + assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(1)); +} + +#[test] +fn test_seek_sequence_whitespace_tolerant() { + let lines: Vec<String> = vec!["foo ", "bar\t"] + .into_iter() + .map(String::from) + .collect(); + let pattern: Vec<String> = vec!["foo", "bar"].into_iter().map(String::from).collect(); + assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0)); +} + +#[test] +fn test_seek_sequence_eof() { + let lines: Vec<String> = vec!["a", "b", "c", "d"] + .into_iter() + .map(String::from) + .collect(); + let pattern: Vec<String> = vec!["c", "d"].into_iter().map(String::from).collect(); + assert_eq!(seek_sequence(&lines, &pattern, 0, true), Some(2)); +} + +#[test] +fn test_parse_no_begin() { + let result = parse_apply_patch("random text"); + assert!(result.is_err()); +} + +#[test] +fn test_parse_heredoc_wrapper() { + let patch = "<<'EOF'\n*** Begin Patch\n*** Add File: test.txt\n+hello\n*** End Patch\nEOF"; + let hunks = parse_apply_patch(patch).unwrap(); + assert_eq!(hunks.len(), 1); +} + +#[test] +fn test_parse_update_without_explicit_at() { + let patch = "*** Begin Patch\n*** Update File: file.py\n import foo\n+bar\n*** End Patch"; + let hunks = parse_apply_patch(patch).unwrap(); + match &hunks[0] { + PatchHunk::UpdateFile { chunks, .. } => { + assert_eq!(chunks.len(), 1); + assert!(chunks[0].change_context.is_none()); + } + _ => panic!("Expected UpdateFile"), + } +} diff --git a/crates/jcode-app-core/src/tool/bash.rs b/crates/jcode-app-core/src/tool/bash.rs new file mode 100644 index 0000000..9e9cf05 --- /dev/null +++ b/crates/jcode-app-core/src/tool/bash.rs @@ -0,0 +1,1178 @@ +use super::{StdinInputRequest, Tool, ToolContext, ToolOutput}; +use crate::background::TaskResult; +use crate::bus::{ + BackgroundTaskProgress, BackgroundTaskProgressKind, BackgroundTaskProgressSource, +}; +use crate::stdin_detect::{self, StdinState}; +use crate::util::truncate_str; +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use serde::Deserialize; +use serde_json::{Value, json}; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::path::Path; +#[cfg(unix)] +use std::process::Command as StdCommand; +use std::process::Stdio; +use std::sync::LazyLock; +use std::time::Duration; +#[cfg(unix)] +use std::time::Instant; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command as TokioCommand; + +const MAX_OUTPUT_LEN: usize = 30000; +const DEFAULT_TIMEOUT_MS: u64 = 120000; +const STDIN_POLL_INTERVAL_MS: u64 = 500; +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."; +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 ...`."; +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 ...`."; + +/// Build a clear timeout message. The `timeout` param is in milliseconds, which +/// agents frequently mistake for seconds (e.g. passing 1000 thinking it means +/// 1000s when it is 1s). Spell out the seconds equivalent and, for suspiciously +/// short timeouts, hint that the unit is milliseconds so the next attempt uses a +/// sane value instead of repeating the same mistake. +fn timeout_message(timeout_ms: u64) -> String { + let secs = timeout_ms as f64 / 1000.0; + let mut msg = format!("Command timed out after {}ms ({:.1}s)", timeout_ms, secs); + if timeout_ms <= 5000 { + msg.push_str( + ". Note: the `timeout` parameter is in MILLISECONDS, not seconds. \ + If you meant a longer limit, pass a larger value (e.g. 600000 = 10min) or omit `timeout`.", + ); + } + msg +} + +fn progress_ratio_regex() -> Result<&'static regex::Regex> { + static REGEX: LazyLock<Result<regex::Regex, regex::Error>> = LazyLock::new(|| { + regex::Regex::new( + r"(?i)\b(?P<current>\d{1,6})\s*/\s*(?P<total>\d{1,6})\b(?:\s*(?P<unit>tests?|steps?|files?|items?|cases?|tasks?|targets?|chunks?|batches?|examples?|crates?|modules?|packages?|workers?))?", + ) + }); + REGEX + .as_ref() + .map_err(|err| anyhow::anyhow!("invalid progress ratio regex: {err}")) +} + +fn progress_of_regex() -> Result<&'static regex::Regex> { + static REGEX: LazyLock<Result<regex::Regex, regex::Error>> = LazyLock::new(|| { + regex::Regex::new( + r"(?i)\b(?P<current>\d{1,6})\s+of\s+(?P<total>\d{1,6})\b(?:\s+(?P<unit>tests?|steps?|files?|items?|cases?|tasks?|targets?|chunks?|batches?|examples?|crates?|modules?|packages?|workers?))?", + ) + }); + REGEX + .as_ref() + .map_err(|err| anyhow::anyhow!("invalid progress-of regex: {err}")) +} + +fn progress_byte_ratio_regex() -> Result<&'static regex::Regex> { + static REGEX: LazyLock<Result<regex::Regex, regex::Error>> = LazyLock::new(|| { + regex::Regex::new( + r"(?i)\b(?P<current>\d+(?:\.\d+)?)\s*/\s*(?P<total>\d+(?:\.\d+)?)\s*(?P<unit>bytes?|[kmgt]i?b)\b", + ) + }); + REGEX + .as_ref() + .map_err(|err| anyhow::anyhow!("invalid progress byte-ratio regex: {err}")) +} + +fn progress_percent_regex() -> Result<&'static regex::Regex> { + static REGEX: LazyLock<Result<regex::Regex, regex::Error>> = + LazyLock::new(|| regex::Regex::new(r"(?i)\b(?P<percent>100|[1-9]?\d)\s*%")); + REGEX + .as_ref() + .map_err(|err| anyhow::anyhow!("invalid progress percent regex: {err}")) +} + +#[derive(Deserialize)] +struct ProgressMarker { + #[serde(default)] + percent: Option<f32>, + #[serde(default)] + message: Option<String>, + #[serde(default)] + current: Option<u64>, + #[serde(default)] + total: Option<u64>, + #[serde(default)] + unit: Option<String>, + #[serde(default)] + eta_seconds: Option<u64>, + #[serde(default)] + kind: Option<String>, + #[serde(default)] + checkpoint: Option<bool>, +} + +fn task_id_from_output_path(path: &Path) -> Option<&str> { + path.file_stem()?.to_str() +} + +fn parse_progress_kind(kind: Option<&str>) -> BackgroundTaskProgressKind { + match kind { + Some("indeterminate") => BackgroundTaskProgressKind::Indeterminate, + _ => BackgroundTaskProgressKind::Determinate, + } +} + +fn summarize_background_command(description: Option<&str>, command: &str) -> String { + if let Some(description) = description + .map(str::trim) + .filter(|description| !description.is_empty()) + { + return truncate_str(description, 28).to_string(); + } + + let trimmed = command.trim(); + if trimmed.is_empty() { + return "bash".to_string(); + } + + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + let start = tokens + .iter() + .position(|token| !token.contains('=')) + .unwrap_or(0); + let tokens = &tokens[start..]; + if tokens.is_empty() { + return truncate_str(trimmed, 28).to_string(); + } + + let label = match tokens { + ["python" | "python3" | "bash" | "sh" | "node", script, ..] => std::path::Path::new(script) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(script) + .to_string(), + ["cargo", subcommand, ..] => format!("cargo {}", subcommand), + ["npm" | "pnpm" | "yarn", command, script, ..] if *command == "run" => { + format!("{} {} {}", tokens[0], command, script) + } + [first, second, ..] => format!("{} {}", first, second), + [first] => first.to_string(), + [] => "bash".to_string(), + }; + + truncate_str(&label, 28).to_string() +} + +fn parse_progress_marker_with_checkpoint(line: &str) -> Option<(BackgroundTaskProgress, bool)> { + let payload = line.trim().strip_prefix(PROGRESS_MARKER_PREFIX)?.trim(); + let marker: ProgressMarker = serde_json::from_str(payload).ok()?; + let is_checkpoint = + marker.checkpoint.unwrap_or(false) || matches!(marker.kind.as_deref(), Some("checkpoint")); + let kind = if marker.percent.is_some() + || matches!((marker.current, marker.total), (_, Some(total)) if total > 0) + { + BackgroundTaskProgressKind::Determinate + } else { + parse_progress_kind(marker.kind.as_deref()) + }; + + Some(( + BackgroundTaskProgress { + kind, + percent: marker.percent, + message: marker.message, + current: marker.current, + total: marker.total, + unit: marker.unit, + eta_seconds: marker.eta_seconds, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + } + .normalize(), + is_checkpoint, + )) +} + +#[cfg(all(test, unix))] +fn parse_progress_marker(line: &str) -> Option<BackgroundTaskProgress> { + parse_progress_marker_with_checkpoint(line).map(|(progress, _)| progress) +} + +fn parse_checkpoint_marker(line: &str) -> Option<BackgroundTaskProgress> { + let payload = line.trim().strip_prefix(CHECKPOINT_MARKER_PREFIX)?.trim(); + let marker: ProgressMarker = serde_json::from_str(payload).unwrap_or_else(|_| ProgressMarker { + percent: None, + message: Some(payload.to_string()), + current: None, + total: None, + unit: None, + eta_seconds: None, + kind: Some("checkpoint".to_string()), + checkpoint: Some(true), + }); + + Some( + BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Indeterminate, + percent: marker.percent, + message: marker.message, + current: marker.current, + total: marker.total, + unit: marker.unit, + eta_seconds: marker.eta_seconds, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::Reported, + } + .normalize(), + ) +} + +fn progress_message_from_line(line: &str, matched_fragment: &str) -> Option<String> { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case(matched_fragment.trim()) { + None + } else { + Some(trimmed.to_string()) + } +} + +fn progress_from_counts( + trimmed: &str, + matched: &str, + current: u64, + total: u64, + unit: Option<String>, +) -> Option<BackgroundTaskProgress> { + if total < 2 || current > total { + return None; + } + + Some( + BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: None, + message: progress_message_from_line(trimmed, matched), + current: Some(current), + total: Some(total), + unit, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::ParsedOutput, + } + .normalize(), + ) +} + +pub(super) fn parse_heuristic_progress(line: &str) -> Result<Option<BackgroundTaskProgress>> { + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + if let Some(captures) = progress_byte_ratio_regex()?.captures(trimmed) { + let current = captures + .name("current") + .and_then(|m| m.as_str().parse::<f64>().ok()); + let total = captures + .name("total") + .and_then(|m| m.as_str().parse::<f64>().ok()); + if let (Some(current), Some(total), Some(matched)) = (current, total, captures.get(0)) + && total > 0.0 + && current <= total + { + return Ok(Some( + BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(((current / total) * 100.0) as f32), + message: progress_message_from_line(trimmed, matched.as_str()), + current: None, + total: None, + unit: captures + .name("unit") + .map(|unit| unit.as_str().to_ascii_lowercase()), + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::ParsedOutput, + } + .normalize(), + )); + } + } + + if let Some(captures) = progress_ratio_regex()?.captures(trimmed) { + let current = captures + .name("current") + .and_then(|m| m.as_str().parse::<u64>().ok()); + let total = captures + .name("total") + .and_then(|m| m.as_str().parse::<u64>().ok()); + if let (Some(current), Some(total), Some(matched)) = (current, total, captures.get(0)) { + return Ok(progress_from_counts( + trimmed, + matched.as_str(), + current, + total, + captures + .name("unit") + .map(|unit| unit.as_str().to_ascii_lowercase()), + )); + } + } + + if let Some(captures) = progress_of_regex()?.captures(trimmed) { + let current = captures + .name("current") + .and_then(|m| m.as_str().parse::<u64>().ok()); + let total = captures + .name("total") + .and_then(|m| m.as_str().parse::<u64>().ok()); + if let (Some(current), Some(total), Some(matched)) = (current, total, captures.get(0)) { + return Ok(progress_from_counts( + trimmed, + matched.as_str(), + current, + total, + captures + .name("unit") + .map(|unit| unit.as_str().to_ascii_lowercase()), + )); + } + } + + if let Some(captures) = progress_percent_regex()?.captures(trimmed) + && let (Some(percent), Some(matched)) = ( + captures + .name("percent") + .and_then(|m| m.as_str().parse::<f32>().ok()), + captures.get(0), + ) + { + return Ok(Some( + BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Determinate, + percent: Some(percent), + message: progress_message_from_line(trimmed, matched.as_str()), + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::ParsedOutput, + } + .normalize(), + )); + } + + const PHASE_PREFIXES: &[&str] = &[ + "Compiling ", + "Downloading ", + "Running ", + "Building ", + "Linking ", + "Resolving ", + "Fetching ", + "Installing ", + ]; + if PHASE_PREFIXES + .iter() + .any(|prefix| trimmed.starts_with(prefix)) + { + return Ok(Some( + BackgroundTaskProgress { + kind: BackgroundTaskProgressKind::Indeterminate, + percent: None, + message: Some(trimmed.to_string()), + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: Utc::now().to_rfc3339(), + source: BackgroundTaskProgressSource::ParsedOutput, + } + .normalize(), + )); + } + + Ok(None) +} + +async fn handle_background_output_line( + output_path: &Path, + file: &mut tokio::fs::File, + 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)) => { + if let Some(task_id) = task_id_from_output_path(output_path) { + let _ = crate::background::global() + .update_progress(task_id, progress) + .await; + } + return; + } + Ok(None) => {} + Err(err) => { + let warning = format!("[jcode warning] failed to parse background progress: {err}\n"); + file.write_all(warning.as_bytes()).await.ok(); + file.flush().await.ok(); + } + } + + let rendered = if stderr { + format!("[stderr] {}\n", raw_line) + } else { + format!("{}\n", raw_line) + }; + file.write_all(rendered.as_bytes()).await.ok(); + file.flush().await.ok(); +} + +fn build_shell_command(cmd_str: &str) -> TokioCommand { + #[cfg(windows)] + { + let mut cmd = TokioCommand::new("cmd.exe"); + cmd.arg("/C").arg(cmd_str); + cmd + } + #[cfg(not(windows))] + { + let mut cmd = TokioCommand::new("bash"); + cmd.arg("-c").arg(cmd_str); + cmd + } +} + +#[cfg(unix)] +fn build_detached_shell_wrapper(command: &str) -> StdCommand { + let mut cmd = StdCommand::new("bash"); + cmd.arg("-lc") + .arg( + r#"eval "$JCODE_RELOAD_DETACH_COMMAND"; status=$?; printf '\n--- Command finished with exit code: %s ---\n' "$status"; exit "$status""#, + ) + .env("JCODE_RELOAD_DETACH_COMMAND", command); + cmd +} + +fn format_command_output(mut output: String, exit_code: Option<i32>) -> String { + if output.len() > MAX_OUTPUT_LEN { + output = truncate_str(&output, MAX_OUTPUT_LEN).to_string(); + output.push_str("\n... (output truncated)"); + } + + if let Some(code) = exit_code.filter(|code| *code != 0) { + output.push_str(&format!("\n\nExit code: {}", code)); + } + + if output.trim().is_empty() { + "Command completed successfully (no output)".to_string() + } else { + output + } +} + +#[cfg(test)] +mod utf8_truncation_tests { + #[cfg(windows)] + use super::build_shell_command; + use super::format_command_output; + + #[test] + fn format_command_output_truncates_on_utf8_boundary() { + let input = format!("{}é", "a".repeat(29_999)); + let output = format_command_output(input, None); + assert!(output.ends_with("\n... (output truncated)")); + assert!(output.starts_with(&"a".repeat(29_999))); + } + + #[cfg(windows)] + #[tokio::test] + async fn build_shell_command_uses_cmd_and_executes_command() { + let output = build_shell_command("echo hello-from-cmd") + .output() + .await + .expect("run cmd command"); + assert!(output.status.success(), "cmd command should succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.to_ascii_lowercase().contains("hello-from-cmd"), + "unexpected stdout: {}", + stdout + ); + } +} + +pub struct BashTool; + +impl BashTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct BashInput { + command: String, + #[serde(default)] + intent: Option<String>, + #[serde(default)] + timeout: Option<u64>, + #[serde(default)] + run_in_background: Option<bool>, + #[serde(default = "default_true")] + notify: bool, + #[serde(default)] + wake: bool, +} + +fn default_true() -> bool { + true +} + +#[async_trait] +impl Tool for BashTool { + fn name(&self) -> &str { + "bash" + } + + fn description(&self) -> &str { + if cfg!(windows) { + WINDOWS_SHELL_TOOL_DESCRIPTION + } else { + BASH_TOOL_DESCRIPTION + } + } + + 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}`." + }; + 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." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let mut params: BashInput = serde_json::from_value(input)?; + let run_in_background = params.run_in_background.unwrap_or(false); + + if run_in_background { + return self.execute_background(params, ctx).await; + } + + // Auto-detect browser bridge commands and rewrite them to the installed + // binary when available, but do not run setup automatically. Browser + // setup should stay an explicit status/setup flow rather than a default + // side effect of trying to use the browser. + if crate::browser::is_browser_command(¶ms.command) { + params.command = crate::browser::rewrite_command_with_full_path(¶ms.command); + + // Start/attach a browser session for this jcode session. + // This gives each agent its own browser tab, preventing + // multi-agent conflicts when using the browser bridge. + if !cfg!(windows) + && std::env::var("BROWSER_SESSION").is_err() + && let Some(session_name) = crate::browser::ensure_browser_session(&ctx.session_id) + { + params.command = format!("BROWSER_SESSION={} {}", session_name, params.command); + } + } + + // Foreground execution with stdin detection + self.execute_foreground(¶ms, &ctx).await + } +} + +impl BashTool { + async fn execute_foreground( + &self, + params: &BashInput, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + #[cfg(unix)] + if self.supports_reload_persistence(ctx) { + return self + .execute_reload_persistable_foreground(params, ctx) + .await; + } + + let timeout_ms = params.timeout.unwrap_or(DEFAULT_TIMEOUT_MS).min(600000); + let timeout_duration = Duration::from_millis(timeout_ms); + + let has_stdin_channel = ctx.stdin_request_tx.is_some(); + + let mut command = build_shell_command(¶ms.command); + command + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if has_stdin_channel { + command.stdin(Stdio::piped()); + } + + if let Some(ref dir) = ctx.working_dir { + command.current_dir(dir); + } + let mut child = command.spawn()?; + + let child_pid = child.id().unwrap_or(0); + let stdin_handle = child.stdin.take(); + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + + // Owned copies so the work can outlive this call if it is promoted to a + // background task on timeout. + let title = params + .intent + .clone() + .unwrap_or_else(|| params.command.clone()); + let stdin_tx = ctx.stdin_request_tx.clone(); + let tool_call_id = ctx.tool_call_id.clone(); + let title_for_work = title.clone(); + + // 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<Result<ToolOutput>> = + 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 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 stdin_task = if has_stdin_channel { + Some(tokio::spawn(async move { + if let (Some(mut stdin_pipe), Some(stdin_tx)) = (stdin_handle, stdin_tx) { + tokio::time::sleep(Duration::from_millis(STDIN_INITIAL_DELAY_MS)).await; + + let mut request_counter = 0u32; + loop { + #[cfg(target_os = "linux")] + let state = stdin_detect::linux::check_process_tree(child_pid); + #[cfg(not(target_os = "linux"))] + let state = stdin_detect::is_waiting_for_stdin(child_pid); + + if state == StdinState::Reading { + request_counter += 1; + let request_id = + format!("stdin-{}-{}", tool_call_id, request_counter); + let (response_tx, response_rx) = + tokio::sync::oneshot::channel(); + + let request = StdinInputRequest { + request_id, + prompt: String::new(), + is_password: false, + response_tx, + }; + + if stdin_tx.send(request).is_err() { + break; + } + + match response_rx.await { + Ok(input) => { + let line = if input.ends_with('\n') { + input + } else { + format!("{}\n", input) + }; + if stdin_pipe.write_all(line.as_bytes()).await.is_err() + { + break; + } + if stdin_pipe.flush().await.is_err() { + break; + } + } + Err(_) => break, + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } else { + tokio::time::sleep(Duration::from_millis( + STDIN_POLL_INTERVAL_MS, + )) + .await; + } + } + } + })) + } else { + drop(stdin_handle); + None + }; + + let status = child.wait().await?; + + if let Some(task) = stdin_task { + task.abort(); + } + + let stdout = stdout_task.await.unwrap_or_default(); + let stderr = stderr_task.await.unwrap_or_default(); + + let mut output = String::new(); + if !stdout.is_empty() { + output.push_str(&stdout); + } + if !stderr.is_empty() { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(&stderr); + } + let output = format_command_output(output, status.code()); + Ok(ToolOutput::new(output).with_title(title_for_work)) + }); + + match tokio::time::timeout(timeout_duration, &mut work_handle).await { + Ok(join_result) => match join_result { + Ok(Ok(output)) => Ok(output), + Ok(Err(e)) => Err(anyhow::anyhow!("Command failed: {}", e)), + Err(join_err) => Err(anyhow::anyhow!("Command task panicked: {}", join_err)), + }, + Err(_) => { + // Timed out, but the command is still running. Instead of killing + // it, promote it to a background task so it keeps running, renders + // as a background-task card, and the agent is told where to find it. + let display_name = + summarize_background_command(params.intent.as_deref(), ¶ms.command); + let info = crate::background::global() + .adopt_with_options( + "bash", + Some(display_name.clone()), + &ctx.session_id, + params.notify, + params.wake, + work_handle, + ) + .await; + + let output = format!( + "Command exceeded the foreground timeout after {:.1}s and is continuing in background (not killed).\n\n\ + Task ID: {}\n\ + Name: {}\n\ + Output file: {}\n\ + Status file: {}\n\n\ + The command is still running; do not rerun it unless you intentionally want a second copy.\n\ + Use `bg` with action=\"wait\" and task_id=\"{}\" to wait for completion or the next progress checkpoint.\n\ + Use `bg` with action=\"output\" and task_id=\"{}\" to inspect output.\n\ + If you expected it to finish quickly and it did not, the `timeout` parameter is in MILLISECONDS; pass a larger value or omit it.", + timeout_ms as f64 / 1000.0, + info.task_id, + display_name, + info.output_file.display(), + info.status_file.display(), + info.task_id, + info.task_id, + ); + + Ok(ToolOutput::new(output) + .with_title(title) + .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": timeout_ms, + }))) + } + } + } + + #[cfg(unix)] + fn supports_reload_persistence(&self, ctx: &ToolContext) -> bool { + matches!( + ctx.execution_mode, + crate::tool::ToolExecutionMode::AgentTurn + ) && ctx.stdin_request_tx.is_none() + && ctx.graceful_shutdown_signal.is_some() + } + + #[cfg(unix)] + async fn execute_reload_persistable_foreground( + &self, + params: &BashInput, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + let timeout_ms = params.timeout.unwrap_or(DEFAULT_TIMEOUT_MS).min(600000); + let timeout_duration = Duration::from_millis(timeout_ms); + let started_at = Utc::now().to_rfc3339(); + let started = Instant::now(); + let manager = crate::background::global(); + let info = manager.reserve_task_info(); + let display_name = summarize_background_command(params.intent.as_deref(), ¶ms.command); + + let mut cmd = build_detached_shell_wrapper(¶ms.command); + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&info.output_file)?; + let stderr = stdout.try_clone()?; + cmd.stdin(Stdio::null()).stdout(stdout).stderr(stderr); + if let Some(ref dir) = ctx.working_dir { + cmd.current_dir(dir); + } + + let mut child = crate::platform::spawn_detached(&mut cmd)?; + let pid = child.id(); + let shutdown_signal = ctx.graceful_shutdown_signal.clone(); + + loop { + if let Some(status) = child.try_wait()? { + let output = tokio::fs::read_to_string(&info.output_file) + .await + .unwrap_or_default(); + let _ = tokio::fs::remove_file(&info.output_file).await; + let _ = tokio::fs::remove_file(&info.status_file).await; + return Ok( + ToolOutput::new(format_command_output(output, status.code())).with_title( + params + .intent + .clone() + .unwrap_or_else(|| params.command.clone()), + ), + ); + } + + if started.elapsed() >= timeout_duration { + let elapsed = started.elapsed(); + manager + .register_detached_task( + &info, + "bash", + Some(display_name.clone()), + &ctx.session_id, + pid, + &started_at, + params.notify, + params.wake, + ) + .await; + + let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); + let output = format!( + "Command exceeded the foreground timeout after {:.1}s and is continuing in background.\n\n\ + Task ID: {}\n\ + Name: {}\n\ + Foreground time used: {:.1}s\n\ + Output file: {}\n\ + Status file: {}\n\n\ + The command is still running; do not rerun it unless you intentionally want a second copy.\n\ + Use `bg` with action=\"wait\" and task_id=\"{}\" to wait for completion or the next progress checkpoint.\n\ + Use `bg` with action=\"output\" and task_id=\"{}\" to inspect output.", + timeout_ms as f64 / 1000.0, + info.task_id, + display_name, + elapsed.as_secs_f64(), + info.output_file.display(), + info.status_file.display(), + info.task_id, + info.task_id, + ); + return Ok(ToolOutput::new(output) + .with_title( + params + .intent + .clone() + .unwrap_or_else(|| params.command.clone()), + ) + .with_metadata(json!({ + "background": true, + "task_id": info.task_id, + "output_file": info.output_file.to_string_lossy(), + "status_file": info.status_file.to_string_lossy(), + "timeout_promoted": true, + "foreground_timeout_ms": timeout_ms, + "foreground_elapsed_ms": elapsed_ms, + "pid": pid, + }))); + } + + if shutdown_signal + .as_ref() + .map(|signal| signal.is_set()) + .unwrap_or(false) + { + manager + .register_detached_task( + &info, + "bash", + Some(display_name.clone()), + &ctx.session_id, + pid, + &started_at, + params.notify, + params.wake, + ) + .await; + 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, + info.output_file.display(), + info.status_file.display(), + info.task_id, + ); + return Ok(ToolOutput::new(output) + .with_title( + params + .intent + .clone() + .unwrap_or_else(|| params.command.clone()), + ) + .with_metadata(json!({ + "background": true, + "task_id": info.task_id, + "output_file": info.output_file.to_string_lossy(), + "status_file": info.status_file.to_string_lossy(), + "reload_persisted": true, + "pid": pid, + }))); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + /// Execute a command in the background + async fn execute_background(&self, params: BashInput, ctx: ToolContext) -> Result<ToolOutput> { + let command = params.command.clone(); + let description = params.intent.clone(); + let display_name = summarize_background_command(description.as_deref(), &command); + let working_dir = ctx.working_dir.clone(); + let timeout_ms = params.timeout.map(|timeout| timeout.min(600000)); + let timeout_duration = timeout_ms.map(Duration::from_millis); + + let wake = params.wake; + let notify = params.notify || wake; + let info = crate::background::global() + .spawn_with_notify( + "bash", + Some(display_name.clone()), + &ctx.session_id, + notify, + wake, + move |output_path| async move { + let mut cmd = build_shell_command(&command); + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + cmd.kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(ref dir) = working_dir { + cmd.current_dir(dir); + } + let mut child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn command: {}", e))?; + + // Stream output to file + let mut file = tokio::fs::File::create(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?; + + // Read stdout and stderr truly concurrently using select! + // Sequential reads can deadlock if the unread pipe fills up. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let mut stdout_lines = stdout.map(|s| BufReader::new(s).lines()); + let mut stderr_lines = stderr.map(|s| BufReader::new(s).lines()); + let mut stdout_done = stdout_lines.is_none(); + let mut stderr_done = stderr_lines.is_none(); + let timeout_sleep = timeout_duration.map(tokio::time::sleep); + tokio::pin!(timeout_sleep); + let mut timed_out = false; + + while !stdout_done || !stderr_done { + tokio::select! { + _ = async { + match timeout_sleep.as_mut().as_pin_mut() { + Some(sleep) => sleep.await, + None => std::future::pending().await, + } + }, if timeout_duration.is_some() => { + timed_out = true; + #[cfg(unix)] + { + if let Some(pid) = child.id() { + let _ = crate::platform::signal_detached_process_group(pid, libc::SIGKILL); + } else { + let _ = child.start_kill(); + } + } + #[cfg(not(unix))] + { + let _ = child.start_kill(); + } + break; + } + line = async { + match stdout_lines.as_mut() { + Some(r) => r.next_line().await, + None => std::future::pending().await, + } + }, if !stdout_done => { + match line { + Ok(Some(line)) => { + handle_background_output_line(&output_path, &mut file, &line, false).await; + } + _ => { stdout_done = true; } + } + } + line = async { + match stderr_lines.as_mut() { + Some(r) => r.next_line().await, + None => std::future::pending().await, + } + }, if !stderr_done => { + match line { + Ok(Some(line)) => { + handle_background_output_line(&output_path, &mut file, &line, true).await; + } + _ => { stderr_done = true; } + } + } + } + } + + if timed_out { + let _ = child.wait().await; + let msg = timeout_message(timeout_ms.unwrap_or_default()); + let timeout_line = format!("\n--- {} ---\n", msg); + file.write_all(timeout_line.as_bytes()).await.ok(); + return Ok(TaskResult::failed(Some(124), msg)); + } + + let status = child.wait().await?; + let exit_code = status.code(); + + // Write final status line + let status_line = format!( + "\n--- Command finished with exit code: {} ---\n", + exit_code.unwrap_or(-1) + ); + file.write_all(status_line.as_bytes()).await.ok(); + + if status.success() { + Ok(TaskResult::completed(exit_code)) + } else { + Ok(TaskResult::failed( + exit_code, + format!("Command exited with code {}", exit_code.unwrap_or(-1)), + )) + } + }, + ) + .await; + + let notify_msg = if wake { + "The agent will be woken when the task completes." + } else if notify { + "You will be notified when the task completes." + } else { + "Notifications disabled. Use `bg` tool to check status." + }; + let output = format!( + "Command started in background.\n\n\ + Task ID: {}\n\ + Name: {}\n\ + 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 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\"", + info.task_id, + display_name, + info.output_file.display(), + info.status_file.display(), + notify_msg, + info.task_id, + info.task_id, + ); + + Ok(ToolOutput::new(output) + .with_title(description.unwrap_or_else(|| format!("Background: {}", params.command))) + .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(), + }))) + } +} + +#[cfg(all(test, not(windows)))] +#[path = "bash_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/tool/bash_tests.rs b/crates/jcode-app-core/src/tool/bash_tests.rs new file mode 100644 index 0000000..7b42977 --- /dev/null +++ b/crates/jcode-app-core/src/tool/bash_tests.rs @@ -0,0 +1,773 @@ +use super::*; +use crate::bus::{BackgroundTaskProgressSource, BackgroundTaskStatus}; +use crate::tool::StdinInputRequest; +use crate::tool::bash::{BashTool, parse_heuristic_progress}; +use serde_json::json; +use tokio::sync::mpsc; + +fn make_ctx(stdin_tx: Option<mpsc::UnboundedSender<StdinInputRequest>>) -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-msg".to_string(), + tool_call_id: "test-call".to_string(), + working_dir: Some(std::path::PathBuf::from("/tmp")), + stdin_request_tx: stdin_tx, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } +} + +fn make_agent_ctx(signal: jcode_agent_runtime::InterruptSignal) -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-msg".to_string(), + tool_call_id: "test-call-agent".to_string(), + working_dir: Some(std::path::PathBuf::from("/tmp")), + stdin_request_tx: None, + graceful_shutdown_signal: Some(signal), + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + } +} + +#[tokio::test] +async fn test_basic_command_no_stdin() { + let tool = BashTool::new(); + let input = json!({"command": "echo hello"}); + let ctx = make_ctx(None); + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("hello")); +} + +#[tokio::test] +async fn test_basic_command_with_unused_stdin_channel() { + let (tx, _rx) = mpsc::unbounded_channel(); + let tool = BashTool::new(); + let input = json!({"command": "echo world"}); + let ctx = make_ctx(Some(tx)); + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("world")); +} + +#[tokio::test] +async fn test_stdin_forwarding_single_line() { + let (tx, mut rx) = mpsc::unbounded_channel::<StdinInputRequest>(); + let tool = BashTool::new(); + + // "head -n1" reads one line from stdin and prints it + let input = json!({"command": "head -n1", "timeout": 10000}); + let ctx = make_ctx(Some(tx)); + + // Spawn the tool execution + let tool_handle = tokio::spawn(async move { tool.execute(input, ctx).await }); + + // Wait for the stdin request to arrive + let req = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .expect("timed out waiting for stdin request") + .expect("channel closed"); + + assert!(req.request_id.starts_with("stdin-test-call-")); + assert!(!req.is_password); + + // Send the response + req.response_tx.send("test_input_line".to_string()).unwrap(); + + // Wait for tool to finish + let result = tokio::time::timeout(std::time::Duration::from_secs(5), tool_handle) + .await + .expect("tool timed out") + .expect("tool panicked") + .expect("tool errored"); + + assert!( + result.output.contains("test_input_line"), + "output should contain the input we sent: {}", + result.output + ); +} + +#[tokio::test] +async fn test_stdin_forwarding_multiple_lines() { + let (tx, mut rx) = mpsc::unbounded_channel::<StdinInputRequest>(); + let tool = BashTool::new(); + + // "head -n2" reads two lines + let input = json!({"command": "head -n2", "timeout": 15000}); + let ctx = make_ctx(Some(tx)); + + let tool_handle = tokio::spawn(async move { tool.execute(input, ctx).await }); + + // First line + let req1 = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .expect("timed out waiting for first stdin request") + .expect("channel closed"); + assert!( + req1.request_id.ends_with("-1"), + "first request should end with -1: {}", + req1.request_id + ); + req1.response_tx.send("line_one".to_string()).unwrap(); + + // Second line + let req2 = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .expect("timed out waiting for second stdin request") + .expect("channel closed"); + assert!( + req2.request_id.ends_with("-2"), + "second request should end with -2: {}", + req2.request_id + ); + req2.response_tx.send("line_two".to_string()).unwrap(); + + let result = tokio::time::timeout(std::time::Duration::from_secs(5), tool_handle) + .await + .expect("tool timed out") + .expect("tool panicked") + .expect("tool errored"); + + assert!( + result.output.contains("line_one"), + "missing line_one in: {}", + result.output + ); + assert!( + result.output.contains("line_two"), + "missing line_two in: {}", + result.output + ); +} + +#[tokio::test] +async fn test_stdin_not_triggered_for_non_blocking_command() { + let (tx, mut rx) = mpsc::unbounded_channel::<StdinInputRequest>(); + let tool = BashTool::new(); + + // This command doesn't read stdin at all + let input = json!({"command": "echo no_stdin_needed", "timeout": 5000}); + let ctx = make_ctx(Some(tx)); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("no_stdin_needed")); + + // No stdin request should have been sent + assert!( + rx.try_recv().is_err(), + "no stdin request should be sent for a command that doesn't read stdin" + ); +} + +#[tokio::test] +async fn test_command_timeout_with_stdin_channel() { + let (tx, _rx) = mpsc::unbounded_channel::<StdinInputRequest>(); + let tool = BashTool::new(); + + // `cat` blocks forever on stdin. With a short timeout and no stdin response, + // the command should be promoted to the background (kept running), not killed + // with an error. + let input = json!({"command": "cat", "timeout": 1000}); + let ctx = make_ctx(Some(tx)); + + let result = tool + .execute(input, ctx) + .await + .expect("timeout should promote to background, not error"); + assert!( + result.output.contains("continuing in background"), + "output should explain background promotion: {}", + result.output + ); + let metadata = result.metadata.expect("expected background metadata"); + assert_eq!(metadata["background"], true); + assert_eq!(metadata["timeout_promoted"], true); + assert_eq!(metadata["foreground_timeout_ms"], 1000); + + // Clean up the still-running background task so it does not linger. + let task_id = metadata["task_id"] + .as_str() + .expect("task_id should be present"); + let _ = crate::background::global().cancel(task_id).await; +} + +#[tokio::test] +async fn test_foreground_timeout_promotes_and_command_keeps_running() { + let tool = BashTool::new(); + // No stdin channel and Direct mode -> plain foreground path. The command runs + // longer than the timeout, so it should be promoted to background and keep + // running to completion rather than being killed at the timeout. + let input = json!({"command": "sleep 0.5; echo fg_promote_ok", "timeout": 100}); + let ctx = make_ctx(None); + + let result = tool + .execute(input, ctx) + .await + .expect("timeout should promote the still-running command to background"); + assert!( + result.output.contains("continuing in background"), + "output should explain background promotion: {}", + result.output + ); + let metadata = result.metadata.expect("expected background metadata"); + assert_eq!(metadata["background"], true); + assert_eq!(metadata["timeout_promoted"], true); + let task_id = metadata["task_id"] + .as_str() + .expect("task_id should be present") + .to_string(); + + // Wait for the promoted command to finish on its own. + let mut final_status = None; + for _ in 0..40 { + if let Some(status) = crate::background::global().status(&task_id).await + && status.status != BackgroundTaskStatus::Running + { + final_status = Some(status); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let status = final_status.expect("promoted background task should finish"); + assert_eq!(status.status, BackgroundTaskStatus::Completed); + + let output = crate::background::global() + .output(&task_id) + .await + .expect("output should exist"); + assert!( + output.contains("fg_promote_ok"), + "command should have continued after foreground timeout: {output}" + ); +} + +#[tokio::test] +async fn test_reload_persistable_bash_continues_in_background() { + let tool = BashTool::new(); + let signal = jcode_agent_runtime::InterruptSignal::new(); + let ctx = make_agent_ctx(signal.clone()); + + let signal_task = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + signal.fire(); + }); + + let result = tool + .execute( + json!({"command": "sleep 1; echo reload_persist_ok", "timeout": 10000}), + ctx, + ) + .await + .expect("reload-persistable command should succeed"); + signal_task.await.expect("signal task should complete"); + + let metadata = result.metadata.expect("expected background metadata"); + assert_eq!(metadata["background"], true); + assert_eq!(metadata["reload_persisted"], true); + let task_id = metadata["task_id"] + .as_str() + .expect("task_id should be present") + .to_string(); + 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"), + ); + + tokio::time::sleep(std::time::Duration::from_millis(1400)).await; + + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + assert_eq!(status.status, BackgroundTaskStatus::Completed); + let output = crate::background::global() + .output(&task_id) + .await + .expect("output should exist"); + assert!(output.contains("reload_persist_ok"), "output was: {output}"); + + let _ = tokio::fs::remove_file(output_file).await; + let _ = tokio::fs::remove_file(status_file).await; +} + +#[tokio::test] +async fn test_reload_persistable_bash_timeout_promotes_to_background() { + 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.4; echo timeout_promote_ok", "timeout": 100}), + ctx, + ) + .await + .expect("timeout should promote the still-running command to background"); + + assert!( + result.output.contains("continuing in background"), + "output should explain background promotion: {}", + result.output + ); + assert!( + result.output.contains("do not rerun"), + "output should tell the agent not to rerun duplicate work: {}", + result.output + ); + + let metadata = result.metadata.expect("expected background metadata"); + assert_eq!(metadata["background"], true); + assert_eq!(metadata["timeout_promoted"], true); + assert_eq!(metadata["foreground_timeout_ms"], 100); + let task_id = metadata["task_id"] + .as_str() + .expect("task_id should be present") + .to_string(); + 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 initial_status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + assert_eq!(initial_status.status, BackgroundTaskStatus::Running); + + let mut final_status = None; + for _ in 0..40 { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if status.status != BackgroundTaskStatus::Running { + final_status = Some(status); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + let status = final_status.expect("promoted background task should finish"); + assert_eq!(status.status, BackgroundTaskStatus::Completed); + assert_eq!(status.exit_code, Some(0)); + + let output = crate::background::global() + .output(&task_id) + .await + .expect("output should exist"); + assert!( + output.contains("timeout_promote_ok"), + "command should have continued after foreground timeout: {output}" + ); + + let _ = tokio::fs::remove_file(output_file).await; + let _ = tokio::fs::remove_file(status_file).await; +} + +#[tokio::test] +async fn test_stderr_captured_with_stdin() { + let (tx, _rx) = mpsc::unbounded_channel::<StdinInputRequest>(); + let tool = BashTool::new(); + + let input = json!({"command": "echo stderr_msg >&2", "timeout": 5000}); + let ctx = make_ctx(Some(tx)); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!( + result.output.contains("stderr_msg"), + "stderr should be captured: {}", + result.output + ); +} + +#[test] +fn test_parse_progress_marker_handles_percent_payloads() { + let progress = parse_progress_marker( + r#"JCODE_PROGRESS {"percent":25,"message":"Downloading dependencies"}"#, + ) + .expect("marker should parse"); + + assert_eq!(progress.percent, Some(25.0)); + assert_eq!( + progress.message.as_deref(), + Some("Downloading dependencies") + ); + assert_eq!(progress.kind, BackgroundTaskProgressKind::Determinate); + assert_eq!(progress.source, BackgroundTaskProgressSource::Reported); +} + +#[test] +fn test_parse_heuristic_progress_handles_ratio_output() { + let progress = parse_heuristic_progress("Running test 3/10 tests") + .expect("heuristic parser should not fail") + .expect("heuristic ratio progress should parse"); + + assert_eq!(progress.current, Some(3)); + assert_eq!(progress.total, Some(10)); + assert_eq!(progress.percent, Some(30.0)); + assert_eq!(progress.unit.as_deref(), Some("tests")); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); +} + +#[test] +fn test_parse_heuristic_progress_handles_percent_output() { + let progress = parse_heuristic_progress("download progress 42% complete") + .expect("heuristic parser should not fail") + .expect("heuristic percent progress should parse"); + + assert_eq!(progress.percent, Some(42.0)); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); + assert_eq!( + progress.message.as_deref(), + Some("download progress 42% complete") + ); +} + +#[test] +fn test_parse_heuristic_progress_handles_phase_output() { + let progress = parse_heuristic_progress("Compiling jcode v0.10.2") + .expect("heuristic parser should not fail") + .expect("phase progress should parse"); + + assert_eq!(progress.kind, BackgroundTaskProgressKind::Indeterminate); + assert_eq!(progress.percent, None); + assert_eq!(progress.message.as_deref(), Some("Compiling jcode v0.10.2")); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); +} + +#[test] +fn test_parse_heuristic_progress_handles_of_output() { + let progress = parse_heuristic_progress("Downloaded 3 of 12 crates") + .expect("heuristic parser should not fail") + .expect("heuristic of progress should parse"); + + assert_eq!(progress.current, Some(3)); + assert_eq!(progress.total, Some(12)); + assert_eq!(progress.percent, Some(25.0)); + assert_eq!(progress.unit.as_deref(), Some("crates")); +} + +#[test] +fn test_parse_heuristic_progress_handles_byte_ratio_output() { + let progress = parse_heuristic_progress("Downloaded 1.5/3.0 GiB") + .expect("heuristic parser should not fail") + .expect("heuristic byte ratio progress should parse"); + + assert_eq!(progress.percent, Some(50.0)); + assert_eq!(progress.unit.as_deref(), Some("gib")); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); +} + +#[tokio::test] +async fn test_background_command_progress_marker_updates_status_and_stays_out_of_output() { + let tool = BashTool::new(); + let ctx = make_ctx(None); + + let result = tool + .execute( + json!({ + "command": "printf '%s\n' 'JCODE_PROGRESS {\"current\":3,\"total\":10,\"unit\":\"steps\",\"message\":\"Building\"}'; sleep 0.1; echo done", + "run_in_background": true, + "notify": false, + "wake": false, + }), + ctx, + ) + .await + .expect("background command should start"); + + let metadata = result.metadata.expect("expected metadata"); + let task_id = metadata["task_id"] + .as_str() + .expect("task id should be present") + .to_string(); + + let mut saw_progress = false; + for _ in 0..50 { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if let Some(progress) = status.progress { + saw_progress = true; + assert_eq!(progress.current, Some(3)); + assert_eq!(progress.total, Some(10)); + assert_eq!(progress.unit.as_deref(), Some("steps")); + assert_eq!(progress.message.as_deref(), Some("Building")); + assert_eq!(progress.percent, Some(30.0)); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!( + saw_progress, + "expected progress to be recorded for {task_id}" + ); + + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let output = crate::background::global() + .output(&task_id) + .await + .expect("output should exist"); + assert!(output.contains("done"), "output was: {output}"); + assert!( + !output.contains("JCODE_PROGRESS"), + "progress marker should be hidden from output: {output}" + ); +} + +#[tokio::test] +async fn test_background_command_ratio_output_updates_progress() { + let tool = BashTool::new(); + let ctx = make_ctx(None); + + let result = tool + .execute( + json!({ + "command": "printf '%s\n' 'Running test 4/8 tests'; sleep 0.1; echo done", + "run_in_background": true, + "notify": false, + "wake": false, + }), + ctx, + ) + .await + .expect("background command should start"); + + let metadata = result.metadata.expect("expected metadata"); + let task_id = metadata["task_id"] + .as_str() + .expect("task id should be present") + .to_string(); + + let mut saw_progress = false; + for _ in 0..50 { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if let Some(progress) = status.progress { + saw_progress = true; + assert_eq!(progress.current, Some(4)); + assert_eq!(progress.total, Some(8)); + assert_eq!(progress.percent, Some(50.0)); + assert_eq!(progress.unit.as_deref(), Some("tests")); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + assert!( + saw_progress, + "expected heuristic progress to be recorded for {task_id}" + ); +} + +#[tokio::test] +async fn test_background_command_byte_ratio_output_updates_progress() { + let tool = BashTool::new(); + let ctx = make_ctx(None); + + let result = tool + .execute( + json!({ + "command": "printf '%s\n' 'Downloaded 1.5/3.0 GiB'; sleep 0.1; echo done", + "run_in_background": true, + "notify": false, + "wake": false, + }), + ctx, + ) + .await + .expect("background command should start"); + + let metadata = result.metadata.expect("expected metadata"); + let task_id = metadata["task_id"] + .as_str() + .expect("task id should be present") + .to_string(); + + let mut saw_progress = false; + for _ in 0..50 { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if let Some(progress) = status.progress { + saw_progress = true; + assert_eq!(progress.percent, Some(50.0)); + assert_eq!(progress.unit.as_deref(), Some("gib")); + assert_eq!(progress.source, BackgroundTaskProgressSource::ParsedOutput); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + assert!( + saw_progress, + "expected byte-ratio progress to be recorded for {task_id}" + ); +} + +#[tokio::test] +async fn test_background_command_respects_timeout() { + let tool = BashTool::new(); + let ctx = make_ctx(None); + + let result = tool + .execute( + json!({ + "command": "sleep 5; echo should_not_print", + "run_in_background": true, + "timeout": 100, + "notify": false, + "wake": false, + }), + ctx, + ) + .await + .expect("background command should start"); + + let metadata = result.metadata.expect("expected metadata"); + let task_id = metadata["task_id"] + .as_str() + .expect("task id should be present") + .to_string(); + + let mut final_status = None; + for _ in 0..50 { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if status.status == BackgroundTaskStatus::Failed { + final_status = Some(status); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + let status = final_status.expect("background task should fail after timeout"); + assert_eq!(status.exit_code, Some(124)); + assert!( + status + .error + .as_deref() + .unwrap_or_default() + .contains("timed out"), + "timeout failure should be recorded: {status:?}" + ); + + let output = crate::background::global() + .output(&task_id) + .await + .expect("output should exist"); + assert!( + output.contains("timed out after 100ms"), + "output was: {output}" + ); + assert!( + !output.contains("should_not_print"), + "timed-out command should not complete normally: {output}" + ); +} + +#[tokio::test] +async fn test_background_command_without_timeout_keeps_running_past_default_foreground_timeout() { + let tool = BashTool::new(); + let ctx = make_ctx(None); + + let result = tool + .execute( + json!({ + "command": "sleep 0.25; echo background_no_implicit_timeout_ok", + "run_in_background": true, + "notify": false, + "wake": false, + }), + ctx, + ) + .await + .expect("background command should start"); + + let metadata = result.metadata.expect("expected metadata"); + let task_id = metadata["task_id"] + .as_str() + .expect("task id should be present") + .to_string(); + 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 mut final_status = None; + for _ in 0..30 { + let status = crate::background::global() + .status(&task_id) + .await + .expect("status should exist"); + if status.status != BackgroundTaskStatus::Running { + final_status = Some(status); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let status = final_status.expect("background task should finish normally"); + assert_eq!(status.status, BackgroundTaskStatus::Completed); + assert_eq!(status.exit_code, Some(0)); + + let output = crate::background::global() + .output(&task_id) + .await + .expect("output should exist"); + assert!( + output.contains("background_no_implicit_timeout_ok"), + "output was: {output}" + ); + + let _ = tokio::fs::remove_file(output_file).await; + let _ = tokio::fs::remove_file(status_file).await; +} + +#[test] +fn test_bash_tool_schema_advertises_background_progress_guidance() { + let schema = BashTool::new().parameters_schema(); + let command_description = schema["properties"]["command"]["description"] + .as_str() + .expect("command description should be a string"); + let background_description = schema["properties"]["run_in_background"]["description"] + .as_str() + .expect("run_in_background description should be a string"); + + assert!( + BashTool::new().description().contains("JCODE_PROGRESS"), + "tool description should teach cooperative progress output" + ); + assert!( + command_description.contains("JCODE_PROGRESS"), + "command description should mention progress marker format" + ); + assert!( + background_description.contains("3/10 tests"), + "background description should mention parseable fallback progress output" + ); +} diff --git a/crates/jcode-app-core/src/tool/batch.rs b/crates/jcode-app-core/src/tool/batch.rs new file mode 100644 index 0000000..bcb7192 --- /dev/null +++ b/crates/jcode-app-core/src/tool/batch.rs @@ -0,0 +1,316 @@ +use super::{Registry, Tool, ToolContext, ToolOutput}; +use crate::bus::{BatchSubcallProgress, BatchSubcallState}; +use crate::message::ToolCall; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashMap; + +const MAX_PARALLEL: usize = 10; + +pub(crate) fn generic_batch_schema() -> Value { + json!({ + "type": "object", + "required": ["tool_calls"], + "properties": { + "intent": super::intent_schema_property(), + "tool_calls": { + "type": "array", + "items": { + "type": "object", + "required": ["tool"], + "properties": { + "tool": { + "type": "string", + "description": "Tool name." + } + }, + "additionalProperties": true + }, + "minItems": 1, + "maxItems": 10 + } + } + }) +} + +fn ordered_batch_subcalls( + subcalls: &[(usize, String, Value)], + running: &HashMap<usize, ToolCall>, + failures: &HashMap<usize, bool>, +) -> Vec<BatchSubcallProgress> { + let mut ordered: Vec<BatchSubcallProgress> = subcalls + .iter() + .map(|(i, tool_name, parameters)| { + let tool_call = running.get(i).cloned().unwrap_or_else(|| ToolCall { + id: format!("batch-{}-{}", i + 1, tool_name), + name: tool_name.clone(), + input: parameters.clone(), + intent: ToolCall::intent_from_input(parameters), + thought_signature: None, + }); + let state = if running.contains_key(i) { + BatchSubcallState::Running + } else if failures.get(i).copied().unwrap_or(false) { + BatchSubcallState::Failed + } else { + BatchSubcallState::Succeeded + }; + + BatchSubcallProgress { + index: i + 1, + tool_call, + state, + } + }) + .collect(); + ordered.sort_by_key(|entry| entry.index); + ordered +} + +pub struct BatchTool { + registry: Registry, +} + +impl BatchTool { + pub fn new(registry: Registry) -> Self { + Self { registry } + } +} + +#[derive(Deserialize)] +struct BatchInput { + tool_calls: Vec<ToolCallInput>, +} + +#[derive(Deserialize, Clone)] +struct ToolCallInput { + #[serde(alias = "name")] + tool: String, + #[serde(default)] + parameters: Option<Value>, +} + +impl ToolCallInput { + fn resolved_parameters(self) -> (String, Value) { + if let Some(params) = self.parameters { + return (self.tool, params); + } + (self.tool, Value::Object(Default::default())) + } +} + +/// Try to fix common LLM mistakes in batch tool_calls: +/// - Parameters placed at the same level as "tool" instead of nested under "parameters" +/// - "name" used instead of "tool" for the tool name key +/// - "arguments", "args", or "input" used instead of "parameters" +fn normalize_batch_input(mut input: Value) -> Value { + if let Some(calls) = input.get_mut("tool_calls").and_then(|v| v.as_array_mut()) { + for call in calls.iter_mut() { + if let Some(obj) = call.as_object_mut() { + // Normalize "name" -> "tool" if the model used the wrong key + if !obj.contains_key("tool") + && let Some(name_val) = obj.remove("name") + { + obj.insert("tool".to_string(), name_val); + } + + if !obj.contains_key("parameters") { + for alias in ["arguments", "args", "input"] { + if let Some(alias_val) = obj.remove(alias) { + obj.insert("parameters".to_string(), alias_val); + break; + } + } + } + + if !obj.contains_key("parameters") && obj.contains_key("tool") { + let tool_name = obj.get("tool").cloned(); + let mut params = serde_json::Map::new(); + let keys: Vec<String> = obj.keys().filter(|k| *k != "tool").cloned().collect(); + for key in keys { + if let Some(val) = obj.remove(&key) { + params.insert(key, val); + } + } + if !params.is_empty() { + obj.insert("parameters".to_string(), Value::Object(params)); + } + if let Some(name) = tool_name { + obj.insert("tool".to_string(), name); + } + } + } + } + } + input +} + +#[async_trait] +impl Tool for BatchTool { + fn name(&self) -> &str { + "batch" + } + + fn description(&self) -> &str { + "Run tools in parallel." + } + + fn parameters_schema(&self) -> Value { + generic_batch_schema() + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let input = normalize_batch_input(input); + let params: BatchInput = serde_json::from_value(input)?; + + if params.tool_calls.is_empty() { + return Err(anyhow::anyhow!("No tool calls provided")); + } + + if params.tool_calls.len() > MAX_PARALLEL { + return Err(anyhow::anyhow!( + "Maximum {} parallel tool calls allowed", + MAX_PARALLEL + )); + } + + // Check for disallowed tools + for tc in ¶ms.tool_calls { + if tc.tool == "batch" { + return Err(anyhow::anyhow!("Cannot batch the 'batch' tool")); + } + } + + // Execute all tools in parallel, emitting progress events as each completes + let num_tools = params.tool_calls.len(); + use futures::StreamExt; + let subcalls: Vec<(usize, String, Value)> = params + .tool_calls + .into_iter() + .enumerate() + .map(|(i, tc)| { + let (tool_name, parameters) = tc.resolved_parameters(); + (i, tool_name, parameters) + }) + .collect(); + + let mut running: HashMap<usize, ToolCall> = subcalls + .iter() + .map(|(i, tool_name, parameters)| { + ( + *i, + ToolCall { + id: format!("batch-{}-{}", i + 1, tool_name), + name: tool_name.clone(), + input: parameters.clone(), + intent: ToolCall::intent_from_input(parameters), + thought_signature: None, + }, + ) + }) + .collect(); + + crate::bus::Bus::global().publish(crate::bus::BusEvent::BatchProgress( + crate::bus::BatchProgress { + session_id: ctx.session_id.clone(), + tool_call_id: ctx.tool_call_id.clone(), + total: num_tools, + completed: 0, + last_completed: None, + running: running.values().cloned().collect(), + subcalls: ordered_batch_subcalls(&subcalls, &running, &HashMap::new()), + }, + )); + + let mut stream: futures::stream::FuturesUnordered<_> = subcalls + .iter() + .map(|(i, tool_name, parameters)| { + let registry = self.registry.clone(); + let i = *i; + let tool_name = tool_name.clone(); + let parameters = parameters.clone(); + let sub_ctx = ctx.for_subcall(format!("batch-{}-{}", i + 1, tool_name.clone())); + async move { + let result = registry.execute(&tool_name, parameters, sub_ctx).await; + (i, tool_name, result) + } + }) + .collect(); + + let mut results: Vec<(usize, String, Result<ToolOutput>)> = Vec::with_capacity(num_tools); + let mut failures: HashMap<usize, bool> = HashMap::new(); + let mut completed_count = 0usize; + while let Some((i, tool_name, result)) = stream.next().await { + completed_count += 1; + let failed = result.is_err(); + running.remove(&i); + failures.insert(i, failed); + crate::bus::Bus::global().publish(crate::bus::BusEvent::BatchProgress( + crate::bus::BatchProgress { + session_id: ctx.session_id.clone(), + tool_call_id: ctx.tool_call_id.clone(), + total: num_tools, + completed: completed_count, + last_completed: Some(tool_name.clone()), + running: running.values().cloned().collect(), + subcalls: ordered_batch_subcalls(&subcalls, &running, &failures), + }, + )); + results.push((i, tool_name, result)); + } + // Restore original order + results.sort_by_key(|(i, _, _)| *i); + + // Format results + let mut output = String::new(); + let mut success_count = 0; + let mut error_count = 0; + let mut failed_tools = Vec::new(); + + for (i, tool_name, result) in results { + output.push_str(&format!("--- [{}] {} ---\n", i + 1, tool_name)); + match result { + Ok(out) => { + success_count += 1; + 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)); + output.push_str("...\n(truncated)"); + } else { + output.push_str(&out.output); + } + } + Err(e) => { + error_count += 1; + failed_tools.push(tool_name.clone()); + output.push_str(&format!("Error: {}", e)); + } + } + output.push_str("\n\n"); + } + + if error_count > 0 { + crate::logging::warn(&format!( + "[tool:batch] {} of {} subcalls failed for {} in session {}: {}", + error_count, + num_tools, + ctx.tool_call_id, + ctx.session_id, + failed_tools.join(", ") + )); + } + + output.push_str(&format!( + "Completed: {} succeeded, {} failed", + success_count, error_count + )); + + Ok(ToolOutput::new(output)) + } +} + +#[cfg(test)] +#[path = "batch_tests.rs"] +mod batch_tests; diff --git a/crates/jcode-app-core/src/tool/batch_tests.rs b/crates/jcode-app-core/src/tool/batch_tests.rs new file mode 100644 index 0000000..b276d99 --- /dev/null +++ b/crates/jcode-app-core/src/tool/batch_tests.rs @@ -0,0 +1,143 @@ +use super::*; +use serde_json::json; + +#[test] +fn test_normalize_flat_params() { + let input = json!({ + "tool_calls": [ + {"tool": "read", "file_path": "file1.txt"}, + {"tool": "read", "file_path": "file2.txt"} + ] + }); + + let normalized = normalize_batch_input(input); + let parsed: BatchInput = serde_json::from_value(normalized).unwrap(); + assert_eq!(parsed.tool_calls.len(), 2); + assert_eq!(parsed.tool_calls[0].tool, "read"); + let params = parsed.tool_calls[0].parameters.as_ref().unwrap(); + assert_eq!(params["file_path"], "file1.txt"); +} + +#[test] +fn test_normalize_already_nested() { + let input = json!({ + "tool_calls": [ + {"tool": "read", "parameters": {"file_path": "file1.txt"}} + ] + }); + + let normalized = normalize_batch_input(input); + let parsed: BatchInput = serde_json::from_value(normalized).unwrap(); + assert_eq!(parsed.tool_calls.len(), 1); + let params = parsed.tool_calls[0].parameters.as_ref().unwrap(); + assert_eq!(params["file_path"], "file1.txt"); +} + +#[test] +fn test_normalize_name_key_to_tool() { + let input = json!({ + "tool_calls": [ + {"name": "read", "parameters": {"file_path": "file1.txt"}}, + {"name": "grep", "pattern": "foo", "path": "src/"} + ] + }); + + let normalized = normalize_batch_input(input); + let parsed: BatchInput = serde_json::from_value(normalized).unwrap(); + assert_eq!(parsed.tool_calls.len(), 2); + assert_eq!(parsed.tool_calls[0].tool, "read"); + let params0 = parsed.tool_calls[0].parameters.as_ref().unwrap(); + assert_eq!(params0["file_path"], "file1.txt"); + assert_eq!(parsed.tool_calls[1].tool, "grep"); + let params1 = parsed.tool_calls[1].parameters.as_ref().unwrap(); + assert_eq!(params1["pattern"], "foo"); +} + +#[test] +fn test_normalize_mixed_tool_and_name_keys() { + let input = json!({ + "tool_calls": [ + {"tool": "read", "parameters": {"file_path": "a.rs"}}, + {"name": "read", "parameters": {"file_path": "b.rs"}}, + {"tool": "grep", "pattern": "test"} + ] + }); + + let normalized = normalize_batch_input(input); + let parsed: BatchInput = serde_json::from_value(normalized).unwrap(); + assert_eq!(parsed.tool_calls.len(), 3); + assert_eq!(parsed.tool_calls[0].tool, "read"); + assert_eq!(parsed.tool_calls[1].tool, "read"); + assert_eq!(parsed.tool_calls[2].tool, "grep"); +} + +#[test] +fn test_normalize_arguments_aliases_to_parameters() { + let input = json!({ + "tool_calls": [ + {"tool": "read", "arguments": {"file_path": "a.rs"}}, + {"tool": "read", "args": {"file_path": "b.rs"}}, + {"tool": "read", "input": {"file_path": "c.rs"}} + ] + }); + + let normalized = normalize_batch_input(input); + let parsed: BatchInput = serde_json::from_value(normalized).unwrap(); + + assert_eq!(parsed.tool_calls.len(), 3); + assert_eq!( + parsed.tool_calls[0].parameters.as_ref().unwrap()["file_path"], + "a.rs" + ); + assert_eq!( + parsed.tool_calls[1].parameters.as_ref().unwrap()["file_path"], + "b.rs" + ); + assert_eq!( + parsed.tool_calls[2].parameters.as_ref().unwrap()["file_path"], + "c.rs" + ); +} + +#[test] +fn test_schema_only_requires_tool() { + let schema = BatchTool::new(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(), + )), + compaction: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::compaction::CompactionManager::new(), + )), + }) + .parameters_schema(); + + assert_eq!( + schema["properties"]["tool_calls"]["items"]["required"], + json!(["tool"]) + ); + assert_eq!( + schema["properties"]["tool_calls"]["items"]["additionalProperties"], + json!(true) + ); + assert_eq!( + schema["properties"]["tool_calls"]["items"]["properties"]["tool"]["description"], + json!("Tool name.") + ); + assert!(schema["properties"]["tool_calls"]["items"]["properties"]["parameters"].is_null()); +} + +#[test] +fn test_schema_keeps_flat_generic_subcall_shape() { + let schema = generic_batch_schema(); + + assert!(schema["properties"]["tool_calls"]["description"].is_null()); + assert!(schema["properties"]["tool_calls"]["items"]["description"].is_null()); + assert_eq!( + schema["properties"]["tool_calls"]["items"]["properties"] + .as_object() + .map(|props| props.len()), + Some(1) + ); + assert!(schema["properties"]["tool_calls"]["items"]["oneOf"].is_null()); +} diff --git a/crates/jcode-app-core/src/tool/bg.rs b/crates/jcode-app-core/src/tool/bg.rs new file mode 100644 index 0000000..dac2a75 --- /dev/null +++ b/crates/jcode-app-core/src/tool/bg.rs @@ -0,0 +1,878 @@ +//! Background task management tool +//! +//! Allows the agent to list, wait on, inspect, read output from, and manage +//! background tasks. + +use super::{Tool, ToolContext, ToolOutput}; +use crate::background; +use crate::bus::BackgroundTaskStatus; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +fn default_watch_notify() -> bool { + true +} + +fn default_watch_wake() -> bool { + true +} + +fn default_wait_return_on_progress() -> bool { + true +} + +const DEFAULT_WAIT_SECONDS: u64 = 60; +const MAX_WAIT_SECONDS: u64 = 60 * 60; +const DEFAULT_TAIL_LINES: usize = 80; +const DEFAULT_WAIT_PREVIEW_LINES: usize = 40; +const MAX_OUTPUT_BYTES: usize = 50_000; + +pub struct BgTool; + +impl BgTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct BgInput { + /// Action to perform: list, status, output, tail, cancel, cleanup, watch, delivery, subscribe, wait + #[serde(default)] + action: Option<String>, + /// Short display label describing why this tool call is being made. + #[serde(default)] + intent: Option<String>, + /// Task ID (for single-task actions) + #[serde(default)] + task_id: Option<String>, + /// Task IDs (for multi-task wait/status/output where supported) + #[serde(default)] + task_ids: Option<Vec<String>>, + /// Use the latest matching task when task_id is omitted + #[serde(default)] + latest: Option<bool>, + /// Restrict implicit selection/listing to this session. Defaults to false for list and true for implicit selection. + #[serde(default)] + session_only: Option<bool>, + /// Status filter, either a string or array of strings: running/completed/failed/superseded/terminal/all + #[serde(default)] + status_filter: Option<Value>, + /// Max age in hours for cleanup (default: 24) + #[serde(default)] + max_age_hours: Option<u64>, + /// Dry-run cleanup without deleting files + #[serde(default)] + dry_run: Option<bool>, + /// Whether to notify on completion when using watch/delivery (default: true) + #[serde(default)] + notify: Option<bool>, + /// Whether to wake on completion when using watch/delivery (default: true) + #[serde(default)] + wake: Option<bool>, + /// Max seconds to block when using wait (default: 60, capped at 3600) + #[serde(default)] + max_wait_seconds: Option<u64>, + /// Whether wait should return on progress/checkpoint events (default: true) + #[serde(default)] + return_on_progress: Option<bool>, + /// Multi-task wait mode: any, all, first_failure + #[serde(default)] + wait_mode: Option<String>, + /// Tail only the last N lines for output/tail and wait previews + #[serde(default)] + tail_lines: Option<usize>, + /// Alias for tail_lines + #[serde(default)] + lines: Option<usize>, + /// Include an output preview when wait returns; failed tasks preview by default + #[serde(default)] + include_output_preview: Option<bool>, + /// Optional grace period for detached cancellation before SIGKILL + #[serde(default)] + graceful_timeout_ms: Option<u64>, +} + +fn infer_action_from_intent(intent: Option<&str>) -> Option<&'static str> { + let intent = intent?.trim().to_ascii_lowercase(); + if intent.is_empty() { + return None; + } + + if intent.contains("wait") || intent.contains("await") { + Some("wait") + } else if intent.contains("tail") { + Some("tail") + } else if intent.contains("output") || intent.contains("log") { + Some("output") + } else if intent.contains("status") || intent.contains("progress") || intent.contains("check") { + Some("status") + } else if intent.contains("cancel") || intent.contains("stop") { + Some("cancel") + } else if intent.contains("clean") { + Some("cleanup") + } else if intent.contains("list") || intent.contains("show") { + Some("list") + } else { + None + } +} + +fn resolve_action(params: &BgInput) -> Result<String> { + if let Some(action) = params + .action + .as_deref() + .map(str::trim) + .filter(|action| !action.is_empty()) + { + return Ok(action.to_ascii_lowercase()); + } + + if let Some(action) = infer_action_from_intent(params.intent.as_deref()) { + return Ok(action.to_string()); + } + + Err(anyhow::anyhow!( + "Missing required bg action. Use one of: list, status, output, tail, cancel, cleanup, delivery, subscribe, wait. For example: bg action=\"wait\"." + )) +} + +fn status_label(status: &BackgroundTaskStatus) -> &'static str { + match status { + BackgroundTaskStatus::Running => "running", + BackgroundTaskStatus::Completed => "completed", + BackgroundTaskStatus::Superseded => "superseded", + BackgroundTaskStatus::Failed => "failed", + } +} + +fn is_terminal(status: &BackgroundTaskStatus) -> bool { + !matches!(status, BackgroundTaskStatus::Running) +} + +fn is_success(status: &BackgroundTaskStatus, exit_code: Option<i32>) -> Option<bool> { + match status { + BackgroundTaskStatus::Running => None, + BackgroundTaskStatus::Completed => Some(exit_code.unwrap_or(0) == 0), + BackgroundTaskStatus::Superseded => Some(true), + BackgroundTaskStatus::Failed => Some(false), + } +} + +fn parse_status_filter(value: Option<&Value>) -> HashSet<&'static str> { + let mut set = HashSet::new(); + let Some(value) = value else { + return set; + }; + + let mut add = |raw: &str| match raw.to_ascii_lowercase().as_str() { + "running" => { + set.insert("running"); + } + "completed" | "success" | "succeeded" => { + set.insert("completed"); + } + "failed" | "failure" | "error" => { + set.insert("failed"); + } + "superseded" => { + set.insert("superseded"); + } + "terminal" | "finished" | "done" => { + set.insert("completed"); + set.insert("failed"); + set.insert("superseded"); + } + "all" | "any" => {} + _ => {} + }; + + match value { + Value::String(s) => add(s), + Value::Array(items) => { + for item in items { + if let Some(s) = item.as_str() { + add(s); + } + } + } + _ => {} + } + + set +} + +fn task_matches_filter(task: &background::TaskStatusFile, filter: &HashSet<&str>) -> bool { + filter.is_empty() || filter.contains(status_label(&task.status)) +} + +fn task_metadata( + manager: &background::BackgroundTaskManager, + task: &background::TaskStatusFile, +) -> Value { + json!({ + "task_id": task.task_id, + "display_name": task.display_name, + "tool_name": task.tool_name, + "status": status_label(&task.status), + "is_terminal": is_terminal(&task.status), + "is_success": is_success(&task.status, task.exit_code), + "exit_code": task.exit_code, + "error": task.error, + "session_id": task.session_id, + "started_at": task.started_at, + "completed_at": task.completed_at, + "duration_secs": task.duration_secs, + "notify": task.notify, + "wake": task.wake, + "pid": task.pid, + "detached": task.detached, + "progress": task.progress, + "event_history": task.event_history, + "output_file": manager.output_path_for(&task.task_id).to_string_lossy(), + "status_file": manager.status_path_for(&task.task_id).to_string_lossy(), + }) +} + +fn format_task_details(task: &background::TaskStatusFile) -> String { + let mut output = format!( + "Task: {}\n\ + Name: {}\n\ + Tool: {}\n\ + Status: {}\n\ + Session: {}\n\ + Started: {}\n", + task.task_id, + crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref() + ), + task.tool_name, + status_label(&task.status), + task.session_id, + task.started_at, + ); + + if let Some(completed) = task.completed_at.as_ref() { + output.push_str(&format!("Completed: {}\n", completed)); + } + if let Some(duration) = task.duration_secs { + output.push_str(&format!("Duration: {:.2}s\n", duration)); + } + if let Some(exit_code) = task.exit_code { + output.push_str(&format!("Exit code: {}\n", exit_code)); + } + if let Some(progress) = task.progress.as_ref() { + output.push_str(&format!( + "Progress: {}\n", + crate::background::format_progress_display(progress, 18) + )); + output.push_str(&format!("Progress updated: {}\n", progress.updated_at)); + } + output.push_str(&format!("Notify: {}\n", task.notify)); + output.push_str(&format!("Wake: {}\n", task.wake)); + if let Some(error) = task.error.as_ref() { + output.push_str(&format!("Error: {}\n", error)); + } + + if !task.event_history.is_empty() { + output.push_str("Recent events:\n"); + let start = task.event_history.len().saturating_sub(5); + for event in &task.event_history[start..] { + let message = event + .message + .as_deref() + .filter(|message| !message.is_empty()) + .map(|message| format!(" · {}", crate::util::truncate_str(message, 80))) + .unwrap_or_default(); + output.push_str(&format!( + "- {:?} · {}{}\n", + event.kind, event.timestamp, message + )); + } + } + + output +} + +fn tail_lines(output: &str, lines: usize) -> String { + if lines == 0 { + return String::new(); + } + let collected: Vec<&str> = output.lines().rev().take(lines).collect(); + collected.into_iter().rev().collect::<Vec<_>>().join("\n") +} + +fn output_preview(output: &str, tail: Option<usize>) -> (String, bool) { + if let Some(lines) = tail { + let tailed = tail_lines(output, lines); + let truncated = tailed.len() < output.len(); + return (tailed, truncated); + } + + if output.len() > MAX_OUTPUT_BYTES { + ( + format!( + "{}...\n\n(Output truncated. Use `read` tool on the output file for full content, or `bg action=\"tail\"` for recent lines.)", + crate::util::truncate_str(output, MAX_OUTPUT_BYTES) + ), + true, + ) + } else { + (output.to_string(), false) + } +} + +fn wait_reason_label(reason: background::BackgroundTaskWaitReason) -> &'static str { + match reason { + background::BackgroundTaskWaitReason::AlreadyFinished => "already_finished", + background::BackgroundTaskWaitReason::Finished => "finished", + background::BackgroundTaskWaitReason::Progress => "progress", + background::BackgroundTaskWaitReason::Checkpoint => "checkpoint", + background::BackgroundTaskWaitReason::Timeout => "timeout", + } +} + +async fn filtered_tasks( + manager: &background::BackgroundTaskManager, + ctx: &ToolContext, + params: &BgInput, + default_session_only: bool, +) -> Vec<background::TaskStatusFile> { + let mut tasks = manager.list().await; + let session_only = params.session_only.unwrap_or(default_session_only); + let filter = parse_status_filter(params.status_filter.as_ref()); + tasks.retain(|task| { + (!session_only || task.session_id == ctx.session_id) && task_matches_filter(task, &filter) + }); + tasks +} + +async fn resolve_task_ids( + manager: &background::BackgroundTaskManager, + ctx: &ToolContext, + params: &BgInput, + action: &str, + allow_multiple: bool, +) -> Result<Vec<String>> { + let task_ids = params.task_ids.as_deref().unwrap_or(&[]); + if !task_ids.is_empty() { + if !allow_multiple && task_ids.len() > 1 { + return Err(anyhow::anyhow!( + "action '{}' accepts only one task_id; got {} task_ids", + action, + task_ids.len() + )); + } + return Ok(task_ids.to_vec()); + } + if let Some(task_id) = params.task_id.clone() { + return Ok(vec![task_id]); + } + + let mut tasks = filtered_tasks(manager, ctx, params, true).await; + if params.latest.unwrap_or(false) { + return tasks + .first() + .map(|task| vec![task.task_id.clone()]) + .ok_or_else(|| anyhow::anyhow!("No matching background tasks found for latest=true")); + } + + tasks.retain(|task| task.status == BackgroundTaskStatus::Running); + match tasks.as_slice() { + [task] => Ok(vec![task.task_id.clone()]), + [] => Err(anyhow::anyhow!( + "task_id is required for {} action unless exactly one matching running task exists in this session. Try `bg action=\"list\" status_filter=\"running\" session_only=true` or pass latest=true.", + action + )), + _ => Err(anyhow::anyhow!( + "Multiple matching running tasks found; pass task_id, task_ids, or latest=true. Matching task IDs: {}", + tasks + .iter() + .map(|task| task.task_id.as_str()) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +async fn wait_many_polling( + manager: &background::BackgroundTaskManager, + task_ids: &[String], + max_wait: Duration, + return_on_progress: bool, + mode: &str, +) -> Result<(String, Vec<background::TaskStatusFile>)> { + let deadline = Instant::now() + max_wait; + let mut last_progress = std::collections::HashMap::new(); + for task_id in task_ids { + if let Some(task) = manager.status(task_id).await { + last_progress.insert(task_id.clone(), task.progress.clone()); + } + } + + loop { + let mut tasks = Vec::new(); + for task_id in task_ids { + if let Some(task) = manager.status(task_id).await { + tasks.push(task); + } + } + + if mode == "first_failure" + && tasks + .iter() + .any(|task| matches!(task.status, BackgroundTaskStatus::Failed)) + { + return Ok(("first_failure".to_string(), tasks)); + } + if mode == "all" && tasks.iter().all(|task| is_terminal(&task.status)) { + return Ok(("all_finished".to_string(), tasks)); + } + if mode != "all" && tasks.iter().any(|task| is_terminal(&task.status)) { + return Ok(("any_finished".to_string(), tasks)); + } + if return_on_progress { + for task in &tasks { + let previous = last_progress.get(&task.task_id).cloned().unwrap_or(None); + if task.progress != previous { + return Ok(("progress".to_string(), tasks)); + } + } + } + + if Instant::now() >= deadline { + return Ok(("timeout".to_string(), tasks)); + } + for task in &tasks { + last_progress.insert(task.task_id.clone(), task.progress.clone()); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +#[async_trait] +impl Tool for BgTool { + fn name(&self) -> &str { + "bg" + } + + 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." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["action"], + "properties": { + "intent": super::intent_schema_property(), + "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." + }, + "task_id": { "type": "string", "description": "Task ID." }, + "task_ids": { "type": "array", "items": {"type":"string"}, "description": "Task IDs for multi-task wait/status." }, + "latest": { "type": "boolean", "description": "Use latest matching task when task_id is omitted." }, + "session_only": { "type": "boolean", "description": "Restrict list/implicit selection to current session." }, + "status_filter": { + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "description": "Status filter string or array: running, completed, failed, superseded, terminal, all." + }, + "max_age_hours": { "type": "integer", "description": "Cleanup age in hours." }, + "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." }, + "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." }, + "include_output_preview": { "type": "boolean", "description": "When wait returns, include a recent output preview. Failed tasks include a preview by default." }, + "graceful_timeout_ms": { "type": "integer", "description": "For cancel, grace period for detached process termination before force kill." } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: BgInput = serde_json::from_value(input)?; + let action = resolve_action(¶ms)?; + let manager = background::global(); + + match action.as_str() { + "list" => { + let tasks = filtered_tasks(manager, &ctx, ¶ms, false).await; + if tasks.is_empty() { + return Ok(ToolOutput::new("No matching background tasks found.") + .with_title("bg list")); + } + + let mut output = String::from("Background Tasks:\n\n"); + output.push_str(&format!( + "{:<12} {:<28} {:<10} {:<12} {:<10} {:<28} {}\n", + "TASK_ID", "NAME", "TOOL", "STATUS", "DURATION", "PROGRESS", "SESSION" + )); + output.push_str(&"-".repeat(121)); + output.push('\n'); + + for task in &tasks { + let duration = task + .duration_secs + .map(|d| format!("{:.1}s", d)) + .unwrap_or_else(|| "running".to_string()); + let progress = task + .progress + .as_ref() + .map(|progress| crate::background::format_progress_display(progress, 10)) + .unwrap_or_else(|| "-".to_string()); + let display_name = crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref(), + ); + output.push_str(&format!( + "{:<12} {:<28} {:<10} {:<12} {:<10} {:<28} {}\n", + task.task_id, + crate::util::truncate_str(&display_name, 28), + task.tool_name, + status_label(&task.status), + duration, + crate::util::truncate_str(&progress, 28), + &task.session_id[..8.min(task.session_id.len())] + )); + } + + Ok(ToolOutput::new(output).with_title("bg list").with_metadata(json!({ + "tasks": tasks.iter().map(|task| task_metadata(manager, task)).collect::<Vec<_>>(), + "count": tasks.len(), + }))) + } + + "status" => { + let task_ids = resolve_task_ids(manager, &ctx, ¶ms, "status", true).await?; + let mut tasks = Vec::new(); + let mut output = String::new(); + for task_id in task_ids { + let task = manager + .status(&task_id) + .await + .ok_or_else(|| anyhow::anyhow!("Task not found: {}", task_id))?; + if !output.is_empty() { + output.push_str("\n---\n"); + } + output.push_str(&format_task_details(&task)); + if matches!(task.status, BackgroundTaskStatus::Failed) { + crate::logging::warn(&format!( + "[tool:bg] task {} ({}) failed in session {} exit_code={:?} error={}", + task.task_id, + task.tool_name, + task.session_id, + task.exit_code, + task.error.as_deref().unwrap_or("<none>") + )); + } + tasks.push(task); + } + Ok(ToolOutput::new(output).with_title("bg status").with_metadata(json!({ + "tasks": tasks.iter().map(|task| task_metadata(manager, task)).collect::<Vec<_>>(), + "task": tasks.first().map(|task| task_metadata(manager, task)), + }))) + } + + "output" | "tail" => { + let task_id = resolve_task_ids(manager, &ctx, ¶ms, "output", false) + .await? + .remove(0); + let tail = if action == "tail" { + Some( + params + .tail_lines + .or(params.lines) + .unwrap_or(DEFAULT_TAIL_LINES), + ) + } else { + params.tail_lines.or(params.lines) + }; + let output = manager.output(&task_id).await.ok_or_else(|| { + anyhow::anyhow!( + "Output not found for task: {}. Task may not exist or output file was deleted.", + task_id + ) + })?; + let (rendered, truncated) = output_preview(&output, tail); + let status = manager.status(&task_id).await; + Ok(ToolOutput::new(rendered) + .with_title(format!("bg {} {}", action, task_id)) + .with_metadata(json!({ + "task_id": task_id, + "task": status.as_ref().map(|task| task_metadata(manager, task)), + "tail_lines": tail, + "truncated": truncated, + "output_bytes": output.len(), + }))) + } + + "cancel" => { + let task_id = resolve_task_ids(manager, &ctx, ¶ms, "cancel", false) + .await? + .remove(0); + let grace = Duration::from_millis(params.graceful_timeout_ms.unwrap_or(400)); + match manager.cancel_with_grace(&task_id, grace).await? { + true => Ok(ToolOutput::new(format!("Task {} cancelled.", task_id)) + .with_title(format!("bg cancel {}", task_id)) + .with_metadata(json!({"task_id": task_id, "cancelled": true, "graceful_timeout_ms": grace.as_millis()}))), + false => Err(anyhow::anyhow!( + "Task {} not found or already completed.", + task_id + )), + } + } + + "cleanup" => { + let max_age = params.max_age_hours.unwrap_or(24); + let filter = parse_status_filter(params.status_filter.as_ref()); + let dry_run = params.dry_run.unwrap_or(false); + let result = manager.cleanup_filtered(max_age, &filter, dry_run).await?; + Ok(ToolOutput::new(format!( + "{} {} old task files (older than {} hours). Skipped {} running task file(s).", + if dry_run { "Would remove" } else { "Removed" }, + result.removed_files, + max_age, + result.skipped_running_files + )) + .with_title("bg cleanup") + .with_metadata(json!({ + "removed_files": result.removed_files, + "matched_files": result.matched_files, + "skipped_running_files": result.skipped_running_files, + "dry_run": dry_run, + "max_age_hours": max_age, + }))) + } + + "watch" | "delivery" | "subscribe" => { + let task_id = resolve_task_ids(manager, &ctx, ¶ms, "delivery", false) + .await? + .remove(0); + let notify = params.notify.unwrap_or_else(default_watch_notify); + let wake = params.wake.unwrap_or_else(default_watch_wake); + 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, + }))), + None => Err(anyhow::anyhow!("Task not found: {}", task_id)), + } + } + + "wait" => { + let task_ids = resolve_task_ids(manager, &ctx, ¶ms, "wait", true).await?; + let requested_wait = params.max_wait_seconds.unwrap_or(DEFAULT_WAIT_SECONDS); + let capped_wait = requested_wait.min(MAX_WAIT_SECONDS); + let wait_duration = Duration::from_secs(capped_wait); + + if task_ids.len() > 1 { + let mode = params.wait_mode.as_deref().unwrap_or("any"); + let mode = if matches!(mode, "all" | "first_failure") { + mode + } else { + "any" + }; + let (reason, tasks) = wait_many_polling( + manager, + &task_ids, + wait_duration, + params + .return_on_progress + .unwrap_or_else(default_wait_return_on_progress), + mode, + ) + .await?; + let mut output = + format!("Multi-task wait returned: {}\nMode: {}\n\n", reason, mode); + for task in &tasks { + output.push_str(&format!( + "- {} · {} · {}\n", + task.task_id, + crate::message::background_task_display_label( + &task.tool_name, + task.display_name.as_deref() + ), + status_label(&task.status) + )); + } + return Ok(ToolOutput::new(output).with_title("bg wait multiple").with_metadata(json!({ + "wait_reason": reason, + "wait_mode": mode, + "timed_out": reason == "timeout", + "max_wait_seconds": capped_wait, + "tasks": tasks.iter().map(|task| task_metadata(manager, task)).collect::<Vec<_>>(), + }))); + } + + let Some(task_id) = task_ids.into_iter().next() else { + return Err(anyhow::anyhow!( + "Missing task_id; provide a task_id or use latest=true" + )); + }; + match manager + .wait( + &task_id, + wait_duration, + params + .return_on_progress + .unwrap_or_else(default_wait_return_on_progress), + ) + .await + { + Some(wait_result) => { + let task = wait_result.task; + let reason = wait_result.reason; + let reason_str = wait_reason_label(reason); + let mut output = match reason { + background::BackgroundTaskWaitReason::AlreadyFinished => { + "Background task was already finished.\n\n".to_string() + } + background::BackgroundTaskWaitReason::Finished => { + "Background task finished.\n\n".to_string() + } + background::BackgroundTaskWaitReason::Progress => { + "Background task emitted a progress event.\n\n".to_string() + } + background::BackgroundTaskWaitReason::Checkpoint => { + "Background task emitted a checkpoint event.\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 + ), + }; + output.push_str(&format_task_details(&task)); + if requested_wait > MAX_WAIT_SECONDS { + output.push_str(&format!( + "Requested wait was capped from {}s to {}s.\n", + requested_wait, MAX_WAIT_SECONDS + )); + } + + let include_preview = params.include_output_preview.unwrap_or({ + matches!(task.status, BackgroundTaskStatus::Failed) + || matches!(reason, background::BackgroundTaskWaitReason::Finished) + }); + let mut preview_meta = Value::Null; + if include_preview + && let Some(full_output) = manager.output(&task.task_id).await + { + let tail = Some( + params + .tail_lines + .or(params.lines) + .unwrap_or(DEFAULT_WAIT_PREVIEW_LINES), + ); + let (preview, truncated) = output_preview(&full_output, tail); + if !preview.trim().is_empty() { + output.push_str("\nOutput preview:\n```text\n"); + output.push_str(&preview); + if !preview.ends_with('\n') { + output.push('\n'); + } + output.push_str("```\n"); + } + preview_meta = json!({ + "tail_lines": tail, + "truncated": truncated, + "output_bytes": full_output.len(), + }); + } + + Ok(ToolOutput::new(output) + .with_title(format!("bg wait {}", task_id)) + .with_metadata(json!({ + "task_id": task.task_id, + "task": task_metadata(manager, &task), + "display_name": task.display_name, + "status": status_label(&task.status), + "wait_reason": reason_str, + "timed_out": matches!(reason, background::BackgroundTaskWaitReason::Timeout), + "max_wait_seconds": capped_wait, + "return_on_progress": params.return_on_progress.unwrap_or_else(default_wait_return_on_progress), + "exit_code": task.exit_code, + "progress": task.progress, + "progress_event": wait_result.progress_event, + "event_record": wait_result.event_record, + "output_preview": preview_meta, + }))) + } + None => Err(anyhow::anyhow!("Task not found: {}", task_id)), + } + } + + _ => Err(anyhow::anyhow!( + "Unknown action: {}. Valid actions: list, status, output, tail, cancel, cleanup, watch, delivery, subscribe, wait", + action + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{Result, anyhow}; + + #[test] + fn status_filter_schema_any_of_branches_have_types() -> Result<()> { + let schema = BgTool::new().parameters_schema(); + let branches = schema["properties"]["status_filter"]["anyOf"] + .as_array() + .ok_or_else(|| anyhow!("status_filter should define anyOf branches"))?; + + assert_eq!(branches[0]["type"], json!("string")); + assert_eq!(branches[1]["type"], json!("array")); + assert_eq!(branches[1]["items"]["type"], json!("string")); + Ok(()) + } + + #[test] + fn resolve_action_infers_wait_from_intent_only_call() -> Result<()> { + let params: BgInput = serde_json::from_value(json!({ + "intent": "Wait for library tests", + "latest": true + }))?; + + assert_eq!(resolve_action(¶ms)?, "wait"); + Ok(()) + } + + #[test] + fn resolve_action_reports_clear_error_when_missing_and_not_inferable() -> Result<()> { + let params: BgInput = serde_json::from_value(json!({ + "intent": "Background task", + }))?; + + let err = resolve_action(¶ms).expect_err("action should be required"); + assert!( + err.to_string().contains("Missing required bg action"), + "err={err:?}" + ); + Ok(()) + } +} diff --git a/crates/jcode-app-core/src/tool/browser.rs b/crates/jcode-app-core/src/tool/browser.rs new file mode 100644 index 0000000..bd9dd08 --- /dev/null +++ b/crates/jcode-app-core/src/tool/browser.rs @@ -0,0 +1,950 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct BrowserTool; + +static FIREFOX_PROVIDER: FirefoxBridgeProvider = FirefoxBridgeProvider; + +impl BrowserTool { + pub fn new() -> Self { + Self + } +} + +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." +} + +#[derive(Debug, Deserialize)] +struct BrowserInput { + action: String, + #[serde(default)] + browser: Option<String>, + #[serde(default)] + provider_action: Option<String>, + #[serde(default)] + params: Option<Value>, + #[serde(default)] + url: Option<String>, + #[serde(default)] + tab_id: Option<i64>, + #[serde(default)] + window_id: Option<i64>, + #[serde(default)] + frame_id: Option<i64>, + #[serde(default)] + all_frames: Option<bool>, + #[serde(default)] + selector: Option<String>, + #[serde(default)] + text: Option<String>, + #[serde(default)] + contains: Option<String>, + #[serde(default)] + script: Option<String>, + #[serde(default)] + key: Option<String>, + #[serde(default)] + x: Option<f64>, + #[serde(default)] + y: Option<f64>, + #[serde(default)] + format: Option<String>, + #[serde(default)] + wait: Option<bool>, + #[serde(default)] + new_tab: Option<bool>, + #[serde(default)] + focus: Option<bool>, + #[serde(default)] + clear: Option<bool>, + #[serde(default)] + submit: Option<bool>, + #[serde(default)] + page_world: Option<bool>, + #[serde(default)] + position: Option<String>, + #[serde(default)] + behavior: Option<String>, + #[serde(default)] + timeout_ms: Option<u64>, + #[serde(default)] + path: Option<String>, + #[serde(default)] + fields: Option<Vec<BrowserField>>, + #[serde(default)] + scroll_to: Option<ScrollTo>, +} + +#[derive(Debug, Deserialize)] +struct BrowserField { + selector: String, + #[serde(default)] + value: Option<String>, + #[serde(default)] + checked: Option<bool>, +} + +#[derive(Debug, Deserialize)] +struct ScrollTo { + #[serde(default)] + x: Option<f64>, + #[serde(default)] + y: Option<f64>, +} + +#[async_trait] +trait BrowserProvider: Send + Sync { + fn id(&self) -> &'static str; + fn supported_browsers(&self) -> &'static [&'static str]; + + async fn status(&self, ctx: &ToolContext) -> Result<ToolOutput>; + async fn setup(&self) -> Result<ToolOutput>; + async fn ensure_ready(&self) -> Result<Option<String>>; + async fn execute( + &self, + action: &str, + input: &BrowserInput, + ctx: &ToolContext, + ) -> Result<ToolOutput>; +} + +struct FirefoxBridgeProvider; + +#[async_trait] +impl BrowserProvider for FirefoxBridgeProvider { + fn id(&self) -> &'static str { + "firefox_agent_bridge" + } + + fn supported_browsers(&self) -> &'static [&'static str] { + &["auto", "firefox"] + } + + async fn status(&self, ctx: &ToolContext) -> Result<ToolOutput> { + Ok(attach_browser_metadata( + firefox_status(self, ctx).await?, + self.id(), + "firefox", + )) + } + + async fn setup(&self) -> Result<ToolOutput> { + Ok(attach_browser_metadata( + firefox_setup(self).await?, + self.id(), + "firefox", + )) + } + + async fn ensure_ready(&self) -> Result<Option<String>> { + ensure_firefox_ready().await + } + + async fn execute( + &self, + action: &str, + input: &BrowserInput, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + Ok(attach_browser_metadata( + execute_firefox_action(self, action, input, ctx).await?, + self.id(), + "firefox", + )) + } +} + +#[async_trait] +impl Tool for BrowserTool { + fn name(&self) -> &str { + "browser" + } + + fn description(&self) -> &str { + browser_tool_description_text() + } + + fn parameters_schema(&self) -> Value { + let mut properties = Map::new(); + properties.insert("intent".into(), super::intent_schema_property()); + properties.insert( + "action".into(), + json!({ + "type": "string", + "enum": [ + "status", "setup", "list_tabs", "new_tab", "select_tab", "get_active_tab", + "list_frames", "open", "snapshot", "get_content", "interactables", "click", "type", + "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." + }), + ); + properties.insert( + "browser".into(), + json!({ + "type": "string", + "enum": ["auto", "firefox", "chrome", "safari", "edge"], + "description": "Browser." + }), + ); + properties.insert( + "provider_action".into(), + json!({ + "type": "string", + "description": "Provider command name." + }), + ); + properties.insert( + "params".into(), + json!({ + "type": "object", + "description": "Raw provider params." + }), + ); + for (name, schema) in [ + ("url", json!({"type": "string"})), + ("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."}), + ), + ("frame_id", json!({"type": "integer"})), + ("all_frames", json!({"type": "boolean"})), + ("selector", json!({"type": "string"})), + ("text", json!({"type": "string"})), + ("contains", json!({"type": "string"})), + ("script", json!({"type": "string"})), + ("key", json!({"type": "string"})), + ("x", json!({"type": "number"})), + ("y", json!({"type": "number"})), + ("wait", json!({"type": "boolean"})), + ("new_tab", json!({"type": "boolean"})), + ("focus", json!({"type": "boolean"})), + ("clear", json!({"type": "boolean"})), + ("submit", json!({"type": "boolean"})), + ("page_world", json!({"type": "boolean"})), + ("position", json!({"type": "string"})), + ("behavior", json!({"type": "string"})), + ("timeout_ms", json!({"type": "integer"})), + ("path", json!({"type": "string"})), + ] { + properties.insert(name.into(), schema); + } + properties.insert( + "format".into(), + json!({ + "type": "string", + "enum": ["annotated", "text", "textFast", "html", "title"], + "description": "Format." + }), + ); + properties.insert( + "fields".into(), + json!({ + "type": "array", + "description": "Form fields.", + "items": { + "type": "object", + "required": ["selector"], + "properties": { + "selector": { "type": "string" }, + "value": { "type": "string" }, + "checked": { "type": "boolean" } + } + } + }), + ); + properties.insert( + "scroll_to".into(), + json!({ + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" } + } + }), + ); + Value::Object(Map::from_iter([ + ("type".into(), json!("object")), + ("required".into(), json!(["action"])), + ("properties".into(), Value::Object(properties)), + ])) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: BrowserInput = serde_json::from_value(input)?; + let provider = resolve_provider(params.browser.as_deref())?; + + match params.action.as_str() { + "status" => provider.status(&ctx).await, + "setup" => provider.setup().await, + other => { + let setup_message = provider.ensure_ready().await?; + let output = provider.execute(other, ¶ms, &ctx).await?; + Ok(match setup_message { + Some(message) if !message.is_empty() => prepend_setup_message(output, &message), + _ => output, + }) + } + } + } +} + +fn prepend_setup_message(mut output: ToolOutput, message: &str) -> ToolOutput { + output.output = format!("{}\n\n{}", message, output.output); + if output.title.is_none() { + output.title = Some("browser".to_string()); + } + + let mut metadata = match output.metadata.take() { + Some(Value::Object(map)) => map, + Some(other) => { + let mut map = Map::new(); + map.insert("result".into(), other); + map + } + None => Map::new(), + }; + metadata.insert("setup_ran".into(), json!(true)); + output.metadata = Some(Value::Object(metadata)); + output +} + +fn attach_browser_metadata( + mut output: ToolOutput, + backend: &'static str, + browser: &'static str, +) -> ToolOutput { + let mut metadata = match output.metadata.take() { + Some(Value::Object(map)) => map, + Some(other) => { + let mut map = Map::new(); + map.insert("result".into(), other); + map + } + None => Map::new(), + }; + metadata.insert("backend".into(), json!(backend)); + metadata.insert("browser".into(), json!(browser)); + output.metadata = Some(Value::Object(metadata)); + output +} + +fn resolve_provider(browser: Option<&str>) -> Result<&'static dyn BrowserProvider> { + let browser = browser.unwrap_or("auto"); + if FIREFOX_PROVIDER.supported_browsers().contains(&browser) { + return Ok(&FIREFOX_PROVIDER); + } + + anyhow::bail!( + "Browser backend '{}' is not wired into the built-in browser tool yet. Use auto/firefox for now.", + browser + ) +} + +async fn firefox_status( + provider: &FirefoxBridgeProvider, + _ctx: &ToolContext, +) -> Result<ToolOutput> { + let status = crate::browser::ensure_browser_ready_noninteractive().await?; + let mut metadata = json!({ + "setup_complete": status.setup_complete, + "binary_installed": status.binary_installed, + "responding": status.responding, + "compatible": status.compatible, + "missing_actions": status.missing_actions, + "ready": status.ready, + "backend": if status.binary_installed || status.setup_complete || status.ready { + provider.id() + } else { + "unconfigured" + }, + "browser": "firefox", + }); + + if status.ready { + return Ok( + ToolOutput::new("Browser bridge is installed and responding.") + .with_title("browser status") + .with_metadata(metadata), + ); + } + + if status.responding && !status.compatible { + let missing = if status.missing_actions.is_empty() { + "unknown required actions".to_string() + } else { + status.missing_actions.join(", ") + }; + return Ok(ToolOutput::new(format!( + "Browser bridge is connected, but the live Firefox extension is out of date and does not support required actions: {}. Use action='setup' only to repair or update the existing install. You do not need to run setup before every browser task.", + missing + )) + .with_title("browser status") + .with_metadata(metadata)); + } + + 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)); + } + + metadata["backend"] = json!("unconfigured"); + Ok(ToolOutput::new( + "Browser bridge is not installed yet. Use action='setup' only for first-time install or repair. You do not need to run setup before every browser task.", + ) + .with_title("browser status") + .with_metadata(metadata)) +} + +async fn firefox_setup(provider: &FirefoxBridgeProvider) -> Result<ToolOutput> { + let log = crate::browser::ensure_browser_setup().await?; + let status = crate::browser::ensure_browser_ready_noninteractive().await?; + let title = if status.ready { + "browser setup" + } else { + "browser setup (incomplete)" + }; + Ok(ToolOutput::new(log).with_title(title).with_metadata(json!({ + "setup_complete": status.setup_complete, + "binary_installed": status.binary_installed, + "responding": status.responding, + "compatible": status.compatible, + "missing_actions": status.missing_actions, + "ready": status.ready, + "backend": provider.id(), + "browser": "firefox" + }))) +} + +async fn ensure_firefox_ready() -> Result<Option<String>> { + if crate::browser::is_setup_complete() { + return Ok(None); + } + + let status = crate::browser::ensure_browser_ready_noninteractive().await?; + if status.ready { + return Ok(None); + } + + 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", + ); + if !status.binary_installed { + message.push_str("Browser bridge binary is not installed yet.\n"); + } else if status.responding && !status.compatible { + message.push_str("Browser bridge is connected, but the live Firefox extension is missing required actions."); + if !status.missing_actions.is_empty() { + message.push_str(&format!( + " Missing actions: {}.", + status.missing_actions.join(", ") + )); + } + message.push('\n'); + } else { + message.push_str("Browser bridge binaries are installed, but the live Firefox bridge is not responding.\n"); + } + message + .push_str("Normal browser tool calls will not reopen the installer automatically anymore."); + anyhow::bail!(message) +} + +async fn execute_firefox_action( + _provider: &FirefoxBridgeProvider, + action: &str, + input: &BrowserInput, + ctx: &ToolContext, +) -> Result<ToolOutput> { + let (bridge_action, bridge_params, title) = bridge_request(action, input)?; + + if bridge_action == "screenshot" { + return screenshot_via_bridge(&bridge_params, title, ctx).await; + } + + let result = firefox_run_bridge_command(&bridge_action, bridge_params, ctx).await?; + Ok(render_browser_output(action, title, result)) +} + +fn bridge_request(action: &str, input: &BrowserInput) -> Result<(String, Value, String)> { + let bridge_action = match action { + "list_tabs" => "listTabs", + "new_tab" => "newSession", + "select_tab" => "setActiveTab", + "get_active_tab" => "getActiveTab", + "list_frames" => "listFrames", + "open" => "navigate", + "snapshot" => "getContent", + "get_content" => "getContent", + "interactables" => "getInteractables", + "click" => "click", + "type" => "type", + "fill_form" => "fillForm", + "select" => "fillForm", + "wait" => "waitFor", + "screenshot" => "screenshot", + "eval" => "evaluate", + "scroll" => "scroll", + "upload" => "uploadFile", + "press" => "evaluate", + "provider_command" => input.provider_action.as_deref().ok_or_else(|| { + anyhow::anyhow!("provider_action is required when action='provider_command'") + })?, + other => anyhow::bail!("Unsupported browser action: {}", other), + } + .to_string(); + + let mut params = Map::new(); + apply_common_targeting(&mut params, input); + + match action { + "new_tab" => { + if let Some(url) = &input.url { + params.insert("url".into(), json!(url)); + } + if let Some(timeout_ms) = input.timeout_ms { + params.insert("timeoutMs".into(), json!(timeout_ms)); + } + } + "select_tab" => { + let tab_id = input + .tab_id + .ok_or_else(|| anyhow::anyhow!("tab_id is required for select_tab"))?; + params.insert("tabId".into(), json!(tab_id)); + if let Some(focus) = input.focus { + params.insert("focus".into(), json!(focus)); + } + } + "open" => { + let url = input + .url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("url is required for open"))?; + params.insert("url".into(), json!(url)); + params.insert("wait".into(), json!(input.wait.unwrap_or(true))); + if let Some(new_tab) = input.new_tab { + params.insert("newTab".into(), json!(new_tab)); + } + if let Some(timeout_ms) = input.timeout_ms { + params.insert("timeoutMs".into(), json!(timeout_ms)); + } + } + "snapshot" => { + params.insert("format".into(), json!("annotated")); + } + "get_content" => { + params.insert( + "format".into(), + json!(input.format.as_deref().unwrap_or("text")), + ); + } + "interactables" => {} + "click" => { + if input.selector.is_none() + && input.text.is_none() + && input.x.is_none() + && input.y.is_none() + { + anyhow::bail!("click requires selector, text, or x/y coordinates"); + } + if let Some(x) = input.x { + params.insert("x".into(), json!(x)); + } + if let Some(y) = input.y { + params.insert("y".into(), json!(y)); + } + } + "type" => { + let text = input + .text + .as_deref() + .ok_or_else(|| anyhow::anyhow!("text is required for type"))?; + params.insert("text".into(), json!(text)); + if let Some(clear) = input.clear { + params.insert("clear".into(), json!(clear)); + } + if let Some(submit) = input.submit { + params.insert("submit".into(), json!(submit)); + } + } + "fill_form" => { + let fields = input + .fields + .as_ref() + .ok_or_else(|| anyhow::anyhow!("fields are required for fill_form"))?; + let mapped: Vec<Value> = fields + .iter() + .map(|field| { + let mut obj = Map::new(); + obj.insert("selector".into(), json!(field.selector)); + if let Some(value) = &field.value { + obj.insert("value".into(), json!(value)); + } + if let Some(checked) = field.checked { + obj.insert("checked".into(), json!(checked)); + } + Value::Object(obj) + }) + .collect(); + params.insert("fields".into(), Value::Array(mapped)); + } + "select" => { + let selector = input + .selector + .as_deref() + .ok_or_else(|| anyhow::anyhow!("selector is required for select"))?; + let value = input.text.as_deref().ok_or_else(|| { + anyhow::anyhow!("text is required for select and is used as the option value") + })?; + params.insert( + "fields".into(), + json!([{ "selector": selector, "value": value }]), + ); + } + "wait" => { + if input.selector.is_none() && input.text.is_none() && input.contains.is_none() { + anyhow::bail!("wait requires selector, text, or contains"); + } + if let Some(timeout_ms) = input.timeout_ms { + params.insert("timeout".into(), json!(timeout_ms)); + } + if let Some(contains) = &input.contains { + params.insert("contains".into(), json!(contains)); + } + } + "screenshot" => {} + "eval" => { + let script = input + .script + .as_deref() + .ok_or_else(|| anyhow::anyhow!("script is required for eval"))?; + params.insert("script".into(), json!(script)); + if let Some(page_world) = input.page_world { + params.insert("pageWorld".into(), json!(page_world)); + } + } + "scroll" => { + if let Some(x) = input.x { + params.insert("x".into(), json!(x)); + } + if let Some(y) = input.y { + params.insert("y".into(), json!(y)); + } + if let Some(position) = &input.position { + params.insert("position".into(), json!(position)); + } + if let Some(behavior) = &input.behavior { + params.insert("behavior".into(), json!(behavior)); + } + if let Some(scroll_to) = &input.scroll_to { + let mut target = Map::new(); + if let Some(x) = scroll_to.x { + target.insert("x".into(), json!(x)); + } + if let Some(y) = scroll_to.y { + target.insert("y".into(), json!(y)); + } + params.insert("scrollTo".into(), Value::Object(target)); + } + if !params.contains_key("x") + && !params.contains_key("y") + && !params.contains_key("selector") + && !params.contains_key("position") + && !params.contains_key("scrollTo") + { + anyhow::bail!("scroll requires x/y, selector, position, or scroll_to"); + } + } + "upload" => { + let path = input + .path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("path is required for upload"))?; + // The native messaging host reads the file from `filePath`, base64-encodes + // it, and forwards it to the content script. It also accepts an optional + // `fileName` override. (Previously this sent `path`, which the host ignored, + // producing a "Missing filePath" error.) + params.insert("filePath".into(), json!(path)); + if let Some(file_name) = std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + { + params.insert("fileName".into(), json!(file_name)); + } + } + "press" => { + let script = build_press_script(input.key.as_deref(), input.selector.as_deref())?; + params.insert("script".into(), json!(script)); + params.insert("pageWorld".into(), json!(true)); + } + "provider_command" => { + if let Some(raw) = &input.params { + return Ok((bridge_action, raw.clone(), format!("browser {}", action))); + } + } + _ => {} + } + + Ok(( + bridge_action, + Value::Object(params), + format!("browser {}", action), + )) +} + +fn apply_common_targeting(params: &mut Map<String, Value>, input: &BrowserInput) { + if let Some(tab_id) = input.tab_id { + params.insert("tabId".into(), json!(tab_id)); + } + if let Some(window_id) = input.window_id { + params.insert("windowId".into(), json!(window_id)); + } + if let Some(frame_id) = input.frame_id { + params.insert("frameId".into(), json!(frame_id)); + } + if let Some(all_frames) = input.all_frames { + params.insert("allFrames".into(), json!(all_frames)); + } + if let Some(selector) = &input.selector { + params.insert("selector".into(), json!(selector)); + } + if let Some(text) = &input.text { + params.insert("text".into(), json!(text)); + } +} + +fn build_press_script(key: Option<&str>, selector: Option<&str>) -> Result<String> { + let key = key.ok_or_else(|| anyhow::anyhow!("key is required for press"))?; + let selector_literal = selector.map(serde_json::to_string).transpose()?; + let selector_expr = selector_literal + .map(|s| format!("document.querySelector({})", s)) + .unwrap_or_else(|| "null".to_string()); + let key_literal = serde_json::to_string(key)?; + Ok(format!( + r#"return (() => {{ + const target = {selector_expr} || document.activeElement || document.body; + if (!target) throw new Error('No target available for key press'); + if (typeof target.focus === 'function') target.focus(); + const key = {key_literal}; + const eventInit = {{ key, bubbles: true, cancelable: true }}; + target.dispatchEvent(new KeyboardEvent('keydown', eventInit)); + target.dispatchEvent(new KeyboardEvent('keypress', eventInit)); + if (key === 'Enter' && target.form && typeof target.form.submit === 'function') {{ + target.form.submit(); + }} + target.dispatchEvent(new KeyboardEvent('keyup', eventInit)); + return {{ pressed: true, key, tag: target.tagName || null }}; +}})();"# + )) +} + +async fn firefox_run_bridge_command( + action: &str, + params: Value, + _ctx: &ToolContext, +) -> Result<Value> { + let bin = crate::browser::browser_binary_path(); + if !bin.exists() { + anyhow::bail!( + "Browser bridge binary is not installed yet. Use action='status' to confirm readiness, then run action='setup' only for first-time install or repair." + ); + } + + let params_json = serde_json::to_string(¶ms)?; + let mut command = tokio::process::Command::new(&bin); + command.arg(action).arg(¶ms_json); + command.stdin(std::process::Stdio::null()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + #[cfg(not(windows))] + if std::env::var("BROWSER_SESSION").is_err() + && let Some(session_name) = crate::browser::ensure_browser_session(&_ctx.session_id) + { + command.env("BROWSER_SESSION", session_name); + } + + let output = command + .output() + .await + .with_context(|| format!("Failed to run browser bridge action '{}'.", action))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if !output.status.success() { + let details = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{}\n{}", stderr, stdout) + }; + if details.contains("Unknown action:") { + anyhow::bail!( + "The connected Firefox browser bridge is missing required support for action '{}'. This usually means the installed extension is older than the browser CLI expected by jcode. Use browser action='status' to confirm, then action='setup' to repair or update the extension.\n\nOriginal bridge error: {}", + action, + details + ); + } + anyhow::bail!(details); + } + + if stdout.is_empty() { + return Ok(json!({ "ok": true })); + } + + serde_json::from_str(&stdout).or_else(|_| Ok(json!({ "raw": stdout }))) +} + +async fn screenshot_via_bridge( + params: &Value, + title: String, + ctx: &ToolContext, +) -> Result<ToolOutput> { + let filename = temp_screenshot_path(); + let mut screenshot_params = params.clone(); + if let Some(map) = screenshot_params.as_object_mut() { + map.insert( + "filename".into(), + json!(filename.to_string_lossy().to_string()), + ); + } + + let result = firefox_run_bridge_command("screenshot", screenshot_params, ctx).await?; + let saved = result + .get("saved") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or(filename); + + let mut output = ToolOutput::new(format!( + "Captured browser screenshot to {}.", + saved.display() + )) + .with_title(title) + .with_metadata(result.clone()); + + if let Ok(bytes) = tokio::fs::read(&saved).await { + output = output.with_labeled_image( + "image/png", + STANDARD.encode(&bytes), + format!("browser screenshot: {}", saved.display()), + ); + let _ = tokio::fs::remove_file(&saved).await; + } + + Ok(output) +} + +fn temp_screenshot_path() -> PathBuf { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + std::env::temp_dir().join(format!("jcode-browser-{}.png", ts)) +} + +fn render_browser_output(action: &str, title: String, result: Value) -> ToolOutput { + let body = match action { + "snapshot" => result + .get("content") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| serde_json::to_string_pretty(&result).unwrap_or_default()), + "get_content" => format_content_result(&result), + "interactables" => format_interactables_result(&result), + "eval" => format_eval_result(&result), + _ => serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()), + }; + + ToolOutput::new(body) + .with_title(title) + .with_metadata(result) +} + +fn format_content_result(result: &Value) -> String { + if let Some(content) = result.get("content").and_then(|v| v.as_str()) { + return content.to_string(); + } + if let Some(text) = result.get("text").and_then(|v| v.as_str()) { + return text.to_string(); + } + if let Some(html) = result.get("html").and_then(|v| v.as_str()) { + return html.to_string(); + } + if let Some(title) = result.get("title").and_then(|v| v.as_str()) { + if let Some(url) = result.get("url").and_then(|v| v.as_str()) { + return format!("{}\n{}", title, url); + } + return title.to_string(); + } + serde_json::to_string_pretty(result).unwrap_or_default() +} + +fn format_eval_result(result: &Value) -> String { + let value = result.get("result").cloned().unwrap_or(Value::Null); + let rendered = if let Some(s) = value.as_str() { + s.to_string() + } else { + serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + }; + + match result.get("type").and_then(|v| v.as_str()) { + Some(kind) => format!("{}\n\n(type: {})", rendered, kind), + None => rendered, + } +} + +fn format_interactables_result(result: &Value) -> String { + let Some(elements) = result.get("elements").and_then(|v| v.as_array()) else { + return serde_json::to_string_pretty(result).unwrap_or_default(); + }; + + if elements.is_empty() { + return "No interactable elements found.".to_string(); + } + + let mut lines = Vec::new(); + for (idx, element) in elements.iter().enumerate() { + let kind = element + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("element"); + let tag = element.get("tag").and_then(|v| v.as_str()).unwrap_or("?"); + let text = element + .get("text") + .or_else(|| element.get("label")) + .or_else(|| element.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let selector = element + .get("selector") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + lines.push(format!( + "{}. [{}] <{}> {} | selector: {}", + idx + 1, + kind, + tag.to_lowercase(), + text, + selector + )); + } + + lines.join("\n") +} + +#[cfg(test)] +#[path = "browser_tests.rs"] +mod browser_tests; diff --git a/crates/jcode-app-core/src/tool/browser_tests.rs b/crates/jcode-app-core/src/tool/browser_tests.rs new file mode 100644 index 0000000..534a2e1 --- /dev/null +++ b/crates/jcode-app-core/src/tool/browser_tests.rs @@ -0,0 +1,217 @@ +use super::*; + +#[test] +fn press_script_uses_selector_when_present() { + let script = build_press_script(Some("Enter"), Some("#email")).unwrap(); + assert!(script.contains("document.querySelector")); + assert!(script.contains("Enter")); +} + +#[test] +fn content_formatter_prefers_content_text() { + let rendered = format_content_result(&json!({"content": "hello", "title": "x"})); + assert_eq!(rendered, "hello"); +} + +#[test] +fn snapshot_maps_to_annotated_get_content() { + let input = BrowserInput { + action: "snapshot".into(), + browser: None, + provider_action: None, + params: None, + url: None, + tab_id: Some(7), + window_id: None, + frame_id: Some(3), + all_frames: Some(true), + selector: None, + text: None, + contains: None, + script: None, + key: None, + x: None, + y: None, + format: None, + wait: None, + new_tab: None, + focus: None, + clear: None, + submit: None, + page_world: None, + position: None, + behavior: None, + timeout_ms: None, + path: None, + fields: None, + scroll_to: None, + }; + + let (action, params, _) = bridge_request("snapshot", &input).unwrap(); + assert_eq!(action, "getContent"); + assert_eq!(params["format"], "annotated"); + assert_eq!(params["tabId"], 7); + assert_eq!(params["frameId"], 3); + assert_eq!(params["allFrames"], true); +} + +#[test] +fn eval_maps_script_and_page_world() { + let input = BrowserInput { + action: "eval".into(), + browser: None, + provider_action: None, + params: None, + url: None, + tab_id: None, + window_id: None, + frame_id: None, + all_frames: None, + selector: None, + text: None, + contains: None, + script: Some("return document.title".into()), + key: None, + x: None, + y: None, + format: None, + wait: None, + new_tab: None, + focus: None, + clear: None, + submit: None, + page_world: Some(true), + position: None, + behavior: None, + timeout_ms: None, + path: None, + fields: None, + scroll_to: None, + }; + + let (action, params, _) = bridge_request("eval", &input).unwrap(); + assert_eq!(action, "evaluate"); + assert_eq!(params["script"], "return document.title"); + assert_eq!(params["pageWorld"], true); +} + +#[test] +fn interactables_maps_to_bridge_action() { + let input = BrowserInput { + action: "interactables".into(), + browser: None, + provider_action: None, + params: None, + url: None, + tab_id: Some(9), + window_id: None, + frame_id: None, + all_frames: None, + selector: Some("main".into()), + text: None, + contains: None, + script: None, + key: None, + x: None, + y: None, + format: None, + wait: None, + new_tab: None, + focus: None, + clear: None, + submit: None, + page_world: None, + position: None, + behavior: None, + timeout_ms: None, + path: None, + fields: None, + scroll_to: None, + }; + + let (action, params, _) = bridge_request("interactables", &input).unwrap(); + assert_eq!(action, "getInteractables"); + assert_eq!(params["tabId"], 9); + assert_eq!(params["selector"], "main"); +} + +#[test] +fn schema_exposes_advanced_browser_fields() { + let schema = BrowserTool::new().parameters_schema(); + let props = schema["properties"] + .as_object() + .expect("browser schema should have properties"); + + assert!(props.contains_key("action")); + assert!(props.contains_key("browser")); + assert!(props.contains_key("url")); + assert!(props.contains_key("tab_id")); + assert!(props.contains_key("frame_id")); + assert!(props.contains_key("selector")); + assert!(props.contains_key("text")); + assert!(props.contains_key("contains")); + assert!(props.contains_key("script")); + assert!(props.contains_key("key")); + assert!(props.contains_key("x")); + assert!(props.contains_key("y")); + assert!(props.contains_key("format")); + assert!(props.contains_key("wait")); + assert!(props.contains_key("new_tab")); + assert!(props.contains_key("timeout_ms")); + assert!(props.contains_key("path")); + assert!(props.contains_key("fields")); + assert!(props.contains_key("provider_action")); + assert!(props.contains_key("params")); + assert!(props.contains_key("all_frames")); + assert!(props.contains_key("focus")); + assert!(props.contains_key("clear")); + assert!(props.contains_key("submit")); + assert!(props.contains_key("page_world")); + assert!(props.contains_key("position")); + assert!(props.contains_key("behavior")); + assert!(props.contains_key("scroll_to")); +} + +#[test] +fn resolve_provider_accepts_auto_and_firefox() { + assert!(resolve_provider(Some("auto")).is_ok()); + assert!(resolve_provider(Some("firefox")).is_ok()); +} + +#[test] +fn resolve_provider_rejects_unsupported_browser() { + let err = resolve_provider(Some("chrome")) + .err() + .expect("chrome should not resolve yet"); + assert!( + err.to_string() + .contains("not wired into the built-in browser tool") + ); +} + +#[test] +fn prepend_setup_message_preserves_images_and_metadata() { + let output = ToolOutput::new("done") + .with_title("browser screenshot") + .with_metadata(json!({"backend": "firefox_agent_bridge"})) + .with_labeled_image("image/png", "abc", "shot"); + + let output = prepend_setup_message(output, "setup log"); + assert!(output.output.starts_with("setup log\n\ndone")); + assert_eq!(output.images.len(), 1); + assert_eq!(output.title.as_deref(), Some("browser screenshot")); + assert_eq!(output.metadata.as_ref().unwrap()["setup_ran"], true); + assert_eq!( + output.metadata.as_ref().unwrap()["backend"], + "firefox_agent_bridge" + ); +} + +#[test] +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")); +} diff --git a/crates/jcode-app-core/src/tool/communicate.rs b/crates/jcode-app-core/src/tool/communicate.rs new file mode 100644 index 0000000..04dff7a --- /dev/null +++ b/crates/jcode-app-core/src/tool/communicate.rs @@ -0,0 +1,3344 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::{Tool, ToolContext, ToolOutput}; +use crate::background::TaskResult; +use crate::plan::PlanItem; +use crate::protocol::{ + AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, CommDeliveryMode, ContextEntry, + HistoryMessage, PlanGraphStatus, Request, ServerEvent, SwarmChannelInfo, TaskGraphNodeSpec, + ToolCallSummary, comm_cleanup_candidate_session_ids, default_comm_await_target_statuses, + default_comm_cleanup_target_statuses, default_comm_run_await_statuses, + format_comm_awaited_members_with_reports, format_comm_channels, format_comm_context_entries, + format_comm_context_history, format_comm_members, format_comm_plan_followup, + format_comm_plan_status, format_comm_status_snapshot, format_comm_tool_summary, + latest_assistant_comm_report, resolve_optional_comm_target_session, +}; +use anyhow::Result; +use async_trait::async_trait; +use jcode_swarm_core::validate_swarm_tldr; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; + +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). +const LIGHT_MODE_DEFAULT_CONCURRENCY: usize = 4; + +mod transport; +use transport::{send_request, send_request_with_timeout}; + +fn fresh_spawn_request_nonce(ctx: &ToolContext) -> String { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + format!("{}-{}-{}", ctx.session_id, ctx.message_id, now_ms) +} + +fn check_error(response: &ServerEvent) -> Option<&str> { + if let ServerEvent::Error { message, .. } = response { + Some(message) + } else { + None + } +} + +fn ensure_success(response: &ServerEvent) -> Result<()> { + if let Some(message) = check_error(response) { + Err(anyhow::anyhow!(message.to_string())) + } else { + Ok(()) + } +} + +fn seed_node_id_collision(response: &ServerEvent) -> Option<&str> { + let message = check_error(response)?; + let (_, tail) = message.split_once("duplicate node id '")?; + let (id, _) = tail.split_once('\'')?; + (!id.is_empty()).then_some(id) +} + +fn plan_graph_node_ids(summary: &PlanGraphStatus) -> HashSet<String> { + summary + .ready_ids + .iter() + .chain(&summary.blocked_ids) + .chain(&summary.active_ids) + .chain(&summary.completed_ids) + .chain(&summary.failed_ids) + .chain(&summary.cycle_ids) + .chain(&summary.unresolved_dependency_ids) + .cloned() + .collect() +} + +fn seed_retry_scope(ctx: &ToolContext) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + ctx.session_id.hash(&mut hasher); + ctx.message_id.hash(&mut hasher); + format!("seed-{:08x}", hasher.finish() as u32) +} + +/// Rename only seed ids that collide with the existing durable plan, then rewrite +/// intra-batch dependency edges to follow them. The scope is stable for a tool +/// turn, so retrying the same call produces the same ids and is itself idempotent. +fn remap_conflicting_seed_nodes( + nodes: &[TaskGraphNodeSpec], + occupied: &HashSet<String>, + conflicting_id: &str, + scope: &str, +) -> (Vec<TaskGraphNodeSpec>, Vec<(String, String)>) { + let original_ids: HashSet<&str> = nodes.iter().map(|node| node.id.as_str()).collect(); + let mut reserved = occupied.clone(); + reserved.extend(original_ids.iter().map(|id| (*id).to_string())); + let mut mapping = HashMap::<String, String>::new(); + + if occupied.contains(conflicting_id) && nodes.iter().any(|node| node.id == conflicting_id) { + let node_id = conflicting_id.to_string(); + let base = format!("{conflicting_id}::{scope}"); + let mut candidate = base.clone(); + let mut discriminator = 2usize; + while reserved.contains(&candidate) { + candidate = format!("{base}-{discriminator}"); + discriminator += 1; + } + reserved.insert(candidate.clone()); + mapping.insert(node_id, candidate); + } + + let remapped = nodes + .iter() + .cloned() + .map(|mut node| { + if let Some(id) = mapping.get(&node.id) { + node.id = id.clone(); + } + for dependency in &mut node.depends_on { + if let Some(id) = mapping.get(dependency) { + *dependency = id.clone(); + } + } + node + }) + .collect(); + let changes = nodes + .iter() + .filter_map(|node| { + mapping + .get(&node.id) + .map(|mapped| (node.id.clone(), mapped.clone())) + }) + .collect(); + (remapped, changes) +} + +fn format_seed_remaps(changes: &[(String, String)]) -> String { + changes + .iter() + .map(|(from, to)| format!("{from} -> {to}")) + .collect::<Vec<_>>() + .join(", ") +} + +async fn fetch_plan_status(session_id: &str) -> Result<PlanGraphStatus> { + let request = Request::CommPlanStatus { + id: REQUEST_ID, + session_id: session_id.to_string(), + }; + match send_request(request).await { + Ok(ServerEvent::CommPlanStatusResponse { summary, .. }) => Ok(summary), + Ok(response) => { + ensure_success(&response)?; + Err(anyhow::anyhow!("No plan status returned.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to get plan status: {}", e)), + } +} + +fn format_plan_followup(summary: &PlanGraphStatus) -> String { + format_comm_plan_followup(summary) +} + +fn default_cleanup_target_statuses() -> Vec<String> { + default_comm_cleanup_target_statuses() +} + +fn default_run_await_statuses() -> Vec<String> { + default_comm_run_await_statuses() +} + +fn cleanup_candidate_session_ids( + owner_session_id: &str, + members: &[AgentInfo], + target_status: &[String], + requested_session_ids: &[String], + force: bool, +) -> Vec<String> { + comm_cleanup_candidate_session_ids( + owner_session_id, + members, + target_status, + requested_session_ids, + force, + ) +} + +fn auto_assignment_needs_spawn(response: &ServerEvent) -> bool { + check_error(response).is_some_and(|message| { + message.contains( + "No ready or completed swarm agents are available for automatic task assignment", + ) + }) +} + +async fn fetch_swarm_members(session_id: &str) -> Result<Vec<AgentInfo>> { + let request = Request::CommList { + id: REQUEST_ID, + session_id: session_id.to_string(), + }; + match send_request(request).await { + Ok(ServerEvent::CommMembers { members, .. }) => Ok(members), + Ok(response) => { + ensure_success(&response)?; + Ok(Vec::new()) + } + Err(e) => Err(anyhow::anyhow!("Failed to list swarm members: {}", e)), + } +} + +fn swarm_member_is_in_flight(member: &AgentInfo) -> bool { + matches!( + member.status.as_deref(), + Some("queued" | "running" | "running_stale") + ) +} + +fn coordination_in_flight_count( + summary: &PlanGraphStatus, + members: &[AgentInfo], + current_session_id: &str, +) -> usize { + summary.active_ids.len().max( + members + .iter() + .filter(|member| member.session_id != current_session_id) + .filter(|member| swarm_member_is_in_flight(member)) + .filter(|member| swarm_member_is_drivable_worker(member, current_session_id)) + .count(), + ) +} + +/// Sessions `run_plan` should await as genuinely in-flight on *this* plan. +/// +/// A member counts only when it is both in-flight (`queued`/`running`) **and** a +/// drivable worker for this run: headless, or owned by the coordinator +/// (`report_back_to_session_id == coordinator`). This deliberately excludes +/// independent, client-attached human sessions that merely share the swarm and +/// happen to sit in a `queued` status. Awaiting those would hang `run_plan` +/// forever even though every plan task is already terminal (they are never auto +/// driven), which is exactly the stall this scoping prevents. +/// +/// Pure over an already-fetched member list so the coordination loop can reuse +/// one `CommList` snapshot for both in-flight scoping and failure-wave +/// classification. +fn in_flight_swarm_session_ids(members: &[AgentInfo], coordinator_session_id: &str) -> Vec<String> { + members + .iter() + .filter(|member| member.session_id != coordinator_session_id) + .filter(|member| swarm_member_is_in_flight(member)) + .filter(|member| swarm_member_is_drivable_worker(member, coordinator_session_id)) + .map(|member| member.session_id.clone()) + .collect() +} + +/// Fetch-and-filter convenience over [`in_flight_swarm_session_ids`] for call +/// sites that do not otherwise need the member snapshot. +async fn fetch_in_flight_swarm_sessions(session_id: &str) -> Result<Vec<String>> { + let members = fetch_swarm_members(session_id).await?; + Ok(in_flight_swarm_session_ids(&members, session_id)) +} + +/// Whether `member` is a worker `run_plan` can rely on to autonomously execute an +/// assignment (and therefore one it is safe to await): a spawned headless worker, +/// or one owned by the coordinator that issued the run. Foreign client-attached +/// sessions are not drivable and must not gate `run_plan` completion. +fn swarm_member_is_drivable_worker(member: &AgentInfo, coordinator_session_id: &str) -> bool { + member.is_headless.unwrap_or(false) + || member.report_back_to_session_id.as_deref() == Some(coordinator_session_id) +} + +async fn cleanup_swarm_workers(ctx: &ToolContext, params: &CommunicateInput) -> Result<String> { + let members = fetch_swarm_members(&ctx.session_id).await?; + let target_status = params + .target_status + .clone() + .unwrap_or_else(default_cleanup_target_statuses); + let session_ids = params.session_ids.clone().unwrap_or_default(); + let force = params.force.unwrap_or(false); + let candidates = cleanup_candidate_session_ids( + &ctx.session_id, + &members, + &target_status, + &session_ids, + force, + ); + + if candidates.is_empty() { + return Ok(format!( + "No cleanup candidates found. Default cleanup only stops sessions spawned by this coordinator with status in [{}].", + target_status.join(", ") + )); + } + + Ok(stop_swarm_sessions(ctx, candidates, force).await.describe()) +} + +/// Result of stopping a batch of swarm sessions: which stops succeeded and +/// which failed (with reasons). Split from the human-readable formatting so +/// callers like the mid-run capacity recovery can count freed slots. +struct WorkerCleanupOutcome { + stopped: Vec<String>, + failed: Vec<String>, +} + +impl WorkerCleanupOutcome { + fn describe(&self) -> String { + let mut output = String::new(); + if self.stopped.is_empty() { + output.push_str("Stopped no swarm workers."); + } else { + output.push_str(&format!( + "Stopped {} swarm worker(s): {}", + self.stopped.len(), + self.stopped.join(", ") + )); + } + if !self.failed.is_empty() { + output.push_str(&format!( + "\nFailed to stop {} worker(s): {}", + self.failed.len(), + self.failed.join(", ") + )); + } + output + } +} + +async fn stop_swarm_sessions( + ctx: &ToolContext, + candidates: Vec<String>, + force: bool, +) -> WorkerCleanupOutcome { + let mut stopped = Vec::new(); + let mut failed = Vec::new(); + for target in candidates { + let request = Request::CommStop { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: target.clone(), + force: Some(force), + }; + match send_request(request).await { + Ok(response) => match ensure_success(&response) { + Ok(()) => stopped.push(target), + Err(error) => failed.push(format!("{} ({})", target, error)), + }, + Err(error) => failed.push(format!("{} ({})", target, error)), + } + } + WorkerCleanupOutcome { stopped, failed } +} + +/// Free swarm member capacity mid-run by stopping finished workers owned by +/// this coordinator. `run_plan` spawns a fresh worker per node by default and +/// normally cleans up only at the end of the run, so on large plans membership +/// grows monotonically toward the swarm member cap and fresh spawns start +/// getting refused. `exclude` protects workers assigned earlier in this loop +/// whose queued status may not have propagated yet. Returns how many workers +/// were stopped. +/// +/// Tradeoff: a stopped `ready` worker may have been a composite planner whose +/// synthesis node would otherwise be routed back to it (planner affinity). +/// Assignment falls back to a fresh or other eligible worker in that case, +/// which is an acceptable degradation when the alternative is aborting the run +/// at the member cap. +async fn cleanup_finished_workers_for_capacity( + ctx: &ToolContext, + exclude: &[String], + reporter: &RunPlanReporter, +) -> usize { + let Ok(members) = fetch_swarm_members(&ctx.session_id).await else { + return 0; + }; + let candidates: Vec<String> = cleanup_candidate_session_ids( + &ctx.session_id, + &members, + &default_cleanup_target_statuses(), + &[], + false, + ) + .into_iter() + .filter(|session_id| !exclude.iter().any(|assigned| assigned == session_id)) + .collect(); + if candidates.is_empty() { + return 0; + } + let outcome = stop_swarm_sessions(ctx, candidates, false).await; + reporter + .log(&format!("member-cap recovery: {}", outcome.describe())) + .await; + outcome.stopped.len() +} + +/// How often the background progress card is refreshed from live plan state +/// while the driver is blocked awaiting workers. +const RUN_PLAN_PROGRESS_REFRESH_SECS: u64 = 15; + +/// Whether the driver should abandon the current member-await and start a new +/// coordination loop because the plan's ready frontier grew while it was +/// blocked. Pure for unit testing. +/// +/// `ready_baseline` is the set of ready item ids observed at the top of the +/// loop that started this await. Any *new* ready id means work the driver has +/// never had a chance to dispatch: a failed node re-queued via `swarm retry`, +/// a node unblocked by an externally-driven completion, or a gate-injected +/// gap. Comparing against the baseline (instead of `!ready.is_empty()`) is +/// what prevents wake storms: items that were already ready when the await +/// began (e.g. just-assigned tasks still momentarily `queued`, or ready nodes +/// that could not be assigned to any drivable worker) do not re-trigger, so a +/// permanently-stuck ready node wakes the driver at most once per await. +fn await_should_wake_for_new_ready( + ready_baseline: &std::collections::HashSet<String>, + summary: &PlanGraphStatus, +) -> bool { + summary + .ready_ids + .iter() + .any(|id| !ready_baseline.contains(id)) +} + +async fn await_swarm_progress( + ctx: &ToolContext, + session_ids: Vec<String>, + timeout_minutes: u64, + reporter: &RunPlanReporter, + assignment_count: usize, + ready_baseline: &std::collections::HashSet<String>, +) -> Result<()> { + let request = Request::CommAwaitMembers { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_status: default_run_await_statuses(), + session_ids, + mode: Some("any".to_string()), + timeout_secs: Some(timeout_minutes.max(1) * 60), + // run_plan needs the result inline to drive its coordination loop, so it + // explicitly opts out of the background-by-default behavior. + background: false, + notify: false, + wake: false, + }; + let socket_timeout = std::time::Duration::from_secs(timeout_minutes.max(1) * 60 + 30); + let await_members = send_request_with_timeout(request, Some(socket_timeout)); + tokio::pin!(await_members); + + // While blocked on the await (potentially many minutes), periodically + // re-read live plan + member state and push it to the progress card. + // Without this, worker completions and externally-assigned work (manual + // `assign_task`) only surface at the driver's own wave boundaries, so the + // card goes stale for the whole await. Refresh failures are ignored: the + // card is best-effort and the await result is what drives the loop. + let refresh_period = std::time::Duration::from_secs(RUN_PLAN_PROGRESS_REFRESH_SECS); + let mut refresh = + tokio::time::interval_at(tokio::time::Instant::now() + refresh_period, refresh_period); + refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + let response = loop { + tokio::select! { + result = &mut await_members => break result, + _ = refresh.tick() => { + let summary = match fetch_plan_status(&ctx.session_id).await { + Ok(summary) => summary, + Err(_) => continue, + }; + if reporter.is_background() { + let live_active = fetch_in_flight_swarm_sessions(&ctx.session_id) + .await + .map(|sessions| sessions.len()) + .unwrap_or(0); + let (completed, total, message) = + run_plan_progress_snapshot(&summary, live_active, assignment_count); + reporter.progress(completed, total, message).await; + } + // Ready frontier grew while blocked (a `swarm retry` re-queued + // failed nodes, an external completion unblocked work, a gate + // injected gaps): return to the coordination loop so the new + // work is dispatched under the normal budget instead of + // waiting out the current wave. The abandoned await is a + // plain request future; dropping it cancels only our wait, + // not the workers. + if await_should_wake_for_new_ready(ready_baseline, &summary) { + reporter + .log("ready frontier grew during await (retry/requeue or external unblock); re-entering dispatch loop") + .await; + return Ok(()); + } + } + } + }; + + match response { + Ok(ServerEvent::CommAwaitMembersResponse { + completed, summary, .. + }) => { + if completed { + Ok(()) + } else { + Err(anyhow::anyhow!( + "Timed out waiting for swarm progress: {}", + summary + )) + } + } + Ok(response) => ensure_success(&response), + Err(e) => Err(anyhow::anyhow!( + "Failed while awaiting swarm progress: {}", + e + )), + } +} + +/// Decide how many swarm workers `run_plan` keeps active at once. +/// +/// Policy: +/// * an explicit `requested` limit always wins (clamped to >= 1); +/// * deep mode with no explicit limit fans out wide: use `deep_cap`, where +/// `0` means "no extra cap" (`usize::MAX`) so the whole ready set is +/// dispatched, bounded only by the swarm member cap; +/// * light mode with no explicit limit keeps the small, cheap fan-out default. +/// +/// Pure and side-effect free so the concurrency contract is unit-testable +/// without a live swarm. +fn resolve_run_plan_concurrency(requested: Option<usize>, is_deep: bool, deep_cap: usize) -> usize { + match requested { + Some(explicit) => explicit.max(1), + None if is_deep => { + if deep_cap == 0 { + usize::MAX + } else { + deep_cap + } + } + None => LIGHT_MODE_DEFAULT_CONCURRENCY, + } +} + +/// Running tally of how well a `run_plan` drive used its concurrency budget. +/// +/// Deep mode's promise is comprehensiveness through parallel fan-out, so a run +/// that finishes with peak parallelism ~1 despite a 32+ slot budget means the +/// graph was decomposed serially and the budget was wasted. Tracking this per +/// loop (max in-flight, plus how often open slots sat idle with no ready work) +/// turns "did we actually use the budget?" into a measured, reportable number +/// instead of a hope. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct RunPlanUtilization { + /// Highest number of simultaneously in-flight tasks observed. + peak_in_flight: usize, + /// Coordination loops observed. + loops: usize, + /// Loops where open worker slots existed but the plan had nothing ready to + /// dispatch into them (budget idle due to graph narrowness, not the cap). + starved_loops: usize, +} + +impl RunPlanUtilization { + /// Record one coordination loop. `open_slots` is `None` when the budget is + /// unbounded (`concurrency_limit == usize::MAX`): an infinite budget has no + /// meaningful starvation denominator, so only peak parallelism is tracked. + fn record_loop(&mut self, in_flight: usize, open_slots: Option<usize>, dispatched: usize) { + self.loops += 1; + self.peak_in_flight = self.peak_in_flight.max(in_flight + dispatched); + if let Some(open_slots) = open_slots + && open_slots > 0 + && dispatched < open_slots + { + self.starved_loops += 1; + } + } + + /// Render the utilization line for the terminal report. In deep mode a + /// starved run also gets an actionable hint, because the fix (wider + /// decomposition) belongs to the model reading this output. + fn report(&self, concurrency_limit: usize, is_deep: bool) -> String { + let limit_label = if concurrency_limit == usize::MAX { + "unbounded".to_string() + } else { + concurrency_limit.to_string() + }; + let mut line = format!( + "Budget utilization: peak {} of {} concurrent worker slot(s); {} of {} loop(s) had idle capacity with nothing ready.", + self.peak_in_flight, limit_label, self.starved_loops, self.loops + ); + let mostly_starved = self.loops > 0 && self.starved_loops * 2 >= self.loops; + let ran_narrow = self.loops >= 3 && self.peak_in_flight <= 2; + if is_deep && (mostly_starved || ran_narrow) { + line.push_str( + "\nDeep-mode hint: the graph ran much narrower than the agent budget. If coverage \ + matters, expand remaining or follow-up work into MANY independent sibling nodes \ + (depends_on only for real data dependencies) so the ready set fills the budget.", + ); + } + line + } +} + +/// Extract the background task id from its output file path +/// (`<task_id>.output`), mirroring the bash tool's convention so progress +/// updates can be routed back to the background task manager. +fn task_id_from_output_path(path: &std::path::Path) -> Option<&str> { + path.file_name()?.to_str()?.strip_suffix(".output") +} + +/// Progress/log sink for a `run_plan` execution. +/// +/// In background mode this appends human-readable lines to the background +/// task's output file and pushes determinate progress (terminal/total plan +/// nodes) into the background task manager, so the UI renders a live swarm +/// progress card and `bg status` stays meaningful. In inline (blocking) mode +/// every method is a no-op. +struct RunPlanReporter { + task_id: Option<String>, + output_path: Option<std::path::PathBuf>, +} + +impl RunPlanReporter { + fn inline() -> Self { + Self { + task_id: None, + output_path: None, + } + } + + fn background(output_path: &std::path::Path) -> Self { + Self { + task_id: task_id_from_output_path(output_path).map(str::to_string), + output_path: Some(output_path.to_path_buf()), + } + } + + /// Whether this reporter feeds a live background progress card (inline + /// reporters are no-ops, so refresh polling would be wasted requests). + fn is_background(&self) -> bool { + self.task_id.is_some() + } + + async fn log(&self, line: &str) { + let Some(path) = &self.output_path else { + return; + }; + use tokio::io::AsyncWriteExt; + if let Ok(mut file) = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .await + { + let _ = file.write_all(format!("{}\n", line).as_bytes()).await; + } + } + + async fn progress(&self, terminal: usize, total: usize, message: String) { + let Some(task_id) = &self.task_id else { + return; + }; + let progress = crate::bus::BackgroundTaskProgress { + kind: crate::bus::BackgroundTaskProgressKind::Determinate, + percent: None, + message: Some(message), + current: Some(terminal as u64), + total: Some(total as u64), + unit: Some("nodes".to_string()), + eta_seconds: None, + updated_at: chrono::Utc::now().to_rfc3339(), + source: crate::bus::BackgroundTaskProgressSource::Reported, + } + .normalize(); + let _ = crate::background::global() + .update_progress(task_id, progress) + .await; + } + + /// Record an explicit checkpoint (a JCODE_CHECKPOINT-style milestone) on + /// the background task, so pause/alert moments surface as checkpoint events + /// in the UI instead of only trailing the output log. No-op inline. + async fn checkpoint(&self, message: &str) { + self.log(message).await; + let Some(task_id) = &self.task_id else { + return; + }; + let progress = crate::bus::BackgroundTaskProgress { + kind: crate::bus::BackgroundTaskProgressKind::Indeterminate, + percent: None, + message: Some(message.to_string()), + current: None, + total: None, + unit: None, + eta_seconds: None, + updated_at: chrono::Utc::now().to_rfc3339(), + source: crate::bus::BackgroundTaskProgressSource::Reported, + } + .normalize(); + let _ = crate::background::global() + .update_checkpoint(task_id, progress) + .await; + } + + /// Rewrite the output file so `summary` leads and the progressive log + /// trails it. Background completion previews take the first ~500 chars of + /// the output file, so the terminal summary must come first for the + /// agent's wake notification to be useful. + async fn finalize(&self, summary: &str) { + let Some(path) = &self.output_path else { + return; + }; + let log = tokio::fs::read_to_string(path).await.unwrap_or_default(); + let content = if log.trim().is_empty() { + format!("{}\n", summary) + } else { + format!("{}\n\n--- run log ---\n{}", summary, log) + }; + let _ = tokio::fs::write(path, content).await; + } +} + +/// Per-process registry of sessions with a `run_plan` driver claimed or +/// running. The duplicate-driver guard does its check-and-insert under this +/// one lock, so two `run_plan` calls racing in the same batch cannot both +/// pass. Deliberately per-process: a stale `Running` status file left on disk +/// by a previous (reloaded/crashed) server process must never block +/// restarting the driver. +fn run_plan_driver_claims() -> &'static std::sync::Mutex<HashMap<String, RunPlanDriverClaim>> { + static CLAIMS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, RunPlanDriverClaim>>> = + std::sync::OnceLock::new(); + CLAIMS.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +enum RunPlanDriverClaim { + /// Claimed, background task not spawned yet. + Starting, + /// Driver spawned as this background task. + Running(String), +} + +enum RunPlanDriverClaimResult { + Claimed(RunPlanClaimGuard), + /// A driver already holds the claim. Carries its task id when known + /// (None while the winner is still between claim and spawn). + AlreadyRunning(Option<String>), +} + +/// RAII holder for a `Starting` claim. Dropping it without +/// [`RunPlanClaimGuard::record_task`] releases the claim, so a cancelled or +/// failed startup path cannot permanently block `run_plan` for the session. +struct RunPlanClaimGuard { + session_id: String, + defused: bool, +} + +impl RunPlanClaimGuard { + /// Upgrade the claim to `Running(task_id)`. From here staleness is + /// resolved via `BackgroundTaskManager::is_live_task`: once the driver + /// task finishes (and is pruned from the live map), the next claim + /// replaces this entry. + fn record_task(mut self, task_id: &str) { + let mut claims = run_plan_driver_claims() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + claims.insert( + self.session_id.clone(), + RunPlanDriverClaim::Running(task_id.to_string()), + ); + self.defused = true; + } +} + +impl Drop for RunPlanClaimGuard { + fn drop(&mut self) { + if self.defused { + return; + } + let mut claims = run_plan_driver_claims() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Only release a claim this guard still owns. + if matches!( + claims.get(&self.session_id), + Some(RunPlanDriverClaim::Starting) + ) { + claims.remove(&self.session_id); + } + } +} + +/// Atomically claim the `run_plan` driver slot for `session_id`. +/// +/// Check-and-insert happens under one lock. An existing `Running` claim only +/// blocks while its background task is still live in this process; a claim +/// left by a finished (pruned) or pre-reload driver is replaced. +fn try_claim_run_plan_driver( + manager: &crate::background::BackgroundTaskManager, + session_id: &str, +) -> RunPlanDriverClaimResult { + let mut claims = run_plan_driver_claims() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match claims.get(session_id) { + Some(RunPlanDriverClaim::Starting) => { + return RunPlanDriverClaimResult::AlreadyRunning(None); + } + Some(RunPlanDriverClaim::Running(task_id)) => { + if manager.is_live_task(task_id) { + return RunPlanDriverClaimResult::AlreadyRunning(Some(task_id.clone())); + } + // Stale claim: the driver task already finished or belonged to a + // previous process image. Fall through and take over. + } + None => {} + } + claims.insert(session_id.to_string(), RunPlanDriverClaim::Starting); + RunPlanDriverClaimResult::Claimed(RunPlanClaimGuard { + session_id: session_id.to_string(), + defused: false, + }) +} + +/// Drive `run_plan` as a managed background task and return immediately. +/// +/// The coordinating agent stays responsive: the plan loop runs inside the +/// shared `BackgroundTaskManager` (task id, progress card, `bg` tool +/// integration), and completion is delivered through the standard notify/wake +/// path like any other background task. +async fn run_swarm_plan_in_background( + ctx: &ToolContext, + params: CommunicateInput, +) -> Result<ToolOutput> { + // Validate the plan inline so an empty/broken plan errors immediately + // instead of as a delayed background failure. + let initial_summary = fetch_plan_status(&ctx.session_id).await?; + if initial_summary.item_count == 0 { + return Ok(ToolOutput::new("No swarm plan items to run.")); + } + + // Refuse to start a second driver for the same session: two concurrent + // run_plan loops would race on assignments and double-spawn workers. The + // claim is check-and-insert under one lock, so two run_plan calls in the + // same batch cannot both pass. Only drivers live in this process count; a + // stale "running" status file left by a server reload must not block + // restarting the driver (the claim map is per-process and dead task ids + // fail the is_live_task check). + let manager = crate::background::global(); + let claim = match try_claim_run_plan_driver(manager, &ctx.session_id) { + RunPlanDriverClaimResult::Claimed(claim) => claim, + RunPlanDriverClaimResult::AlreadyRunning(existing) => { + return Ok(ToolOutput::new(match existing { + Some(task_id) => format!( + "A swarm run_plan driver is already running for this session (task {}). \ + Check it with `bg action=\"status\" task_id=\"{}\"` or `swarm plan_status` instead of starting another.", + task_id, task_id + ), + None => "A swarm run_plan driver is already starting for this session. \ + Check it with `swarm plan_status` instead of starting another." + .to_string(), + })); + } + }; + + let notify = params.notify.unwrap_or(true); + let wake = params.wake.unwrap_or(true); + // Keep the display name free of the "·" separator used by the background + // notification markdown header, or downstream parsing mis-splits the label. + let display_name = format!( + "run_plan ({} nodes, {} mode)", + initial_summary.item_count, initial_summary.mode + ); + + let bg_ctx = ctx.clone(); + let info = crate::background::global() + .spawn_with_notify( + "swarm", + Some(display_name.clone()), + &ctx.session_id, + notify, + wake, + move |output_path| async move { + let reporter = RunPlanReporter::background(&output_path); + match run_swarm_plan_to_terminal(&bg_ctx, ¶ms, &reporter).await { + Ok(output) => { + reporter.finalize(&output.output).await; + Ok(TaskResult::completed(Some(0))) + } + Err(error) => { + let message = format!("run_plan failed: {}", error); + reporter.finalize(&message).await; + Ok(TaskResult::failed(None, message)) + } + } + }, + ) + .await; + claim.record_task(&info.task_id); + + let delivery_note = if wake { + "You'll be woken with the result when the plan reaches a terminal state." + } else if notify { + "A notification will appear when the plan reaches a terminal state." + } else { + "Notifications disabled. Use the `bg` tool to check status." + }; + let output = format!( + "🐝 Swarm plan running in background.\n\n\ + Task ID: {}\n\ + Plan: {} node(s), {} mode\n\ + Output file: {}\n\n\ + {}\n\ + Check progress: use the `bg` tool with action=\"status\" and task_id=\"{}\", or `swarm plan_status`.\n\ + Note: a server reload stops this driver (workers keep running); rerun `swarm run_plan` to resume driving the same plan.", + info.task_id, + initial_summary.item_count, + initial_summary.mode, + info.output_file.display(), + delivery_note, + info.task_id, + ); + + Ok(ToolOutput::new(output) + .with_title(format!("Swarm run_plan in background: {}", info.task_id)) + .with_metadata(json!({ + "background": true, + "swarm": 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(), + }))) +} + +/// Hint appended to every `run_plan` driver failure: the driver exits without +/// the end-of-run cleanup, so spawned workers keep running even when +/// `retain_agents=false`, and the caller must know how to stop or resume them. +const RUN_PLAN_WORKER_RETENTION_HINT: &str = "\nSpawned workers were retained; run `swarm cleanup` to stop them, rerun `swarm run_plan` to resume driving the same plan, or `swarm plan_status` to inspect."; + +/// Append the worker-retention hint to a driver failure message, idempotently +/// so wrappers that re-report an already-hinted error do not duplicate it. +fn with_worker_retention_hint(message: String) -> String { + if message.contains("swarm cleanup") { + message + } else { + format!("{message}{RUN_PLAN_WORKER_RETENTION_HINT}") + } +} + +/// How the `run_plan` assignment loop should react to a `CommAssignNext` error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AssignErrorAction { + /// No more runnable work or no eligible workers: stop assigning this loop + /// and continue with in-flight work. + BreakGracefully, + /// The swarm hit its total member cap so fresh spawns are refused: free + /// finished owned workers and/or fall back to reusing ready workers instead + /// of aborting the whole run. + RecoverCapacity, + /// Anything else is a real failure. + Fail, +} + +fn classify_assign_error(message: &str) -> AssignErrorAction { + if message.contains("No runnable unassigned tasks") + || message.contains("No ready or completed swarm agents") + { + AssignErrorAction::BreakGracefully + } else if message.contains("Swarm member limit reached") { + AssignErrorAction::RecoverCapacity + } else { + AssignErrorAction::Fail + } +} + +/// Next step for a slot whose assignment was refused by the member cap. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CapRecoveryStep { + /// Cleanup freed capacity: retry the slot keeping the fresh-spawn preference. + RetryFresh, + /// Nothing was freed: retry the slot in reuse-only mode (no spawning). + RetryReuse, + /// Recovery already ran and the cap still refuses this slot: stop assigning + /// this loop and continue with in-flight work. + GiveUp, +} + +/// Pure recovery policy for a member-cap refusal, keyed on how many times this +/// slot already hit the cap (`cap_hits`) and how many workers the incremental +/// cleanup freed. Kept side-effect free so the fallback contract is unit +/// testable without a live swarm. +fn cap_recovery_step(cap_hits: usize, freed: usize) -> CapRecoveryStep { + if cap_hits > 1 { + CapRecoveryStep::GiveUp + } else if freed > 0 { + CapRecoveryStep::RetryFresh + } else { + CapRecoveryStep::RetryReuse + } +} + +/// Count each plan node at most once as terminal: completed, failed, blocked, +/// and cycle sets overlap in places (and failed nodes appear in none of the +/// legacy three), so a plain sum both over- and under-counts. +fn plan_terminal_node_count(summary: &PlanGraphStatus) -> usize { + summary + .completed_ids + .iter() + .chain(summary.failed_ids.iter()) + .chain(summary.blocked_ids.iter()) + .chain(summary.cycle_ids.iter()) + .collect::<std::collections::HashSet<_>>() + .len() +} + +/// Numbers for the `run_plan` background progress card. Pure for unit testing. +/// +/// The percent-driving pair is `(completed, total)`: only *completed* nodes +/// count toward 100%, so a run where most nodes failed reads as mostly +/// unfinished instead of "98% complete" (failed/blocked counts are surfaced in +/// the message instead). `live_active` is the count of in-flight worker +/// sessions observed from member state; the card shows whichever of plan +/// execution state (`active_ids`) or live member state is larger, so nodes +/// assigned outside this driver (e.g. manual `assign_task`) still show as +/// active. +fn run_plan_progress_snapshot( + summary: &PlanGraphStatus, + live_active: usize, + assignment_count: usize, +) -> (usize, usize, String) { + let completed = summary.completed_ids.len(); + let active = summary.active_ids.len().max(live_active); + let message = format!( + "completed {} · failed {} · blocked {} · active {} · assignments {}", + completed, + summary.failed_ids.len(), + summary.blocked_ids.len(), + active, + assignment_count + ); + (completed, summary.item_count, message) +} + +/// Terminal-state summary line for `run_plan`, including failed nodes so a run +/// with failures never reads like a clean finish. Pure for unit testing. +fn format_run_plan_terminal_summary( + loop_count: usize, + summary: &PlanGraphStatus, + assignment_count: usize, +) -> String { + let mut output = format!( + "Swarm plan reached terminal/blocked state after {} loop(s). completed={} failed={} blocked={} cycles={} active={} assignments={}", + loop_count, + summary.completed_ids.len(), + summary.failed_ids.len(), + summary.blocked_ids.len(), + summary.cycle_ids.len(), + summary.active_ids.len(), + assignment_count + ); + if summary.mode.eq_ignore_ascii_case("deep") { + output.push_str(&format!( + "\nGrowth: {} seeded -> {} nodes ({} machinery-grown: expansions, gate-injected gaps, gates).", + summary.seeded_count, summary.item_count, summary.grown_count + )); + } + if !summary.failed_ids.is_empty() { + output.push_str(&format!( + "\nFailed nodes: {}. This run did NOT finish cleanly; inspect them with `swarm plan_status` and retry or salvage before trusting the result.", + summary.failed_ids.join(", ") + )); + // Recorded failure reasons make the summary self-explanatory: a wave + // of "task failed: ... 401 Unauthorized" lines names the root cause + // without another plan_status round-trip. + for id in &summary.failed_ids { + if let Some(reason) = summary.failed_reasons.get(id) { + output.push_str(&format!("\n {}: {}", id, reason)); + } + } + } + output +} + +/// Minimum number of credential-failed workers that count as a wave rather +/// than an isolated bad worker. +const CREDENTIAL_FAILURE_WAVE_MIN_WORKERS: usize = 2; + +/// How recent a worker's credential failure must be (via `status_age_secs`) to +/// count toward a wave. Old failed workers from a previous, already-diagnosed +/// wave must not re-trip the breaker after the user fixes auth and retries. +const CREDENTIAL_FAILURE_WAVE_WINDOW_SECS: u64 = 60; + +/// A wave of worker failures that share one credential-shaped root cause. +/// +/// When dispatched workers die within seconds of assignment with 401 / +/// `invalid_grant` / `authentication_error`-style errors, the credential is +/// broken for every worker on that route: assigning more nodes only fails more +/// of the plan. Detecting the wave lets `run_plan` pause dispatching and +/// surface the one real fix instead of silently burning the graph. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CredentialFailureWave { + /// Failed worker sessions in the wave. + session_ids: Vec<String>, + /// Representative failure detail (first observed). + sample_detail: String, + /// Provider named by the failing workers, when known (e.g. "anthropic"). + provider: Option<String>, +} + +/// Detect a credential-failure wave in a swarm member snapshot. +/// +/// A wave exists when, with **zero completed plan nodes**, at least +/// [`CREDENTIAL_FAILURE_WAVE_MIN_WORKERS`] drivable workers sit in `failed` +/// status whose detail classifies as a credential failure (via the shared +/// [`crate::provider::error_looks_like_credential_failure`] classifier) and +/// whose failure is recent (`status_age_secs <= window_secs`). Pure over its +/// inputs so the breaker contract is unit-testable without a live swarm. +fn detect_credential_failure_wave( + members: &[AgentInfo], + coordinator_session_id: &str, + completed_node_count: usize, + window_secs: u64, +) -> Option<CredentialFailureWave> { + if completed_node_count > 0 { + return None; + } + let mut session_ids = Vec::new(); + let mut sample_detail: Option<String> = None; + let mut provider: Option<String> = None; + for member in members { + if member.session_id == coordinator_session_id { + continue; + } + if !swarm_member_is_drivable_worker(member, coordinator_session_id) { + continue; + } + if member.status.as_deref() != Some("failed") { + continue; + } + let Some(detail) = member.detail.as_deref() else { + continue; + }; + if !crate::provider::error_looks_like_credential_failure(detail) { + continue; + } + // Require a known, recent failure age: stale failed workers (or ones + // whose age did not propagate) must not re-trip the breaker. + if !matches!(member.status_age_secs, Some(age) if age <= window_secs) { + continue; + } + session_ids.push(member.session_id.clone()); + if sample_detail.is_none() { + sample_detail = Some(detail.to_string()); + } + if provider.is_none() { + provider = member.provider_name.clone(); + } + } + if session_ids.len() < CREDENTIAL_FAILURE_WAVE_MIN_WORKERS { + return None; + } + Some(CredentialFailureWave { + session_ids, + sample_detail: sample_detail.unwrap_or_default(), + provider, + }) +} + +/// The `jcode login` invocation most likely to fix a credential wave for +/// `provider`, mapping provider names to their login provider keys. +fn credential_login_fix_hint(provider: Option<&str>) -> String { + let lowered = provider.map(str::to_ascii_lowercase); + let target = match lowered.as_deref() { + Some("anthropic" | "claude") => "claude", + Some("openai" | "codex") => "openai", + Some("google" | "gemini") => "gemini", + Some(other) if !other.trim().is_empty() => other, + _ => "<provider>", + }; + format!("`jcode login --provider {target}`") +} + +/// Actionable pause message for a credential-failure wave: names the failed +/// workers, the credential-shaped root cause, and the fix. Pure for unit +/// testing; used both as the run error and as the swarm broadcast body. +fn format_credential_failure_wave_error(wave: &CredentialFailureWave, window_secs: u64) -> String { + format!( + "run_plan paused dispatching: {count} worker(s) failed within {window_secs}s with \ + credential/auth failures and no plan node has completed (e.g. {first}: \"{sample}\"). \ + A broken credential (expired OAuth session, revoked refresh token, or invalid API key) \ + fails every worker on that route, so assigning more nodes would only fail more of the \ + plan. Fix auth first: run {login_hint} (or pin a working API-key route), then requeue \ + the failed nodes (`swarm retry`) and run `swarm run_plan` again.", + count = wave.session_ids.len(), + first = wave + .session_ids + .first() + .map(String::as_str) + .unwrap_or("worker"), + sample = wave.sample_detail, + login_hint = credential_login_fix_hint(wave.provider.as_deref()), + ) +} + +/// Best-effort broadcast of a plan-level alert to the whole swarm, so live +/// members and attached UIs see why dispatch stopped. +async fn broadcast_plan_alert(ctx: &ToolContext, message: &str) -> Result<()> { + let request = Request::CommMessage { + id: REQUEST_ID, + from_session: ctx.session_id.clone(), + message: message.to_string(), + to_session: None, + channel: None, + wake: None, + delivery: None, + tldr: Some("run_plan paused: credential failure wave; fix auth then retry".to_string()), + }; + match send_request(request).await { + Ok(response) => ensure_success(&response), + Err(e) => Err(anyhow::anyhow!("Failed to broadcast plan alert: {}", e)), + } +} + +async fn run_swarm_plan_to_terminal( + ctx: &ToolContext, + params: &CommunicateInput, + reporter: &RunPlanReporter, +) -> Result<ToolOutput> { + // Every driver-failure exit (assignment failure, await timeout, stall, + // max-loops, even a mid-run plan-status fetch error) leaves spawned workers + // running because the end-of-run cleanup never executes, regardless of + // retain_agents. Append the retention hint uniformly here so no failure + // path can forget it. + match run_swarm_plan_loop(ctx, params, reporter).await { + Ok(output) => Ok(output), + Err(error) => Err(anyhow::anyhow!(with_worker_retention_hint( + error.to_string() + ))), + } +} + +async fn run_swarm_plan_loop( + ctx: &ToolContext, + params: &CommunicateInput, + reporter: &RunPlanReporter, +) -> Result<ToolOutput> { + let initial_summary = fetch_plan_status(&ctx.session_id).await?; + let is_deep = initial_summary.mode.eq_ignore_ascii_case("deep"); + + let configured_deep_cap = crate::config::config().agents.swarm_max_concurrent_agents; + let concurrency_limit = + resolve_run_plan_concurrency(params.concurrency_limit, is_deep, configured_deep_cap); + let timeout_minutes = params.timeout_minutes.unwrap_or(60).max(1); + let retain_agents = params.retain_agents.unwrap_or(false); + let spawn_if_needed = params.spawn_if_needed.or(Some(true)); + // Default to a fresh worker per task-graph node. Reusing a worker that already + // completed a *different* node carries that node's conversation into the next + // assignment, and the model often just re-reports its prior result instead of + // doing the new work (observed leaving gap/synthesis nodes stuck). The task-DAG + // model assumes clean, isolated workers, so unless the caller explicitly opts + // into reuse (`prefer_spawn=false`), prefer spawning a fresh worker per node. + let prefer_spawn = params.prefer_spawn.or(Some(true)); + let mut assignment_count = 0usize; + let mut loop_count = 0usize; + let max_loops = 200usize; + let mut utilization = RunPlanUtilization::default(); + // Consecutive loops where an active task exists but no drivable worker is + // awaitable. This is normally a brief transition (a composite re-waking to + // synthesize, or a just-finished task whose member status has not propagated), + // so we back off and re-check a few times before declaring a real stall. + let mut transient_stall_loops = 0usize; + let max_transient_stall_loops = 5usize; + + loop { + loop_count += 1; + if loop_count > max_loops { + return Err(anyhow::anyhow!( + "run_plan exceeded {} coordination loops; leaving workers untouched for inspection", + max_loops + )); + } + + let summary = fetch_plan_status(&ctx.session_id).await?; + if summary.item_count == 0 { + return Ok(ToolOutput::new("No swarm plan items to run.")); + } + + let members = fetch_swarm_members(&ctx.session_id).await?; + let in_flight_sessions = in_flight_swarm_session_ids(&members, &ctx.session_id); + + // Credential-failure circuit breaker: when a wave of workers dies with + // 401/invalid_grant-style auth errors and nothing has completed, the + // credential is broken for every worker on that route. Pausing here + // (before the terminal check) means even a fully-burned first wave + // surfaces the root cause and the fix instead of a bare + // "failed=N" terminal summary with no explanation. + if let Some(wave) = detect_credential_failure_wave( + &members, + &ctx.session_id, + summary.completed_ids.len(), + CREDENTIAL_FAILURE_WAVE_WINDOW_SECS, + ) { + let message = + format_credential_failure_wave_error(&wave, CREDENTIAL_FAILURE_WAVE_WINDOW_SECS); + reporter.checkpoint(&message).await; + if let Err(error) = broadcast_plan_alert(ctx, &message).await { + reporter + .log(&format!( + "failed to broadcast credential-failure alert to the swarm: {error}" + )) + .await; + } + return Err(anyhow::anyhow!(message)); + } + + let terminal_count = plan_terminal_node_count(&summary); + let (progress_completed, progress_total, progress_message) = + run_plan_progress_snapshot(&summary, in_flight_sessions.len(), assignment_count); + reporter + .progress(progress_completed, progress_total, progress_message) + .await; + let no_more_runnable = summary.active_ids.is_empty() + && summary.next_ready_ids.is_empty() + && in_flight_sessions.is_empty(); + if no_more_runnable || terminal_count >= summary.item_count { + let mut output = + format_run_plan_terminal_summary(loop_count, &summary, assignment_count); + output.push_str(&format!( + "\n{}", + utilization.report(concurrency_limit, is_deep) + )); + if !summary.low_confidence_ids.is_empty() { + output.push_str(&format!( + "\nConfidence coverage: {} completed node(s) self-reported LOW confidence: {}. \ + Consider seeding follow-up nodes to shore these up before trusting the result.", + summary.low_confidence_ids.len(), + summary.low_confidence_ids.join(", ") + )); + } + if retain_agents { + output.push_str("\nRetained spawned workers because retain_agents=true."); + } else { + // Run the automatic end-of-plan cleanup with a sanitized input: + // `force`, `session_ids`, and `target_status` on the run_plan + // call are meant for explicit stop/cleanup/await actions, and + // leaking `force=true` here would force-stop every terminal + // swarm member (including user-created idle sessions) instead + // of only the workers this coordinator owns. + let cleanup_params = CommunicateInput { + force: None, + session_ids: None, + target_status: None, + ..params.clone() + }; + let cleanup = cleanup_swarm_workers(ctx, &cleanup_params).await?; + output.push_str(&format!("\n{}", cleanup)); + } + return Ok(ToolOutput::new(output)); + } + + let active_count = summary.active_ids.len().max(in_flight_sessions.len()); + let available_slots = concurrency_limit.saturating_sub(active_count); + let mut assigned_sessions = Vec::new(); + // Member-cap fallback state, reset each coordination loop. When the swarm + // hits its total member cap, fresh spawns are refused; instead of aborting + // the whole run we first free finished owned workers (incremental cleanup) + // and retry, then fall back to reuse-only assignment (no spawning), and + // only after that stop assigning and continue with in-flight work. + let mut cap_hits = 0usize; + let mut reuse_only = false; + let mut slots_remaining = available_slots; + while slots_remaining > 0 { + let request = Request::CommAssignNext { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: params.target_session.clone(), + working_dir: params.working_dir.clone(), + prefer_spawn: if reuse_only { + Some(false) + } else { + prefer_spawn + }, + spawn_if_needed: if reuse_only { + Some(false) + } else { + spawn_if_needed + }, + message: params.message.clone(), + model: params.model.clone(), + effort: params.effort.clone(), + }; + match send_request(request).await { + Ok(ServerEvent::CommAssignTaskResponse { + task_id, + target_session, + .. + }) => { + assignment_count += 1; + slots_remaining -= 1; + reporter + .log(&format!("assigned {} -> {}", task_id, target_session)) + .await; + assigned_sessions.push(target_session); + } + Ok(ServerEvent::Error { message, .. }) => { + match classify_assign_error(&message) { + AssignErrorAction::BreakGracefully => break, + AssignErrorAction::RecoverCapacity => { + cap_hits += 1; + let freed = if cap_hits == 1 { + cleanup_finished_workers_for_capacity( + ctx, + &assigned_sessions, + reporter, + ) + .await + } else { + 0 + }; + match cap_recovery_step(cap_hits, freed) { + CapRecoveryStep::RetryFresh => { + // Cleanup freed member slots; retry this slot + // with the fresh-spawn preference intact. + } + CapRecoveryStep::RetryReuse => { + reuse_only = true; + reporter + .log( + "member cap reached and no finished workers to free; \ + falling back to reusing ready workers (prefer_spawn=false)", + ) + .await; + } + CapRecoveryStep::GiveUp => { + reporter + .log( + "member cap still reached after recovery; \ + continuing with in-flight work", + ) + .await; + break; + } + } + } + AssignErrorAction::Fail => { + return Err(anyhow::anyhow!(message)); + } + } + } + Ok(response) => ensure_success(&response)?, + Err(e) => return Err(anyhow::anyhow!("Failed to assign next swarm task: {}", e)), + } + } + utilization.record_loop( + active_count, + (concurrency_limit != usize::MAX).then_some(available_slots), + assigned_sessions.len(), + ); + + let await_sessions = if assigned_sessions.is_empty() { + in_flight_sessions + } else { + assigned_sessions + }; + + if await_sessions.is_empty() { + if active_count > 0 { + // An active task exists but nothing drivable is awaitable. This is + // usually transient: a composite is re-waking to synthesize, or a + // worker just finished and its member status has not propagated yet. + // Re-check a few times with a short backoff before giving up, and + // bail early if the plan reaches a terminal state in the meantime. + transient_stall_loops += 1; + if transient_stall_loops <= max_transient_stall_loops { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + continue; + } + return Err(anyhow::anyhow!( + "run_plan found {} active task(s) but no running swarm members to await after {} re-checks; inspect plan_status and member list before retrying", + active_count, + max_transient_stall_loops + )); + } + // Nothing was assigned this loop, nothing is in flight, yet the plan is + // not terminal. This means some non-terminal task cannot be driven, e.g. + // it is already assigned to a session run_plan cannot drive (a foreign or + // stale member). Spinning here would busy-loop to the max-loop cap, so + // surface the stuck state with the offending tasks instead. + let stuck: Vec<String> = summary + .next_ready_ids + .iter() + .chain(summary.ready_ids.iter()) + .cloned() + .collect::<std::collections::BTreeSet<_>>() + .into_iter() + .collect(); + let detail = if stuck.is_empty() { + "no ready tasks and no in-flight workers".to_string() + } else { + format!( + "runnable task(s) {} could not be assigned to any drivable worker", + stuck.join(", ") + ) + }; + return Err(anyhow::anyhow!( + "run_plan stalled after {} loop(s): {}. This usually means a task is assigned to a session run_plan cannot drive (foreign or stale member). Reassign with an explicit target_session, or clear the stale assignment, then retry.", + loop_count, + detail + )); + } + // Baseline for requeue pickup: everything ready at the top of this + // loop either gets assigned below or is known-undispatchable this + // wave. Anything ready *beyond* this set while we block (a retried + // failure, an external unblock) should cut the await short. + let ready_baseline: std::collections::HashSet<String> = + summary.ready_ids.iter().cloned().collect(); + await_swarm_progress( + ctx, + await_sessions, + timeout_minutes, + reporter, + assignment_count, + &ready_baseline, + ) + .await?; + // Real progress (an await completed); clear the transient-stall backoff so + // a later genuine stall starts counting fresh. + transient_stall_loops = 0; + } +} + +async fn spawn_assignment_session(ctx: &ToolContext, params: &CommunicateInput) -> Result<String> { + let spawn_request = Request::CommSpawn { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + working_dir: params.working_dir.clone(), + initial_message: None, + request_nonce: Some(fresh_spawn_request_nonce(ctx)), + spawn_mode: params.spawn_mode.clone(), + model: params.model.clone(), + effort: params.effort.clone(), + label: None, + }; + + match send_request(spawn_request).await { + Ok(ServerEvent::CommSpawnResponse { new_session_id, .. }) if !new_session_id.is_empty() => { + Ok(new_session_id) + } + Ok(spawn_response) => { + ensure_success(&spawn_response)?; + Err(anyhow::anyhow!( + "Spawn succeeded but new session ID was not returned." + )) + } + Err(e) => Err(anyhow::anyhow!( + "Failed to spawn agent for task assignment: {}", + e + )), + } +} + +async fn assign_task_to_session( + ctx: &ToolContext, + params: &CommunicateInput, + target_session: String, + spawned_suffix: &str, +) -> Result<ToolOutput> { + let retry_request = Request::CommAssignTask { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: Some(target_session.clone()), + task_id: params.task_id.clone(), + message: params.message.clone(), + }; + + match send_request(retry_request).await { + Ok(ServerEvent::CommAssignTaskResponse { task_id, .. }) => Ok(ToolOutput::new(format!( + "Task '{}' assigned to {}{}", + task_id, target_session, spawned_suffix + ))), + Ok(retry_response) => { + ensure_success(&retry_response)?; + Ok(ToolOutput::new(format!( + "Assigned next runnable task to {}{}", + target_session, spawned_suffix + ))) + } + Err(e) => Err(anyhow::anyhow!( + "Failed to assign task after selecting {}: {}", + target_session, + e + )), + } +} + +fn format_context_entries(entries: &[ContextEntry]) -> ToolOutput { + ToolOutput::new(format_comm_context_entries(entries)) +} + +fn format_members(ctx: &ToolContext, members: &[AgentInfo]) -> ToolOutput { + ToolOutput::new(format_comm_members(&ctx.session_id, members)) +} + +fn format_tool_summary(target: &str, calls: &[ToolCallSummary]) -> ToolOutput { + ToolOutput::new(format_comm_tool_summary(target, calls)) +} + +fn format_status_snapshot(snapshot: &AgentStatusSnapshot) -> ToolOutput { + ToolOutput::new(format_comm_status_snapshot(snapshot)) +} + +fn format_plan_status(summary: &PlanGraphStatus) -> ToolOutput { + let mut output = format_comm_plan_status(summary); + if let Some(budget_line) = plan_status_budget_line( + summary, + crate::config::config().agents.swarm_max_concurrent_agents, + ) { + output.push_str(&budget_line); + } + ToolOutput::new(output) +} + +/// Deep-mode budget line for `plan_status`: how wide the ready frontier is +/// versus the concurrency budget, with a widen-the-graph nudge when the ready +/// set cannot fill the slots. This makes under-utilization visible at plan +/// time, before `run_plan` even starts, so the coordinator can restructure the +/// graph instead of discovering the waste after the run. Pure over its inputs +/// for unit testing; returns `None` for light plans. +fn plan_status_budget_line(summary: &PlanGraphStatus, deep_cap: usize) -> Option<String> { + if !summary.mode.eq_ignore_ascii_case("deep") { + return None; + } + let budget = resolve_run_plan_concurrency(None, true, deep_cap); + let budget_label = if budget == usize::MAX { + format!("{} (member cap)", jcode_swarm_core::MAX_SWARM_MEMBERS) + } else { + budget.to_string() + }; + let ready_width = summary.ready_ids.len(); + let active_width = summary.active_ids.len(); + let mut line = format!( + " Parallel budget: {} concurrent worker slot(s); ready set is {} wide ({} active).\n", + budget_label, ready_width, active_width + ); + let effective_budget = if budget == usize::MAX { + jcode_swarm_core::MAX_SWARM_MEMBERS + } else { + budget + }; + // Nudge only when narrowness is structural: the frontier cannot fill the + // budget while other non-terminal work exists but is serialized behind + // depends_on edges. A small plan that is simply almost done gets no nudge. + let frontier = ready_width + active_width; + let terminal = summary.completed_ids.len() + summary.cycle_ids.len(); + let serialized_remaining = summary.item_count > terminal + frontier; + if frontier < effective_budget && serialized_remaining { + line.push_str( + " The ready frontier is narrower than the budget while more work waits behind \ + depends_on edges: prefer expand_node with MANY independent siblings (depends_on \ + only for real data dependencies) to widen it.\n", + ); + } + Some(line) +} + +fn format_context_history(target: &str, messages: &[HistoryMessage]) -> ToolOutput { + ToolOutput::new(format_comm_context_history(target, messages)) +} + +#[cfg(test)] +fn format_awaited_members( + completed: bool, + summary: &str, + members: &[AwaitedMemberStatus], +) -> ToolOutput { + format_awaited_members_with_reports(completed, summary, members, &HashMap::new()) +} + +fn latest_assistant_report(messages: &[HistoryMessage]) -> Option<String> { + latest_assistant_comm_report(messages) +} + +fn resolve_optional_target_session(target: Option<String>, current_session: &str) -> String { + resolve_optional_comm_target_session(target, current_session) +} + +fn format_awaited_members_with_reports( + completed: bool, + summary: &str, + members: &[AwaitedMemberStatus], + reports: &HashMap<String, String>, +) -> ToolOutput { + ToolOutput::new(format_comm_awaited_members_with_reports( + completed, summary, members, reports, + )) +} + +async fn fetch_awaited_member_reports( + ctx: &ToolContext, + members: &[AwaitedMemberStatus], +) -> HashMap<String, String> { + let mut reports = HashMap::new(); + for member in members.iter().filter(|member| member.done) { + let request = Request::CommReadContext { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: member.session_id.clone(), + }; + match send_request(request).await { + Ok(ServerEvent::CommContextHistory { messages, .. }) => { + if let Some(report) = latest_assistant_report(&messages) { + reports.insert(member.session_id.clone(), report); + } + } + Ok(response) => { + if check_error(&response).is_some() { + continue; + } + } + Err(_) => continue, + } + } + reports +} + +fn default_await_target_statuses() -> Vec<String> { + default_comm_await_target_statuses() +} + +fn format_channels(channels: &[SwarmChannelInfo]) -> ToolOutput { + ToolOutput::new(format_comm_channels(channels)) +} + +/// Render the swarm model catalog for the `list_models` action: the current +/// (spawn-default) model, any config pin, and one line per route with +/// availability, auth method, and a relative cost estimate. +fn format_swarm_model_list( + current_model: Option<&str>, + configured_swarm_model: Option<&str>, + model_routes: &[jcode_provider_core::ModelRoute], +) -> String { + let mut out = String::new(); + out.push_str(&format!( + "Current model (spawn default when no override): {}\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("No agents.swarm_model pin configured (workers inherit the coordinator's model unless a per-spawn 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.", + ); + 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"); + for route in model_routes { + let availability = if route.available { + "" + } else { + " [unavailable]" + }; + let cost = route + .estimated_reference_cost_micros() + .map(|micros| format!(" ~${:.2}/ref-task", micros as f64 / 1_000_000.0)) + .unwrap_or_default(); + let detail = if route.detail.is_empty() { + String::new() + } else { + format!(" ({})", route.detail) + }; + out.push_str(&format!( + "- {} via {} [{}]{}{}{}\n", + 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 +} + +pub struct CommunicateTool { + /// Full tool description including the user-tunable swarm prompt + /// (model-routing guidance loaded from `swarm-prompt.md`). Computed once at + /// registry construction so `description()` can hand out a borrowed str. + description: String, +} + +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); + let description = if swarm_prompt.is_empty() { + BASE_DESCRIPTION.to_string() + } else { + format!( + "{BASE_DESCRIPTION}\n\nSwarm prompt (user-tunable via ~/.jcode/swarm-prompt.md):\n{swarm_prompt}" + ) + }; + Self { description } + } +} + +#[derive(Clone, Deserialize)] +struct CommunicateInput { + action: String, + #[serde(default)] + key: Option<String>, + #[serde(default)] + value: Option<String>, + #[serde(default)] + message: Option<String>, + #[serde(default)] + to_session: Option<String>, + #[serde(default)] + channel: Option<String>, + #[serde(default)] + proposer_session: Option<String>, + #[serde(default)] + reason: Option<String>, + #[serde(default)] + target_session: Option<String>, + #[serde(default)] + role: Option<String>, + #[serde(default)] + working_dir: Option<String>, + #[serde(default)] + initial_message: Option<String>, + #[serde(default)] + prompt: Option<String>, + #[serde(default)] + limit: Option<usize>, + #[serde(default)] + task_id: Option<String>, + #[serde(default)] + spawn_if_needed: Option<bool>, + #[serde(default)] + prefer_spawn: Option<bool>, + #[serde(default)] + plan_items: Option<Vec<PlanItem>>, + #[serde(default)] + node_id: Option<String>, + #[serde(default)] + gate_id: Option<String>, + /// Task-DAG node specs for task_graph/expand_node/inject_gap actions. + #[serde(default)] + nodes: Option<Vec<crate::protocol::TaskGraphNodeSpec>>, + /// Handoff artifact (object) for complete_node. + #[serde(default)] + artifact: Option<serde_json::Value>, + #[serde(default)] + target_status: Option<Vec<String>>, + #[serde(default)] + session_ids: Option<Vec<String>>, + #[serde(default)] + mode: Option<String>, + #[serde(default)] + timeout_minutes: Option<u64>, + #[serde(default)] + wake: Option<bool>, + #[serde(default)] + background: Option<bool>, + #[serde(default)] + notify: Option<bool>, + #[serde(default)] + delivery: Option<CommDeliveryMode>, + #[serde(default)] + concurrency_limit: Option<usize>, + #[serde(default)] + force: Option<bool>, + #[serde(default)] + retain_agents: Option<bool>, + #[serde(default)] + status: Option<String>, + #[serde(default)] + validation: Option<String>, + #[serde(default)] + follow_up: Option<String>, + #[serde(default)] + spawn_mode: Option<String>, + /// One-line summary shown collapsed in the recipient's UI for long + /// message/report bodies. Required when the body exceeds the collapse + /// threshold. + #[serde(default)] + tldr: Option<String>, + /// Per-spawn model override for spawn/assign_task/assign_next/run_plan + /// spawns. Takes precedence over agents.swarm_model config. + #[serde(default)] + model: Option<String>, + /// Reasoning effort for spawned agents (none|low|medium|high|xhigh|max). + #[serde(default)] + effort: Option<String>, + /// Short human-readable label for a spawned agent shown in swarm UI. + /// Required and nonblank for the explicit `spawn` action. + #[serde(default)] + label: Option<String>, +} + +impl CommunicateInput { + fn spawn_initial_message(&self) -> Option<String> { + self.initial_message.clone().or_else(|| self.prompt.clone()) + } + + fn required_spawn_label(&self) -> anyhow::Result<String> { + let label = self + .label + .as_deref() + .ok_or_else(|| anyhow::anyhow!("'label' is required for spawn action"))? + .trim(); + if label.is_empty() { + return Err(anyhow::anyhow!( + "'label' must not be blank for spawn action" + )); + } + Ok(label.to_string()) + } +} + +/// Map common action synonyms/typos to the canonical swarm action name. Models +/// frequently invent near-miss verbs (e.g. `inbox` for reading messages, `send` +/// for `message`), which previously produced an "Unknown action" error. Unknown +/// inputs are returned unchanged so the normal validation path still reports them. +fn canonical_swarm_action(action: &str) -> &str { + match action.trim().to_ascii_lowercase().as_str() { + "inbox" | "messages" | "check_messages" | "read_messages" | "read_inbox" => "read", + "send" | "msg" | "send_message" => "message", + "dm_session" | "direct_message" | "whisper" => "dm", + "broadcast_all" | "announce" => "broadcast", + "agents" | "members" | "list_agents" | "list_members" | "roster" => "list", + "models" | "model_list" | "list_model" | "list_providers" | "list_routes" => "list_models", + "plan" | "status_plan" => "plan_status", + "assign" => "assign_task", + "kill" | "terminate" => "stop", + _ => action, + } +} + +#[async_trait] +impl Tool for CommunicateTool { + fn name(&self) -> &str { + "swarm" + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters_schema(&self) -> Value { + let mut schema = json!({ + "type": "object", + "required": ["action"], + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["share", "share_append", "read", "message", "broadcast", "dm", "channel", "list", "list_channels", "channel_members", + "propose_plan", "approve_plan", "reject_plan", "spawn", "stop", "assign_role", + "status", "report", "plan_status", "summary", "read_context", "resync_plan", "assign_task", "assign_next", "fill_slots", "run_plan", "cleanup", + "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." + }, + "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." + }, + "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." + }, + "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." + }, + "status": { + "type": "string", + "description": "For action=report: completion status to record, usually ready, blocked, failed, or completed. Defaults to ready." + }, + "validation": { + "type": "string", + "description": "For action=report: tests or validation performed." + }, + "follow_up": { + "type": "string", + "description": "For action=report: blockers or follow-up work." + }, + "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." + }, + "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." + }, + "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." + }, + "role": { + "type": "string", + "enum": ["agent", "coordinator"] + }, + "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')." + }, + "working_dir": { + "type": "string", + "description": "Optional working directory for spawn." + }, + "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." + }, + "initial_message": { + "type": "string", + "description": "Explicit initial task/instructions for spawn. If both initial_message and prompt are supplied, initial_message wins." + }, + "limit": { + "type": "integer", + "minimum": 1, + "description": "Optional max items for summary-style reads." + }, + "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." + }, + "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." + }, + "prefer_spawn": { + "type": "boolean", + "description": "For assign_task without an explicit target_session: prefer a fresh spawned agent even if reusable workers are available." + }, + "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." + }, + "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." + }, + "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." + }, + "session_ids": { + "type": "array", + "items": {"type": "string"} + }, + "mode": { + "type": "string", + "enum": ["all", "any"], + "description": "For await_members: wait for all targeted members or wake when any targeted member matches." + }, + "target_status": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional completion statuses for await_members. Defaults to ready/completed/stopped/failed." + }, + "timeout_minutes": { + "type": "integer", + "minimum": 1, + "description": "Optional timeout for await_members." + }, + "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." + }, + "notify": { + "type": "boolean", + "description": "For await_members/run_plan: surface a notification card when the background task resolves. 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." + }, + "force": { + "type": "boolean", + "description": "For stop/cleanup: allow stopping non-owned/user-created swarm sessions. Defaults to false." + }, + "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." + }, + "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." + }, + "delivery": { + "type": "string", + "enum": ["notify", "interrupt", "wake"], + "description": "Optional delivery mode for dm/channel messaging." + }, + "plan_items": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }); + + // Task-DAG properties are added after the macro to keep `json!` nesting + // depth under the macro recursion limit. + if let Some(props) = schema + .get_mut("properties") + .and_then(|value| value.as_object_mut()) + { + props.insert( + "node_id".to_string(), + json!({ + "type": "string", + "description": "Task-DAG node id for expand_node/complete_node." + }), + ); + props.insert( + "gate_id".to_string(), + json!({ + "type": "string", + "description": "Gate node id for inject_gap (a critique/verify gate the caller owns)." + }), + ); + props.insert( + "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.", + "items": { "type": "object", "additionalProperties": true } + }), + ); + props.insert( + "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[].", + "additionalProperties": true + }), + ); + } + + // `swarm` is a multi-action tool, so putting `label` in the top-level + // `required` array would incorrectly require it for read/list/message and + // every other action. Use mutually exclusive action branches instead: + // the spawn branch requires label, while the non-spawn branch does not. + // `anyOf` object branches are supported by our provider schema adapters + // and avoid the less-portable JSON Schema `if`/`then` keywords. + let non_spawn_actions: Vec<Value> = schema["properties"]["action"]["enum"] + .as_array() + .into_iter() + .flatten() + .filter(|action| action.as_str() != Some("spawn")) + .cloned() + .collect(); + schema["anyOf"] = json!([ + { + "type": "object", + "required": ["action", "label"], + "properties": { + "action": { "type": "string", "enum": ["spawn"] } + } + }, + { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": non_spawn_actions } + } + } + ]); + + schema + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let mut params: CommunicateInput = serde_json::from_value(input)?; + + // `to_session` and `target_session` both name a single session id. Historically + // different actions required different field names (e.g. `dm` wanted `to_session` + // while `assign_role`/`summary`/`status`/`start`/`resume` wanted `target_session`), + // which models frequently confuse, producing repeated "'to_session' is required" / + // "'target_session' is required" errors. Treat the two fields as interchangeable + // aliases so either name works for any action that targets a session. + match (params.to_session.is_some(), params.target_session.is_some()) { + (true, false) => params.target_session = params.to_session.clone(), + (false, true) => params.to_session = params.target_session.clone(), + _ => {} + } + + // Normalize common action synonyms that models invent (e.g. `inbox`, `send`, + // `msg`) so a near-miss verb maps to the real action instead of erroring out. + params.action = canonical_swarm_action(¶ms.action).to_string(); + + match params.action.as_str() { + "share" | "share_append" => { + let key = params + .key + .ok_or_else(|| anyhow::anyhow!("'key' is required for share action"))?; + let value = params + .value + .ok_or_else(|| anyhow::anyhow!("'value' is required for share action"))?; + + let request = Request::CommShare { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + key: key.clone(), + value: value.clone(), + append: params.action == "share_append", + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + let verb = if params.action == "share_append" { + "Appended shared context" + } else { + "Shared with other agents" + }; + Ok(ToolOutput::new(format!("{}: {} = {}", verb, key, value))) + } + Err(e) => Err(anyhow::anyhow!("Failed to share: {}", e)), + } + } + + "read" => { + let request = Request::CommRead { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + key: params.key.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommContext { entries, .. }) => { + Ok(format_context_entries(&entries)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No shared context found.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to read shared context: {}", e)), + } + } + + "message" => { + // `message` is the general-purpose send: it routes by the fields + // provided. With `to_session` it acts as a DM, with `channel` it + // posts to that channel, and with neither it broadcasts to the + // sender's spawned subtree (whole swarm only for the coordinator). + let message = params + .message + .ok_or_else(|| anyhow::anyhow!("'message' is required for message action"))?; + let tldr = validate_swarm_tldr(params.tldr.as_deref(), &message, "this message") + .map_err(|e| anyhow::anyhow!(e))?; + let to_session = params.to_session.clone(); + let channel = params.channel.clone(); + + let request = Request::CommMessage { + id: REQUEST_ID, + from_session: ctx.session_id.clone(), + message: message.clone(), + to_session: to_session.clone(), + channel: channel.clone(), + wake: params.wake, + delivery: params.delivery, + tldr, + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + let confirmation = match (to_session, channel) { + (Some(target), _) => { + format!("Direct message sent to {}: {}", target, message) + } + (None, Some(channel)) => { + format!("Channel message sent to #{}: {}", channel, message) + } + (None, None) => { + format!("Broadcast sent to your spawned subtree: {}", message) + } + }; + Ok(ToolOutput::new(confirmation)) + } + Err(e) => Err(anyhow::anyhow!("Failed to send message: {}", e)), + } + } + + "broadcast" => { + // `broadcast` targets the sender's spawned subtree (the swarm + // coordinator reaches the whole swarm). Any `to_session`/ + // `channel` is intentionally ignored so the action stays an + // unambiguous group send; use `message`/`dm`/`channel` to target. + // Prefer DMs or task-graph artifacts; group sends are for rare + // coordination moments, not routine status updates. + let message = params + .message + .ok_or_else(|| anyhow::anyhow!("'message' is required for broadcast action"))?; + let tldr = validate_swarm_tldr(params.tldr.as_deref(), &message, "this broadcast") + .map_err(|e| anyhow::anyhow!(e))?; + + let request = Request::CommMessage { + id: REQUEST_ID, + from_session: ctx.session_id.clone(), + message: message.clone(), + to_session: None, + channel: None, + wake: params.wake, + delivery: params.delivery, + tldr, + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Broadcast sent to your spawned subtree: {}", + message + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to broadcast message: {}", e)), + } + } + + "dm" => { + let message = params + .message + .ok_or_else(|| anyhow::anyhow!("'message' is required for dm action"))?; + let tldr = validate_swarm_tldr(params.tldr.as_deref(), &message, "this DM") + .map_err(|e| anyhow::anyhow!(e))?; + let to_session = params.to_session.ok_or_else(|| { + anyhow::anyhow!("'to_session' (or 'target_session') is required for dm action") + })?; + + let request = Request::CommMessage { + id: REQUEST_ID, + from_session: ctx.session_id.clone(), + message: message.clone(), + to_session: Some(to_session.clone()), + channel: None, + delivery: params.delivery, + wake: params.wake, + tldr, + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Direct message sent to {}: {}", + to_session, message + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to send DM: {}", e)), + } + } + + "channel" => { + let message = params + .message + .ok_or_else(|| anyhow::anyhow!("'message' is required for channel action"))?; + let tldr = + validate_swarm_tldr(params.tldr.as_deref(), &message, "this channel message") + .map_err(|e| anyhow::anyhow!(e))?; + let channel = params + .channel + .ok_or_else(|| anyhow::anyhow!("'channel' is required for channel action"))?; + + let request = Request::CommMessage { + id: REQUEST_ID, + from_session: ctx.session_id.clone(), + message: message.clone(), + to_session: None, + channel: Some(channel.clone()), + delivery: params.delivery, + wake: params.wake, + tldr, + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Channel message sent to #{}: {}", + channel, message + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to send channel message: {}", e)), + } + } + + "list" => { + let request = Request::CommList { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommMembers { members, .. }) => { + Ok(format_members(&ctx, &members)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No agents found.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to list agents: {}", e)), + } + } + + "list_channels" => { + let request = Request::CommListChannels { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommChannels { channels, .. }) => { + Ok(format_channels(&channels)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No channels found.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to list channels: {}", e)), + } + } + + "channel_members" => { + let channel = params.channel.ok_or_else(|| { + anyhow::anyhow!("'channel' is required for channel_members action") + })?; + let request = Request::CommChannelMembers { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + channel: channel.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommMembers { members, .. }) => { + let mut output = format!("Members subscribed to #{}:\n\n", channel); + if members.is_empty() { + output.push_str(" (none)\n"); + } else { + for member in members { + let name = member.friendly_name.unwrap_or(member.session_id); + let status = member.status.unwrap_or_else(|| "unknown".to_string()); + output.push_str(&format!(" {} ({})\n", name, status)); + } + } + Ok(ToolOutput::new(output)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No channel members found.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to list channel members: {}", e)), + } + } + + "propose_plan" => { + let items = params.plan_items.ok_or_else(|| { + anyhow::anyhow!("'plan_items' is required for propose_plan action") + })?; + if items.is_empty() { + return Err(anyhow::anyhow!( + "'plan_items' must include at least one item" + )); + } + let item_count = items.len() as u64; + + let request = Request::CommProposePlan { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + items, + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Plan proposal submitted ({} items).", + item_count + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to propose plan: {}", e)), + } + } + + "approve_plan" => { + let proposer = params.proposer_session.ok_or_else(|| { + anyhow::anyhow!("'proposer_session' is required for approve_plan action") + })?; + + let request = Request::CommApprovePlan { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + proposer_session: proposer.clone(), + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Approved plan proposal from {}", + proposer + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to approve plan: {}", e)), + } + } + + "reject_plan" => { + let proposer = params.proposer_session.ok_or_else(|| { + anyhow::anyhow!("'proposer_session' is required for reject_plan action") + })?; + let reason = params.reason.clone(); + + let request = Request::CommRejectPlan { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + proposer_session: proposer.clone(), + reason: reason.clone(), + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + let reason_msg = reason + .as_ref() + .map(|r| format!(" (reason: {})", r)) + .unwrap_or_default(); + Ok(ToolOutput::new(format!( + "Rejected plan proposal from {}{}", + proposer, reason_msg + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to reject plan: {}", e)), + } + } + + "task_graph" | "seed_graph" => { + let nodes = params + .nodes + .clone() + .ok_or_else(|| anyhow::anyhow!("'nodes' is required for task_graph action"))?; + if nodes.is_empty() { + return Err(anyhow::anyhow!("'nodes' must include at least one node")); + } + let count = nodes.len(); + let mut seed_nodes = nodes.clone(); + let request = Request::CommSeedGraph { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + mode: params.mode.clone(), + nodes: seed_nodes.clone(), + }; + let mut response = send_request(request) + .await + .map_err(|e| anyhow::anyhow!("Failed to seed task graph: {}", e))?; + let mut changes = Vec::new(); + let mut occupied = None; + // At most one durable collision can be resolved per request. The + // extra iteration consumes the final success response after all + // colliding ids have been remapped. + for _ in 0..=nodes.len() { + let Some(conflicting_id) = seed_node_id_collision(&response) else { + ensure_success(&response)?; + let suffix = if changes.is_empty() { + String::new() + } else { + format!( + " Renamed conflicting ids: {}.", + format_seed_remaps(&changes) + ) + }; + return Ok(ToolOutput::new(format!( + "Seeded task graph ({} nodes).{}", + count, suffix + ))); + }; + if occupied.is_none() { + let summary = fetch_plan_status(&ctx.session_id).await?; + occupied = Some(plan_graph_node_ids(&summary)); + } + let occupied = occupied + .as_ref() + .expect("occupied ids were initialized above"); + let (remapped, mut remaps) = remap_conflicting_seed_nodes( + &seed_nodes, + occupied, + conflicting_id, + &seed_retry_scope(&ctx), + ); + if remaps.is_empty() { + ensure_success(&response)?; + unreachable!("a duplicate seed error should have returned above") + } + seed_nodes = remapped; + changes.append(&mut remaps); + response = send_request(Request::CommSeedGraph { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + mode: params.mode.clone(), + nodes: seed_nodes.clone(), + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to retry task graph seed: {}", e))?; + } + ensure_success(&response)?; + unreachable!("seed retry loop only exhausts while the server returns collisions") + } + + "expand_node" => { + let node_id = params.node_id.clone().ok_or_else(|| { + anyhow::anyhow!("'node_id' is required for expand_node action") + })?; + let children = params.nodes.clone().ok_or_else(|| { + anyhow::anyhow!("'nodes' (children) is required for expand_node action") + })?; + if children.is_empty() { + return Err(anyhow::anyhow!("expand_node requires at least one child")); + } + let count = children.len(); + let request = Request::CommExpandNode { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + node_id: node_id.clone(), + children, + }; + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Decomposed '{}' into {} children.", + node_id, count + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to expand node: {}", e)), + } + } + + "complete_node" => { + let node_id = params.node_id.clone().ok_or_else(|| { + anyhow::anyhow!("'node_id' is required for complete_node action") + })?; + let artifact_json = match params.artifact.clone() { + Some(value) => serde_json::to_string(&value) + .map_err(|e| anyhow::anyhow!("invalid artifact: {}", e))?, + None => { + return Err(anyhow::anyhow!( + "'artifact' object is required for complete_node action" + )); + } + }; + let request = Request::CommCompleteNode { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + node_id: node_id.clone(), + artifact_json, + }; + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!("Completed node '{}'.", node_id))) + } + Err(e) => Err(anyhow::anyhow!("Failed to complete node: {}", e)), + } + } + + "inject_gap" => { + let gate_id = params + .gate_id + .clone() + .or_else(|| params.node_id.clone()) + .ok_or_else(|| { + anyhow::anyhow!("'gate_id' is required for inject_gap action") + })?; + let nodes = params + .nodes + .clone() + .ok_or_else(|| anyhow::anyhow!("'nodes' is required for inject_gap action"))?; + if nodes.is_empty() { + return Err(anyhow::anyhow!("inject_gap requires at least one node")); + } + let count = nodes.len(); + let request = Request::CommInjectGap { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + gate_id: gate_id.clone(), + nodes, + }; + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Injected {} gap node(s) from gate '{}'.", + count, gate_id + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to inject gap nodes: {}", e)), + } + } + + "spawn" => { + let label = params.required_spawn_label()?; + let request = Request::CommSpawn { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + working_dir: params.working_dir.clone(), + initial_message: params.spawn_initial_message(), + request_nonce: None, + spawn_mode: params.spawn_mode.clone(), + model: params.model.clone(), + effort: params.effort.clone(), + label: Some(label), + }; + + match send_request(request).await { + Ok(ServerEvent::CommSpawnResponse { new_session_id, .. }) + if !new_session_id.is_empty() => + { + Ok(ToolOutput::new(format!( + "Spawned new agent: {}", + new_session_id + ))) + } + Ok(response) => { + ensure_success(&response)?; + Err(anyhow::anyhow!( + "Spawn succeeded but new session ID was not returned." + )) + } + Err(e) => Err(anyhow::anyhow!("Failed to spawn agent: {}", e)), + } + } + + "list_models" => { + let request = Request::CommListModels { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + }; + match send_request(request).await { + Ok(ServerEvent::CommListModelsResponse { + current_model, + configured_swarm_model, + model_routes, + .. + }) => Ok(ToolOutput::new(format_swarm_model_list( + current_model.as_deref(), + configured_swarm_model.as_deref(), + &model_routes, + ))), + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No model catalog returned.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to list models: {}", e)), + } + } + + "stop" => { + let target = params.target_session.ok_or_else(|| { + anyhow::anyhow!("'target_session' is required for stop action") + })?; + + let request = Request::CommStop { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: target.clone(), + force: params.force, + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!("Stopped agent: {}", target))) + } + Err(e) => Err(anyhow::anyhow!("Failed to stop agent: {}", e)), + } + } + + "cleanup" => cleanup_swarm_workers(&ctx, ¶ms) + .await + .map(ToolOutput::new), + + "assign_role" => { + let target_raw = params.target_session.ok_or_else(|| { + anyhow::anyhow!("'target_session' is required for assign_role action") + })?; + let role = params + .role + .ok_or_else(|| anyhow::anyhow!("'role' is required for assign_role action"))?; + + // Resolve "current" to the caller's own session ID + let target = if target_raw == "current" { + ctx.session_id.clone() + } else { + target_raw + }; + + let request = Request::CommAssignRole { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: target.clone(), + role: role.clone(), + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Assigned role '{}' to {}", + role, target + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to assign role: {}", e)), + } + } + + "status" => { + let target = + resolve_optional_target_session(params.target_session, &ctx.session_id); + + let request = Request::CommStatus { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: target.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommStatusResponse { snapshot, .. }) => { + Ok(format_status_snapshot(&snapshot)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No status snapshot returned.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to get status snapshot: {}", e)), + } + } + + "report" => { + let message = params + .message + .ok_or_else(|| anyhow::anyhow!("'message' is required for report action"))?; + let tldr = validate_swarm_tldr(params.tldr.as_deref(), &message, "this report") + .map_err(|e| anyhow::anyhow!(e))?; + let request = Request::CommReport { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + status: params.status, + message, + validation: params.validation, + follow_up: params.follow_up, + tldr, + }; + match send_request(request).await { + Ok(ServerEvent::CommReportResponse { + status, message, .. + }) => Ok(ToolOutput::new(format!( + "Report recorded with status `{status}`. {message}" + ))), + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("Report recorded.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to record report: {}", e)), + } + } + + "plan_status" => { + let summary = fetch_plan_status(&ctx.session_id).await?; + Ok(format_plan_status(&summary)) + } + + "summary" => { + let target = params.target_session.ok_or_else(|| { + anyhow::anyhow!("'target_session' is required for summary action") + })?; + + let request = Request::CommSummary { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: target.clone(), + limit: params.limit, + }; + + match send_request(request).await { + Ok(ServerEvent::CommSummaryResponse { tool_calls, .. }) => { + Ok(format_tool_summary(&target, &tool_calls)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No tool call data returned.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to get summary: {}", e)), + } + } + + "read_context" => { + let target = params.target_session.ok_or_else(|| { + anyhow::anyhow!("'target_session' is required for read_context action") + })?; + + let request = Request::CommReadContext { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: target.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommContextHistory { messages, .. }) => { + Ok(format_context_history(&target, &messages)) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("No context data returned.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to read context: {}", e)), + } + } + + "resync_plan" => { + let request = Request::CommResyncPlan { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("Swarm plan re-synced to your session.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to resync plan: {}", e)), + } + } + + "assign_task" => { + let target = params + .target_session + .clone() + .unwrap_or_else(|| "next available agent".to_string()); + let spawn_if_needed = params.spawn_if_needed.unwrap_or(false); + let prefer_spawn = params.prefer_spawn.unwrap_or(false); + + if prefer_spawn && params.target_session.is_none() { + let spawned_session = spawn_assignment_session(&ctx, ¶ms).await?; + return assign_task_to_session( + &ctx, + ¶ms, + spawned_session, + " (spawned by planner preference)", + ) + .await; + } + + let request = Request::CommAssignTask { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: params.target_session.clone(), + task_id: params.task_id.clone(), + message: params.message.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommAssignTaskResponse { + task_id, + target_session, + .. + }) => { + let mut output = + format!("Task '{}' assigned to {}", task_id, target_session); + if let Ok(summary) = fetch_plan_status(&ctx.session_id).await { + output.push_str(&format!("\n{}", format_plan_followup(&summary))); + } + Ok(ToolOutput::new(output)) + } + Ok(response) + if spawn_if_needed + && params.target_session.is_none() + && auto_assignment_needs_spawn(&response) => + { + let spawned_session = spawn_assignment_session(&ctx, ¶ms).await?; + assign_task_to_session( + &ctx, + ¶ms, + spawned_session, + " (spawned automatically)", + ) + .await + } + Ok(response) => { + ensure_success(&response)?; + let msg = params.task_id.as_deref().map_or_else( + || format!("Assigned next runnable task to {}", target), + |task_id| format!("Task '{}' assigned to {}", task_id, target), + ); + Ok(ToolOutput::new(msg)) + } + Err(e) => Err(anyhow::anyhow!("Failed to assign task: {}", e)), + } + } + + "assign_next" => { + let target = params + .target_session + .clone() + .unwrap_or_else(|| "next available agent".to_string()); + + let request = Request::CommAssignNext { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: params.target_session.clone(), + working_dir: params.working_dir.clone(), + prefer_spawn: params.prefer_spawn, + spawn_if_needed: params.spawn_if_needed, + message: params.message.clone(), + model: params.model.clone(), + effort: params.effort.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommAssignTaskResponse { + task_id, + target_session, + .. + }) => Ok(ToolOutput::new(format!( + "Task '{}' assigned to {}", + task_id, target_session + ))), + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!( + "Assigned next runnable task to {}", + target + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to assign next task: {}", e)), + } + } + + "fill_slots" => { + let concurrency_limit = params.concurrency_limit.ok_or_else(|| { + anyhow::anyhow!("'concurrency_limit' is required for fill_slots action") + })?; + + let summary = fetch_plan_status(&ctx.session_id).await?; + let members = fetch_swarm_members(&ctx.session_id).await?; + + let active_count = + coordination_in_flight_count(&summary, &members, &ctx.session_id); + if active_count >= concurrency_limit { + return Ok(ToolOutput::new(format!( + "Window already full: {} active/in-flight task(s) >= limit {}", + active_count, concurrency_limit + ))); + } + + let mut assignments = Vec::new(); + let available_slots = concurrency_limit.saturating_sub(active_count); + for _ in 0..available_slots { + let request = Request::CommAssignNext { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_session: params.target_session.clone(), + working_dir: params.working_dir.clone(), + prefer_spawn: params.prefer_spawn, + spawn_if_needed: params.spawn_if_needed, + message: params.message.clone(), + model: params.model.clone(), + effort: params.effort.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommAssignTaskResponse { + task_id, + target_session, + .. + }) => assignments.push(format!("{} -> {}", task_id, target_session)), + Ok(ServerEvent::Error { message, .. }) + if message.contains("No runnable unassigned tasks") + || message.contains("No ready or completed swarm agents") => + { + break; + } + Ok(response) => { + ensure_success(&response)?; + } + Err(e) => { + return Err(anyhow::anyhow!("Failed to fill slots: {}", e)); + } + } + } + + if assignments.is_empty() { + Ok(ToolOutput::new(format!( + "No assignments made. Active: {}, limit: {}", + active_count, concurrency_limit + ))) + } else { + let mut output = format!( + "Filled {} slot(s):\n{}", + assignments.len(), + assignments + .into_iter() + .map(|line| format!("- {}", line)) + .collect::<Vec<_>>() + .join("\n") + ); + if let Ok(summary) = fetch_plan_status(&ctx.session_id).await { + output.push_str(&format!("\n{}", format_plan_followup(&summary))); + } + Ok(ToolOutput::new(output)) + } + } + + "run_plan" => { + // Background-by-default: the plan driver runs as a managed + // background task (progress card, bg tool, notify/wake) so the + // coordinating agent stays responsive. Pass background=false + // to block inline until the plan reaches a terminal state. + if params.background.unwrap_or(true) { + run_swarm_plan_in_background(&ctx, params.clone()).await + } else { + run_swarm_plan_to_terminal(&ctx, ¶ms, &RunPlanReporter::inline()).await + } + } + + "start" | "start_task" | "wake" | "resume" | "retry" | "reassign" | "replace" + | "salvage" => { + let task_id = match params.task_id.clone() { + Some(task_id) => task_id, + None if params.target_session.is_some() => String::new(), + None => { + return Err(anyhow::anyhow!( + "'task_id' is required for {} action unless 'target_session' uniquely identifies the assigned task. Use `swarm list`/`swarm plan_status` to inspect assignments, or pass task_id explicitly.", + params.action + )); + } + }; + if matches!(params.action.as_str(), "reassign" | "replace" | "salvage") + && params.target_session.is_none() + { + return Err(anyhow::anyhow!( + "'target_session' is required for {} action", + params.action + )); + } + + let control_action = if params.action == "start_task" { + "start".to_string() + } else { + params.action.clone() + }; + + let request = Request::CommTaskControl { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + action: control_action.clone(), + task_id: task_id.clone(), + target_session: params.target_session.clone(), + message: params.message.clone(), + }; + + match send_request(request).await { + Ok(ServerEvent::CommTaskControlResponse { + task_id, + action, + target_session, + status, + summary, + .. + }) => { + let mut output = format!("Task '{}' {}", task_id, action); + if let Some(target_session) = target_session { + output.push_str(&format!(" -> {}", target_session)); + } + output.push_str(&format!("\nStatus: {}", status)); + if !summary.next_ready_ids.is_empty() { + output.push_str(&format!( + "\nNext ready: {}", + summary.next_ready_ids.join(", ") + )); + } + if !summary.newly_ready_ids.is_empty() { + output.push_str(&format!( + "\nNewly ready: {}", + summary.newly_ready_ids.join(", ") + )); + } + Ok(ToolOutput::new(output)) + } + Ok(response) => { + ensure_success(&response)?; + let target_suffix = params + .target_session + .as_deref() + .map(|target| format!(" -> {}", target)) + .unwrap_or_default(); + Ok(ToolOutput::new(format!( + "Task '{}' {}{}", + task_id, params.action, target_suffix + ))) + } + Err(e) => Err(anyhow::anyhow!("Failed to {} task: {}", control_action, e)), + } + } + + "subscribe_channel" => { + let channel = params.channel.ok_or_else(|| { + anyhow::anyhow!("'channel' is required for subscribe_channel action") + })?; + + let request = Request::CommSubscribeChannel { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + channel: channel.clone(), + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!("Subscribed to #{}", channel))) + } + Err(e) => Err(anyhow::anyhow!("Failed to subscribe: {}", e)), + } + } + + "unsubscribe_channel" => { + let channel = params.channel.ok_or_else(|| { + anyhow::anyhow!("'channel' is required for unsubscribe_channel action") + })?; + + let request = Request::CommUnsubscribeChannel { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + channel: channel.clone(), + }; + + match send_request(request).await { + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new(format!("Unsubscribed from #{}", channel))) + } + Err(e) => Err(anyhow::anyhow!("Failed to unsubscribe: {}", e)), + } + } + + "await_members" => { + let target_status = params + .target_status + .unwrap_or_else(default_await_target_statuses); + let mut session_ids = params.session_ids.unwrap_or_default(); + if let Some(target_session) = params.target_session.clone() + && !session_ids.iter().any(|id| id == &target_session) + { + session_ids.push(target_session); + } + let timeout_minutes = params.timeout_minutes.unwrap_or(60); + let timeout_secs = timeout_minutes * 60; + // Public member waits are always asynchronous. The blocking + // CommAwaitMembers protocol remains available internally for the + // run_plan coordination loop, but agents must not park an entire + // turn waiting on a worker or a long-lived socket. + let blocking_was_requested = params.background == Some(false); + let background = true; + let notify = params.notify.unwrap_or(true); + let wake = params.wake.unwrap_or(true); + + let request = Request::CommAwaitMembers { + id: REQUEST_ID, + session_id: ctx.session_id.clone(), + target_status, + session_ids, + mode: params.mode.clone(), + timeout_secs: Some(timeout_secs), + background, + notify, + wake, + }; + + // Background waits return promptly with a snapshot; only blocking + // waits need the long socket timeout that covers the full wait. + let socket_timeout = if background { + std::time::Duration::from_secs(30) + } else { + std::time::Duration::from_secs(timeout_secs + 30) + }; + + match send_request_with_timeout(request, Some(socket_timeout)).await { + Ok(ServerEvent::CommAwaitMembersResponse { + completed, + members, + summary, + background_started, + .. + }) => { + if background_started { + let compatibility_note = if blocking_was_requested { + "\n\n(Blocking member waits are no longer supported; this wait was started asynchronously.)" + } else { + "\n\n(You can keep working; this wait runs in the background.)" + }; + return Ok(ToolOutput::new(format!( + "{}{}", + summary, compatibility_note + ))); + } + let reports = fetch_awaited_member_reports(&ctx, &members).await; + Ok(format_awaited_members_with_reports( + completed, &summary, &members, &reports, + )) + } + Ok(response) => { + ensure_success(&response)?; + Ok(ToolOutput::new("Await completed.")) + } + Err(e) => Err(anyhow::anyhow!("Failed to await members: {}", e)), + } + } + + _ => Err(anyhow::anyhow!( + "Unknown action '{}'. Valid actions: share, share_append, read, message, broadcast, dm, channel, list, list_channels, channel_members, \ + propose_plan, approve_plan, reject_plan, spawn, stop, assign_role, status, report, plan_status, summary, read_context, \ + resync_plan, assign_task, assign_next, fill_slots, run_plan, cleanup, start, start_task, wake, resume, retry, reassign, replace, salvage, subscribe_channel, unsubscribe_channel, await_members. \ + To read messages addressed to you, use action='read'.", + params.action + )), + } + } +} + +#[cfg(test)] +#[path = "communicate_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/tool/communicate/transport.rs b/crates/jcode-app-core/src/tool/communicate/transport.rs new file mode 100644 index 0000000..8f09f65 --- /dev/null +++ b/crates/jcode-app-core/src/tool/communicate/transport.rs @@ -0,0 +1,119 @@ +use crate::protocol::{Request, ServerEvent}; +use anyhow::Result; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +fn request_type_from_json(json: &str) -> String { + serde_json::from_str::<Value>(json) + .ok() + .and_then(|value| { + value + .get("type") + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub(super) async fn send_request(request: Request) -> Result<ServerEvent> { + send_request_with_timeout(request, None).await +} + +pub(super) async fn send_request_with_timeout( + request: Request, + timeout: Option<std::time::Duration>, +) -> Result<ServerEvent> { + let path = crate::server::socket_path(); + let stream = crate::server::connect_socket(&path).await?; + let (reader, mut writer) = stream.into_split(); + + let request_id = request.id(); + let deadline = + tokio::time::Instant::now() + timeout.unwrap_or(std::time::Duration::from_secs(30)); + + let json = serde_json::to_string(&request)? + "\n"; + let request_type = request_type_from_json(&json); + writer.write_all(json.as_bytes()).await?; + + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + // Read lines until we find the terminal response for our request ID. + // Skip: ack events, notification events, swarm_status broadcasts, etc. + // Terminal events: done, error, comm_spawn_response, comm_await_members_response, + // and any other typed response with matching id. + loop { + line.clear(); + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + crate::logging::warn(&format!( + "[tool:communicate] request timed out type={} id={} socket={} after waiting for response", + request_type, + request_id, + path.display() + )); + anyhow::bail!("Timed out waiting for response") + } + let n = tokio::time::timeout(remaining, reader.read_line(&mut line)).await??; + if n == 0 { + crate::logging::warn(&format!( + "[tool:communicate] connection closed before response type={} id={} socket={}", + request_type, + request_id, + path.display() + )); + return Err(anyhow::anyhow!( + "Connection closed before receiving response" + )); + } + + let value: Value = serde_json::from_str(line.trim()).map_err(|err| { + crate::logging::warn(&format!( + "[tool:communicate] failed to parse response type={} id={} socket={} error={} payload={}", + request_type, + request_id, + path.display(), + err, + crate::util::truncate_str(line.trim(), 240) + )); + err + })?; + + let event_type = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let event_id = value.get("id").and_then(|v| v.as_u64()); + + if event_type != "ack" && event_id != Some(request_id) { + continue; + } + + match event_type { + // Skip ack — not a response + "ack" => continue, + // Skip broadcast/async events that are not tied to our request + "swarm_status" + | "swarm_plan" + | "swarm_plan_proposal" + | "swarm_event" + | "notification" + | "soft_interrupt_injected" + | "session" + | "session_id" + | "history" + | "mcp_status" + | "memory_injected" + | "compaction" + | "connection_type" + | "connection_phase" + | "status_detail" + | "upstream_provider" + | "reloading" + | "reload_progress" + | "available_models_updated" + | "side_panel_state" + | "transcript" + | "interrupted" => continue, + // Terminal responses and typed request responses with matching ids. + _ => return Ok(serde_json::from_value(value)?), + } + } +} diff --git a/crates/jcode-app-core/src/tool/communicate_tests.rs b/crates/jcode-app-core/src/tool/communicate_tests.rs new file mode 100644 index 0000000..7e123bb --- /dev/null +++ b/crates/jcode-app-core/src/tool/communicate_tests.rs @@ -0,0 +1,1767 @@ +use super::{ + CommunicateInput, CommunicateTool, canonical_swarm_action, cleanup_candidate_session_ids, + coordination_in_flight_count, default_await_target_statuses, default_cleanup_target_statuses, + format_awaited_members, format_awaited_members_with_reports, format_members, + format_plan_status, format_swarm_model_list, latest_assistant_report, + resolve_optional_target_session, resolve_run_plan_concurrency, swarm_member_is_drivable_worker, + swarm_member_is_in_flight, +}; +use crate::message::{Message, StreamEvent, ToolDefinition}; +use crate::protocol::{ + AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, HistoryMessage, NotificationType, Request, + ServerEvent, SessionActivitySnapshot, ToolCallSummary, +}; +use crate::provider::{EventStream, Provider}; +use crate::server::Server; +use crate::tool::{Tool, ToolContext, ToolExecutionMode}; +use crate::transport::{ReadHalf, Stream, WriteHalf}; +use anyhow::Result; +use async_trait::async_trait; +use futures::StreamExt; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +#[test] +fn tool_is_named_swarm() { + assert_eq!(CommunicateTool::new().name(), "swarm"); +} + +#[test] +fn task_graph_seed_collision_is_detected_from_server_error() { + let response = ServerEvent::Error { + id: 1, + message: "Seed rejected: duplicate node id 'final-synthesis'".to_string(), + retry_after_secs: None, + }; + assert_eq!( + super::seed_node_id_collision(&response), + Some("final-synthesis") + ); + assert_eq!( + super::seed_node_id_collision(&ServerEvent::Done { id: 1 }), + None + ); +} + +#[test] +fn conflicting_seed_ids_are_scoped_and_dependencies_follow_the_remap() { + let nodes = vec![ + crate::protocol::TaskGraphNodeSpec { + id: "explore".to_string(), + content: "explore".to_string(), + kind: Some("explore".to_string()), + depends_on: Vec::new(), + priority: 10, + }, + crate::protocol::TaskGraphNodeSpec { + id: "final-synthesis".to_string(), + content: "synthesize".to_string(), + kind: Some("synthesize".to_string()), + depends_on: vec!["explore".to_string()], + priority: 20, + }, + crate::protocol::TaskGraphNodeSpec { + id: "verify".to_string(), + content: "verify".to_string(), + kind: Some("verify".to_string()), + depends_on: vec!["final-synthesis".to_string()], + priority: 30, + }, + ]; + let occupied = HashSet::from([ + // This node represents an exact replay. The server would have accepted it + // after the conflicting id was fixed, so the client must not rename every + // occupied id preemptively. + "explore".to_string(), + "final-synthesis".to_string(), + "final-synthesis::seed-deadbeef".to_string(), + ]); + + let (remapped, changes) = + super::remap_conflicting_seed_nodes(&nodes, &occupied, "final-synthesis", "seed-deadbeef"); + + assert_eq!( + changes, + vec![( + "final-synthesis".to_string(), + "final-synthesis::seed-deadbeef-2".to_string() + )] + ); + assert_eq!(remapped[0], nodes[0], "non-conflicting nodes stay stable"); + assert_eq!(remapped[1].id, "final-synthesis::seed-deadbeef-2"); + assert_eq!( + remapped[2].depends_on, + vec!["final-synthesis::seed-deadbeef-2".to_string()] + ); + assert_eq!( + super::format_seed_remaps(&changes), + "final-synthesis -> final-synthesis::seed-deadbeef-2" + ); +} + +#[test] +fn task_id_from_output_path_extracts_background_task_id() { + assert_eq!( + super::task_id_from_output_path(Path::new("/tmp/tasks/123abc.output")), + Some("123abc") + ); + assert_eq!( + super::task_id_from_output_path(Path::new("/tmp/tasks/123abc.status.json")), + None + ); +} + +#[tokio::test] +async fn run_plan_reporter_finalize_puts_summary_before_log() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let output_path = dir.path().join("tsk42.output"); + let reporter = super::RunPlanReporter::background(&output_path); + assert_eq!(reporter.task_id.as_deref(), Some("tsk42")); + + reporter.log("assigned a -> session_fox").await; + reporter.log("assigned b -> session_wolf").await; + reporter + .finalize("Swarm plan reached terminal state.") + .await; + + let content = tokio::fs::read_to_string(&output_path) + .await + .expect("output file"); + let summary_idx = content + .find("Swarm plan reached terminal state.") + .expect("summary present"); + let log_idx = content.find("assigned a -> session_fox").expect("log kept"); + assert!( + summary_idx < log_idx, + "summary must lead the output file so completion previews are useful:\n{content}" + ); +} + +#[tokio::test] +async fn run_plan_reporter_inline_is_a_no_op() { + let reporter = super::RunPlanReporter::inline(); + assert!(reporter.task_id.is_none()); + // Must not panic or create files. + reporter.log("ignored").await; + reporter.progress(1, 2, "ignored".to_string()).await; + reporter.finalize("ignored").await; +} + +#[tokio::test] +async fn run_plan_driver_guard_blocks_while_driver_task_is_live() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let manager = crate::background::BackgroundTaskManager::with_output_dir(dir.path().into()); + let session = "session-guard-live"; + + // Keep the fake driver alive long enough for the second claim to observe it. + let info = manager + .spawn_with_notify("swarm", None, session, false, false, |_| async { + tokio::time::sleep(Duration::from_secs(5)).await; + Ok(crate::background::TaskResult::completed(Some(0))) + }) + .await; + assert!(manager.is_live_task(&info.task_id)); + + match super::try_claim_run_plan_driver(&manager, session) { + super::RunPlanDriverClaimResult::Claimed(claim) => claim.record_task(&info.task_id), + super::RunPlanDriverClaimResult::AlreadyRunning(_) => { + panic!("first claim for a fresh session must succeed") + } + } + + match super::try_claim_run_plan_driver(&manager, session) { + super::RunPlanDriverClaimResult::AlreadyRunning(Some(task_id)) => { + assert_eq!(task_id, info.task_id); + } + super::RunPlanDriverClaimResult::AlreadyRunning(None) => { + panic!("claim was recorded with a task id, so the blocker should carry it") + } + super::RunPlanDriverClaimResult::Claimed(_) => { + panic!("second claim must be blocked while the driver task is live") + } + } + + manager.cancel(&info.task_id).await.expect("cancel driver"); +} + +#[tokio::test] +async fn run_plan_driver_guard_allows_restart_after_stale_driver() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let manager = crate::background::BackgroundTaskManager::with_output_dir(dir.path().into()); + let session = "session-guard-stale"; + + // Simulate the pre-reload world: a status file on disk still says a swarm + // driver is Running for this session, and the per-process claim map still + // holds its task id, but no such task is live in this process (the map is + // fresh after reload / the task was pruned on completion). + let stale_task_id = "stalezzzz1"; + let stale_status = serde_json::json!({ + "task_id": stale_task_id, + "tool_name": "swarm", + "session_id": session, + "status": "running", + "exit_code": null, + "error": null, + "started_at": chrono::Utc::now().to_rfc3339(), + "completed_at": null, + "duration_secs": null, + "detached": false, + "notify": false, + "wake": false + }); + tokio::fs::write( + manager.status_path_for(stale_task_id), + serde_json::to_string_pretty(&stale_status).expect("serialize stale status"), + ) + .await + .expect("write stale status file"); + + match super::try_claim_run_plan_driver(&manager, session) { + super::RunPlanDriverClaimResult::Claimed(claim) => claim.record_task(stale_task_id), + super::RunPlanDriverClaimResult::AlreadyRunning(_) => { + panic!("fresh session must be claimable") + } + } + assert!( + !manager.is_live_task(stale_task_id), + "stale task must not be live in this process" + ); + + // The stale Running status file and stale claim must not block restarting + // the driver. + match super::try_claim_run_plan_driver(&manager, session) { + super::RunPlanDriverClaimResult::Claimed(_claim) => {} + super::RunPlanDriverClaimResult::AlreadyRunning(_) => { + panic!("stale (non-live) driver must not block a restart") + } + } +} + +#[tokio::test] +async fn run_plan_driver_guard_is_atomic_for_racing_claims() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let manager: &'static crate::background::BackgroundTaskManager = Box::leak(Box::new( + crate::background::BackgroundTaskManager::with_output_dir(dir.path().into()), + )); + let session = "session-guard-race"; + + // Two run_plan calls in one batch race the claim; exactly one may win. + let mut join_set = tokio::task::JoinSet::new(); + for _ in 0..2 { + join_set.spawn(async move { + match super::try_claim_run_plan_driver(manager, session) { + // Keep the claim held (as the winner does while spawning). + super::RunPlanDriverClaimResult::Claimed(claim) => Some(claim), + super::RunPlanDriverClaimResult::AlreadyRunning(_) => None, + } + }); + } + let mut held_claims = Vec::new(); + while let Some(result) = join_set.join_next().await { + if let Some(claim) = result.expect("claim task should not panic") { + held_claims.push(claim); + } + } + assert_eq!(held_claims.len(), 1, "exactly one racing claim may win"); +} + +#[tokio::test] +async fn run_plan_driver_guard_releases_claim_on_drop_without_task() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let manager = crate::background::BackgroundTaskManager::with_output_dir(dir.path().into()); + let session = "session-guard-drop"; + + match super::try_claim_run_plan_driver(&manager, session) { + super::RunPlanDriverClaimResult::Claimed(claim) => drop(claim), + super::RunPlanDriverClaimResult::AlreadyRunning(_) => { + panic!("fresh session must be claimable") + } + } + + // A failed/cancelled startup path must not permanently block the session. + match super::try_claim_run_plan_driver(&manager, session) { + super::RunPlanDriverClaimResult::Claimed(_claim) => {} + super::RunPlanDriverClaimResult::AlreadyRunning(_) => { + panic!("dropped Starting claim must be released") + } + } +} + +#[test] +fn run_plan_concurrency_is_mode_aware() { + // Light mode (no explicit limit) keeps the small cheap fan-out default. + assert_eq!( + resolve_run_plan_concurrency(None, false, 32), + super::LIGHT_MODE_DEFAULT_CONCURRENCY + ); + + // Deep mode (no explicit limit) fans out wide using the configured cap, + // NOT the old hardcoded 3 and NOT the light default. + assert_eq!(resolve_run_plan_concurrency(None, true, 32), 32); + assert_eq!(resolve_run_plan_concurrency(None, true, 64), 64); + + // Deep mode with the cap set to 0 means "no extra cap": dispatch the whole + // ready set, bounded only by the swarm member cap. + assert_eq!(resolve_run_plan_concurrency(None, true, 0), usize::MAX); + + // An explicit request always wins over the mode default, in both modes, + // and is clamped to at least 1. + assert_eq!(resolve_run_plan_concurrency(Some(5), true, 32), 5); + assert_eq!(resolve_run_plan_concurrency(Some(5), false, 32), 5); + assert_eq!(resolve_run_plan_concurrency(Some(0), true, 32), 1); +} + +#[test] +fn run_plan_utilization_tracks_peak_and_starvation() { + let mut util = super::RunPlanUtilization::default(); + + // Loop 1: 0 in flight, 8 open slots, dispatched 8 -> budget fully used. + util.record_loop(0, Some(8), 8); + // Loop 2: 8 in flight, 0 open slots, dispatched 0 -> saturated, not starved. + util.record_loop(8, Some(0), 0); + // Loop 3: 2 in flight, 6 open slots, dispatched 1 -> starved (5 idle slots). + util.record_loop(2, Some(6), 1); + + assert_eq!(util.peak_in_flight, 8); + assert_eq!(util.loops, 3); + assert_eq!(util.starved_loops, 1); + + let report = util.report(8, true); + assert!(report.contains("peak 8 of 8")); + assert!(report.contains("1 of 3 loop(s)")); + // 1/3 starved and peak 8: healthy run, no hint. + assert!(!report.contains("Deep-mode hint")); +} + +#[test] +fn run_plan_utilization_flags_serial_deep_runs() { + // A deep run that trickles one task at a time despite a 32-slot budget. + let mut util = super::RunPlanUtilization::default(); + for _ in 0..4 { + util.record_loop(0, Some(32), 1); + } + assert_eq!(util.peak_in_flight, 1); + assert_eq!(util.starved_loops, 4); + + let deep_report = util.report(32, true); + assert!(deep_report.contains("peak 1 of 32")); + assert!(deep_report.contains("Deep-mode hint")); + assert!(deep_report.contains("expand")); + + // The same shape in light mode is by design; no nagging. + let light_report = util.report(32, false); + assert!(!light_report.contains("Deep-mode hint")); +} + +#[test] +fn run_plan_utilization_handles_unbounded_budget() { + let mut util = super::RunPlanUtilization::default(); + // Unbounded budget (deep_cap=0): open slots are not meaningful, so no + // starvation accounting, but peak parallelism still records. + util.record_loop(10, None, 5); + assert_eq!(util.peak_in_flight, 15); + assert_eq!(util.starved_loops, 0); + let report = util.report(usize::MAX, true); + assert!(report.contains("peak 15 of unbounded")); +} + +#[test] +fn await_wakes_only_for_ready_items_beyond_the_wave_baseline() { + let baseline: std::collections::HashSet<String> = + ["stuck".to_string(), "assigned".to_string()].into(); + let mut summary = crate::protocol::PlanGraphStatus { + swarm_id: None, + version: 3, + item_count: 6, + ready_ids: vec!["stuck".to_string()], + blocked_ids: Vec::new(), + active_ids: vec!["a1".to_string()], + completed_ids: Vec::new(), + failed_ids: Vec::new(), + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "deep".to_string(), + seeded_count: 0, + grown_count: 0, + }; + + // Items already ready at wave start (even permanently-undispatchable + // ones) must not wake the driver: that would busy-spin the await. + assert!(!super::await_should_wake_for_new_ready(&baseline, &summary)); + + // No ready items at all: keep waiting on members. + summary.ready_ids.clear(); + assert!(!super::await_should_wake_for_new_ready(&baseline, &summary)); + + // A retried failed node re-enters ready as a NEW id -> wake and dispatch. + summary.ready_ids = vec!["stuck".to_string(), "retried-node".to_string()]; + assert!(super::await_should_wake_for_new_ready(&baseline, &summary)); +} + +#[test] +fn run_plan_progress_counts_only_completed_toward_percent_and_shows_live_active() { + // Regression: a plan with 33 completed / 116 failed of 152 used to report + // terminal/total = 149/152 (~98%) with active 0 while four externally + // assigned workers were still running. + let summary = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 9, + item_count: 152, + ready_ids: Vec::new(), + blocked_ids: Vec::new(), + active_ids: Vec::new(), + completed_ids: (0..33).map(|i| format!("c{i}")).collect(), + failed_ids: (0..116).map(|i| format!("f{i}")).collect(), + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "deep".to_string(), + seeded_count: 0, + grown_count: 0, + }; + + let (completed, total, message) = super::run_plan_progress_snapshot(&summary, 4, 137); + // Percent driver is completed/total: 33/152 (~22%), never ~98%. + assert_eq!(completed, 33); + assert_eq!(total, 152); + // Failed nodes are surfaced separately, and live in-flight workers show as + // active even when the plan's own active_ids is empty (external + // assign_task dispatches). + assert_eq!( + message, + "completed 33 · failed 116 · blocked 0 · active 4 · assignments 137" + ); + + // The normalized background progress percent derived from (current,total) + // must match completed/total, not terminal/total. + let progress = crate::bus::BackgroundTaskProgress { + kind: crate::bus::BackgroundTaskProgressKind::Determinate, + percent: None, + message: Some(message), + current: Some(completed as u64), + total: Some(total as u64), + unit: Some("nodes".to_string()), + eta_seconds: None, + updated_at: chrono::Utc::now().to_rfc3339(), + source: crate::bus::BackgroundTaskProgressSource::Reported, + } + .normalize(); + let percent = progress.percent.expect("determinate percent"); + assert!( + (percent - 21.71).abs() < 0.1, + "33/152 must normalize to ~21.7%, got {percent}" + ); +} + +#[test] +fn run_plan_progress_active_prefers_plan_execution_state_when_larger() { + let summary = crate::protocol::PlanGraphStatus { + swarm_id: None, + version: 1, + item_count: 10, + ready_ids: Vec::new(), + blocked_ids: vec!["b1".to_string()], + active_ids: vec!["a1".to_string(), "a2".to_string(), "a3".to_string()], + completed_ids: vec!["c1".to_string(), "c2".to_string()], + failed_ids: vec!["f1".to_string()], + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "light".to_string(), + seeded_count: 0, + grown_count: 0, + }; + + // Plan says 3 active but only 1 live member is observable (e.g. status + // propagation lag): keep the larger plan-state number. + let (completed, total, message) = super::run_plan_progress_snapshot(&summary, 1, 5); + assert_eq!((completed, total), (2, 10)); + assert_eq!( + message, + "completed 2 · failed 1 · blocked 1 · active 3 · assignments 5" + ); +} + +#[test] +fn plan_status_budget_line_is_deep_only_and_nudges_serialized_graphs() { + let base = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 1, + item_count: 10, + ready_ids: vec!["a".to_string()], + blocked_ids: Vec::new(), + active_ids: vec!["b".to_string()], + completed_ids: vec!["c".to_string()], + failed_ids: Vec::new(), + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "deep".to_string(), + seeded_count: 0, + grown_count: 0, + }; + + // Light plans get no budget line at all. + let light = crate::protocol::PlanGraphStatus { + mode: "light".to_string(), + ..base.clone() + }; + assert_eq!(super::plan_status_budget_line(&light, 32), None); + + // Deep + narrow frontier (2 of 32) with 7 more items serialized behind + // edges -> budget line plus the widen nudge. + let narrow = super::plan_status_budget_line(&base, 32).expect("deep plans get a budget line"); + assert!(narrow.contains("Parallel budget: 32")); + assert!(narrow.contains("ready set is 1 wide (1 active)")); + assert!(narrow.contains("expand_node")); + + // Deep + the frontier is all that remains -> line but no nudge. + let almost_done = crate::protocol::PlanGraphStatus { + item_count: 3, + ..base.clone() + }; + let line = super::plan_status_budget_line(&almost_done, 32).unwrap(); + assert!(line.contains("Parallel budget: 32")); + assert!(!line.contains("expand_node")); + + // deep_cap=0 (unbounded) surfaces the member cap as the budget. + let unbounded = super::plan_status_budget_line(&base, 0).unwrap(); + assert!(unbounded.contains("1000 (member cap)")); +} + +#[test] +fn assign_error_classification_recovers_on_member_cap_instead_of_failing() { + use super::AssignErrorAction; + + // Graceful exhaustion of work or workers ends the assignment burst. + assert_eq!( + super::classify_assign_error( + "No runnable unassigned tasks are available in the swarm plan" + ), + AssignErrorAction::BreakGracefully + ); + assert_eq!( + super::classify_assign_error( + "No ready or completed swarm agents are available for automatic task assignment." + ), + AssignErrorAction::BreakGracefully + ); + + // The member cap must trigger recovery (cleanup + reuse fallback), not a + // run-aborting failure. The server wraps the cap message in a spawn-failure + // prefix, so classification must match on the substring. + assert_eq!( + super::classify_assign_error( + "Failed to spawn preferred worker: Swarm member limit reached (max 1000). \ + This swarm already has 1000 agents; it cannot spawn more." + ), + AssignErrorAction::RecoverCapacity + ); + + // Anything else is still a real failure. + assert_eq!( + super::classify_assign_error("Not in a swarm."), + AssignErrorAction::Fail + ); +} + +#[test] +fn cap_recovery_prefers_cleanup_then_reuse_then_gives_up() { + use super::CapRecoveryStep; + + // First cap hit with freed capacity: retry keeping the fresh-spawn preference. + assert_eq!(super::cap_recovery_step(1, 3), CapRecoveryStep::RetryFresh); + // First cap hit but nothing could be freed: fall back to reusing ready workers. + assert_eq!(super::cap_recovery_step(1, 0), CapRecoveryStep::RetryReuse); + // Recovery already ran and the cap still refuses: continue with in-flight + // work instead of aborting or spinning. + assert_eq!(super::cap_recovery_step(2, 0), CapRecoveryStep::GiveUp); + assert_eq!(super::cap_recovery_step(3, 5), CapRecoveryStep::GiveUp); +} + +#[test] +fn run_plan_driver_failures_carry_worker_retention_hint() { + // Every driver-failure path must tell the caller the spawned workers are + // still running and how to stop them. + let hinted = super::with_worker_retention_hint( + "run_plan stalled after 3 loop(s): no ready tasks and no in-flight workers.".to_string(), + ); + assert!(hinted.contains("Spawned workers were retained")); + assert!(hinted.contains("swarm cleanup")); + + // Max-loops keeps its intentional retention-for-inspection wording but + // still gains the actionable hint. + let max_loops = super::with_worker_retention_hint( + "run_plan exceeded 200 coordination loops; leaving workers untouched for inspection" + .to_string(), + ); + assert!(max_loops.contains("swarm cleanup")); + + // Idempotent: re-wrapping (e.g. the background wrapper re-reporting the + // error) must not duplicate the hint. + let twice = super::with_worker_retention_hint(hinted.clone()); + assert_eq!(twice.matches("Spawned workers were retained").count(), 1); +} + +#[test] +fn run_plan_terminal_summary_reports_failed_nodes() { + let base = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 1, + item_count: 4, + ready_ids: Vec::new(), + blocked_ids: Vec::new(), + active_ids: Vec::new(), + completed_ids: vec!["a".to_string(), "b".to_string()], + failed_ids: vec!["c".to_string(), "d".to_string()], + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "deep".to_string(), + seeded_count: 0, + grown_count: 0, + }; + + let with_failures = super::format_run_plan_terminal_summary(5, &base, 7); + assert!(with_failures.contains("completed=2")); + assert!(with_failures.contains("failed=2")); + assert!(with_failures.contains("Failed nodes: c, d")); + assert!(with_failures.contains("did NOT finish cleanly")); + + // A clean run reports failed=0 and no failure callout. + let clean = crate::protocol::PlanGraphStatus { + completed_ids: vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ], + failed_ids: Vec::new(), + failed_reasons: Default::default(), + ..base + }; + let clean_summary = super::format_run_plan_terminal_summary(5, &clean, 7); + assert!(clean_summary.contains("failed=0")); + assert!(!clean_summary.contains("Failed nodes")); +} + +#[test] +fn plan_terminal_node_count_includes_failed_without_double_counting() { + let summary = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 1, + item_count: 4, + ready_ids: Vec::new(), + blocked_ids: vec!["x".to_string()], + active_ids: Vec::new(), + completed_ids: vec!["a".to_string()], + failed_ids: vec!["c".to_string()], + failed_reasons: Default::default(), + // "x" is both blocked and cyclic; it must count once. + cycle_ids: vec!["x".to_string()], + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "light".to_string(), + seeded_count: 0, + grown_count: 0, + }; + // a (completed) + c (failed) + x (blocked/cycle, deduped) = 3. Without + // failed_ids in the terminal count a run with failed nodes would never + // satisfy terminal_count >= item_count and run_plan could spin or stall. + assert_eq!(super::plan_terminal_node_count(&summary), 3); +} + +#[test] +fn canonical_swarm_action_maps_common_synonyms() { + assert_eq!(canonical_swarm_action("inbox"), "read"); + assert_eq!(canonical_swarm_action("read_messages"), "read"); + assert_eq!(canonical_swarm_action("send"), "message"); + assert_eq!(canonical_swarm_action("msg"), "message"); + assert_eq!(canonical_swarm_action("direct_message"), "dm"); + assert_eq!(canonical_swarm_action("announce"), "broadcast"); + assert_eq!(canonical_swarm_action("agents"), "list"); + assert_eq!(canonical_swarm_action("plan"), "plan_status"); + assert_eq!(canonical_swarm_action("assign"), "assign_task"); + assert_eq!(canonical_swarm_action("kill"), "stop"); +} + +#[test] +fn canonical_swarm_action_is_case_insensitive_and_trims() { + assert_eq!(canonical_swarm_action(" Inbox "), "read"); + assert_eq!(canonical_swarm_action("SEND"), "message"); +} + +#[test] +fn canonical_swarm_action_passes_through_known_and_unknown_actions() { + // Real actions are unchanged. + assert_eq!(canonical_swarm_action("spawn"), "spawn"); + assert_eq!(canonical_swarm_action("dm"), "dm"); + assert_eq!(canonical_swarm_action("assign_role"), "assign_role"); + // Genuinely unknown actions are returned unchanged for normal validation. + assert_eq!(canonical_swarm_action("totally_made_up"), "totally_made_up"); +} + +#[test] +fn communicate_input_aliases_to_session_and_target_session() { + // Either field name should be accepted; the execute() normalization mirrors them. + let from_target: CommunicateInput = serde_json::from_value( + json!({ "action": "dm", "message": "hi", "target_session": "worker-1" }), + ) + .expect("parse target_session input"); + assert_eq!(from_target.target_session.as_deref(), Some("worker-1")); + assert_eq!(from_target.to_session, None); + + let from_to: CommunicateInput = + serde_json::from_value(json!({ "action": "summary", "to_session": "worker-2" })) + .expect("parse to_session input"); + assert_eq!(from_to.to_session.as_deref(), Some("worker-2")); + assert_eq!(from_to.target_session, None); +} + +#[test] +fn format_plan_status_includes_next_ready() { + let output = format_plan_status(&crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 3, + item_count: 4, + ready_ids: vec!["task-2".to_string(), "task-3".to_string()], + blocked_ids: vec!["task-4".to_string()], + active_ids: vec!["task-1".to_string()], + completed_ids: vec!["setup".to_string()], + failed_ids: Vec::new(), + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: vec!["task-2".to_string()], + newly_ready_ids: vec!["task-3".to_string()], + low_confidence_ids: Vec::new(), + mode: "deep".to_string(), + seeded_count: 0, + grown_count: 0, + }); + let text = output.output; + assert!(text.contains("Plan status for swarm swarm-a")); + assert!(text.contains("Next up: task-2")); + assert!(text.contains("Newly ready: task-3")); + assert!(text.contains("Blocked: task-4")); +} + +#[test] +fn in_flight_slot_accounting_counts_queued_workers_not_coordinator() { + let summary = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 3, + item_count: 4, + ready_ids: vec!["queued-assigned".to_string()], + blocked_ids: Vec::new(), + active_ids: vec!["running-plan-task".to_string()], + completed_ids: Vec::new(), + failed_ids: Vec::new(), + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: vec!["queued-assigned".to_string()], + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "light".to_string(), + seeded_count: 0, + grown_count: 0, + }; + let members = vec![ + AgentInfo { + session_id: "coord".to_string(), + friendly_name: None, + files_touched: Vec::new(), + status: Some("running".to_string()), + detail: None, + role: Some("coordinator".to_string()), + is_headless: Some(false), + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + AgentInfo { + session_id: "worker-queued".to_string(), + friendly_name: None, + files_touched: Vec::new(), + status: Some("queued".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("coord".to_string()), + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + AgentInfo { + session_id: "worker-ready".to_string(), + friendly_name: None, + files_touched: Vec::new(), + status: Some("ready".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("coord".to_string()), + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + ]; + + assert!(swarm_member_is_in_flight(&members[1])); + assert!(!swarm_member_is_in_flight(&members[2])); + assert_eq!(coordination_in_flight_count(&summary, &members, "coord"), 1); +} + +#[test] +fn in_flight_count_excludes_foreign_queued_session() { + // A stale, independent (non-owned, client-attached) session that merely shares + // the swarm and happens to sit in `queued` must NOT count as in-flight for + // run_plan: it is never auto-driven, so awaiting it would hang the run even + // though no plan task is assigned to it. Regression for the run_plan stall. + let summary = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 1, + item_count: 1, + ready_ids: Vec::new(), + blocked_ids: Vec::new(), + active_ids: Vec::new(), + completed_ids: vec!["done-task".to_string()], + failed_ids: Vec::new(), + failed_reasons: Default::default(), + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "light".to_string(), + seeded_count: 0, + grown_count: 0, + }; + let members = vec![ + AgentInfo { + session_id: "coord".to_string(), + status: Some("running".to_string()), + role: Some("coordinator".to_string()), + is_headless: Some(false), + report_back_to_session_id: None, + ..Default::default() + }, + AgentInfo { + session_id: "foreign-human".to_string(), + status: Some("queued".to_string()), + role: Some("agent".to_string()), + is_headless: Some(false), + // Not owned by coord, and a live client is attached. + report_back_to_session_id: None, + live_attachments: Some(1), + ..Default::default() + }, + ]; + + // It is technically "in flight" by status, but not a drivable worker, so the + // scoped count is zero and run_plan can reach its terminal check. + assert!(swarm_member_is_in_flight(&members[1])); + assert!(!swarm_member_is_drivable_worker(&members[1], "coord")); + assert_eq!(coordination_in_flight_count(&summary, &members, "coord"), 0); +} + +#[test] +fn latest_assistant_report_uses_last_non_empty_assistant_message() { + let messages = vec![ + HistoryMessage { + role: "assistant".to_string(), + content: " earlier ".to_string(), + tool_calls: None, + tool_data: None, + }, + HistoryMessage { + role: "user".to_string(), + content: "ignored".to_string(), + tool_calls: None, + tool_data: None, + }, + HistoryMessage { + role: "assistant".to_string(), + content: " final report ".to_string(), + tool_calls: None, + tool_data: None, + }, + ]; + + assert_eq!( + latest_assistant_report(&messages).as_deref(), + Some("final report") + ); +} + +#[test] +fn format_awaited_members_includes_completion_reports() { + let members = vec![AwaitedMemberStatus { + session_id: "session_worker".to_string(), + friendly_name: Some("worker".to_string()), + status: "ready".to_string(), + done: true, + completion_report: Some("Structured report wins.".to_string()), + }]; + let reports = HashMap::from([( + "session_worker".to_string(), + "Outcome: finished. Validation: tests passed.".to_string(), + )]); + + let output = format_awaited_members_with_reports( + true, + "All 1 members are done: worker", + &members, + &reports, + ) + .output; + + assert!(output.contains("Completion reports:")); + assert!(output.contains("--- worker (ready) ---")); + assert!(output.contains("Structured report wins.")); + assert!(!output.contains("Outcome: finished")); +} + +#[test] +fn resolve_optional_target_session_defaults_to_current() { + assert_eq!( + resolve_optional_target_session(None, "session_current"), + "session_current" + ); + assert_eq!( + resolve_optional_target_session(Some("current".to_string()), "session_current"), + "session_current" + ); + assert_eq!( + resolve_optional_target_session(Some("session_other".to_string()), "session_current"), + "session_other" + ); +} + +#[test] +fn schema_still_requires_action() { + let schema = CommunicateTool::new().parameters_schema(); + assert_eq!(schema["required"], json!(["action"])); +} + +#[test] +fn schema_advertises_model_and_effort_spawn_overrides() { + let schema = CommunicateTool::new().parameters_schema(); + let props = schema["properties"] + .as_object() + .expect("swarm schema should have properties"); + + assert!(props.contains_key("model")); + assert!( + props["model"]["description"] + .as_str() + .expect("model description") + .contains("list_models"), + "model param should point at the list_models action" + ); + assert!(props.contains_key("effort")); + assert_eq!( + props["effort"]["enum"], + json!(["none", "low", "medium", "high", "xhigh", "max"]) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("list_models")) + ); +} + +#[test] +fn schema_requires_a_nonblank_label_for_spawn() { + let schema = CommunicateTool::new().parameters_schema(); + assert_eq!(schema["properties"]["label"]["minLength"], json!(1)); + assert!( + schema["properties"]["label"]["description"] + .as_str() + .expect("label description") + .contains("Required for spawn") + ); + assert!( + schema["properties"]["action"]["description"] + .as_str() + .expect("action description") + .contains("Spawn requires a nonblank label") + ); + + let branches = schema["anyOf"] + .as_array() + .expect("swarm schema should declare action-specific branches"); + let spawn_branch = branches + .iter() + .find(|branch| branch["properties"]["action"]["enum"] == json!(["spawn"])) + .expect("spawn schema branch"); + assert_eq!(spawn_branch["required"], json!(["action", "label"])); + + let non_spawn_branch = branches + .iter() + .find(|branch| { + branch["properties"]["action"]["enum"] + .as_array() + .is_some_and(|actions| !actions.contains(&json!("spawn"))) + }) + .expect("non-spawn schema branch"); + assert_eq!(non_spawn_branch["required"], json!(["action"])); +} + +#[test] +fn spawn_label_validation_rejects_missing_or_blank_labels() { + let missing: CommunicateInput = + serde_json::from_value(json!({"action": "spawn"})).expect("spawn input"); + assert_eq!( + missing + .required_spawn_label() + .expect_err("missing label must fail") + .to_string(), + "'label' is required for spawn action" + ); + + let blank: CommunicateInput = serde_json::from_value(json!({ + "action": "spawn", + "label": " \n\t " + })) + .expect("spawn input"); + assert_eq!( + blank + .required_spawn_label() + .expect_err("blank label must fail") + .to_string(), + "'label' must not be blank for spawn action" + ); +} + +#[test] +fn spawn_label_validation_trims_valid_labels() { + let params: CommunicateInput = serde_json::from_value(json!({ + "action": "spawn", + "label": " api reviewer " + })) + .expect("spawn input"); + assert_eq!( + params.required_spawn_label().expect("valid label"), + "api reviewer" + ); +} + +#[tokio::test] +async fn spawn_execute_rejects_missing_label_before_sending_request() { + let working_dir = tempfile::tempdir().expect("working dir"); + let error = CommunicateTool::new() + .execute( + json!({"action": "spawn", "prompt": "review the API"}), + test_ctx("session-parent", working_dir.path()), + ) + .await + .expect_err("missing spawn label must fail locally"); + + assert_eq!(error.to_string(), "'label' is required for spawn action"); +} + +#[test] +fn description_includes_swarm_prompt_guidance() { + let tool = CommunicateTool::new(); + let description = tool.description(); + assert!( + description.contains("Swarm prompt"), + "description should embed the swarm prompt section" + ); +} + +#[test] +fn format_swarm_model_list_renders_routes_and_pin() { + let routes = vec![ + jcode_provider_core::ModelRoute { + model: "gpt-5.5".to_string(), + provider: "OpenAI".to_string(), + api_method: "openai-api-key".to_string(), + available: true, + detail: "API key".to_string(), + cheapness: None, + }, + jcode_provider_core::ModelRoute { + model: "claude-fable-5".to_string(), + provider: "Anthropic".to_string(), + api_method: "anthropic-api-key".to_string(), + available: false, + detail: String::new(), + 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("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")); +} + +#[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("No model routes reported")); +} + +#[test] +fn schema_advertises_supported_swarm_fields() { + let schema = CommunicateTool::new().parameters_schema(); + let props = schema["properties"] + .as_object() + .expect("swarm schema should have properties"); + + assert!(props.contains_key("action")); + assert!(props.contains_key("key")); + assert!(props.contains_key("value")); + assert!(props.contains_key("message")); + 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." + ) + ); + assert!(props.contains_key("channel")); + assert!(props.contains_key("proposer_session")); + assert!(props.contains_key("reason")); + 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." + ) + ); + assert!(props.contains_key("role")); + assert!(props.contains_key("prompt")); + assert!(props.contains_key("working_dir")); + assert!(props.contains_key("limit")); + assert!(props.contains_key("task_id")); + assert!(props.contains_key("spawn_if_needed")); + assert!(props.contains_key("prefer_spawn")); + assert!(props.contains_key("session_ids")); + assert!(props.contains_key("mode")); + assert!(props.contains_key("target_status")); + assert!(props.contains_key("timeout_minutes")); + assert!(props.contains_key("concurrency_limit")); + assert!(props.contains_key("wake")); + assert!(props.contains_key("delivery")); + assert!(props.contains_key("plan_items")); + assert!(props.contains_key("initial_message")); + assert!(props.contains_key("force")); + assert!(props.contains_key("retain_agents")); + assert!(props.contains_key("background")); + assert!( + props["background"]["description"] + .as_str() + .expect("background description") + .contains("run_plan"), + "background flag should document run_plan support" + ); + assert!(props.contains_key("notify")); + assert!(props.contains_key("status")); + assert!(props.contains_key("validation")); + assert!(props.contains_key("follow_up")); + assert_eq!( + props["delivery"]["enum"], + json!(["notify", "interrupt", "wake"]) + ); + assert_eq!( + props["plan_items"]["items"]["additionalProperties"], + json!(true) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("status")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("report")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("plan_status")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("start")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("start_task")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("assign_next")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("fill_slots")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("run_plan")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("cleanup")) + ); + assert!( + schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .contains(&json!("salvage")) + ); +} + +struct EnvGuard { + key: &'static str, + original: Option<std::ffi::OsString>, +} + +impl EnvGuard { + fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self { + let original = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, original } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.original.take() { + crate::env::set_var(self.key, value); + } else { + crate::env::remove_var(self.key); + } + } +} + +struct DelayedTestProvider { + delay: Duration, +} + +#[async_trait] +impl Provider for DelayedTestProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> Result<EventStream> { + let delay = self.delay; + let stream = futures::stream::once(async move { + tokio::time::sleep(delay).await; + Ok(StreamEvent::TextDelta("ok".to_string())) + }) + .chain(futures::stream::once(async { + Ok(StreamEvent::MessageEnd { stop_reason: None }) + })); + Ok(Box::pin(stream)) + } + + fn name(&self) -> &str { + "test" + } + + fn fork(&self) -> Arc<dyn Provider> { + Arc::new(Self { delay: self.delay }) + } +} + +struct RawClient { + reader: BufReader<ReadHalf>, + writer: WriteHalf, + next_id: u64, +} + +impl RawClient { + async fn connect(path: &Path) -> Result<Self> { + let stream = Stream::connect(path).await?; + let (reader, writer) = stream.into_split(); + Ok(Self { + reader: BufReader::new(reader), + writer, + next_id: 1, + }) + } + + async fn send_request(&mut self, request: Request) -> Result<u64> { + let id = request.id(); + let json = serde_json::to_string(&request)? + "\n"; + self.writer.write_all(json.as_bytes()).await?; + Ok(id) + } + + async fn read_event(&mut self) -> Result<ServerEvent> { + let mut line = String::new(); + let n = self.reader.read_line(&mut line).await?; + if n == 0 { + anyhow::bail!("server disconnected") + } + Ok(serde_json::from_str(&line)?) + } + + async fn read_until<F>(&mut self, timeout: Duration, mut predicate: F) -> Result<ServerEvent> + where + F: FnMut(&ServerEvent) -> bool, + { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let event = tokio::time::timeout(remaining, self.read_event()).await??; + if predicate(&event) { + return Ok(event); + } + } + } + + async fn subscribe(&mut self, working_dir: &Path) -> Result<()> { + let id = self.next_id; + self.next_id += 1; + self.send_request(Request::Subscribe { + id, + working_dir: Some(working_dir.display().to_string()), + selfdev: None, + target_session_id: None, + client_instance_id: None, + client_has_local_history: false, + allow_session_takeover: false, + terminal_env: Vec::new(), + }) + .await?; + self.read_until( + Duration::from_secs(5), + |event| matches!(event, ServerEvent::Done { id: done_id } if *done_id == id), + ) + .await?; + Ok(()) + } + + async fn session_id(&mut self) -> Result<String> { + let id = self.next_id; + self.next_id += 1; + self.send_request(Request::GetState { id }).await?; + match self + .read_until( + Duration::from_secs(5), + |event| matches!(event, ServerEvent::State { id: event_id, .. } if *event_id == id), + ) + .await? + { + ServerEvent::State { session_id, .. } => Ok(session_id), + other => anyhow::bail!("unexpected state response: {other:?}"), + } + } + + async fn send_message(&mut self, content: &str) -> Result<u64> { + let id = self.next_id; + self.next_id += 1; + self.send_request(Request::Message { + id, + content: content.to_string(), + images: vec![], + system_reminder: None, + }) + .await + } + + async fn wait_for_done(&mut self, request_id: u64) -> Result<()> { + self.read_until( + Duration::from_secs(10), + |event| matches!(event, ServerEvent::Done { id } if *id == request_id), + ) + .await?; + Ok(()) + } + + async fn comm_list(&mut self, session_id: &str) -> Result<Vec<AgentInfo>> { + let id = self.next_id; + self.next_id += 1; + self.send_request(Request::CommList { + id, + session_id: session_id.to_string(), + }) + .await?; + match self + .read_until(Duration::from_secs(5), |event| { + matches!(event, ServerEvent::CommMembers { id: event_id, .. } if *event_id == id) + }) + .await? + { + ServerEvent::CommMembers { members, .. } => Ok(members), + other => anyhow::bail!("unexpected comm_list response: {other:?}"), + } + } + + async fn comm_status( + &mut self, + session_id: &str, + target_session: &str, + ) -> Result<AgentStatusSnapshot> { + let id = self.next_id; + self.next_id += 1; + self.send_request(Request::CommStatus { + id, + session_id: session_id.to_string(), + target_session: target_session.to_string(), + }) + .await?; + match self + .read_until(Duration::from_secs(5), |event| { + matches!(event, ServerEvent::CommStatusResponse { id: event_id, .. } if *event_id == id) + }) + .await? + { + ServerEvent::CommStatusResponse { snapshot, .. } => Ok(snapshot), + other => anyhow::bail!("unexpected comm_status response: {other:?}"), + } + } + + /// Wait for the next `Message` notification and return its scope + /// ("dm", "channel", or "broadcast"). Other events are skipped. + async fn next_message_notification(&mut self, timeout: Duration) -> Result<Option<String>> { + match self + .read_until(timeout, |event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { .. }, + .. + } + ) + }) + .await? + { + ServerEvent::Notification { + notification_type: NotificationType::Message { scope, .. }, + .. + } => Ok(scope), + other => anyhow::bail!("unexpected notification response: {other:?}"), + } + } +} + +async fn wait_for_server_socket( + path: &Path, + server_task: &mut tokio::task::JoinHandle<Result<()>>, +) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if server_task.is_finished() { + let result = server_task.await?; + return Err(anyhow::anyhow!( + "server exited before socket became ready: {:?}", + result + )); + } + match Stream::connect(path).await { + Ok(stream) => { + drop(stream); + return Ok(()); + } + Err(err) => { + if tokio::time::Instant::now() >= deadline { + return Err(err.into()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + } +} + +fn test_ctx(session_id: &str, working_dir: &Path) -> ToolContext { + ToolContext { + session_id: session_id.to_string(), + message_id: "msg-1".to_string(), + tool_call_id: "call-1".to_string(), + working_dir: Some(working_dir.to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + } +} + +async fn wait_for_member_status( + client: &mut RawClient, + requester_session: &str, + target_session: &str, + expected_status: &str, +) -> Result<Vec<AgentInfo>> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let members = client.comm_list(requester_session).await?; + if members + .iter() + .find(|member| member.session_id == target_session) + .and_then(|member| member.status.as_deref()) + == Some(expected_status) + { + return Ok(members); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for member {} to reach status {}", + target_session, + expected_status + ); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +async fn wait_for_member_presence( + client: &mut RawClient, + requester_session: &str, + target_session: &str, +) -> Result<Vec<AgentInfo>> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let members = client.comm_list(requester_session).await?; + if members + .iter() + .any(|member| member.session_id == target_session) + { + return Ok(members); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("timed out waiting for member {} to appear", target_session); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +#[test] +fn default_await_members_targets_include_ready() { + assert_eq!( + default_await_target_statuses(), + vec!["ready", "completed", "stopped", "failed", "crashed"] + ); +} + +fn credential_failed_worker(session_id: &str, detail: &str, age_secs: u64) -> AgentInfo { + AgentInfo { + session_id: session_id.to_string(), + status: Some("failed".to_string()), + detail: Some(detail.to_string()), + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("coord".to_string()), + status_age_secs: Some(age_secs), + provider_name: Some("anthropic".to_string()), + ..Default::default() + } +} + +#[test] +fn credential_failure_wave_detected_for_recent_auth_failed_workers() { + // The observed incident: every dispatched worker died within seconds with + // an Anthropic 401 (expired OAuth + revoked refresh token) and nothing + // completed. That must classify as a wave, not as N independent failures. + let members = vec![ + AgentInfo { + session_id: "coord".to_string(), + status: Some("running".to_string()), + role: Some("coordinator".to_string()), + ..Default::default() + }, + credential_failed_worker("w1", "Anthropic API error (401 Unauthorized)", 2), + credential_failed_worker("w2", "Anthropic API error (401 Unauthorized)", 3), + credential_failed_worker("w3", "invalid_grant: refresh token invalid", 5), + ]; + let wave = super::detect_credential_failure_wave(&members, "coord", 0, 60) + .expect("three recent credential failures with zero completions is a wave"); + assert_eq!(wave.session_ids, vec!["w1", "w2", "w3"]); + assert_eq!(wave.sample_detail, "Anthropic API error (401 Unauthorized)"); + assert_eq!(wave.provider.as_deref(), Some("anthropic")); + + let message = super::format_credential_failure_wave_error(&wave, 60); + assert!(message.contains("paused dispatching")); + assert!(message.contains("3 worker(s)")); + assert!(message.contains("401 Unauthorized")); + assert!(message.contains("`jcode login --provider claude`")); +} + +#[test] +fn credential_failure_wave_requires_at_least_two_workers() { + let members = vec![credential_failed_worker( + "w1", + "Anthropic API error (401 Unauthorized)", + 2, + )]; + assert_eq!( + super::detect_credential_failure_wave(&members, "coord", 0, 60), + None, + "one bad worker is not a wave" + ); +} + +#[test] +fn credential_failure_wave_not_detected_once_anything_completed() { + // Completions prove the credential works (or worked); later auth failures + // are then per-worker problems, not a route-wide outage to halt over. + let members = vec![ + credential_failed_worker("w1", "Anthropic API error (401 Unauthorized)", 2), + credential_failed_worker("w2", "Anthropic API error (401 Unauthorized)", 3), + ]; + assert_eq!( + super::detect_credential_failure_wave(&members, "coord", 1, 60), + None + ); +} + +#[test] +fn credential_failure_wave_ignores_stale_and_non_credential_failures() { + let members = vec![ + // Stale: failed long before this window (e.g. a previous, already + // diagnosed wave; the user has since re-authenticated and retried). + credential_failed_worker("old", "Anthropic API error (401 Unauthorized)", 3600), + // Unknown age must not count either. + AgentInfo { + status_age_secs: None, + ..credential_failed_worker("ageless", "401 Unauthorized", 0) + }, + // Non-credential failure. + credential_failed_worker("crashed", "worker panicked: index out of bounds", 2), + // Only one recent credential failure remains: below the wave minimum. + credential_failed_worker("w1", "Anthropic API error (401 Unauthorized)", 2), + ]; + assert_eq!( + super::detect_credential_failure_wave(&members, "coord", 0, 60), + None + ); +} + +#[test] +fn credential_failure_wave_ignores_foreign_members() { + // A foreign, client-attached session that failed with an auth error is not + // one of run_plan's workers; it must not trip the breaker. + let foreign = AgentInfo { + is_headless: Some(false), + report_back_to_session_id: None, + ..credential_failed_worker("foreign", "401 Unauthorized", 2) + }; + let members = vec![ + foreign, + credential_failed_worker("w1", "401 Unauthorized", 2), + ]; + assert_eq!( + super::detect_credential_failure_wave(&members, "coord", 0, 60), + None + ); +} + +#[test] +fn credential_login_fix_hint_maps_provider_names() { + assert_eq!( + super::credential_login_fix_hint(Some("anthropic")), + "`jcode login --provider claude`" + ); + assert_eq!( + super::credential_login_fix_hint(Some("OpenAI")), + "`jcode login --provider openai`" + ); + assert_eq!( + super::credential_login_fix_hint(Some("copilot")), + "`jcode login --provider copilot`" + ); + assert_eq!( + super::credential_login_fix_hint(None), + "`jcode login --provider <provider>`" + ); +} + +#[test] +fn run_plan_terminal_summary_includes_recorded_failure_reasons() { + let mut failed_reasons = std::collections::BTreeMap::new(); + failed_reasons.insert( + "c".to_string(), + "task failed: Anthropic API error (401 Unauthorized)".to_string(), + ); + let summary = crate::protocol::PlanGraphStatus { + swarm_id: Some("swarm-a".to_string()), + version: 1, + item_count: 2, + ready_ids: Vec::new(), + blocked_ids: Vec::new(), + active_ids: Vec::new(), + completed_ids: vec!["a".to_string()], + failed_ids: vec!["c".to_string()], + failed_reasons, + cycle_ids: Vec::new(), + unresolved_dependency_ids: Vec::new(), + next_ready_ids: Vec::new(), + newly_ready_ids: Vec::new(), + low_confidence_ids: Vec::new(), + mode: "light".to_string(), + seeded_count: 0, + grown_count: 0, + }; + let output = super::format_run_plan_terminal_summary(3, &summary, 2); + assert!(output.contains("Failed nodes: c")); + assert!( + output.contains("c: task failed: Anthropic API error (401 Unauthorized)"), + "terminal summary must carry the recorded failure reason:\n{output}" + ); + + let plan_status = format_plan_status(&summary).output; + assert!( + plan_status.contains("c: task failed: Anthropic API error (401 Unauthorized)"), + "plan_status must display the recorded failure reason:\n{plan_status}" + ); +} + +include!("communicate_tests/input_format.rs"); +include!("communicate_tests/end_to_end.rs"); +include!("communicate_tests/assignment.rs"); diff --git a/crates/jcode-app-core/src/tool/communicate_tests/assignment.rs b/crates/jcode-app-core/src/tool/communicate_tests/assignment.rs new file mode 100644 index 0000000..8ace3ed --- /dev/null +++ b/crates/jcode-app-core/src/tool/communicate_tests/assignment.rs @@ -0,0 +1,635 @@ +#[tokio::test] +async fn communicate_assign_task_can_spawn_fallback_agent() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + tool.execute( + json!({ + "action": "assign_role", + "target_session": watcher_session, + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + + tool.execute( + json!({ + "action": "propose_plan", + "plan_items": [{ + "id": "task-a", + "content": "Implement planner follow-up", + "status": "queued", + "priority": "high" + }] + }), + ctx.clone(), + ) + .await + .expect("plan proposal should succeed"); + + let assign_output = tool + .execute( + json!({ + "action": "assign_task", + "spawn_if_needed": true + }), + ctx, + ) + .await + .expect("assign_task should spawn a fallback worker"); + + assert!( + assign_output.output.contains("spawned automatically"), + "expected fallback spawn in output, got: {}", + assign_output.output + ); + assert!( + assign_output.output.contains("task-a"), + "expected selected task id in output, got: {}", + assign_output.output + ); + + let spawned_session = assign_output + .output + .strip_prefix("Task 'task-a' assigned to ") + .and_then(|rest| rest.strip_suffix(" (spawned automatically)")) + .expect("assign output should include spawned session id") + .trim() + .to_string(); + + assert!( + !spawned_session.is_empty(), + "spawned session id should not be empty" + ); + + wait_for_member_presence(&mut watcher, &watcher_session, &spawned_session) + .await + .expect("spawned fallback worker should appear in swarm"); + + let members = watcher + .comm_list(&watcher_session) + .await + .expect("comm_list should succeed"); + let spawned_member = members + .iter() + .find(|member| member.session_id == spawned_session) + .expect("spawned worker should be listed"); + assert_eq!(spawned_member.role.as_deref(), Some("agent")); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_assign_next_assigns_next_runnable_task() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + tool.execute( + json!({ + "action": "assign_role", + "target_session": watcher_session, + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + + let spawn_output = tool + .execute( + json!({ + "action": "spawn", + "label": "next-task worker" + }), + ctx.clone(), + ) + .await + .expect("worker spawn should succeed"); + let worker_session = spawn_output + .output + .strip_prefix("Spawned new agent: ") + .expect("spawn output should include session id") + .trim() + .to_string(); + + wait_for_member_presence(&mut watcher, &watcher_session, &worker_session) + .await + .expect("spawned worker should appear in swarm"); + + tool.execute( + json!({ + "action": "propose_plan", + "plan_items": [{ + "id": "setup", + "content": "setup", + "status": "completed", + "priority": "high" + }, { + "id": "next", + "content": "Take the next task", + "status": "queued", + "priority": "high", + "blocked_by": ["setup"] + }] + }), + ctx.clone(), + ) + .await + .expect("plan proposal should succeed"); + + let assign_output = tool + .execute( + json!({ + "action": "assign_next", + "target_session": worker_session + }), + ctx, + ) + .await + .expect("assign_next should succeed"); + + assert!( + assign_output.output.contains("Task 'next' assigned to"), + "unexpected assign_next output: {}", + assign_output.output + ); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_assign_next_can_prefer_fresh_spawn_server_side() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + tool.execute( + json!({ + "action": "assign_role", + "target_session": watcher_session, + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + + let existing_output = tool + .execute( + json!({"action": "spawn", "label": "existing worker"}), + ctx.clone(), + ) + .await + .expect("existing worker spawn should succeed"); + let existing_worker = existing_output + .output + .strip_prefix("Spawned new agent: ") + .expect("spawn output should include session id") + .trim() + .to_string(); + wait_for_member_presence(&mut watcher, &watcher_session, &existing_worker) + .await + .expect("existing worker should appear in swarm"); + + tool.execute( + json!({ + "action": "propose_plan", + "plan_items": [{ + "id": "task-c", + "content": "Use a fresh worker", + "status": "queued", + "priority": "high" + }] + }), + ctx.clone(), + ) + .await + .expect("plan proposal should succeed"); + + let assign_output = tool + .execute( + json!({ + "action": "assign_next", + "prefer_spawn": true + }), + ctx, + ) + .await + .expect("assign_next with prefer_spawn should succeed"); + + let preferred_session = assign_output + .output + .strip_prefix("Task 'task-c' assigned to ") + .expect("assign_next output should include session id") + .trim() + .to_string(); + + assert_ne!( + preferred_session, existing_worker, + "server-side prefer_spawn should choose a fresh worker" + ); + + wait_for_member_presence(&mut watcher, &watcher_session, &preferred_session) + .await + .expect("preferred spawned worker should appear in swarm"); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_assign_next_can_spawn_if_needed_server_side() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + tool.execute( + json!({ + "action": "assign_role", + "target_session": watcher_session, + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + + tool.execute( + json!({ + "action": "propose_plan", + "plan_items": [{ + "id": "task-d", + "content": "Spawn if no worker exists", + "status": "queued", + "priority": "high" + }] + }), + ctx.clone(), + ) + .await + .expect("plan proposal should succeed"); + + let assign_output = tool + .execute( + json!({ + "action": "assign_next", + "spawn_if_needed": true + }), + ctx, + ) + .await + .expect("assign_next with spawn_if_needed should succeed"); + + let spawned_session = assign_output + .output + .strip_prefix("Task 'task-d' assigned to ") + .expect("assign_next output should include session id") + .trim() + .to_string(); + assert!( + !spawned_session.is_empty(), + "server-side spawn_if_needed should assign a spawned worker" + ); + + wait_for_member_presence(&mut watcher, &watcher_session, &spawned_session) + .await + .expect("spawn_if_needed worker should appear in swarm"); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_fill_slots_tops_up_to_concurrency_limit() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(300), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + tool.execute( + json!({ + "action": "assign_role", + "target_session": watcher_session, + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + + tool.execute( + json!({ + "action": "propose_plan", + "plan_items": [{ + "id": "task-1", + "content": "first task", + "status": "queued", + "priority": "high" + }, { + "id": "task-2", + "content": "second task", + "status": "queued", + "priority": "high" + }, { + "id": "task-3", + "content": "third task", + "status": "queued", + "priority": "high" + }] + }), + ctx.clone(), + ) + .await + .expect("plan proposal should succeed"); + + let output = tool + .execute( + json!({ + "action": "fill_slots", + "concurrency_limit": 2, + "spawn_if_needed": true + }), + ctx, + ) + .await + .expect("fill_slots should succeed"); + + assert!( + output.output.contains("Filled 2 slot(s):"), + "unexpected fill_slots output: {}", + output.output + ); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_assign_task_can_prefer_fresh_spawn_over_reuse() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + tool.execute( + json!({ + "action": "assign_role", + "target_session": watcher_session, + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + + let existing_output = tool + .execute( + json!({ + "action": "spawn", + "label": "reusable worker" + }), + ctx.clone(), + ) + .await + .expect("existing reusable worker should spawn"); + let existing_worker = existing_output + .output + .strip_prefix("Spawned new agent: ") + .expect("spawn output should include session id") + .trim() + .to_string(); + wait_for_member_presence(&mut watcher, &watcher_session, &existing_worker) + .await + .expect("existing worker should appear in swarm"); + + tool.execute( + json!({ + "action": "propose_plan", + "plan_items": [{ + "id": "task-b", + "content": "Investigate a separate subsystem", + "status": "queued", + "priority": "high" + }] + }), + ctx.clone(), + ) + .await + .expect("plan proposal should succeed"); + + let assign_output = tool + .execute( + json!({ + "action": "assign_task", + "prefer_spawn": true + }), + ctx, + ) + .await + .expect("assign_task with prefer_spawn should succeed"); + + assert!( + assign_output + .output + .contains("spawned by planner preference"), + "expected planner-preference spawn in output, got: {}", + assign_output.output + ); + assert!( + assign_output.output.contains("task-b"), + "expected selected task id in output, got: {}", + assign_output.output + ); + + let preferred_session = assign_output + .output + .strip_prefix("Task 'task-b' assigned to ") + .and_then(|rest| rest.strip_suffix(" (spawned by planner preference)")) + .expect("assign output should include preferred spawned session id") + .trim() + .to_string(); + + assert_ne!( + preferred_session, existing_worker, + "prefer_spawn should choose a fresh worker instead of reusing the existing one" + ); + + wait_for_member_presence(&mut watcher, &watcher_session, &preferred_session) + .await + .expect("preferred spawned worker should appear in swarm"); + + server_task.abort(); +} diff --git a/crates/jcode-app-core/src/tool/communicate_tests/end_to_end.rs b/crates/jcode-app-core/src/tool/communicate_tests/end_to_end.rs new file mode 100644 index 0000000..83a8968 --- /dev/null +++ b/crates/jcode-app-core/src/tool/communicate_tests/end_to_end.rs @@ -0,0 +1,670 @@ +#[tokio::test] +async fn communicate_list_and_await_members_work_end_to_end() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(300), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + let socket_path = runtime_dir.path().join("jcode.sock"); + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + let mut peer = RawClient::connect(&socket_path) + .await + .expect("peer should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + peer.subscribe(&repo_dir).await.expect("peer subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let peer_session = peer.session_id().await.expect("peer session id"); + + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + let list_output = tool + .execute(json!({"action": "list"}), ctx.clone()) + .await + .expect("communicate list should succeed"); + assert!( + list_output.output.contains("Status: ready"), + "expected communicate list to render member status, got: {}", + list_output.output + ); + + let peer_message_id = peer + .send_message("Reply with a short acknowledgement.") + .await + .expect("peer message request should send"); + + let running_members = + wait_for_member_status(&mut watcher, &watcher_session, &peer_session, "running") + .await + .expect("peer should enter running state"); + let running_peer = running_members + .iter() + .find(|member| member.session_id == peer_session) + .expect("peer should be listed while running"); + assert_eq!(running_peer.status.as_deref(), Some("running")); + + // Legacy background=false input is upgraded to a durable asynchronous wait. + let await_output = tokio::time::timeout( + Duration::from_secs(5), + tool.execute( + json!({ + "action": "await_members", + "session_ids": [peer_session.clone()], + "timeout_minutes": 1, + "background": false + }), + ctx.clone(), + ), + ) + .await + .expect("legacy blocking request should return promptly") + .expect("await_members should start"); + assert!( + await_output.output.contains("no longer supported") + && await_output.output.contains("asynchronously"), + "expected compatibility hand-off output, got: {}", + await_output.output + ); + + peer.wait_for_done(peer_message_id) + .await + .expect("peer message should finish"); + + let event = watcher + .read_until(Duration::from_secs(5), |event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { scope: Some(scope), .. }, + .. + } if scope == "swarm_await" + ) + }) + .await + .expect("upgraded asynchronous wait should notify on completion"); + let ServerEvent::Notification { message, .. } = event else { + panic!("expected swarm_await notification, got: {event:?}"); + }; + assert!( + message.contains("(ready)"), + "expected await_members to treat ready as done, got: {}", + message + ); + + let ready_members = + wait_for_member_status(&mut watcher, &watcher_session, &peer_session, "ready") + .await + .expect("peer should return to ready state"); + let ready_peer = ready_members + .iter() + .find(|member| member.session_id == peer_session) + .expect("peer should still be listed when ready"); + assert_eq!(ready_peer.status.as_deref(), Some("ready")); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_await_members_background_returns_immediately_and_notifies() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(300), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + let socket_path = runtime_dir.path().join("jcode.sock"); + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + let mut peer = RawClient::connect(&socket_path) + .await + .expect("peer should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + peer.subscribe(&repo_dir).await.expect("peer subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let peer_session = peer.session_id().await.expect("peer session id"); + + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + // Put the peer into a running state so the await actually has to wait. + let peer_message_id = peer + .send_message("Reply with a short acknowledgement.") + .await + .expect("peer message request should send"); + wait_for_member_status(&mut watcher, &watcher_session, &peer_session, "running") + .await + .expect("peer should enter running state"); + + // Background await (the default) must return promptly with a hand-off + // message instead of blocking until the peer finishes. + let await_output = tokio::time::timeout( + Duration::from_secs(5), + tool.execute( + json!({ + "action": "await_members", + "session_ids": [peer_session.clone()], + "timeout_minutes": 1 + }), + ctx.clone(), + ), + ) + .await + .expect("background await should return promptly") + .expect("await_members should succeed"); + assert!( + await_output.output.contains("background"), + "expected background hand-off message, got: {}", + await_output.output + ); + + peer.wait_for_done(peer_message_id) + .await + .expect("peer message should finish"); + + // The backgrounded watcher should deliver a swarm-await notification to the + // requesting (watcher) session once the peer reaches ready. + let event = watcher + .read_until(Duration::from_secs(5), |event| { + matches!( + event, + ServerEvent::Notification { + notification_type: NotificationType::Message { scope: Some(scope), .. }, + .. + } if scope == "swarm_await" + ) + }) + .await + .expect("background await should deliver a swarm_await notification"); + let ServerEvent::Notification { message, .. } = event else { + panic!("expected swarm_await notification, got: {event:?}"); + }; + assert!( + message.contains("Swarm await finished"), + "expected swarm await completion body, got: {}", + message + ); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_run_plan_with_empty_plan_returns_inline_even_in_background_mode() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(50), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + let socket_path = runtime_dir.path().join("jcode.sock"); + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut client = RawClient::connect(&socket_path) + .await + .expect("client should connect"); + client.subscribe(&repo_dir).await.expect("subscribe"); + let session = client.session_id().await.expect("session id"); + + let tool = CommunicateTool::new(); + let ctx = test_ctx(&session, &repo_dir); + + // Background is the default; with no plan the validation happens inline and + // no background task should be started. + let output = tokio::time::timeout( + Duration::from_secs(5), + tool.execute(json!({"action": "run_plan"}), ctx.clone()), + ) + .await + .expect("run_plan should return promptly") + .expect("run_plan should succeed"); + assert!( + output.output.contains("No swarm plan items to run."), + "expected inline empty-plan response, got: {}", + output.output + ); + assert!( + output.metadata.is_none(), + "empty plan must not start a background driver" + ); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_status_returns_busy_snapshot_for_running_member() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(300), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + let mut peer = RawClient::connect(&socket_path) + .await + .expect("peer should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + peer.subscribe(&repo_dir).await.expect("peer subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let peer_session = peer.session_id().await.expect("peer session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + let peer_message_id = peer + .send_message("Reply with a short acknowledgement.") + .await + .expect("peer message request should send"); + + wait_for_member_status(&mut watcher, &watcher_session, &peer_session, "running") + .await + .expect("peer should enter running state"); + + let snapshot = watcher + .comm_status(&watcher_session, &peer_session) + .await + .expect("comm_status should succeed while peer is busy"); + assert_eq!(snapshot.session_id, peer_session); + assert_eq!(snapshot.status.as_deref(), Some("running")); + assert!( + snapshot + .activity + .as_ref() + .is_some_and(|activity| activity.is_processing) + ); + + let output = tool + .execute( + json!({ + "action": "status", + "target_session": peer_session.clone() + }), + ctx, + ) + .await + .expect("status action should succeed"); + assert!(output.output.contains("Lifecycle: running")); + assert!(output.output.contains("Activity: busy")); + + peer.wait_for_done(peer_message_id) + .await + .expect("peer message should finish"); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_spawn_reports_completion_back_to_spawner() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + let socket_path = runtime_dir.path().join("jcode.sock"); + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + let spawn_output = tool + .execute( + json!({ + "action": "spawn", + "label": "report-back worker", + "prompt": "Reply with exactly AUTH_TEST_OK and nothing else." + }), + ctx, + ) + .await + .expect("spawn with prompt should succeed"); + let spawned_session = spawn_output + .output + .strip_prefix("Spawned new agent: ") + .expect("spawn output should include session id") + .trim() + .to_string(); + + watcher + .read_until(Duration::from_secs(15), |event| { + matches!( + event, + ServerEvent::Notification { + from_session, + notification_type: crate::protocol::NotificationType::Message { + scope: Some(scope), + channel: None, + tldr: None, + }, + message, + .. + } if from_session == &spawned_session + && scope == "swarm" + && message.contains("finished their work and is ready for more") + ) + }) + .await + .expect("spawner should receive completion report-back notification"); + + server_task.abort(); +} + +#[tokio::test] +async fn communicate_spawn_with_prompt_and_summary_work_end_to_end() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + let socket_path = runtime_dir.path().join("jcode.sock"); + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut watcher = RawClient::connect(&socket_path) + .await + .expect("watcher should connect"); + watcher + .subscribe(&repo_dir) + .await + .expect("watcher subscribe"); + + let watcher_session = watcher.session_id().await.expect("watcher session id"); + let tool = CommunicateTool::new(); + let ctx = test_ctx(&watcher_session, &repo_dir); + + let spawn_output = tool + .execute( + json!({ + "action": "spawn", + "label": "summary worker", + "prompt": "Reply with a short acknowledgement." + }), + ctx.clone(), + ) + .await + .expect("spawn with prompt should succeed"); + let spawned_session = spawn_output + .output + .strip_prefix("Spawned new agent: ") + .expect("spawn output should include session id") + .trim() + .to_string(); + assert!( + !spawned_session.is_empty(), + "spawned session id should not be empty" + ); + + wait_for_member_presence(&mut watcher, &watcher_session, &spawned_session) + .await + .expect("spawned member should appear in swarm list"); + + let summary_output = { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + match tool + .execute( + json!({ + "action": "summary", + "target_session": spawned_session + }), + ctx.clone(), + ) + .await + { + Ok(output) => break output, + Err(err) + if (err.to_string().contains("Unknown session") + || err.to_string().contains(" is busy;")) + && tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(err) => panic!("summary for spawned agent should succeed: {err}"), + } + } + }; + assert!( + summary_output.output.contains("Tool call summary for") + || summary_output.output.contains("No tool calls found for"), + "unexpected summary output: {}", + summary_output.output + ); + + server_task.abort(); +} + +/// `message` routes by the fields supplied (DM when `to_session` is set, +/// broadcast otherwise), while `broadcast` is a group send scoped to the +/// sender's spawned subtree (whole swarm when the sender is the coordinator). +/// Regression test for the bug where `message` and `broadcast` were identical +/// because the tool discarded `to_session`/`channel` for both. +#[tokio::test] +async fn communicate_message_routes_as_dm_while_broadcast_targets_swarm() { + let _env_lock = crate::storage::lock_test_env(); + let runtime_dir = tempfile::TempDir::new().expect("runtime tempdir"); + let repo_dir = std::env::current_dir().expect("repo cwd"); + let socket_path = runtime_dir.path().join("jcode.sock"); + let _runtime = EnvGuard::set("JCODE_RUNTIME_DIR", runtime_dir.path()); + let _socket = EnvGuard::set("JCODE_SOCKET", &socket_path); + let _debug = EnvGuard::set("JCODE_DEBUG_CONTROL", "1"); + + let provider: Arc<dyn Provider> = Arc::new(DelayedTestProvider { + delay: Duration::from_millis(100), + }); + let server = Arc::new(Server::new(provider)); + let mut server_task = { + let server = Arc::clone(&server); + tokio::spawn(async move { server.run().await }) + }; + + wait_for_server_socket(&socket_path, &mut server_task) + .await + .expect("server socket should be ready"); + + let mut sender = RawClient::connect(&socket_path) + .await + .expect("sender should connect"); + let mut peer = RawClient::connect(&socket_path) + .await + .expect("peer should connect"); + sender.subscribe(&repo_dir).await.expect("sender subscribe"); + peer.subscribe(&repo_dir).await.expect("peer subscribe"); + + let sender_session = sender.session_id().await.expect("sender session id"); + let peer_session = peer.session_id().await.expect("peer session id"); + + // Ensure both sessions are part of the same swarm before messaging. + wait_for_member_presence(&mut sender, &sender_session, &peer_session) + .await + .expect("peer should join the swarm"); + + let tool = CommunicateTool::new(); + let ctx = test_ctx(&sender_session, &repo_dir); + + // `message` with a `to_session` should arrive at the peer scoped as a DM. + let dm_output = tool + .execute( + json!({ + "action": "message", + "message": "ping-dm", + "to_session": peer_session.clone() + }), + ctx.clone(), + ) + .await + .expect("message with to_session should succeed"); + assert!( + dm_output.output.contains("Direct message sent to"), + "message with to_session should report a DM, got: {}", + dm_output.output + ); + let dm_scope = peer + .next_message_notification(Duration::from_secs(5)) + .await + .expect("peer should receive the targeted message"); + assert_eq!( + dm_scope.as_deref(), + Some("dm"), + "message with to_session should be delivered with dm scope" + ); + + // Broadcasts are scoped to the sender's spawned subtree; the coordinator + // keeps whole-swarm reach as an escape hatch. The peer was not spawned by + // the sender, so promote the sender to coordinator (self-promotion is + // allowed while the swarm has no coordinator) so the broadcast reaches it. + let assign_output = tool + .execute( + json!({ + "action": "assign_role", + "target_session": sender_session.clone(), + "role": "coordinator" + }), + ctx.clone(), + ) + .await + .expect("self-promotion to coordinator should succeed"); + assert!( + assign_output.output.contains("Assigned role 'coordinator'"), + "unexpected assign_role output: {}", + assign_output.output + ); + + // `broadcast` should reach the peer scoped as a broadcast even though no + // explicit target is supplied. + let broadcast_output = tool + .execute( + json!({ + "action": "broadcast", + "message": "ping-all" + }), + ctx.clone(), + ) + .await + .expect("broadcast should succeed"); + assert!( + broadcast_output + .output + .contains("Broadcast sent to your spawned subtree"), + "broadcast should report a subtree-scoped group send, got: {}", + broadcast_output.output + ); + let broadcast_scope = peer + .next_message_notification(Duration::from_secs(5)) + .await + .expect("peer should receive the broadcast"); + assert_eq!( + broadcast_scope.as_deref(), + Some("broadcast"), + "broadcast should be delivered with broadcast scope" + ); + + server_task.abort(); +} 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 new file mode 100644 index 0000000..b20f536 --- /dev/null +++ b/crates/jcode-app-core/src/tool/communicate_tests/input_format.rs @@ -0,0 +1,412 @@ +#[test] +fn spawn_initial_message_accepts_prompt_alias_and_prefers_explicit_initial_message() { + let from_prompt: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "prompt": "review the diff" + })) + .expect("prompt alias should deserialize"); + assert_eq!( + from_prompt.spawn_initial_message().as_deref(), + Some("review the diff") + ); + + let preferred: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "spawn", + "initial_message": "preferred", + "prompt": "fallback" + })) + .expect("spawn payload should deserialize"); + assert_eq!( + preferred.spawn_initial_message().as_deref(), + Some("preferred") + ); +} + +#[test] +fn communicate_input_accepts_delivery_and_share_append() { + let delivery: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "dm", + "message": "ping", + "to_session": "sess-2", + "delivery": "wake" + })) + .expect("delivery mode should deserialize"); + assert_eq!( + delivery.delivery, + Some(crate::protocol::CommDeliveryMode::Wake) + ); + + let append: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "share_append", + "key": "task/123/notes", + "value": "new line" + })) + .expect("share_append should deserialize"); + assert_eq!(append.action, "share_append"); +} + +#[test] +fn communicate_input_accepts_spawn_if_needed() { + let parsed: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "assign_task", + "spawn_if_needed": true + })) + .expect("spawn_if_needed should deserialize"); + assert_eq!(parsed.spawn_if_needed, Some(true)); +} + +#[test] +fn communicate_input_accepts_prefer_spawn() { + let parsed: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "assign_task", + "prefer_spawn": true + })) + .expect("prefer_spawn should deserialize"); + assert_eq!(parsed.prefer_spawn, Some(true)); +} + +#[test] +fn communicate_input_accepts_cleanup_lifecycle_flags() { + let parsed: CommunicateInput = serde_json::from_value(serde_json::json!({ + "action": "run_plan", + "force": true, + "retain_agents": true + })) + .expect("lifecycle flags should deserialize"); + assert_eq!(parsed.force, Some(true)); + assert_eq!(parsed.retain_agents, Some(true)); +} + +#[test] +fn cleanup_candidates_default_to_owned_terminal_workers() { + let members = vec![ + AgentInfo { + session_id: "coord".to_string(), + friendly_name: Some("coord".to_string()), + files_touched: vec![], + status: Some("ready".to_string()), + detail: None, + role: Some("coordinator".to_string()), + is_headless: None, + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + AgentInfo { + session_id: "owned-done".to_string(), + friendly_name: Some("owned".to_string()), + files_touched: vec![], + status: Some("completed".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("coord".to_string()), + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + AgentInfo { + session_id: "user-created".to_string(), + friendly_name: Some("user".to_string()), + files_touched: vec![], + status: Some("completed".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: None, + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + AgentInfo { + session_id: "owned-running".to_string(), + friendly_name: Some("running".to_string()), + files_touched: vec![], + status: Some("running".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("coord".to_string()), + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + ]; + let statuses = default_cleanup_target_statuses(); + assert_eq!( + cleanup_candidate_session_ids("coord", &members, &statuses, &[], false), + vec!["owned-done".to_string()] + ); + assert_eq!( + cleanup_candidate_session_ids("coord", &members, &statuses, &[], true), + vec!["owned-done".to_string(), "user-created".to_string()] + ); +} + +#[test] +fn format_tool_summary_includes_call_count() { + let output = super::format_tool_summary( + "session-123", + &[ + ToolCallSummary { + tool_name: "read".to_string(), + brief_output: "Read 20 lines".to_string(), + timestamp_secs: None, + }, + ToolCallSummary { + tool_name: "grep".to_string(), + brief_output: "Found 3 matches".to_string(), + timestamp_secs: None, + }, + ], + ); + + assert!( + output + .output + .contains("Tool call summary for session-123 (2 calls):") + ); + assert!(output.output.contains("read — Read 20 lines")); + assert!(output.output.contains("grep — Found 3 matches")); +} + +#[test] +fn format_members_includes_status_and_detail() { + let ctx = ToolContext { + session_id: "sess-self".to_string(), + message_id: "msg-1".to_string(), + tool_call_id: "call-1".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + }; + + let output = format_members( + &ctx, + &[AgentInfo { + session_id: "sess-peer".to_string(), + friendly_name: Some("bear".to_string()), + files_touched: vec!["src/main.rs".to_string()], + status: Some("running".to_string()), + detail: Some("working on tests".to_string()), + role: Some("agent".to_string()), + is_headless: Some(true), + report_back_to_session_id: Some("sess-self".to_string()), + latest_completion_report: None, + live_attachments: Some(0), + status_age_secs: Some(12), + ..Default::default() + }], + ); + + assert!(output.output.contains("Status: running — working on tests")); + assert!(output.output.contains("· 12s")); + assert!(output.output.contains("Files: src/main.rs")); + assert!( + output + .output + .contains("Meta: headless · owned_by_you · attachments=0") + ); +} + +#[test] +fn format_members_renders_activity_progress_churn_and_turns() { + let ctx = test_ctx( + "session_self_1234567890_deadbeefcafebabe", + std::path::Path::new("."), + ); + + let output = format_members( + &ctx, + &[AgentInfo { + session_id: "session_peer_1234567890_aaaaaaaaaaaa0001".to_string(), + friendly_name: Some("otter".to_string()), + files_touched: vec![], + status: Some("running".to_string()), + detail: Some("implementing".to_string()), + task_label: None, + role: Some("agent".to_string()), + is_headless: Some(false), + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: Some(1), + status_age_secs: Some(8), + last_activity_age_secs: Some(3), + activity: Some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: Some("edit".to_string()), + }), + provider_name: Some("anthropic".to_string()), + provider_model: Some("claude-sonnet".to_string()), + turn_count: Some(7), + recent_total_tokens: Some(12_345), + recent_output_tokens: Some(2_000), + recent_window_secs: Some(10), + cumulative_total_tokens: Some(98_765), + todos_completed: Some(3), + todos_total: Some(7), + }], + ); + + let text = output.output; + assert!(text.contains("Activity: working (edit)"), "got: {text}"); + assert!(text.contains("Progress: 3/7 todos"), "got: {text}"); + 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}"); + // 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 + // not read as a dead worker. + assert!(text.contains("· active 3s ago"), "got: {text}"); + assert!(!text.contains("idle"), "got: {text}"); +} + +#[test] +fn format_members_labels_idle_ready_agent() { + let ctx = test_ctx( + "session_self_1234567890_deadbeefcafebabe", + std::path::Path::new("."), + ); + + let output = format_members( + &ctx, + &[AgentInfo { + session_id: "session_peer_1234567890_bbbbbbbbbbbb0002".to_string(), + friendly_name: Some("idle-one".to_string()), + files_touched: vec![], + status: Some("ready".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: None, + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: Some(0), + status_age_secs: Some(90), + ..Default::default() + }], + ); + + assert!(output.output.contains("idle 1m"), "got: {}", output.output); +} + +#[test] +fn format_members_disambiguates_duplicate_friendly_names() { + let ctx = test_ctx( + "session_self_1234567890_deadbeefcafebabe", + std::path::Path::new("."), + ); + let output = format_members( + &ctx, + &[ + AgentInfo { + session_id: "session_shark_1234567890_aaaaaaaaaaaa0001".to_string(), + friendly_name: Some("shark".to_string()), + files_touched: vec![], + status: Some("ready".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: None, + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + AgentInfo { + session_id: "session_shark_1234567890_bbbbbbbbbbbb0002".to_string(), + friendly_name: Some("shark".to_string()), + files_touched: vec![], + status: Some("ready".to_string()), + detail: None, + role: Some("agent".to_string()), + is_headless: None, + report_back_to_session_id: None, + latest_completion_report: None, + live_attachments: None, + status_age_secs: None, + ..Default::default() + }, + ], + ); + + assert!(output.output.contains("shark [aa0001]")); + assert!(output.output.contains("shark [bb0002]")); +} + +#[test] +fn format_awaited_members_disambiguates_duplicate_friendly_names() { + let output = format_awaited_members( + true, + "done", + &[ + AwaitedMemberStatus { + session_id: "session_shark_1234567890_aaaaaaaaaaaa0001".to_string(), + friendly_name: Some("shark".to_string()), + status: "ready".to_string(), + done: true, + completion_report: None, + }, + AwaitedMemberStatus { + session_id: "session_shark_1234567890_bbbbbbbbbbbb0002".to_string(), + friendly_name: Some("shark".to_string()), + status: "ready".to_string(), + done: true, + completion_report: None, + }, + ], + ); + + assert!(output.output.contains("✓ shark [aa0001] (ready)")); + assert!(output.output.contains("✓ shark [bb0002] (ready)")); +} + +#[test] +fn format_status_snapshot_includes_activity_and_metadata() { + let output = super::format_status_snapshot(&AgentStatusSnapshot { + session_id: "sess-peer".to_string(), + friendly_name: Some("bear".to_string()), + swarm_id: Some("swarm-test".to_string()), + status: Some("running".to_string()), + detail: Some("working on observability".to_string()), + role: Some("agent".to_string()), + is_headless: Some(true), + live_attachments: Some(0), + status_age_secs: Some(7), + last_activity_age_secs: Some(3), + joined_age_secs: Some(42), + files_touched: vec!["src/server/comm_sync.rs".to_string()], + activity: Some(SessionActivitySnapshot { + is_processing: true, + current_tool_name: Some("bash".to_string()), + }), + provider_name: None, + provider_model: None, + }); + + assert!( + output + .output + .contains("Status snapshot for bear (sess-peer)") + ); + assert!( + output + .output + .contains("Lifecycle: running — working on observability") + ); + assert!(output.output.contains("Activity: busy (bash)")); + assert!(output.output.contains("Swarm: swarm-test")); + assert!( + output + .output + .contains("Meta: headless · attachments=0 · active=3s ago · status_age=7s · joined=42s") + ); + assert!(output.output.contains("Files: src/server/comm_sync.rs")); +} diff --git a/crates/jcode-app-core/src/tool/computer/ax.rs b/crates/jcode-app-core/src/tool/computer/ax.rs new file mode 100644 index 0000000..b91950a --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/ax.rs @@ -0,0 +1,403 @@ +//! Tier 1: Accessibility (AX) read + action. +//! +//! This is the *background* control path. It drives other apps' UI elements by +//! reference through `System Events`, so it can press buttons and set field +//! values without moving the cursor and (for many actions) without bringing the +//! target app to the front. +//! +//! Elements are addressed by a structural path: an app (by name) plus a chain of +//! 1-based child indices from the front window. `find_element` / `ui` return +//! these paths; the action verbs accept them. + +use super::osa; +use anyhow::{Result, bail}; +use jcode_tool_types::ToolOutput; +use serde::Deserialize; +use serde_json::json; +use std::time::Duration; + +/// A structural handle to an AX element. +#[derive(Debug, Clone, Deserialize)] +pub struct ElementHandle { + /// Application process name (e.g. "Safari"). + pub app: String, + /// 1-based child index chain from the app's front window to the element. + /// Empty path == the front window itself. + #[serde(default)] + pub path: Vec<u32>, +} + +impl ElementHandle { + /// Emit an AppleScript expression that resolves to this element, bound to a + /// variable name `el`. Assumes a `tell application "System Events"` context + /// and that `frontApp` is the target process. + fn resolve_script(&self) -> String { + let mut expr = String::from("front window of frontApp"); + for idx in &self.path { + expr = format!("UI element {idx} of ({expr})"); + } + expr + } +} + +fn tell(app: &str, body: &str) -> String { + format!( + "tell application \"System Events\"\n\ + set frontApp to first application process whose name is {app}\n\ + {body}\n\ + end tell", + app = osa::as_quote(app), + body = body + ) +} + +/// Dump the AX tree of an app (or the frontmost app) to a given depth. +pub fn ui_tree(app: Option<&str>, depth: u32) -> Result<ToolOutput> { + let target = match app { + Some(a) => format!( + "first application process whose name is {}", + osa::as_quote(a) + ), + None => "first application process whose frontmost is true".to_string(), + }; + let script = format!( + r##" +using terms from application "System Events" + on dumpEl(el, lvl, maxlvl, idxPath) + set out to "" + if lvl > maxlvl then return out + set r to "?" + try + set rawR to role of el + if rawR is not missing value then set r to (rawR as text) + end try + set t to "" + try + set rawT to title of el + if rawT is not missing value then set t to (rawT as text) + end try + if t is "" then + try + set rawV to value of el + if rawV is not missing value then set t to (rawV as text) + end try + end if + set d to "" + try + set rawD to description of el + if rawD is not missing value then set d to (rawD as text) + end try + set pos to "" + try + set p to position of el + set sz to size of el + set pos to " @(" & (item 1 of p) & "," & (item 2 of p) & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" + end try + set indent to "" + repeat lvl times + set indent to indent & " " + end repeat + set ln to indent & "#" & idxPath & " " & r + if t is not "" then set ln to ln & " \"" & t & "\"" + if d is not "" then set ln to ln & " [" & d & "]" + set ln to ln & pos & linefeed + set out to out & ln + try + set i to 0 + repeat with child in (UI elements of el) + set i to i + 1 + set out to out & (my dumpEl(child, lvl + 1, maxlvl, idxPath & "." & i)) + end repeat + end try + return out + end dumpEl +end using terms from + +tell application "System Events" + set frontApp to {target} + set appName to name of frontApp + set out to "App: " & appName & " (element paths shown as #a.b.c == child indices from front window)" & linefeed + set winCount to 0 + try + set winCount to (count of windows of frontApp) + end try + if winCount is 0 then + set out to out & "(app \"" & appName & "\" has no open windows right now -- it may be a background/menu-bar app, or all its windows are closed or on another Space)" + else + try + set win to front window of frontApp + set out to out & (my dumpEl(win, 0, {depth}, "")) + on error errMsg + set out to out & "(could not read front window: " & errMsg & ")" + end try + end if + return out +end tell +"##, + target = target, + depth = depth + ); + let tree = osa::run_applescript(&script)?; + let tree = if tree.trim().is_empty() { + "(empty Accessibility tree)".to_string() + } else { + tree + }; + Ok(ToolOutput::new(tree).with_title("ui tree")) +} + +/// Find elements matching role/title/value within an app, returning their paths. +pub fn find_element( + app: &str, + role: Option<&str>, + title: Option<&str>, + value: Option<&str>, + max_depth: u32, +) -> Result<ToolOutput> { + let role_m = role.map(osa::as_quote).unwrap_or_else(|| "\"\"".into()); + let title_m = title.map(osa::as_quote).unwrap_or_else(|| "\"\"".into()); + let value_m = value.map(osa::as_quote).unwrap_or_else(|| "\"\"".into()); + let script = format!( + r#" +using terms from application "System Events" + on findEl(el, lvl, maxlvl, idxPath, roleM, titleM, valueM) + set out to "" + if lvl > maxlvl then return out + set r to "" + try + set rawR to role of el + if rawR is not missing value then set r to (rawR as text) + end try + set t to "" + try + set rawT to title of el + if rawT is not missing value then set t to (rawT as text) + end try + set v to "" + try + set rawV to value of el + if rawV is not missing value then set v to (rawV as text) + end try + set ok to true + if roleM is not "" and r is not roleM then set ok to false + if titleM is not "" and t does not contain titleM then set ok to false + if valueM is not "" and v does not contain valueM then set ok to false + if ok and idxPath is not "" then + set pos to "" + try + set p to position of el + set sz to size of el + set pos to " @(" & (item 1 of p) & "," & (item 2 of p) & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" + end try + set out to out & idxPath & " " & r & " \"" & t & "\"" & pos & linefeed + end if + try + set i to 0 + repeat with child in (UI elements of el) + set i to i + 1 + set out to out & (my findEl(child, lvl + 1, maxlvl, idxPath & "." & i, roleM, titleM, valueM)) + end repeat + end try + return out + end findEl +end using terms from + +tell application "System Events" + set frontApp to first application process whose name is {app} + set winCount to 0 + try + set winCount to (count of windows of frontApp) + end try + if winCount is 0 then + set out to "(app has no open windows right now -- it may be a background/menu-bar app, or all its windows are closed or on another Space)" + else + try + set win to front window of frontApp + set out to (my findEl(win, 0, {depth}, "", {role_m}, {title_m}, {value_m})) + on error errMsg + set out to "(error: " & errMsg & ")" + end try + if out is "" then set out to "(no matching elements)" + end if + return out +end tell +"#, + app = osa::as_quote(app), + depth = max_depth, + role_m = role_m, + title_m = title_m, + value_m = value_m, + ); + let res = osa::run_applescript(&script)?; + Ok(ToolOutput::new(format!( + "Matches in {app} (path role title @pos). Use the path with press/set_value/get_value:\n{res}", + )) + .with_title("find_element")) +} + +/// Perform AXPress on an element (background click). +pub fn press(handle: &ElementHandle) -> Result<ToolOutput> { + let body = format!( + "perform action \"AXPress\" of ({})", + handle.resolve_script() + ); + osa::run_applescript_timeout(&tell(&handle.app, &body), Duration::from_secs(10))?; + Ok(ToolOutput::new(format!( + "pressed element {:?} in {} (no cursor movement)", + handle.path, handle.app + ))) +} + +/// Perform an arbitrary AX action on an element. +pub fn perform_action(handle: &ElementHandle, ax_action: &str) -> Result<ToolOutput> { + let body = format!( + "perform action {} of ({})", + osa::as_quote(ax_action), + handle.resolve_script() + ); + osa::run_applescript_timeout(&tell(&handle.app, &body), Duration::from_secs(10))?; + Ok(ToolOutput::new(format!( + "performed {ax_action} on element {:?} in {}", + handle.path, handle.app + ))) +} + +/// Set the value of an element (background typing into a field). +pub fn set_value(handle: &ElementHandle, value: &str) -> Result<ToolOutput> { + let body = format!( + "set value of ({}) to {}", + handle.resolve_script(), + osa::as_quote(value) + ); + osa::run_applescript_timeout(&tell(&handle.app, &body), Duration::from_secs(10))?; + Ok(ToolOutput::new(format!( + "set value of element {:?} in {} to {} chars", + handle.path, + handle.app, + value.chars().count() + ))) +} + +/// Read the value of an element. +pub fn get_value(handle: &ElementHandle) -> Result<ToolOutput> { + let body = format!("return value of ({}) as text", handle.resolve_script()); + let v = osa::run_applescript_timeout(&tell(&handle.app, &body), Duration::from_secs(10))?; + Ok(ToolOutput::new(v).with_title("get_value")) +} + +/// Select a menu-bar item by path, e.g. ["File", "Export…"]. +pub fn select_menu(app: &str, path: &[String]) -> Result<ToolOutput> { + if path.len() < 2 { + bail!("select_menu needs at least a top menu and one item, e.g. [\"File\",\"Save\"]"); + } + // The menu bar belongs to the process; menu access requires the app be + // frontmost, so activate it first. + let top = &path[0]; + let mut expr = format!( + "menu bar item {top} of menu bar 1 of frontApp", + top = osa::as_quote(top) + ); + for item in path.iter().skip(1) { + expr = format!( + "menu item {item} of menu 1 of ({expr})", + item = osa::as_quote(item), + expr = expr + ); + } + let body = format!( + "set frontmost of frontApp to true\n\ + delay 0.2\n\ + click ({expr})" + ); + osa::run_applescript_timeout(&tell(app, &body), Duration::from_secs(10))?; + Ok(ToolOutput::new(format!( + "selected menu {} in {app}", + path.join(" > ") + ))) +} + +/// Return the element at a screen point (role/title), useful to confirm targets. +pub fn element_at(app: &str, x: f64, y: f64) -> Result<ToolOutput> { + // Hit-test by walking the AX tree, but PRUNE: only descend into a subtree + // whose own frame contains the point. A child's frame is contained in its + // parent's, so a parent that doesn't contain the point can't have a matching + // descendant. This turns a full-tree walk (which times out on huge apps like + // System Settings) into a path-length walk. Also bound the depth defensively. + let script = format!( + r#" +using terms from application "System Events" + on inFrame(el, px, py) + try + set p to position of el + set sz to size of el + set x1 to item 1 of p + set y1 to item 2 of p + if px >= x1 and px <= (x1 + (item 1 of sz)) and py >= y1 and py <= (y1 + (item 2 of sz)) then + return true + end if + end try + return false + end inFrame + on hit(el, px, py, idxPath, maxlvl, lvl, best) + set bestHit to best + if lvl > maxlvl then return bestHit + -- Record this element if it contains the point (deepest wins). + if my inFrame(el, px, py) then + set r to "" + try + set rawR to role of el + if rawR is not missing value then set r to (rawR as text) + end try + set t to "" + try + set rawT to title of el + if rawT is not missing value then set t to (rawT as text) + end try + set p to position of el + set sz to size of el + set bestHit to idxPath & " " & r & " \"" & t & "\" @(" & (item 1 of p) & "," & (item 2 of p) & " " & (item 1 of sz) & "x" & (item 2 of sz) & ")" + end if + -- Only descend into children that themselves contain the point. + try + set i to 0 + repeat with child in (UI elements of el) + set i to i + 1 + if my inFrame(child, px, py) then + set bestHit to my hit(child, px, py, idxPath & "." & i, maxlvl, lvl + 1, bestHit) + end if + end repeat + end try + return bestHit + end hit +end using terms from + +tell application "System Events" + set frontApp to first application process whose name is {app} + set winCount to 0 + try + set winCount to (count of windows of frontApp) + end try + if winCount is 0 then + set out to "(app has no open windows right now -- it may be a background/menu-bar app, or all its windows are closed or on another Space)" + else + try + set win to front window of frontApp + set out to my hit(win, {x}, {y}, "", 40, 0, "(none)") + on error errMsg + set out to "(error: " & errMsg & ")" + end try + end if + return out +end tell +"#, + app = osa::as_quote(app), + x = x, + y = y + ); + let res = osa::run_applescript_timeout(&script, Duration::from_secs(12))?; + Ok(ToolOutput::new(format!( + "Deepest element at ({x:.0},{y:.0}) in {app}:\n{res}" + )) + .with_title("element_at") + .with_metadata(json!({"app": app, "x": x, "y": y}))) +} diff --git a/crates/jcode-app-core/src/tool/computer/coverage_tests.rs b/crates/jcode-app-core/src/tool/computer/coverage_tests.rs new file mode 100644 index 0000000..f049e15 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/coverage_tests.rs @@ -0,0 +1,194 @@ +//! Exhaustive live coverage of EVERY computer action. These mutate the desktop +//! (open TextEdit, move windows, clipboard, etc.) so they are `#[ignore]`d and +//! run explicitly: +//! cargo test -p jcode-app-core tool::computer::coverage -- --ignored --nocapture --test-threads=1 +//! +//! Each test asserts the action returns Ok and (where checkable) the expected +//! effect. The goal is to prove no action panics or silently misbehaves. + +use super::*; +use jcode_tool_core::{ToolContext, ToolExecutionMode}; + +fn ctx() -> ToolContext { + ToolContext { + session_id: "cov".into(), + message_id: "cov".into(), + tool_call_id: "cov".into(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + } +} + +async fn act(v: Value) -> Result<ToolOutput> { + ComputerTool::new().execute(v, ctx()).await +} + +async fn ok(v: Value) -> ToolOutput { + let label = v.to_string(); + match act(v).await { + Ok(o) => { + eprintln!("PASS {label} -> {}", o.output.lines().next().unwrap_or("")); + o + } + Err(e) => panic!("FAIL {label} -> {e}"), + } +} + +async fn textedit_new() { + ok(json!({"action":"run_applescript","script": + "tell application \"TextEdit\" to activate\ndelay 0.3\ntell application \"TextEdit\" to make new document\ndelay 0.3"})).await; +} +async fn textedit_quit() { + let _ = act(json!({"action":"run_applescript","script": + "tell application \"TextEdit\" to close every document saving no\ntell application \"TextEdit\" to quit"})).await; +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_observe() { + ok(json!({"action":"check_permissions"})).await; + ok(json!({"action":"setup"})).await; // already granted -> reports ready quickly + ok(json!({"action":"screenshot"})).await; + ok(json!({"action":"cursor"})).await; + ok(json!({"action":"system_state"})).await; + ok(json!({"action":"discover","category":"all"})).await; + // OCR may require swift; tolerate absence but it should not panic. + match act(json!({"action":"ocr"})).await { + Ok(o) => eprintln!("PASS ocr -> {}", o.output.lines().next().unwrap_or("")), + Err(e) => eprintln!("SKIP ocr -> {e}"), + } +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_input() { + ok(json!({"action":"move","x":300,"y":300})).await; + ok(json!({"action":"click","x":300,"y":300})).await; + ok(json!({"action":"double_click","x":300,"y":300})).await; + ok(json!({"action":"right_click","x":300,"y":300})).await; + // dismiss any context menu + ok(json!({"action":"key","keys":"esc"})).await; + ok(json!({"action":"drag","x":300,"y":300,"to_x":320,"to_y":320})).await; + ok(json!({"action":"scroll","x":400,"y":400,"dy":-3})).await; + ok(json!({"action":"key_down","keys":"shift"})).await; + ok(json!({"action":"key_up","keys":"shift"})).await; +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_keyboard_into_textedit() { + textedit_new().await; + // type goes to focused app (TextEdit just activated) + ok(json!({"action":"type","text":"hello "})).await; + ok(json!({"action":"key","keys":"cmd+a"})).await; // select all + ok(json!({"action":"key","keys":"delete"})).await; + textedit_quit().await; +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_ax() { + textedit_new().await; + ok(json!({"action":"ui","app":"TextEdit","depth":3})).await; + ok(json!({"action":"find_element","app":"TextEdit","role":"AXTextArea"})).await; + ok(json!({"action":"element_at","app":"TextEdit","x":700,"y":400})).await; + // background set/get on the text area (path 1.1) + let el = json!({"app":"TextEdit","path":[1,1]}); + ok(json!({"action":"set_value","element":el,"value":"ax-coverage"})).await; + let g = ok(json!({"action":"get_value","element":el})).await; + assert!( + g.output.contains("ax-coverage"), + "get_value got: {}", + g.output + ); + textedit_quit().await; +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_select_menu() { + textedit_new().await; + // Format menu exists in TextEdit; "Make Plain Text" or "Wrap to Page" toggles. + // Use a stable, reversible item: Edit > Select All. + let r = act(json!({"action":"select_menu","app":"TextEdit","menu_path":["Edit","Select All"]})) + .await; + match r { + Ok(o) => eprintln!("PASS select_menu -> {}", o.output), + Err(e) => panic!("FAIL select_menu -> {e}"), + } + textedit_quit().await; +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_windows_apps() { + textedit_new().await; + ok(json!({"action":"list_apps"})).await; + ok(json!({"action":"list_windows"})).await; + ok(json!({"action":"activate_app","app":"TextEdit"})).await; + ok(json!({"action":"move_window","app":"TextEdit","x":120,"y":120})).await; + ok(json!({"action":"resize_window","app":"TextEdit","w":700,"h":500})).await; + ok(json!({"action":"focus_window","app":"TextEdit"})).await; + // window_screenshot needs an id from list_windows; find TextEdit's. + let lw = ok(json!({"action":"list_windows"})).await; + if let Some(id) = first_window_id_for(&lw.output, "TextEdit") { + ok(json!({"action":"window_screenshot","window_id":id})).await; + } else { + eprintln!("SKIP window_screenshot (no TextEdit window id parsed)"); + } + ok(json!({"action":"minimize_window","app":"TextEdit"})).await; + // restore + close + ok(json!({"action":"activate_app","app":"TextEdit"})).await; + textedit_quit().await; +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_clipboard_scripting_system() { + ok(json!({"action":"set_clipboard","text":"cov-clip"})).await; + let c = ok(json!({"action":"get_clipboard"})).await; + assert!(c.output.contains("cov-clip")); + ok(json!({"action":"run_applescript","script":"return 7 * 6"})).await; + ok(json!({"action":"run_jxa","script":"2 + 3"})).await; + ok(json!({"action":"notify","text":"jcode coverage test","title":"jcode"})).await; + // wait_for against a known app/text with short timeout (Finder always has a menu) + textedit_new().await; + let _ = + act(json!({"action":"wait_for","app":"TextEdit","contains":"","timeout_ms":1500})).await; + textedit_quit().await; + // set_brightness may be unavailable; tolerate. + match act(json!({"action":"set_brightness","level":0.8})).await { + Ok(o) => eprintln!("PASS set_brightness -> {}", o.output), + Err(e) => eprintln!("SKIP set_brightness -> {e}"), + } +} + +#[tokio::test] +#[ignore = "live"] +async fn coverage_destructive_quit_close() { + textedit_new().await; + ok(json!({"action":"close_window","app":"TextEdit"})).await; + // A new empty doc closes without a sheet. Discard anything then quit. + let _ = act(json!({"action":"run_applescript","script": + "tell application \"TextEdit\" to close every document saving no"})) + .await; + ok(json!({"action":"quit_app","app":"TextEdit"})).await; +} + +/// Parse the first CG window id whose owner matches `owner` from list_windows +/// output lines of the form: "<id>\t<owner>\t<title>\t<bounds>". +fn first_window_id_for(text: &str, owner: &str) -> Option<i64> { + for line in text.lines() { + let mut parts = line.splitn(4, '\t'); + let id = parts.next()?.trim(); + let own = parts.next().unwrap_or("").trim(); + if own == owner { + if let Ok(n) = id.parse::<i64>() { + return Some(n); + } + } + } + None +} diff --git a/crates/jcode-app-core/src/tool/computer/discover.rs b/crates/jcode-app-core/src/tool/computer/discover.rs new file mode 100644 index 0000000..b2f1b18 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/discover.rs @@ -0,0 +1,125 @@ +//! Progressive disclosure: return full specs for advanced actions on demand, +//! so the always-on tool schema stays small. + +use anyhow::Result; +use jcode_tool_types::ToolOutput; + +const MOUSE: &str = "\ +mouse actions (visible coordinate input; moves the real cursor): +- move {x, y} +- click {x?, y?} click at point (or current cursor position) +- double_click{x?, y?} +- right_click {x?, y?} +- drag {x, y, to_x, to_y} +- scroll {x?, y?, dx, dy} dy>0 scrolls up +- cursor {} report current cursor position"; + +const KEYBOARD: &str = "\ +keyboard actions (go to the focused app): +- type {text} type a UTF-8 string +- key {keys} chord, e.g. 'cmd+space', 'return', 'ctrl+shift+t' +- key_down {keys} hold a key/chord down +- key_up {keys} release a key/chord"; + +const OBSERVE: &str = "\ +observe actions (see the screen): +- screenshot {} full main display; reports point/pixel scale +- window_screenshot {window_id} capture one window (even if occluded) +- ocr {region?:[x,y,w,h]} recognize on-screen text + bounding boxes (Vision) +- ui {app?, depth?} dump the Accessibility tree; element paths shown as #a.b.c"; + +const AX: &str = "\ +accessibility actions (BACKGROUND control; no cursor movement, app need not be frontmost). +Element handle = {app:\"AppName\", path:[child indices from the front window]} from find_element/ui: +- find_element {app, role?, title?, value?, depth?} -> matching elements with paths +- element_at {app, x, y} -> deepest element at a point +- press {element} -> AXPress (background click) +- set_value {element, value} -> set a field's value (background type) +- get_value {element} +- perform_action {element, ax_action} -> any AX action, e.g. 'AXShowMenu' +- select_menu {app, menu_path:[\"File\",\"Save\"]} -> drive the menu bar"; + +const WINDOWS: &str = "\ +window actions (act on an app's front window; AX-based, can target background windows): +- list_windows {} all on-screen windows with ids/owners/bounds +- focus_window {app} raise + activate the app's front window +- move_window {app, x, y} +- resize_window {app, w, h} +- minimize_window{app} +- close_window {app}"; + +const APPS: &str = "\ +app actions: +- list_apps {} running (non-background) apps +- activate_app {app} bring an app to the front +- hide_app {app} hide an app (no quit) +- quit_app {app} quit an app"; + +const CLIPBOARD: &str = "\ +clipboard actions: +- get_clipboard {} +- set_clipboard {text}"; + +const SCRIPTING: &str = "\ +scripting actions (headless control of scriptable apps; no UI, no cursor): +- run_applescript {script} run AppleScript, returns its result +- run_jxa {script} run JavaScript-for-Automation +- wait_for {app, contains, timeout_ms?} poll an app's AX tree until text appears"; + +const SYSTEM: &str = "\ +system actions: +- notify {text, title?} post a Notification Center banner +- system_state {} battery / date / power summary +- set_brightness {level} 0..1 (needs the `brightness` cli)"; + +const SETUP: &str = "\ +setup actions: +- check_permissions {} report Accessibility / Screen Recording / Swift status +- setup {} request permissions, deep-link to the right Settings panes, poll until ready + +Note: the Accessibility toggle itself cannot be enabled programmatically (macOS security); +setup gets you one click away."; + +fn section(cat: &str) -> Option<&'static str> { + Some(match cat { + "mouse" => MOUSE, + "keyboard" => KEYBOARD, + "observe" => OBSERVE, + "ax" => AX, + "windows" => WINDOWS, + "apps" => APPS, + "clipboard" => CLIPBOARD, + "scripting" => SCRIPTING, + "system" => SYSTEM, + "setup" => SETUP, + _ => return None, + }) +} + +pub fn discover(category: Option<&str>) -> Result<ToolOutput> { + let cat = category.unwrap_or("all"); + let body = if cat == "all" { + [ + OBSERVE, MOUSE, KEYBOARD, AX, WINDOWS, APPS, CLIPBOARD, SCRIPTING, SYSTEM, SETUP, + ] + .join("\n\n") + } else if let Some(s) = section(cat) { + s.to_string() + } else { + format!( + "Unknown category '{cat}'. Valid: mouse, keyboard, observe, ax, windows, apps, \ + clipboard, scripting, system, setup, all." + ) + }; + Ok(ToolOutput::new(format!( + "macos_computer_use actions — category '{cat}'. All actions are fields on the same `macos_computer_use` tool.\n\n{body}\n\n\ + Default policy: this is the user's own live machine, so stay out of their way. \ + Prefer background AX/scripting actions over visible coordinate input when the target \ + element is resolvable; only fall back to click/type (which move your cursor and steal \ + focus) when AX can't reach it. Do not move the cursor, change the frontmost app, or \ + move/resize/activate windows unless the user asked or the task strictly requires it. \ + Act only on the task you were given — never take proactive control of the desktop \ + upfront. When a visible action is unavoidable, do the minimum and restore focus." + )) + .with_title("discover")) +} diff --git a/crates/jcode-app-core/src/tool/computer/input.rs b/crates/jcode-app-core/src/tool/computer/input.rs new file mode 100644 index 0000000..48b6739 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/input.rs @@ -0,0 +1,312 @@ +//! Synthetic mouse + keyboard input via Core Graphics CGEvents. +//! +//! This is the *visible* control path: events go to the shared HID stream, so +//! they move the real cursor and type into the focused app. Background control +//! lives in `ax.rs` instead. + +use super::keys; +use anyhow::{Context, Result, bail}; +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField, + ScrollEventUnit, +}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use core_graphics::geometry::CGPoint; +use std::thread::sleep; +use std::time::Duration; + +#[derive(Clone, Copy)] +pub enum Button { + Left, + Right, +} + +fn source() -> Result<CGEventSource> { + CGEventSource::new(CGEventSourceStateID::HIDSystemState).map_err(|_| { + anyhow::anyhow!( + "failed to create CGEventSource. Grant Accessibility permission (run the `setup` action)." + ) + }) +} + +fn post(event: CGEvent) { + event.post(CGEventTapLocation::HID); +} + +/// Current cursor position in global (top-left origin) screen points. +pub fn current_cursor() -> Result<CGPoint> { + let src = source()?; + let evt = CGEvent::new(src).map_err(|_| anyhow::anyhow!("failed to read cursor position"))?; + Ok(evt.location()) +} + +pub fn move_to(x: f64, y: f64) -> Result<()> { + let src = source()?; + let evt = CGEvent::new_mouse_event( + src, + CGEventType::MouseMoved, + CGPoint::new(x, y), + CGMouseButton::Left, + ) + .map_err(|_| anyhow::anyhow!("failed to create mouse-move event"))?; + post(evt); + Ok(()) +} + +pub fn click(x: Option<f64>, y: Option<f64>, button: Button, count: u32) -> Result<CGPoint> { + let point = match (x, y) { + (Some(x), Some(y)) => CGPoint::new(x, y), + _ => current_cursor()?, + }; + let (down, up, cg_button) = match button { + Button::Left => ( + CGEventType::LeftMouseDown, + CGEventType::LeftMouseUp, + CGMouseButton::Left, + ), + Button::Right => ( + CGEventType::RightMouseDown, + CGEventType::RightMouseUp, + CGMouseButton::Right, + ), + }; + + let src = source()?; + let mv = CGEvent::new_mouse_event(src, CGEventType::MouseMoved, point, cg_button) + .map_err(|_| anyhow::anyhow!("failed to create move event"))?; + post(mv); + sleep(Duration::from_millis(10)); + + for i in 1..=count { + let src_d = source()?; + let down_evt = CGEvent::new_mouse_event(src_d, down, point, cg_button) + .map_err(|_| anyhow::anyhow!("failed to create mouse-down event"))?; + if count > 1 { + down_evt.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, i as i64); + } + post(down_evt); + + let src_u = source()?; + let up_evt = CGEvent::new_mouse_event(src_u, up, point, cg_button) + .map_err(|_| anyhow::anyhow!("failed to create mouse-up event"))?; + if count > 1 { + up_evt.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, i as i64); + } + post(up_evt); + sleep(Duration::from_millis(20)); + } + Ok(point) +} + +pub fn drag(from_x: f64, from_y: f64, to_x: f64, to_y: f64) -> Result<()> { + let from = CGPoint::new(from_x, from_y); + let to = CGPoint::new(to_x, to_y); + + let src = source()?; + let down = CGEvent::new_mouse_event(src, CGEventType::LeftMouseDown, from, CGMouseButton::Left) + .map_err(|_| anyhow::anyhow!("failed to create drag-down event"))?; + post(down); + sleep(Duration::from_millis(30)); + + let steps = 10; + for i in 1..=steps { + let t = i as f64 / steps as f64; + let p = CGPoint::new(from_x + (to_x - from_x) * t, from_y + (to_y - from_y) * t); + let src_m = source()?; + let mv = + CGEvent::new_mouse_event(src_m, CGEventType::LeftMouseDragged, p, CGMouseButton::Left) + .map_err(|_| anyhow::anyhow!("failed to create drag-move event"))?; + post(mv); + sleep(Duration::from_millis(15)); + } + + let src_u = source()?; + let up = CGEvent::new_mouse_event(src_u, CGEventType::LeftMouseUp, to, CGMouseButton::Left) + .map_err(|_| anyhow::anyhow!("failed to create drag-up event"))?; + post(up); + Ok(()) +} + +pub fn scroll(x: Option<f64>, y: Option<f64>, dx: i32, dy: i32) -> Result<()> { + if let (Some(x), Some(y)) = (x, y) { + move_to(x, y)?; + sleep(Duration::from_millis(10)); + } + let src = source()?; + let evt = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, dy, dx, 0) + .map_err(|_| anyhow::anyhow!("failed to create scroll event"))?; + post(evt); + Ok(()) +} + +/// Type a UTF-8 string as a single synthesized keyboard event (Unicode payload), +/// layout-independent. Goes to the focused app. +pub fn type_text(text: &str) -> Result<()> { + let src = source()?; + let down = CGEvent::new_keyboard_event(src, 0, true) + .map_err(|_| anyhow::anyhow!("failed to create keyboard event"))?; + down.set_string(text); + post(down); + + let src_up = source()?; + let up = CGEvent::new_keyboard_event(src_up, 0, false) + .map_err(|_| anyhow::anyhow!("failed to create keyboard event"))?; + up.set_string(text); + post(up); + Ok(()) +} + +/// Parse a chord like "cmd+shift+t" into (modifier flags, main keycode). +pub fn parse_chord(chord: &str) -> Result<(CGEventFlags, u16)> { + let mut flags = CGEventFlags::CGEventFlagNull; + let mut keycode: Option<u16> = None; + for raw in chord.split('+') { + let part = raw.trim().to_lowercase(); + if part.is_empty() { + continue; + } + match part.as_str() { + "cmd" | "command" | "meta" | "super" => flags |= CGEventFlags::CGEventFlagCommand, + "ctrl" | "control" => flags |= CGEventFlags::CGEventFlagControl, + "alt" | "opt" | "option" => flags |= CGEventFlags::CGEventFlagAlternate, + "shift" => flags |= CGEventFlags::CGEventFlagShift, + "fn" => flags |= CGEventFlags::CGEventFlagSecondaryFn, + other => { + if keycode.is_some() { + bail!("key chord '{chord}' has more than one non-modifier key"); + } + keycode = Some( + keys::keycode_for(other) + .with_context(|| format!("unknown key '{other}' in chord '{chord}'"))?, + ); + } + } + } + let code = keycode.with_context(|| format!("chord '{chord}' has no main key"))?; + Ok((flags, code)) +} + +pub fn key_chord(chord: &str) -> Result<()> { + let (flags, code) = parse_chord(chord)?; + let src = source()?; + let down = CGEvent::new_keyboard_event(src, code, true) + .map_err(|_| anyhow::anyhow!("failed to create key-down event"))?; + down.set_flags(flags); + post(down); + sleep(Duration::from_millis(15)); + + let src_up = source()?; + let up = CGEvent::new_keyboard_event(src_up, code, false) + .map_err(|_| anyhow::anyhow!("failed to create key-up event"))?; + up.set_flags(flags); + post(up); + Ok(()) +} + +/// Parse a chord into (modifier flags, optional main keycode). Unlike +/// `parse_chord`, a modifier-only chord (e.g. "shift") is allowed and returns +/// `None` for the keycode. Used for key_down/key_up holds. +pub fn parse_chord_opt(chord: &str) -> Result<(CGEventFlags, Option<u16>)> { + let mut flags = CGEventFlags::CGEventFlagNull; + let mut keycode: Option<u16> = None; + for raw in chord.split('+') { + let part = raw.trim().to_lowercase(); + if part.is_empty() { + continue; + } + match part.as_str() { + "cmd" | "command" | "meta" | "super" => flags |= CGEventFlags::CGEventFlagCommand, + "ctrl" | "control" => flags |= CGEventFlags::CGEventFlagControl, + "alt" | "opt" | "option" => flags |= CGEventFlags::CGEventFlagAlternate, + "shift" => flags |= CGEventFlags::CGEventFlagShift, + "fn" => flags |= CGEventFlags::CGEventFlagSecondaryFn, + other => { + if keycode.is_some() { + bail!("key chord '{chord}' has more than one non-modifier key"); + } + keycode = Some( + keys::keycode_for(other) + .with_context(|| format!("unknown key '{other}' in chord '{chord}'"))?, + ); + } + } + } + if keycode.is_none() && flags == CGEventFlags::CGEventFlagNull { + bail!("chord '{chord}' is empty"); + } + Ok((flags, keycode)) +} + +/// Keycode for a single modifier name, for modifier-only holds. +fn modifier_keycode(chord: &str) -> Option<u16> { + match chord.trim().to_lowercase().as_str() { + "cmd" | "command" | "meta" | "super" => Some(0x37), + "shift" => Some(0x38), + "alt" | "opt" | "option" => Some(0x3A), + "ctrl" | "control" => Some(0x3B), + "fn" => Some(0x3F), + _ => None, + } +} + +/// Hold a key or chord down (down_state=true) or release it (false). +/// Supports modifier-only holds (e.g. "shift") via a FlagsChanged event. +pub fn key_hold(chord: &str, down_state: bool) -> Result<()> { + let (flags, keycode) = parse_chord_opt(chord)?; + let src = source()?; + match keycode { + Some(code) => { + let evt = CGEvent::new_keyboard_event(src, code, down_state) + .map_err(|_| anyhow::anyhow!("failed to create key event"))?; + evt.set_flags(flags); + post(evt); + } + None => { + // Modifier-only: emit a FlagsChanged event carrying the modifier + // keycode, with the flags set while held and cleared on release. + let code = modifier_keycode(chord) + .with_context(|| format!("unsupported modifier-only hold '{chord}'"))?; + let evt = CGEvent::new_keyboard_event(src, code, down_state) + .map_err(|_| anyhow::anyhow!("failed to create modifier event"))?; + evt.set_type(CGEventType::FlagsChanged); + evt.set_flags(if down_state { + flags + } else { + CGEventFlags::CGEventFlagNull + }); + post(evt); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_modifier_only_chord() { + let (flags, code) = parse_chord_opt("shift").unwrap(); + assert!(flags.contains(CGEventFlags::CGEventFlagShift)); + assert!(code.is_none()); + } + + #[test] + fn parses_chord_with_key() { + let (flags, code) = parse_chord_opt("cmd+a").unwrap(); + assert!(flags.contains(CGEventFlags::CGEventFlagCommand)); + assert_eq!(code, Some(0x00)); + } + + #[test] + fn rejects_empty_chord() { + assert!(parse_chord_opt("").is_err()); + } + + #[test] + fn modifier_keycodes_known() { + assert_eq!(modifier_keycode("cmd"), Some(0x37)); + assert_eq!(modifier_keycode("shift"), Some(0x38)); + assert_eq!(modifier_keycode("nope"), None); + } +} diff --git a/crates/jcode-app-core/src/tool/computer/keys.rs b/crates/jcode-app-core/src/tool/computer/keys.rs new file mode 100644 index 0000000..eb39d47 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/keys.rs @@ -0,0 +1,129 @@ +//! Layout-independent virtual keycode mapping for key chords. + +use core_graphics::event::CGKeyCode; + +/// Map a key name (already lowercased, single token) to a US virtual keycode. +/// Returns None for unknown keys. +pub fn keycode_for(key: &str) -> Option<CGKeyCode> { + use core_graphics::event::KeyCode; + let code = match key { + "return" | "enter" => KeyCode::RETURN, + "tab" => KeyCode::TAB, + "space" => KeyCode::SPACE, + "delete" | "backspace" => KeyCode::DELETE, + "esc" | "escape" => KeyCode::ESCAPE, + "left" => KeyCode::LEFT_ARROW, + "right" => KeyCode::RIGHT_ARROW, + "down" => KeyCode::DOWN_ARROW, + "up" => KeyCode::UP_ARROW, + "home" => KeyCode::HOME, + "end" => KeyCode::END, + "pageup" => KeyCode::PAGE_UP, + "pagedown" => KeyCode::PAGE_DOWN, + "forwarddelete" => KeyCode::FORWARD_DELETE, + "f1" => 0x7A, + "f2" => 0x78, + "f3" => 0x63, + "f4" => 0x76, + "f5" => 0x60, + "f6" => 0x61, + "f7" => 0x62, + "f8" => 0x64, + "f9" => 0x65, + "f10" => 0x6D, + "f11" => 0x67, + "f12" => 0x6F, + other => return ansi_keycode(other), + }; + Some(code) +} + +/// US ANSI virtual keycodes for single letters, digits, and common punctuation. +/// Layout-independent hardware positions. +pub fn ansi_keycode(key: &str) -> Option<CGKeyCode> { + let mut chars = key.chars(); + let first = chars.next()?; + if chars.next().is_some() { + return None; + } + let code: CGKeyCode = match first { + 'a' => 0x00, + 'b' => 0x0B, + 'c' => 0x08, + 'd' => 0x02, + 'e' => 0x0E, + 'f' => 0x03, + 'g' => 0x05, + 'h' => 0x04, + 'i' => 0x22, + 'j' => 0x26, + 'k' => 0x28, + 'l' => 0x25, + 'm' => 0x2E, + 'n' => 0x2D, + 'o' => 0x1F, + 'p' => 0x23, + 'q' => 0x0C, + 'r' => 0x0F, + 's' => 0x01, + 't' => 0x11, + 'u' => 0x20, + 'v' => 0x09, + 'w' => 0x0D, + 'x' => 0x07, + 'y' => 0x10, + 'z' => 0x06, + '0' => 0x1D, + '1' => 0x12, + '2' => 0x13, + '3' => 0x14, + '4' => 0x15, + '5' => 0x17, + '6' => 0x16, + '7' => 0x1A, + '8' => 0x1C, + '9' => 0x19, + '-' => 0x1B, + '=' => 0x18, + '[' => 0x21, + ']' => 0x1E, + '\\' => 0x2A, + ';' => 0x29, + '\'' => 0x27, + ',' => 0x2B, + '.' => 0x2F, + '/' => 0x2C, + '`' => 0x32, + _ => return None, + }; + Some(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_named_keys() { + assert_eq!(keycode_for("return"), Some(0x24)); + assert_eq!(keycode_for("space"), Some(0x31)); + assert_eq!(keycode_for("esc"), Some(0x35)); + assert_eq!(keycode_for("escape"), Some(0x35)); + assert_eq!(keycode_for("left"), Some(0x7B)); + assert_eq!(keycode_for("f5"), Some(0x60)); + } + + #[test] + fn maps_letters_and_digits() { + assert_eq!(keycode_for("a"), Some(0x00)); + assert_eq!(keycode_for("z"), Some(0x06)); + assert_eq!(keycode_for("0"), Some(0x1D)); + assert_eq!(keycode_for(","), Some(0x2B)); + } + + #[test] + fn rejects_unknown_keys() { + assert_eq!(ansi_keycode("nope"), None); + assert_eq!(keycode_for("nope"), None); + } +} diff --git a/crates/jcode-app-core/src/tool/computer/mod.rs b/crates/jcode-app-core/src/tool/computer/mod.rs new file mode 100644 index 0000000..409a626 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/mod.rs @@ -0,0 +1,528 @@ +//! Native macOS "computer use" tool. +//! +//! The desktop analog of the `browser` tool: a single `action`-dispatched tool +//! that lets the agent see the screen and control the macOS GUI. +//! +//! ## Mechanisms and visibility +//! +//! - **Coordinate input** (`click`/`type`/`key`/`scroll`/`drag`) uses Core +//! Graphics CGEvents on the shared HID stream, so it is *visible*: it moves the +//! real cursor and types into the focused app. +//! - **Accessibility actions** (`press`/`set_value`/`select_menu`/...) and +//! **scripting** (`run_applescript`) act on apps *by reference*, so they can +//! work in the **background** without moving the cursor. +//! +//! By default the tool prefers non-disruptive mechanisms and restraint: because +//! this runs on the user's own live machine, the agent should act only on the +//! requested task, prefer background AX/scripting over moving the cursor or +//! stealing focus, and never take proactive control of the desktop. This policy +//! is conveyed to the model via the tool description and the `discover` output. +//! +//! ## Progressive disclosure +//! +//! Only a small set of common actions is described in the always-on schema to +//! keep prompt cost low. The full action set is fetched on demand via +//! `action="discover"` with a `category`. +//! +//! Everything is gated behind `cfg(target_os = "macos")`; other platforms return +//! a clear "unsupported" error. + +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[cfg(target_os = "macos")] +mod ax; +#[cfg(target_os = "macos")] +mod discover; +#[cfg(target_os = "macos")] +mod input; +#[cfg(target_os = "macos")] +mod keys; +#[cfg(target_os = "macos")] +mod osa; +#[cfg(target_os = "macos")] +mod screen; +#[cfg(target_os = "macos")] +mod setup; +#[cfg(target_os = "macos")] +mod sys; +#[cfg(target_os = "macos")] +mod win; + +pub struct ComputerTool; + +impl ComputerTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +struct ComputerInput { + action: String, + // discovery + #[serde(default)] + category: Option<String>, + // coordinates + #[serde(default)] + x: Option<f64>, + #[serde(default)] + y: Option<f64>, + #[serde(default)] + to_x: Option<f64>, + #[serde(default)] + to_y: Option<f64>, + #[serde(default)] + w: Option<f64>, + #[serde(default)] + h: Option<f64>, + // text / keys + #[serde(default)] + text: Option<String>, + #[serde(default)] + keys: Option<String>, + #[serde(default)] + dx: Option<i32>, + #[serde(default)] + dy: Option<i32>, + #[serde(default)] + depth: Option<u32>, + // AX / scoping + #[serde(default)] + app: Option<String>, + #[serde(default)] + role: Option<String>, + #[serde(default)] + title: Option<String>, + #[serde(default)] + value: Option<String>, + #[serde(default)] + element: Option<Value>, + #[serde(default)] + ax_action: Option<String>, + #[serde(default)] + menu_path: Option<Vec<String>>, + // windows + #[serde(default)] + window_id: Option<i64>, + // scripting / wait / system + #[serde(default)] + script: Option<String>, + #[serde(default)] + contains: Option<String>, + #[serde(default)] + timeout_ms: Option<u64>, + #[serde(default)] + region: Option<[f64; 4]>, + #[serde(default)] + level: Option<f64>, + /// For mutating actions: resolve and report the target without acting. + #[serde(default)] + dry_run: Option<bool>, +} + +/// Cap a tool output's text so a huge AX tree / clipboard / OCR dump cannot +/// blow up the context. Keeps the head and notes how much was dropped. +#[cfg(target_os = "macos")] +fn cap_output(mut out: ToolOutput, max_chars: usize) -> ToolOutput { + if out.output.len() > max_chars { + let mut cut = max_chars; + while cut > 0 && !out.output.is_char_boundary(cut) { + cut -= 1; + } + let dropped = out.output.len() - cut; + let head = out.output[..cut].to_string(); + out.output = format!("{head}\n… [truncated {dropped} chars]"); + } + out +} + +/// Actions that change desktop/app state. Used for dry_run gating. +#[cfg(target_os = "macos")] +fn is_mutating(action: &str) -> bool { + matches!( + action, + "move" + | "click" + | "double_click" + | "right_click" + | "drag" + | "scroll" + | "type" + | "key" + | "key_down" + | "key_up" + | "press" + | "set_value" + | "perform_action" + | "select_menu" + | "activate_app" + | "hide_app" + | "quit_app" + | "focus_window" + | "move_window" + | "resize_window" + | "minimize_window" + | "close_window" + | "set_clipboard" + | "run_applescript" + | "run_jxa" + | "notify" + | "set_brightness" + ) +} + +#[async_trait] +impl Tool for ComputerTool { + fn name(&self) -> &str { + "macos_computer_use" + } + + fn description(&self) -> &str { + "Control the macOS desktop: see the screen (screenshot/ocr/ui tree), click and type \ + (visible coordinate input), act on UI elements in the BACKGROUND via Accessibility \ + (press/set_value, no cursor movement), manage apps and windows, use the clipboard, and \ + run AppleScript. Coordinates are in points (top-left origin). This is the user's live \ + machine: act only on the requested task (not proactively) and prefer BACKGROUND \ + AX/scripting over moving the cursor or stealing focus; click/type only when AX can't \ + reach the target. Call action='discover' with a category for the full action set. Run \ + action='setup' first if permissions are missing." + } + + fn parameters_schema(&self) -> Value { + // Progressive disclosure: only the common actions + discover are spelled + // out here to keep always-on prompt cost low (~370 tokens). Advanced + // actions and their params are returned by action="discover". + json!({ + "type": "object", + "required": ["action"], + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "description": "Common: screenshot, ocr, ui (see); click, type, key (visible input); \ + press, set_value (BACKGROUND AX action on an `element` handle); find_element; \ + run_applescript; setup, check_permissions; discover (load full action set). \ + Many more actions (move, drag, scroll, window/app management, clipboard, \ + select_menu, notify, ...) take the same fields; call discover for their params." + }, + "category": { + "type": "string", + "enum": ["mouse","keyboard","observe","ax","windows","apps","clipboard","scripting","system","setup","all"], + "description": "For action='discover': which group to return full action specs for." + }, + "x": { "type": "number", "description": "Screen X in points (top-left origin)." }, + "y": { "type": "number", "description": "Screen Y in points." }, + "text": { "type": "string", "description": "Text for type / set_clipboard / notify." }, + "keys": { "type": "string", "description": "Key chord, e.g. cmd+space, return, esc, ctrl+shift+t." }, + "app": { "type": "string", "description": "Target app/process name (AX, windows, scripting scope)." }, + "role": { "type": "string", "description": "AX role filter for find_element, e.g. AXButton." }, + "title": { "type": "string", "description": "AX title/label substring for find_element." }, + "value": { "type": "string", "description": "Value to match (find_element) or set (set_value)." }, + "element": { + "type": "object", + "description": "Element handle from find_element/ui: {app, path:[child indices]}. Used by press/set_value/get_value/perform_action.", + "properties": { + "app": { "type": "string" }, + "path": { "type": "array", "items": { "type": "integer" } } + } + }, + "script": { "type": "string", "description": "AppleScript (run_applescript) or JS (run_jxa) source." }, + "depth": { "type": "integer", "description": "Max AX tree depth for ui/find_element (default 12)." }, + "to_x": { "type": "number" }, + "to_y": { "type": "number" }, + "dx": { "type": "integer" }, + "dy": { "type": "integer" }, + "w": { "type": "number" }, + "h": { "type": "number" }, + "window_id": { "type": "integer", "description": "window_screenshot id (from list_windows)." }, + "menu_path": { "type": "array", "items": { "type": "string" }, "description": "select_menu path, e.g. [\"File\",\"Save\"]." }, + "ax_action": { "type": "string", "description": "perform_action AX action, e.g. AXShowMenu." }, + "contains": { "type": "string", "description": "wait_for: substring to await." }, + "timeout_ms": { "type": "integer" }, + "region": { "type": "array", "items": { "type": "number" }, "description": "ocr region [x,y,w,h]; omit for full screen." }, + "level": { "type": "number", "description": "set_brightness 0..1." }, + "dry_run": { "type": "boolean", "description": "Mutating actions: report intended action without doing it." } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result<ToolOutput> { + let parsed: ComputerInput = + serde_json::from_value(input).context("invalid `macos_computer_use` tool input")?; + tokio::task::spawn_blocking(move || run(parsed)) + .await + .context("macos_computer_use tool task panicked")? + } +} + +#[cfg(not(target_os = "macos"))] +fn run(_input: ComputerInput) -> Result<ToolOutput> { + bail!("The `macos_computer_use` tool is only supported on macOS.") +} + +#[cfg(target_os = "macos")] +fn run(input: ComputerInput) -> Result<ToolOutput> { + let action = input.action.as_str(); + + // dry_run: for mutating actions, report the intended target and stop. + if input.dry_run == Some(true) && is_mutating(action) { + return Ok(ToolOutput::new(format!( + "[dry_run] would perform '{action}' (no action taken). \ + Re-issue without dry_run to execute." + ))); + } + + let result = dispatch(action, &input); + // Cap large textual outputs to protect context (images are unaffected). + result.map(|o| cap_output(o, 16_000)) +} + +#[cfg(target_os = "macos")] +fn dispatch(action: &str, input: &ComputerInput) -> Result<ToolOutput> { + match action { + // ---- discovery & setup ---- + "discover" => discover::discover(input.category.as_deref()), + "setup" => setup::setup(), + "check_permissions" => setup::check_permissions(), + + // ---- observe ---- + "screenshot" => screen::screenshot(), + "ocr" => screen::ocr(input.region), + "window_screenshot" => { + let id = input + .window_id + .context("window_screenshot requires `window_id`")?; + screen::window_screenshot(id) + } + "ui" => ax::ui_tree(input.app.as_deref(), input.depth.unwrap_or(12)), + "cursor" => { + let p = input::current_cursor()?; + Ok( + ToolOutput::new(format!("cursor at ({:.0}, {:.0})", p.x, p.y)) + .with_metadata(json!({ "x": p.x, "y": p.y })), + ) + } + + // ---- coordinate input (visible) ---- + "move" => { + let (x, y) = require_xy(input)?; + input::move_to(x, y)?; + Ok(ToolOutput::new(format!("moved cursor to ({x:.0}, {y:.0})"))) + } + "click" => { + let p = input::click(input.x, input.y, input::Button::Left, 1)?; + Ok(ToolOutput::new(format!( + "clicked at ({:.0}, {:.0})", + p.x, p.y + ))) + } + "double_click" => { + let p = input::click(input.x, input.y, input::Button::Left, 2)?; + Ok(ToolOutput::new(format!( + "double-clicked at ({:.0}, {:.0})", + p.x, p.y + ))) + } + "right_click" => { + let p = input::click(input.x, input.y, input::Button::Right, 1)?; + Ok(ToolOutput::new(format!( + "right-clicked at ({:.0}, {:.0})", + p.x, p.y + ))) + } + "drag" => { + let (x, y) = require_xy(input)?; + match (input.to_x, input.to_y) { + (Some(tx), Some(ty)) => { + input::drag(x, y, tx, ty)?; + Ok(ToolOutput::new(format!( + "dragged from ({x:.0},{y:.0}) to ({tx:.0},{ty:.0})" + ))) + } + _ => bail!("action='drag' requires `to_x` and `to_y`"), + } + } + "scroll" => { + let dx = input.dx.unwrap_or(0); + let dy = input.dy.unwrap_or(0); + if dx == 0 && dy == 0 { + bail!("action='scroll' requires non-zero `dx` and/or `dy`"); + } + input::scroll(input.x, input.y, dx, dy)?; + Ok(ToolOutput::new(format!("scrolled dx={dx} dy={dy}"))) + } + "type" => { + let text = input + .text + .as_deref() + .filter(|s| !s.is_empty()) + .context("action='type' requires non-empty `text`")?; + input::type_text(text)?; + Ok(ToolOutput::new(format!( + "typed {} characters", + text.chars().count() + ))) + } + "key" => { + let keys = input + .keys + .as_deref() + .filter(|s| !s.is_empty()) + .context("action='key' requires a `keys` chord, e.g. 'cmd+space'")?; + input::key_chord(keys)?; + Ok(ToolOutput::new(format!("pressed {keys}"))) + } + "key_down" | "key_up" => { + let keys = input + .keys + .as_deref() + .filter(|s| !s.is_empty()) + .context("requires a `keys` value")?; + input::key_hold(keys, action == "key_down")?; + Ok(ToolOutput::new(format!("{action} {keys}"))) + } + + // ---- AX background actions (Tier 1) ---- + "find_element" => { + let app = input + .app + .as_deref() + .context("find_element requires `app`")?; + ax::find_element( + app, + input.role.as_deref(), + input.title.as_deref(), + input.value.as_deref(), + input.depth.unwrap_or(20), + ) + } + "element_at" => { + let app = input.app.as_deref().context("element_at requires `app`")?; + let (x, y) = require_xy(input)?; + ax::element_at(app, x, y) + } + "press" => ax::press(&parse_element(input)?), + "get_value" => ax::get_value(&parse_element(input)?), + "set_value" => { + let v = input + .value + .as_deref() + .context("set_value requires `value`")?; + ax::set_value(&parse_element(input)?, v) + } + "perform_action" => { + let a = input + .ax_action + .as_deref() + .context("perform_action requires `ax_action`")?; + ax::perform_action(&parse_element(input)?, a) + } + "select_menu" => { + let app = input.app.as_deref().context("select_menu requires `app`")?; + let path = input + .menu_path + .as_ref() + .context("select_menu requires `menu_path`")?; + ax::select_menu(app, path) + } + + // ---- windows / apps (Tier 2) ---- + "list_apps" => win::list_apps(), + "list_windows" => win::list_windows(), + "activate_app" => win::activate_app(req_app(input)?), + "hide_app" => win::hide_app(req_app(input)?), + "quit_app" => win::quit_app(req_app(input)?), + "focus_window" => win::focus_window(req_app(input)?), + "move_window" => { + let (x, y) = require_xy(input)?; + win::move_window(req_app(input)?, x, y) + } + "resize_window" => { + let w = input.w.context("resize_window requires `w`")?; + let h = input.h.context("resize_window requires `h`")?; + win::resize_window(req_app(input)?, w, h) + } + "minimize_window" => win::minimize_window(req_app(input)?), + "close_window" => win::close_window(req_app(input)?), + + // ---- clipboard / scripting / system (Tier 3/4) ---- + "get_clipboard" => sys::get_clipboard(), + "set_clipboard" => { + let t = input + .text + .as_deref() + .context("set_clipboard requires `text`")?; + sys::set_clipboard(t) + } + "run_applescript" => { + let s = input + .script + .as_deref() + .context("run_applescript requires `script`")?; + sys::run_applescript(s) + } + "run_jxa" => { + let s = input + .script + .as_deref() + .context("run_jxa requires `script`")?; + sys::run_jxa(s) + } + "wait_for" => { + let app = input.app.as_deref().context("wait_for requires `app`")?; + let c = input + .contains + .as_deref() + .context("wait_for requires `contains`")?; + sys::wait_for(app, c, input.timeout_ms.unwrap_or(10_000)) + } + "notify" => { + let t = input.text.as_deref().context("notify requires `text`")?; + sys::notify(t, input.title.as_deref()) + } + "system_state" => sys::system_state(), + "set_brightness" => { + let l = input + .level + .context("set_brightness requires `level` (0..1)")?; + sys::set_brightness(l) + } + + other => bail!( + "Unknown macos_computer_use action: {other}. Call action='discover' (category='all') to list every action." + ), + } +} + +#[cfg(target_os = "macos")] +fn require_xy(input: &ComputerInput) -> Result<(f64, f64)> { + match (input.x, input.y) { + (Some(x), Some(y)) => Ok((x, y)), + _ => bail!("action='{}' requires both `x` and `y`", input.action), + } +} + +#[cfg(target_os = "macos")] +fn req_app<'a>(input: &'a ComputerInput) -> Result<&'a str> { + input + .app + .as_deref() + .with_context(|| format!("action='{}' requires `app`", input.action)) +} + +#[cfg(target_os = "macos")] +fn parse_element(input: &ComputerInput) -> Result<ax::ElementHandle> { + let raw = input.element.clone().context( + "this action requires an `element` handle {app, path:[...]} from find_element/ui", + )?; + serde_json::from_value(raw).context("invalid `element` handle") +} + +#[cfg(all(test, target_os = "macos"))] +mod coverage_tests; +#[cfg(all(test, target_os = "macos"))] +mod tests; diff --git a/crates/jcode-app-core/src/tool/computer/osa.rs b/crates/jcode-app-core/src/tool/computer/osa.rs new file mode 100644 index 0000000..118f6a3 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/osa.rs @@ -0,0 +1,225 @@ +//! Centralized `osascript` / JXA execution for the `macos_computer_use` tool. +//! +//! Many macOS capabilities (Accessibility actions, window/app management, system +//! state) are reachable through AppleScript / JavaScript-for-Automation without +//! extra native bindings. This module funnels all of that through one place so +//! escaping, error mapping (especially the TCC permission errors), and timeouts +//! are handled consistently. +//! +//! Every external command runs under a wall-clock timeout: a hung target app +//! must never freeze the agent. AppleScript also gets an internal +//! `with timeout` guard so System Events stops waiting on an unresponsive app. + +use anyhow::{Result, bail}; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Default wall-clock limit for a scripting call. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20); + +/// Run an AppleScript and return stdout (trimmed). Maps the common macOS +/// permission / automation errors to actionable messages. +pub fn run_applescript(script: &str) -> Result<String> { + run(&["-e", script], "AppleScript", DEFAULT_TIMEOUT) +} + +/// Run AppleScript with an explicit timeout. +pub fn run_applescript_timeout(script: &str, timeout: Duration) -> Result<String> { + run(&["-e", script], "AppleScript", timeout) +} + +/// Run a JavaScript-for-Automation (JXA) script. +pub fn run_jxa(script: &str) -> Result<String> { + run(&["-l", "JavaScript", "-e", script], "JXA", DEFAULT_TIMEOUT) +} + +fn run(args: &[&str], lang: &str, timeout: Duration) -> Result<String> { + let (status, stdout, stderr) = run_command_timed("/usr/bin/osascript", args, timeout)?; + + if status { + return Ok(stdout.trim_end().to_string()); + } + + bail!("{}", classify_osa_error(stderr.trim(), lang)); +} + +/// Map an osascript stderr message to an actionable error string. Pure so it can +/// be unit-tested without shelling out. Ordering matters: permission errors are +/// the most actionable and have distinctive text, so they are checked before the +/// generic "reference does not resolve" index errors. +fn classify_osa_error(trimmed: &str, lang: &str) -> String { + let lower = trimmed.to_lowercase(); + + // Permission errors first. The real "not allowed assistive access" denial + // always carries that phrase; -25211 is errAXAPIDisabled. + if lower.contains("assistive") + || lower.contains("not allowed") + || lower.contains("-25211") + || lower.contains("1002") + { + return format!( + "Accessibility permission required. Run the `setup` action, or grant it in \ + System Settings > Privacy & Security > Accessibility for your terminal/jcode. \ + ({trimmed})" + ); + } + if lower.contains("-1743") || lower.contains("not authorized to send apple events") { + return format!( + "Automation permission required for the target app. Approve the prompt, or grant it \ + in System Settings > Privacy & Security > Automation. ({trimmed})" + ); + } + // -1719 (errAEIllegalIndex / "Invalid index") and -1728 (errAENoSuchObject / + // "Can't get ...") mean the target reference does not resolve: the app isn't + // running, has no front window, or an AX path index is out of range. This is + // NOT a permission problem, so report it accurately instead of sending the + // user to grant Accessibility (the previous behavior, which was misleading). + if lower.contains("-1719") || lower.contains("-1728") || lower.contains("invalid index") { + return format!( + "target not found: the app may not be running, has no front window, or an AX path \ + index is out of range. Check `list_apps`/`ui` and retry. ({trimmed})" + ); + } + if trimmed.is_empty() { + return format!("{lang} failed (no error output)"); + } + format!("{lang} failed: {trimmed}") +} + +/// Run a command with a wall-clock timeout. Returns (success, stdout, stderr). +/// On timeout the child is killed and an error is returned. +pub fn run_command_timed( + program: &str, + args: &[&str], + timeout: Duration, +) -> Result<(bool, String, String)> { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| anyhow::anyhow!("failed to spawn {program}: {e}"))?; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut out = String::new(); + let mut err = String::new(); + if let Some(mut s) = child.stdout.take() { + let _ = s.read_to_string(&mut out); + } + if let Some(mut s) = child.stderr.take() { + let _ = s.read_to_string(&mut err); + } + return Ok((status.success(), out, err)); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!( + "command timed out after {}s (a target app may be unresponsive): {program}", + timeout.as_secs() + ); + } + std::thread::sleep(Duration::from_millis(25)); + } + Err(e) => bail!("error waiting on {program}: {e}"), + } + } +} + +/// Quote a string as an AppleScript string literal (wraps in quotes, escapes +/// backslash and double-quote). Use for interpolating untrusted text into +/// generated AppleScript. +pub fn as_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + _ => out.push(ch), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_and_escapes() { + assert_eq!(as_quote("hi"), "\"hi\""); + assert_eq!(as_quote("a\"b"), "\"a\\\"b\""); + assert_eq!(as_quote("a\\b"), "\"a\\\\b\""); + } + + #[test] + fn timed_command_succeeds_fast() { + let (ok, out, _) = run_command_timed("/bin/echo", &["hi"], Duration::from_secs(5)).unwrap(); + assert!(ok); + assert_eq!(out.trim(), "hi"); + } + + #[test] + fn timed_command_times_out() { + let err = run_command_timed("/bin/sleep", &["5"], Duration::from_millis(200)) + .unwrap_err() + .to_string(); + assert!(err.contains("timed out"), "got: {err}"); + } + + #[test] + fn classifies_permission_error() { + let msg = classify_osa_error( + "execution error: System Events got an error: osascript is not allowed assistive access. (-25211)", + "AppleScript", + ); + assert!( + msg.contains("Accessibility permission required"), + "got: {msg}" + ); + } + + #[test] + fn classifies_invalid_index_as_not_found_not_permission() { + // Regression: -1719 used to be misreported as "Accessibility permission + // required" even though it means the target reference didn't resolve. + let msg = classify_osa_error( + "execution error: System Events got an error: Can\u{2019}t get application process 1 whose name = \"Codex\". Invalid index. (-1719)", + "AppleScript", + ); + assert!(msg.contains("target not found"), "got: {msg}"); + assert!(!msg.contains("Accessibility permission"), "got: {msg}"); + } + + #[test] + fn classifies_no_such_object_as_not_found() { + let msg = classify_osa_error( + "execution error: System Events got an error: Can\u{2019}t get front window of process \"Foo\". (-1728)", + "AppleScript", + ); + assert!(msg.contains("target not found"), "got: {msg}"); + } + + #[test] + fn classifies_automation_error() { + let msg = classify_osa_error( + "execution error: Not authorized to send Apple events to Finder. (-1743)", + "AppleScript", + ); + assert!(msg.contains("Automation permission required"), "got: {msg}"); + } + + #[test] + fn classifies_generic_error() { + let msg = classify_osa_error("execution error: something weird (-2700)", "JXA"); + assert!(msg.contains("JXA failed"), "got: {msg}"); + } +} diff --git a/crates/jcode-app-core/src/tool/computer/screen.rs b/crates/jcode-app-core/src/tool/computer/screen.rs new file mode 100644 index 0000000..8013b25 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/screen.rs @@ -0,0 +1,287 @@ +//! Screen observation: full-screen + per-window screenshots and OCR. + +use super::osa; +use anyhow::{Context, Result, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use core_graphics::display::CGDisplay; +use jcode_tool_types::ToolOutput; +use serde_json::json; +use std::process::Command; +use std::time::Duration; + +/// Read width/height from a PNG IHDR chunk. Returns None if not a PNG. +pub fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + if bytes.len() < 24 || bytes[..8] != PNG_SIG { + return None; + } + let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + Some((w, h)) +} + +fn capture_to_temp(extra_args: &[&str]) -> Result<Vec<u8>> { + let tmp = std::env::temp_dir().join(format!( + "jcode_computer_{}_{}.png", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + let tmp_str = tmp.to_string_lossy().to_string(); + let mut args: Vec<&str> = vec!["-x"]; + args.extend_from_slice(extra_args); + args.push(&tmp_str); + let (ok, _out, err) = + osa::run_command_timed("/usr/sbin/screencapture", &args, Duration::from_secs(15))?; + if !ok { + let _ = std::fs::remove_file(&tmp); + bail!("screencapture failed: {}", err.trim()); + } + let bytes = std::fs::read(&tmp).context("failed to read screenshot file")?; + let _ = std::fs::remove_file(&tmp); + if bytes.is_empty() { + bail!( + "screenshot was empty. Grant Screen Recording permission (run the `setup` action), or \ + in System Settings > Privacy & Security > Screen Recording." + ); + } + Ok(bytes) +} + +pub fn screenshot() -> Result<ToolOutput> { + let bytes = capture_to_temp(&[])?; + let bounds = CGDisplay::main().bounds(); + let point_w = bounds.size.width; + let point_h = bounds.size.height; + let (pixel_w, pixel_h) = png_dimensions(&bytes).unwrap_or((point_w as u32, point_h as u32)); + let scale = if point_w > 0.0 { + pixel_w as f64 / point_w + } else { + 1.0 + }; + let summary = format!( + "Captured main display: {pixel_w}x{pixel_h} pixels = {point_w:.0}x{point_h:.0} points \ + (scale {scale:.2}x). Click/move coordinates are in POINTS: for a feature at image pixel \ + (px, py), use x = px / {scale:.2}, y = py / {scale:.2}.", + ); + Ok(ToolOutput::new(summary) + .with_title("screenshot") + .with_labeled_image("image/png", STANDARD.encode(&bytes), "screen") + .with_metadata(json!({ + "width_points": point_w, "height_points": point_h, + "width_pixels": pixel_w, "height_pixels": pixel_h, "scale": scale, + }))) +} + +/// Screenshot a single window by its CoreGraphics window id, even if occluded. +pub fn window_screenshot(window_id: i64) -> Result<ToolOutput> { + let bytes = capture_to_temp(&["-o", "-l", &window_id.to_string()])?; + let (pixel_w, pixel_h) = png_dimensions(&bytes).unwrap_or((0, 0)); + Ok(ToolOutput::new(format!( + "Captured window {window_id}: {pixel_w}x{pixel_h} pixels." + )) + .with_title("window screenshot") + .with_labeled_image("image/png", STANDARD.encode(&bytes), "window") + .with_metadata( + json!({ "window_id": window_id, "width_pixels": pixel_w, "height_pixels": pixel_h }), + )) +} + +/// OCR a region (or the whole screen) using the macOS Vision framework via a +/// small inline Swift/JXA bridge. Returns recognized strings with bounding +/// boxes so the model can click located text in apps with no Accessibility. +/// +/// Region cropping is done in-process (CoreGraphics) rather than via +/// `screencapture -R`, which is broken on macOS 26.x (it fails with +/// "could not create image from rect"). We always capture the full screen and +/// crop the resulting image, converting the caller's point rect to pixels. +pub fn ocr(region: Option<[f64; 4]>) -> Result<ToolOutput> { + // Always capture the full screen (region capture via `-R` is unreliable on + // recent macOS), then crop in the Vision helper. + let bytes = capture_to_temp(&[])?; + + // Figure out the pixel<->point scale so we can convert the requested region + // (in points) into pixel coordinates for cropping. + let bounds = CGDisplay::main().bounds(); + let point_w = bounds.size.width; + let (pixel_w, pixel_h) = png_dimensions(&bytes).unwrap_or((point_w as u32, 0)); + let scale = if point_w > 0.0 && pixel_w > 0 { + pixel_w as f64 / point_w + } else { + 1.0 + }; + + // Persist the capture to a temp file for the Swift helper to read. + let tmp = std::env::temp_dir().join(format!("jcode_ocr_{}.png", std::process::id())); + std::fs::write(&tmp, &bytes).context("failed to write OCR capture to temp file")?; + let img_path = tmp.to_string_lossy().to_string(); + + // Convert the requested point rect to a pixel crop rect, clamped to the + // captured image bounds. Origin is top-left in image (pixel) space. + let crop = region.and_then(|[x, y, w, h]| { + if w <= 0.0 || h <= 0.0 || pixel_w == 0 || pixel_h == 0 { + return None; + } + let px = (x * scale).max(0.0); + let py = (y * scale).max(0.0); + let pw = (w * scale).min(pixel_w as f64 - px).max(0.0); + let ph = (h * scale).min(pixel_h as f64 - py).max(0.0); + if pw < 1.0 || ph < 1.0 { + return None; + } + Some([px, py, pw, ph]) + }); + + let result = run_vision_ocr(&img_path, crop); + let _ = std::fs::remove_file(&tmp); + let text = result?; + let summary = if text.trim().is_empty() { + "OCR found no text.".to_string() + } else { + text + }; + Ok(ToolOutput::new(summary) + .with_title("ocr") + .with_metadata(json!({ + "scale": scale, + "captured_width_pixels": pixel_w, + "captured_height_pixels": pixel_h, + "region": region, + }))) +} + +/// Run the Vision OCR via the `osascript`-launched Swift one-liner is not viable; +/// instead use a tiny Swift program through `swift` if available, falling back to +/// a clear message. Vision has no AppleScript binding, so we shell to Swift. +/// +/// `crop` is an optional pixel rect `[x, y, w, h]` (origin top-left) to crop the +/// loaded image before OCR. Cropping in CoreGraphics avoids the broken +/// `screencapture -R` path. +fn run_vision_ocr(image_path: &str, crop: Option<[f64; 4]>) -> Result<String> { + // Swift literal for the optional crop rect (origin top-left, in pixels). + let crop_literal = match crop { + Some([x, y, w, h]) => format!( + "CGRect(x: {x}, y: {y}, width: {w}, height: {h})", + x = x as i64, + y = y as i64, + w = w as i64, + h = h as i64 + ), + None => "nil".to_string(), + }; + // Prefer a compiled helper if present; otherwise use `swift` to run inline. + let swift_src = format!( + r#" +import Foundation +import Vision +import AppKit + +let url = URL(fileURLWithPath: "{path}") +guard let img = NSImage(contentsOf: url), var cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else {{ + FileHandle.standardError.write("could not load image\n".data(using: .utf8)!) + exit(2) +}} +let cropRect: CGRect? = {crop} +if let r = cropRect {{ + if let cropped = cg.cropping(to: r) {{ + cg = cropped + }} +}} +FileHandle.standardError.write("ocr_image_size \(cg.width)x\(cg.height)\n".data(using: .utf8)!) +let req = VNRecognizeTextRequest {{ request, error in + guard let obs = request.results as? [VNRecognizedTextObservation] else {{ return }} + for o in obs {{ + guard let top = o.topCandidates(1).first else {{ continue }} + let b = o.boundingBox // normalized, origin bottom-left + print("\(b.origin.x),\(b.origin.y),\(b.size.width),\(b.size.height)\t\(top.string)") + }} +}} +req.recognitionLevel = .accurate +req.usesLanguageCorrection = true +let handler = VNImageRequestHandler(cgImage: cg, options: [:]) +try? handler.perform([req]) +"#, + path = image_path, + crop = crop_literal, + ); + + let swift = which_swift(); + let Some(swift) = swift else { + bail!( + "OCR needs the Swift toolchain (Vision framework has no scripting bridge). \ + Install Xcode command line tools: xcode-select --install" + ); + }; + + let out = Command::new(&swift) + .arg("-") + .arg(image_path) + .env("JCODE_OCR_IMG", image_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(swift_src.as_bytes())?; + } + child.wait_with_output() + }) + .context("failed to run swift for OCR")?; + + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + bail!("Vision OCR failed: {}", err.trim()); + } + // The Swift helper reports the (possibly cropped) image size on stderr. + let stderr = String::from_utf8_lossy(&out.stderr); + let img_size = stderr + .lines() + .find_map(|l| l.strip_prefix("ocr_image_size ")) + .map(str::to_string); + let raw = String::from_utf8_lossy(&out.stdout); + // Each line: x,y,w,h\ttext (bbox normalized, origin bottom-left). + let header = match img_size { + Some(size) => format!( + "Recognized text in {size} px image (bbox normalized 0..1, origin bottom-left; \ + multiply by image size):" + ), + None => { + "Recognized text (bbox normalized 0..1, origin bottom-left; multiply by image size):" + .to_string() + } + }; + let mut lines = vec![header]; + for line in raw.lines() { + lines.push(line.to_string()); + } + Ok(lines.join("\n")) +} + +fn which_swift() -> Option<String> { + for p in ["/usr/bin/swift", "/usr/local/bin/swift"] { + if std::path::Path::new(p).exists() { + return Some(p.to_string()); + } + } + // try PATH + let out = Command::new("/usr/bin/which").arg("swift").output().ok()?; + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + None +} + +/// Used by `osa`-based callers that just need a quick AX-free description. +#[allow(dead_code)] +pub fn frontmost_app() -> Result<String> { + osa::run_applescript( + "tell application \"System Events\" to get name of first application process whose frontmost is true", + ) +} diff --git a/crates/jcode-app-core/src/tool/computer/setup.rs b/crates/jcode-app-core/src/tool/computer/setup.rs new file mode 100644 index 0000000..821f80c --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/setup.rs @@ -0,0 +1,138 @@ +//! Permission setup: check, request, deep-link, and poll the macOS TCC grants +//! needed for desktop control. + +use super::osa; +use anyhow::Result; +use jcode_tool_types::ToolOutput; +use serde_json::json; +use std::process::Command; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +fn accessibility_ok() -> bool { + // System Events reports whether assistive access is enabled for us. + osa::run_applescript("tell application \"System Events\" to return UI elements enabled") + .map(|s| s.trim() == "true") + .unwrap_or(false) +} + +fn screen_recording_ok() -> bool { + let tmp = std::env::temp_dir().join(format!("jcode_setup_{}.png", std::process::id())); + let ok = Command::new("/usr/sbin/screencapture") + .arg("-x") + .arg(&tmp) + .status() + .map(|s| s.success()) + .unwrap_or(false) + && std::fs::metadata(&tmp) + .map(|m| m.len() > 0) + .unwrap_or(false); + let _ = std::fs::remove_file(&tmp); + ok +} + +fn yes_no(b: bool) -> &'static str { + if b { "granted" } else { "NOT granted" } +} + +/// Report status only. +pub fn check_permissions() -> Result<ToolOutput> { + let ax = accessibility_ok(); + let screen = screen_recording_ok(); + let swift = std::path::Path::new("/usr/bin/swift").exists() + || Command::new("/usr/bin/which") + .arg("swift") + .status() + .map(|s| s.success()) + .unwrap_or(false); + + let mut lines = vec![ + format!("Accessibility (input + AX control): {}", yes_no(ax)), + format!("Screen Recording (screenshots/OCR): {}", yes_no(screen)), + format!( + "Swift toolchain (for OCR): {}", + if swift { "present" } else { "missing" } + ), + ]; + if !ax || !screen { + lines.push("Run action='setup' to request these and open the right settings pane.".into()); + } + Ok(ToolOutput::new(lines.join("\n")).with_metadata(json!({ + "accessibility": ax, "screen_recording": screen, "swift": swift, + }))) +} + +/// Request permissions: prompt, deep-link, and poll Accessibility until granted. +pub fn setup() -> Result<ToolOutput> { + let mut log = Vec::new(); + + let ax0 = accessibility_ok(); + let screen0 = screen_recording_ok(); + log.push(format!( + "Initial: accessibility={}, screen_recording={}", + ax0, screen0 + )); + + // Trigger the Screen Recording prompt by attempting a capture (already done + // in screen_recording_ok). For Accessibility, prompt + pre-add jcode by + // opening the pane; the trust prompt itself is shown by the host process the + // first time it calls an AX API. + if !ax0 { + // Deep-link to the exact Accessibility pane. + let _ = Command::new("/usr/bin/open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") + .status(); + log.push( + "Opened Privacy & Security > Accessibility. Add and enable your terminal/jcode there." + .into(), + ); + } + if !screen0 { + let _ = Command::new("/usr/bin/open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + .status(); + log.push( + "Opened Privacy & Security > Screen Recording. Add and enable your terminal/jcode there." + .into(), + ); + } + + // Poll Accessibility for up to ~30s so the agent can report "ready". + if !ax0 { + let deadline = Instant::now() + Duration::from_secs(30); + let mut granted = false; + while Instant::now() < deadline { + if accessibility_ok() { + granted = true; + break; + } + sleep(Duration::from_millis(1000)); + } + log.push(format!( + "Accessibility after wait: {}", + if granted { + "granted" + } else { + "still not granted (toggle it, then re-run check_permissions)" + } + )); + } + + let ax = accessibility_ok(); + let screen = screen_recording_ok(); + log.push(format!( + "Final: accessibility={}, screen_recording={}", + ax, screen + )); + if !ax { + log.push( + "NOTE: the Accessibility toggle cannot be enabled programmatically (macOS security). \ + It is the one switch you must flip by hand." + .into(), + ); + } + + Ok(ToolOutput::new(log.join("\n")).with_metadata(json!({ + "accessibility": ax, "screen_recording": screen, + }))) +} diff --git a/crates/jcode-app-core/src/tool/computer/sys.rs b/crates/jcode-app-core/src/tool/computer/sys.rs new file mode 100644 index 0000000..8d6a4af --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/sys.rs @@ -0,0 +1,170 @@ +//! Tier 3/4: clipboard, scripting bridge, waits, notifications, system state. + +use super::osa; +use anyhow::{Result, bail}; +use jcode_tool_types::ToolOutput; +use serde_json::json; +use std::process::Command; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +pub fn get_clipboard() -> Result<ToolOutput> { + // pbpaste is the most reliable text read. + let out = Command::new("/usr/bin/pbpaste") + .output() + .map_err(|e| anyhow::anyhow!("failed to run pbpaste: {e}"))?; + let text = String::from_utf8_lossy(&out.stdout).to_string(); + Ok(ToolOutput::new(text).with_title("clipboard")) +} + +pub fn set_clipboard(text: &str) -> Result<ToolOutput> { + use std::io::Write; + let mut child = Command::new("/usr/bin/pbcopy") + .stdin(std::process::Stdio::piped()) + .spawn() + .map_err(|e| anyhow::anyhow!("failed to run pbcopy: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(text.as_bytes()) + .map_err(|e| anyhow::anyhow!("failed to write to pbcopy: {e}"))?; + } + child + .wait() + .map_err(|e| anyhow::anyhow!("pbcopy failed: {e}"))?; + Ok(ToolOutput::new(format!( + "copied {} chars to clipboard", + text.chars().count() + ))) +} + +pub fn run_applescript(script: &str) -> Result<ToolOutput> { + let out = osa::run_applescript(script)?; + Ok(ToolOutput::new(if out.is_empty() { + "(AppleScript ran, no output)".to_string() + } else { + out + }) + .with_title("applescript")) +} + +pub fn run_jxa(script: &str) -> Result<ToolOutput> { + let out = osa::run_jxa(script)?; + Ok(ToolOutput::new(if out.is_empty() { + "(JXA ran, no output)".to_string() + } else { + out + }) + .with_title("jxa")) +} + +pub fn notify(text: &str, title: Option<&str>) -> Result<ToolOutput> { + let title = title.unwrap_or("jcode"); + osa::run_applescript(&format!( + "display notification {} with title {}", + osa::as_quote(text), + osa::as_quote(title) + ))?; + Ok(ToolOutput::new(format!("posted notification: {text}"))) +} + +/// Poll an app's AX tree until a substring appears (element_appears) or a +/// timeout elapses. Cheap structural wait instead of fixed sleeps. +pub fn wait_for(app: &str, contains: &str, timeout_ms: u64) -> Result<ToolOutput> { + let total = Duration::from_millis(timeout_ms.min(60_000)); + let deadline = Instant::now() + total; + // Keep each poll bounded by the per-poll timeout below so it returns even on + // big apps. Depth 8 captures most visible labels/values while staying cheap. + let script = format!( + r#" +using terms from application "System Events" + on dumpEl(el, lvl, maxlvl) + set out to "" + if lvl > maxlvl then return out + try + set out to out & (title of el as text) & " " + end try + try + set out to out & (value of el as text) & " " + end try + try + set out to out & (description of el as text) & " " + end try + try + repeat with child in (UI elements of el) + set out to out & (my dumpEl(child, lvl + 1, maxlvl)) + end repeat + end try + return out + end dumpEl +end using terms from +tell application "System Events" + set frontApp to first application process whose name is {app} + try + return my dumpEl(front window of frontApp, 0, 8) + on error + return "" + end try +end tell +"#, + app = osa::as_quote(app) + ); + loop { + // Bound each poll by the time left so the whole call honors timeout_ms, + // and never let a single poll exceed ~3s. + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("wait_for timed out after {timeout_ms}ms (no '{contains}' in {app})"); + } + let poll_budget = remaining.min(Duration::from_secs(3)); + let tree = osa::run_applescript_timeout(&script, poll_budget).unwrap_or_default(); + if tree.contains(contains) { + return Ok(ToolOutput::new(format!("matched '{contains}' in {app}"))); + } + if Instant::now() >= deadline { + bail!("wait_for timed out after {timeout_ms}ms (no '{contains}' in {app})"); + } + sleep(Duration::from_millis(200)); + } +} + +/// Read common system state (battery, display brightness, focus, etc.). +pub fn system_state() -> Result<ToolOutput> { + let battery = Command::new("/usr/bin/pmset") + .args(["-g", "batt"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + let date = Command::new("/bin/date") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + Ok(ToolOutput::new(format!("date: {date}\n{battery}")) + .with_title("system_state") + .with_metadata(json!({"battery_raw": battery}))) +} + +/// Set display brightness 0.0..1.0 using the `brightness` cli if present, else +/// fall back to AppleScript key events. Brightness has no stable public API, so +/// this is best-effort. +pub fn set_brightness(level: f64) -> Result<ToolOutput> { + let level = level.clamp(0.0, 1.0); + // Try the `brightness` homebrew tool first. + for path in ["/opt/homebrew/bin/brightness", "/usr/local/bin/brightness"] { + if std::path::Path::new(path).exists() { + let ok = Command::new(path) + .arg(format!("{level}")) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + return Ok(ToolOutput::new(format!("set brightness to {level:.2}"))); + } + } + } + bail!( + "no brightness control available. Install with `brew install brightness`, or adjust via the \ + brightness keys." + ) +} diff --git a/crates/jcode-app-core/src/tool/computer/tests.rs b/crates/jcode-app-core/src/tool/computer/tests.rs new file mode 100644 index 0000000..7de0662 --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/tests.rs @@ -0,0 +1,321 @@ +//! Tests for the macos_computer_use tool. Pure-logic tests run anywhere on macOS; live +//! tests that synthesize events / capture the screen are `#[ignore]`d. + +use super::*; +use jcode_tool_core::{ToolContext, ToolExecutionMode}; + +fn ctx() -> ToolContext { + ToolContext { + session_id: "test".into(), + message_id: "test".into(), + tool_call_id: "test".into(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + } +} + +async fn run_action(v: Value) -> Result<ToolOutput> { + ComputerTool::new().execute(v, ctx()).await +} + +// ---- pure logic ---- + +#[tokio::test] +async fn rejects_bad_action() { + let err = run_action(json!({ "action": "frobnicate" })) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("Unknown macos_computer_use action") + ); +} + +#[tokio::test] +async fn move_requires_coords() { + let err = run_action(json!({ "action": "move" })).await.unwrap_err(); + assert!(err.to_string().contains("requires")); +} + +#[tokio::test] +async fn discover_all_lists_actions() { + let out = run_action(json!({ "action": "discover", "category": "all" })) + .await + .unwrap(); + // Spot-check that several categories are present. + for needle in [ + "press", + "set_value", + "run_applescript", + "list_windows", + "screenshot", + ] { + assert!(out.output.contains(needle), "missing {needle}"); + } +} + +#[tokio::test] +async fn discover_category_scopes() { + let out = run_action(json!({ "action": "discover", "category": "ax" })) + .await + .unwrap(); + assert!(out.output.contains("find_element")); + assert!(!out.output.contains("set_brightness")); +} + +#[tokio::test] +async fn press_requires_element() { + let err = run_action(json!({ "action": "press" })).await.unwrap_err(); + assert!(err.to_string().contains("element")); +} + +#[tokio::test] +async fn dry_run_skips_mutation() { + let out = run_action(json!({ "action": "click", "x": 10, "y": 10, "dry_run": true })) + .await + .unwrap(); + assert!(out.output.contains("dry_run")); + assert!(out.output.contains("click")); +} + +#[tokio::test] +async fn dry_run_ignored_for_readonly() { + let out = run_action(json!({ "action": "discover", "category": "ax", "dry_run": true })) + .await + .unwrap(); + assert!(out.output.contains("find_element")); +} + +#[test] +fn cap_output_truncates() { + let big = "x".repeat(20_000); + let capped = super::cap_output(ToolOutput::new(big), 16_000); + assert!(capped.output.len() < 16_200); + assert!(capped.output.contains("truncated")); +} + +#[test] +fn is_mutating_classifies() { + assert!(super::is_mutating("click")); + assert!(super::is_mutating("quit_app")); + assert!(super::is_mutating("set_value")); + assert!(!super::is_mutating("screenshot")); + assert!(!super::is_mutating("ui")); + assert!(!super::is_mutating("discover")); +} + +#[test] +fn schema_declares_every_input_field() { + // Regression guard for the "schema omits half its fields" bug: every field + // `dispatch` can require must be declared in `parameters_schema()`, or the + // model can never send it and the action is dead on arrival. + let tool = ComputerTool::new(); + let schema = tool.parameters_schema(); + let props = schema["properties"].as_object().expect("properties object"); + for field in [ + "action", + "category", + "x", + "y", + "to_x", + "to_y", + "w", + "h", + "text", + "keys", + "dx", + "dy", + "depth", + "app", + "role", + "title", + "value", + "element", + "ax_action", + "menu_path", + "window_id", + "script", + "contains", + "timeout_ms", + "region", + "level", + "dry_run", + ] { + assert!( + props.contains_key(field), + "schema is missing field `{field}`" + ); + } +} + +#[test] +fn schema_is_compact() { + // Guard against context bloat: the always-on schema + description must stay + // small. Action *specs* live in `discover` (progressive disclosure), but + // every input *field* must be declared here or the model can't send it, so + // the field set (not the action set) sets the floor. Keep always-on cost + // roughly under ~900 tokens; alert if it balloons past that. + let tool = ComputerTool::new(); + let schema = serde_json::to_string(&tool.parameters_schema()).unwrap(); + let total = tool.description().len() + schema.len(); + // ~4 chars/token; keep always-on cost roughly under ~875 tokens. The bound + // leaves room for the full input-field set plus the short safety/restraint + // guidance in the description (act only on the requested task; prefer + // background mechanisms) while still flagging any real ballooning. + assert!( + total < 3500, + "macos_computer_use tool always-on size grew to {total} chars (~{} tokens)", + total / 4 + ); +} + +// ---- live (need GUI + permissions); run with --ignored ---- + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_check_permissions() { + let out = run_action(json!({ "action": "check_permissions" })) + .await + .unwrap(); + eprintln!("{}", out.output); + assert!(out.metadata.is_some()); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_cursor_and_move() { + run_action(json!({ "action": "move", "x": 400, "y": 300 })) + .await + .unwrap(); + let after = run_action(json!({ "action": "cursor" })).await.unwrap(); + let meta = after.metadata.unwrap(); + assert!((meta["x"].as_f64().unwrap() - 400.0).abs() < 5.0); + assert!((meta["y"].as_f64().unwrap() - 300.0).abs() < 5.0); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_screenshot() { + let out = run_action(json!({ "action": "screenshot" })).await.unwrap(); + assert_eq!(out.images.len(), 1); + assert_eq!(out.images[0].media_type, "image/png"); + eprintln!("{}", out.output); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_ui_tree() { + let out = run_action(json!({ "action": "ui", "depth": 3 })) + .await + .unwrap(); + eprintln!("{}", out.output); + assert!(out.output.contains("App:")); + // Regression for #396: empty AX titles must not surface as the literal + // string "missing value". + assert!( + !out.output.contains("\"missing value\""), + "AX dump leaked literal 'missing value' for an empty title" + ); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_ocr_full_screen() { + let out = run_action(json!({ "action": "ocr" })).await.unwrap(); + eprintln!("{}", out.output); + // Should not bail; either text or the explicit "no text" message. + assert!(out.output.contains("text") || !out.output.is_empty()); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_ocr_region() { + // Regression for #395: region OCR must not fail via `screencapture -R` on + // macOS 26.x. We crop in-process instead, so a region request should + // succeed and report the cropped image size. + let out = run_action(json!({ "action": "ocr", "region": [0.0, 0.0, 400.0, 80.0] })) + .await + .unwrap(); + eprintln!("{}", out.output); + assert!(!out.output.contains("could not create image from rect")); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_list_windows() { + let out = run_action(json!({ "action": "list_windows" })) + .await + .unwrap(); + eprintln!("{}", out.output); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_clipboard_roundtrip() { + run_action(json!({ "action": "set_clipboard", "text": "jcode-clip-test" })) + .await + .unwrap(); + let out = run_action(json!({ "action": "get_clipboard" })) + .await + .unwrap(); + assert!(out.output.contains("jcode-clip-test")); +} + +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_applescript() { + let out = run_action(json!({ "action": "run_applescript", "script": "return 2 + 2" })) + .await + .unwrap(); + assert!(out.output.contains("4")); +} + +/// Headline capability: set a TextEdit field's value via AX while TextEdit is +/// NOT frontmost, proving background control with no cursor movement. +#[tokio::test] +#[ignore = "requires GUI + permissions"] +async fn live_background_set_value() { + // Open a fresh TextEdit document. + run_action(json!({ + "action": "run_applescript", + "script": "tell application \"TextEdit\" to activate\ndelay 0.4\ntell application \"TextEdit\" to make new document\ndelay 0.4" + })) + .await + .unwrap(); + + // Move focus away so TextEdit is in the background. + run_action(json!({ + "action": "run_applescript", + "script": "tell application \"System Events\" to set frontmost of (first process whose name is \"System Events\") to true" + })) + .await + .ok(); + + let marker = "background-ax-marker-42"; + // Set the text area value by AX path (AXScrollArea[1] -> AXTextArea[1]). + run_action(json!({ + "action": "set_value", + "element": { "app": "TextEdit", "path": [1, 1] }, + "value": marker + })) + .await + .unwrap(); + + let content = run_action(json!({ + "action": "run_applescript", + "script": "tell application \"TextEdit\" to get text of document 1" + })) + .await + .unwrap(); + assert!(content.output.contains(marker), "got: {}", content.output); + + // Cleanup. + run_action(json!({ + "action": "run_applescript", + "script": "tell application \"TextEdit\" to close every document saving no\ntell application \"TextEdit\" to quit" + })) + .await + .ok(); +} diff --git a/crates/jcode-app-core/src/tool/computer/win.rs b/crates/jcode-app-core/src/tool/computer/win.rs new file mode 100644 index 0000000..4aba89a --- /dev/null +++ b/crates/jcode-app-core/src/tool/computer/win.rs @@ -0,0 +1,138 @@ +//! Tier 2: application and window management via System Events / NSWorkspace. + +use super::osa; +use anyhow::Result; +use jcode_tool_types::ToolOutput; + +pub fn list_apps() -> Result<ToolOutput> { + let script = "tell application \"System Events\" to get name of every application process whose background only is false"; + let res = osa::run_applescript(script)?; + let apps: Vec<String> = res.split(", ").map(|s| s.trim().to_string()).collect(); + Ok(ToolOutput::new(format!( + "Running apps ({}):\n{}", + apps.len(), + apps.join("\n") + )) + .with_title("list_apps")) +} + +pub fn activate_app(app: &str) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application {} to activate", + osa::as_quote(app) + ))?; + Ok(ToolOutput::new(format!("activated {app}"))) +} + +pub fn hide_app(app: &str) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application \"System Events\" to set visible of (first process whose name is {}) to false", + osa::as_quote(app) + ))?; + Ok(ToolOutput::new(format!("hid {app}"))) +} + +pub fn quit_app(app: &str) -> Result<ToolOutput> { + // `quit` blocks if the app shows a modal (e.g. an unsaved-changes sheet), so + // use a short timeout and report that case instead of hanging the agent. + match osa::run_applescript_timeout( + &format!("tell application {} to quit", osa::as_quote(app)), + std::time::Duration::from_secs(8), + ) { + Ok(_) => Ok(ToolOutput::new(format!("quit {app}"))), + Err(e) if e.to_string().contains("timed out") => Ok(ToolOutput::new(format!( + "{app} did not quit within 8s — it is likely showing a dialog (e.g. unsaved changes). \ + Handle the dialog (screenshot + click, or press a key), then quit again." + ))), + Err(e) => Err(e), + } +} + +/// List on-screen windows with CG window ids, owners, titles, and bounds. +pub fn list_windows() -> Result<ToolOutput> { + // JXA can read the CG window list with ids reliably. + let script = r#" +ObjC.import('CoreGraphics'); +ObjC.import('Foundation'); +var opts = $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements; +var arr = $.CGWindowListCopyWindowInfo(opts, $.kCGNullWindowID); +var n = $.CFArrayGetCount(arr); +var out = []; +for (var i = 0; i < n; i++) { + var d = $.CFArrayGetValueAtIndex(arr, i); + var dict = ObjC.castRefToObject(d); + var id = dict.objectForKey($('kCGWindowNumber')); + var owner = dict.objectForKey($('kCGWindowOwnerName')); + var name = dict.objectForKey($('kCGWindowName')); + var b = dict.objectForKey($('kCGWindowBounds')); + var bx = ObjC.deepUnwrap(b) || {}; + var ownerS = owner ? ObjC.unwrap(owner) : ''; + var nameS = name ? ObjC.unwrap(name) : ''; + var idN = id ? ObjC.unwrap(id) : ''; + out.push(idN + '\t' + ownerS + '\t' + (nameS||'') + '\t@(' + (bx.X|0) + ',' + (bx.Y|0) + ' ' + (bx.Width|0) + 'x' + (bx.Height|0) + ')'); +} +out.join('\n'); +"#; + let res = osa::run_jxa(script)?; + Ok(ToolOutput::new(format!( + "Windows (id owner title bounds):\n{}", + if res.trim().is_empty() { + "(none)" + } else { + &res + } + )) + .with_title("list_windows")) +} + +/// Window ops that target a window of an app by its (1-based) index or title. +/// We address via System Events AX windows of the owning process. +pub fn focus_window(app: &str) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application \"System Events\" to perform action \"AXRaise\" of (front window of (first process whose name is {}))", + osa::as_quote(app) + ))?; + // also bring app forward + let _ = activate_app(app); + Ok(ToolOutput::new(format!("focused front window of {app}"))) +} + +pub fn move_window(app: &str, x: f64, y: f64) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application \"System Events\" to set position of front window of (first process whose name is {}) to {{{x}, {y}}}", + osa::as_quote(app), + x = x as i64, + y = y as i64 + ))?; + Ok(ToolOutput::new(format!( + "moved {app} front window to ({x:.0},{y:.0})" + ))) +} + +pub fn resize_window(app: &str, w: f64, h: f64) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application \"System Events\" to set size of front window of (first process whose name is {}) to {{{w}, {h}}}", + osa::as_quote(app), + w = w as i64, + h = h as i64 + ))?; + Ok(ToolOutput::new(format!( + "resized {app} front window to {w:.0}x{h:.0}" + ))) +} + +pub fn minimize_window(app: &str) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application \"System Events\" to set value of attribute \"AXMinimized\" of front window of (first process whose name is {}) to true", + osa::as_quote(app) + ))?; + Ok(ToolOutput::new(format!("minimized {app} front window"))) +} + +pub fn close_window(app: &str) -> Result<ToolOutput> { + osa::run_applescript(&format!( + "tell application \"System Events\" to perform action \"AXPress\" of (button 1 of front window of (first process whose name is {}))", + osa::as_quote(app) + ))?; + Ok(ToolOutput::new(format!("closed {app} front window"))) +} diff --git a/crates/jcode-app-core/src/tool/conversation_search.rs b/crates/jcode-app-core/src/tool/conversation_search.rs new file mode 100644 index 0000000..d7ea60c --- /dev/null +++ b/crates/jcode-app-core/src/tool/conversation_search.rs @@ -0,0 +1,402 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +//! Conversation search tool - RAG for compacted conversation history + +use super::{Tool, ToolContext, ToolOutput}; +use crate::compaction::CompactionManager; +use crate::message::{Message, Role}; +use crate::session::Session; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Deserialize)] +struct SearchInput { + /// Search query (keyword search) + #[serde(default)] + query: Option<String>, + + /// Get specific turns by range + #[serde(default)] + turns: Option<TurnRange>, + + /// Get stats about conversation + #[serde(default)] + stats: Option<bool>, +} + +#[derive(Debug, Deserialize)] +struct TurnRange { + start: usize, + end: usize, +} + +pub struct ConversationSearchTool { + compaction: Arc<RwLock<CompactionManager>>, +} + +impl ConversationSearchTool { + pub fn new(compaction: Arc<RwLock<CompactionManager>>) -> Self { + Self { compaction } + } +} + +#[async_trait] +impl Tool for ConversationSearchTool { + fn name(&self) -> &str { + "conversation_search" + } + + fn description(&self) -> &str { + "Search conversation history." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "query": { + "type": "string", + "description": "Search query." + }, + "turns": { + "type": "object", + "properties": { + "start": {"type": "integer", "description": "Start turn."}, + "end": {"type": "integer", "description": "End turn."} + }, + "required": ["start", "end"], + "description": "Turn range." + }, + "stats": { + "type": "boolean", + "description": "Return stats." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: SearchInput = serde_json::from_value(input)?; + let manager = self.compaction.read().await; + let session_messages = load_session_messages(&ctx.session_id); + if session_messages.is_none() { + crate::logging::warn(&format!( + "[tool:conversation_search] failed to load session history for session {}", + ctx.session_id + )); + } + + let mut output = String::new(); + + // Handle stats request + if params.stats == Some(true) { + let stats = manager.stats(); + output.push_str(&format!( + "## Conversation Stats\n\n\ + - Total turns: {}\n\ + - Active messages in context: {}\n\ + - Has summary: {}\n\ + - Compaction in progress: {}\n\ + - Estimated tokens: {}\n\ + - Context usage: {:.1}%\n", + stats.total_turns, + stats.active_messages, + stats.has_summary, + stats.is_compacting, + stats.token_estimate, + stats.context_usage * 100.0 + )); + } + + // Handle keyword search + if let Some(query) = params.query { + let results = session_messages + .as_deref() + .map(|messages| search_messages(messages, &query)) + .unwrap_or_default(); + + if results.is_empty() { + output.push_str(&format!( + "## Search Results\n\nNo results found for '{}'\n", + query + )); + } else { + output.push_str(&format!( + "## Search Results for '{}'\n\nFound {} matches:\n\n", + query, + results.len() + )); + + for result in results.iter().take(10) { + let role = match result.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + output.push_str(&format!( + "**Turn {} ({}):**\n{}\n\n", + result.turn, role, result.snippet + )); + } + + if results.len() > 10 { + crate::logging::warn(&format!( + "[tool:conversation_search] truncating displayed search results for session {} query={} total_results={}", + ctx.session_id, + query, + results.len() + )); + output.push_str(&format!("... and {} more results\n", results.len() - 10)); + } + } + } + + // Handle turn range request + if let Some(range) = params.turns { + let turns = session_messages.as_deref().map(|messages| { + messages + .iter() + .skip(range.start) + .take(range.end.saturating_sub(range.start)) + .collect::<Vec<_>>() + }); + + if turns.as_ref().map(|t| t.is_empty()).unwrap_or(true) { + output.push_str(&format!( + "## Turns {}-{}\n\nNo turns found in that range.\n", + range.start, range.end + )); + } else if let Some(turns) = turns { + output.push_str(&format!("## Turns {}-{}\n\n", range.start, range.end)); + + for (idx, msg) in turns.iter().enumerate() { + let turn_num = range.start + idx; + let role = match msg.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + + output.push_str(&format!("**Turn {} ({}):**\n", turn_num, role)); + + for block in &msg.content { + match block { + crate::message::ContentBlock::Text { text, .. } => { + // Truncate very long messages + if text.len() > 1000 { + output.push_str(crate::util::truncate_str(text, 1000)); + output.push_str("... (truncated)\n"); + } else { + output.push_str(text); + output.push('\n'); + } + } + crate::message::ContentBlock::ToolUse { name, .. } => { + output.push_str(&format!("[Tool call: {}]\n", name)); + } + crate::message::ContentBlock::ToolResult { content, .. } => { + let preview = if content.len() > 200 { + format!("{}...", crate::util::truncate_str(content, 200)) + } else { + content.clone() + }; + output.push_str(&format!("[Tool result: {}]\n", preview)); + } + crate::message::ContentBlock::Reasoning { .. } + | crate::message::ContentBlock::ReasoningTrace { .. } + | crate::message::ContentBlock::AnthropicThinking { .. } + | crate::message::ContentBlock::OpenAIReasoning { .. } => {} + crate::message::ContentBlock::Image { .. } => { + output.push_str("[Image]\n"); + } + crate::message::ContentBlock::OpenAICompaction { .. } => { + output.push_str("[OpenAI native compaction]\n"); + } + } + } + output.push('\n'); + } + } + } + + if output.is_empty() { + output = "Please provide a 'query' to search, 'turns' range to retrieve, \ + or 'stats': true to see conversation statistics." + .to_string(); + } + + Ok(ToolOutput::new(output).with_title("conversation_search")) + } +} + +/// Search result from conversation history +struct SearchResult { + turn: usize, + role: Role, + snippet: String, +} + +fn load_session_messages(session_id: &str) -> Option<Vec<Message>> { + let session = Session::load(session_id).ok()?; + Some( + session + .messages + .into_iter() + .map(|msg| msg.to_message()) + .collect(), + ) +} + +fn search_messages(messages: &[Message], query: &str) -> Vec<SearchResult> { + let query_lower = query.to_lowercase(); + let mut results = Vec::new(); + + for (idx, msg) in messages.iter().enumerate() { + let text = message_to_text(msg); + if text.to_lowercase().contains(&query_lower) { + let snippet = extract_snippet(&text, &query_lower); + results.push(SearchResult { + turn: idx, + role: msg.role.clone(), + snippet, + }); + } + } + + results +} + +fn message_to_text(msg: &Message) -> String { + msg.content + .iter() + .filter_map(|block| match block { + crate::message::ContentBlock::Text { text, .. } => Some(text.clone()), + crate::message::ContentBlock::ToolResult { content, .. } => Some(content.clone()), + crate::message::ContentBlock::OpenAICompaction { .. } => { + Some("[OpenAI native compaction]".to_string()) + } + _ => None, + }) + .collect::<Vec<_>>() + .join("\n") +} + +fn extract_snippet(text: &str, query: &str) -> String { + let lower = text.to_lowercase(); + if let Some(pos) = lower.find(query) { + let start = pos.saturating_sub(50); + let end = (pos + query.len() + 50).min(text.len()); + let mut snippet = text[start..end].to_string(); + if start > 0 { + snippet = format!("...{}", snippet); + } + if end < text.len() { + snippet = format!("{}...", snippet); + } + snippet + } else { + text.chars().take(100).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compaction::CompactionManager; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn create_test_tool() -> ConversationSearchTool { + let manager = Arc::new(RwLock::new(CompactionManager::new())); + ConversationSearchTool::new(manager) + } + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + crate::storage::lock_test_env() + } + + fn setup_session(messages: Vec<Message>) -> (ToolContext, std::path::PathBuf, Option<String>) { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let base = std::env::temp_dir().join(format!("jcode-test-{}", nonce)); + let _ = std::fs::create_dir_all(base.join("sessions")); + + let previous_home = std::env::var("JCODE_HOME").ok(); + crate::env::set_var("JCODE_HOME", &base); + + let session_id = format!("test-session-{}", nonce); + let mut session = Session::create_with_id(session_id.clone(), None, None); + for msg in messages { + session.add_message(msg.role.clone(), msg.content.clone()); + } + session.save().unwrap(); + + let ctx = ToolContext { + session_id, + message_id: "test-message".to_string(), + tool_call_id: "test-tool-call".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + }; + + (ctx, base, previous_home) + } + + fn restore_env(base: std::path::PathBuf, previous_home: Option<String>) { + if let Some(prev) = previous_home { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn test_tool_name() { + let tool = create_test_tool(); + assert_eq!(tool.name(), "conversation_search"); + } + + #[tokio::test] + async fn test_stats() { + let _guard = env_lock(); + let tool = create_test_tool(); + let (ctx, base, previous_home) = setup_session(Vec::new()); + let input = json!({"stats": true}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("Conversation Stats")); + assert!(result.output.contains("Total turns")); + restore_env(base, previous_home); + } + + #[tokio::test] + async fn test_empty_search() { + let _guard = env_lock(); + let tool = create_test_tool(); + let (ctx, base, previous_home) = setup_session(Vec::new()); + let input = json!({"query": "nonexistent"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("No results found")); + restore_env(base, previous_home); + } + + #[tokio::test] + async fn test_empty_turns() { + let _guard = env_lock(); + let tool = create_test_tool(); + let (ctx, base, previous_home) = setup_session(Vec::new()); + let input = json!({"turns": {"start": 0, "end": 5}}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("No turns found")); + restore_env(base, previous_home); + } +} diff --git a/crates/jcode-app-core/src/tool/debug_socket.rs b/crates/jcode-app-core/src/tool/debug_socket.rs new file mode 100644 index 0000000..6f9e4c4 --- /dev/null +++ b/crates/jcode-app-core/src/tool/debug_socket.rs @@ -0,0 +1,167 @@ +//! Debug socket tool - send commands to the jcode debug socket +//! +//! This tool provides direct access to the debug socket API, allowing the agent +//! to control visual debugging, spawn test instances, and inspect agent state. + +use crate::protocol::{Request, ServerEvent}; +use crate::server; +use crate::tool::{Tool, ToolContext, ToolOutput}; +use crate::transport::Stream; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +fn next_debug_request_id() -> u64 { + static NEXT_ID: OnceLock<AtomicU64> = OnceLock::new(); + NEXT_ID + .get_or_init(|| AtomicU64::new(1)) + .fetch_add(1, Ordering::Relaxed) +} + +#[derive(Debug, Deserialize)] +struct DebugSocketInput { + command: String, + #[serde(default)] + session_id: Option<String>, + #[serde(default)] + timeout_secs: Option<u64>, +} + +pub struct DebugSocketTool; + +impl DebugSocketTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for DebugSocketTool { + fn name(&self) -> &str { + "debug_socket" + } + + fn description(&self) -> &str { + "Send a debug socket command." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "command": { + "type": "string", + "description": "Debug command." + }, + "session_id": { + "type": "string", + "description": "Target session ID." + }, + "timeout_secs": { + "type": "integer", + "description": "Timeout in seconds." + } + }, + "required": ["command"] + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result<ToolOutput> { + let params: DebugSocketInput = serde_json::from_value(input)?; + let timeout_secs = params.timeout_secs.unwrap_or(30); + let session_label = params + .session_id + .clone() + .unwrap_or_else(|| "<none>".to_string()); + + // Build title based on command namespace + let title = + if params.command.starts_with("client:") || params.command.starts_with("tester:") { + format!("debug_socket {}", params.command) + } else { + format!("debug_socket server:{}", params.command) + }; + + let result = execute_debug_command(¶ms.command, params.session_id, timeout_secs).await; + + match result { + Ok(output) => Ok(ToolOutput::new(output).with_title(title)), + Err(e) => { + crate::logging::warn(&format!( + "[tool:debug_socket] command failed command={} session_id={} timeout_secs={} error={}", + params.command, session_label, timeout_secs, e + )); + Ok(ToolOutput::new(format!("Error: {}", e)).with_title(title)) + } + } + } +} + +/// Execute a debug command via the debug socket +async fn execute_debug_command( + command: &str, + session_id: Option<String>, + timeout_secs: u64, +) -> Result<String> { + let socket_path = server::debug_socket_path(); + + // Connect to debug socket + let stream = tokio::time::timeout( + std::time::Duration::from_secs(5), + Stream::connect(&socket_path), + ) + .await + .map_err(|_| anyhow::anyhow!("Timeout connecting to debug socket"))? + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to debug socket at {}: {}", + socket_path.display(), + e + ) + })?; + + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + // Build request + let request = Request::DebugCommand { + id: next_debug_request_id(), + command: command.to_string(), + session_id, + }; + + let json = serde_json::to_string(&request)? + "\n"; + writer.write_all(json.as_bytes()).await?; + + // Read response with timeout + let mut line = String::new(); + let read_result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + reader.read_line(&mut line), + ) + .await + .map_err(|_| anyhow::anyhow!("Timeout waiting for response ({}s)", timeout_secs))?; + + read_result?; + + // Parse response + let event: ServerEvent = serde_json::from_str(&line) + .map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?; + + match event { + ServerEvent::DebugResponse { ok, output, .. } => { + if ok { + Ok(output) + } else { + Err(anyhow::anyhow!("{}", output)) + } + } + ServerEvent::Error { message, .. } => Err(anyhow::anyhow!("{}", message)), + _ => Err(anyhow::anyhow!("Unexpected response: {:?}", line.trim())), + } +} diff --git a/crates/jcode-app-core/src/tool/discover.rs b/crates/jcode-app-core/src/tool/discover.rs new file mode 100644 index 0000000..49c04f6 --- /dev/null +++ b/crates/jcode-app-core/src/tool/discover.rs @@ -0,0 +1,849 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::fmt; +use std::time::Duration; +use std::time::Instant; + +/// Hard timeout for discovery requests. Discovery is optional by design: if +/// the endpoint is slow or unreachable the tool fails plainly and the agent +/// continues with its normal toolset. No cache, no offline fallback, no retry. +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"; + +#[derive(Debug)] +struct DiscoveryFetchResult { + listing: Value, + http_status: u16, + response_bytes: u64, +} + +#[derive(Debug)] +struct DiscoveryFetchError { + message: String, + failure_reason: &'static str, + http_status: Option<u16>, + response_bytes: Option<u64>, +} + +impl fmt::Display for DiscoveryFetchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for DiscoveryFetchError {} + +#[allow(clippy::too_many_arguments)] +fn record_discovery_telemetry( + request_id: &str, + started_at: Instant, + endpoint: &str, + phase: &str, + category: Option<&str>, + selected_tool: Option<&str>, + outcome: &str, + failure_reason: Option<&str>, + http_status: Option<u16>, + response_bytes: Option<u64>, + result_count: Option<u32>, + query_present: bool, + reason_present: bool, +) { + crate::telemetry::record_discovery_event(crate::telemetry::DiscoveryTelemetry { + request_id, + phase, + category, + selected_tool, + outcome, + failure_reason, + http_status, + latency_ms: started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64, + response_bytes, + result_count, + query_present, + reason_present, + endpoint, + }); +} + +/// `discover_tools`: fetch discoverable third-party tools for a category from +/// the hosted sponsored-discovery manifest. +/// +/// Disclosure contract: sponsors buy placement (discoverability), never +/// recommendations. Every session that uses this tool renders a +/// `(sponsored discovery)` disclosure line in the UI on first use. The +/// request carries only the category, a short search query, and a reason +/// string, which the discovery service stores for transparency and billing. +/// It must never include session content or private information. +pub struct DiscoverToolsTool { + client: reqwest::Client, +} + +impl DiscoverToolsTool { + pub fn new() -> Self { + Self { + client: crate::provider::shared_http_client(), + } + } +} + +#[derive(Deserialize)] +struct DiscoverToolsInput { + category: String, + #[serde(default)] + query: Option<String>, + #[serde(default)] + reason: Option<String>, + #[serde(default)] + tool: Option<String>, +} + +#[async_trait] +impl Tool for DiscoverToolsTool { + fn name(&self) -> &str { + "discover_tools" + } + + fn description(&self) -> &str { + "Use when a third-party tool would help complete the task. Every listing is vetted, and \ + one-tool-call setup is enforced for seamless integration. Sponsors pay only for \ + directory inclusion, never influence selection. Choose the best option for the user \ + without sponsorship bias." + } + + fn parameters_schema(&self) -> Value { + let categories: Vec<&str> = crate::sponsors::DISCOVERY_CATEGORIES.to_vec(); + json!({ + "type": "object", + "required": ["category", "reason"], + "properties": { + "intent": super::intent_schema_property(), + "category": { + "type": "string", + "enum": categories, + "description": "Category." + }, + "query": { + "type": "string", + "description": "Capability needed. No private data." + }, + "reason": { + "type": "string", + "description": "Need or selection rationale; compare alternatives. No private data." + }, + "tool": { + "type": "string", + "description": "Tool name to select." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result<ToolOutput> { + let started_at = Instant::now(); + let request_id = uuid::Uuid::new_v4().to_string(); + let config = crate::config::config(); + let endpoint = config.sponsors.endpoint.clone(); + if !config.sponsors.enabled { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "unknown", + None, + None, + "failure", + Some("disabled"), + None, + None, + None, + false, + false, + ); + return Err(anyhow::anyhow!( + "sponsored discovery is disabled (set [sponsors] enabled = true in config.toml)" + )); + } + + let params: DiscoverToolsInput = match serde_json::from_value(input) { + Ok(params) => params, + Err(err) => { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "unknown", + None, + None, + "failure", + Some("invalid_input"), + None, + None, + None, + false, + false, + ); + return Err(err.into()); + } + }; + let category = params.category.trim().to_ascii_lowercase(); + let query_present = params + .query + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let reason_present = params + .reason + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + if !crate::sponsors::DISCOVERY_CATEGORIES.contains(&category.as_str()) { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "unknown", + None, + None, + "failure", + Some("invalid_category"), + None, + None, + None, + query_present, + reason_present, + ); + return Err(anyhow::anyhow!( + "unknown discovery category '{}'. Available: {}", + category, + crate::sponsors::DISCOVERY_CATEGORIES.join(", ") + )); + } + + let tool_selection = params + .tool + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_ascii_lowercase); + + // Select phase: return one tool's full setup instructions. The + // selection (and the agent's reason for it) is recorded server-side. + // Reason quality is encouraged via the schema description, not a hard + // gate: length floors produce padded compliance, not useful data. + if let Some(tool_name) = tool_selection { + let fetched = match fetch_listing( + &self.client, + &endpoint, + &request_id, + &category, + params.query.as_deref(), + params.reason.as_deref(), + Some(&tool_name), + ) + .await + { + Ok(result) => result, + Err(err) => { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "select", + Some(&category), + None, + "failure", + Some(err.failure_reason), + err.http_status, + err.response_bytes, + None, + query_present, + reason_present, + ); + return Err(err.into()); + } + }; + let rendered = match render_selection(&category, &tool_name, &fetched.listing) { + Ok(rendered) => rendered, + Err(err) => { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "select", + Some(&category), + None, + "failure", + Some("invalid_response"), + Some(fetched.http_status), + Some(fetched.response_bytes), + None, + query_present, + reason_present, + ); + 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 canonical_tool = fetched + .listing + .get("tool") + .and_then(|tool| tool.get("name")) + .and_then(Value::as_str); + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "select", + Some(&category), + canonical_tool, + "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} {}", + crate::sponsors::SPONSORED_DISCOVERY_TAG + )) + .with_metadata(json!({ + "sponsored_discovery": true, + "category": category, + "selected_tool": tool_name, + "disclosure_url": crate::sponsors::SPONSORED_DISCOVERY_URL, + }))); + } + + let fetched = match fetch_listing( + &self.client, + &endpoint, + &request_id, + &category, + params.query.as_deref(), + params.reason.as_deref(), + None, + ) + .await + { + Ok(result) => result, + Err(err) => { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "browse", + Some(&category), + None, + "failure", + Some(err.failure_reason), + err.http_status, + err.response_bytes, + None, + query_present, + reason_present, + ); + return Err(err.into()); + } + }; + let rendered = match render_listing(&category, &fetched.listing) { + Ok(rendered) => rendered, + Err(err) => { + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "browse", + Some(&category), + None, + "failure", + Some("invalid_response"), + Some(fetched.http_status), + Some(fetched.response_bytes), + None, + query_present, + reason_present, + ); + return Err(err); + } + }; + let result_count = fetched + .listing + .get("tools") + .and_then(Value::as_array) + .map(|tools| tools.len().min(u32::MAX as usize) as u32); + + // Remember MCP setups from this listing so a later `mcp connect` + // matching one of them is tagged with discovery provenance (and + // metered coarsely; see jcode_base::sponsors::provenance). + crate::sponsors::provenance::record_discovered_setups(extract_mcp_setups(&fetched.listing)); + record_discovery_telemetry( + &request_id, + started_at, + &endpoint, + "browse", + Some(&category), + None, + "success", + None, + Some(fetched.http_status), + Some(fetched.response_bytes), + result_count, + query_present, + reason_present, + ); + + Ok(ToolOutput::new(rendered) + .with_title(format!( + "{} {}", + category, + crate::sponsors::SPONSORED_DISCOVERY_TAG + )) + .with_metadata(json!({ + "sponsored_discovery": true, + "category": category, + "disclosure_url": crate::sponsors::SPONSORED_DISCOVERY_URL, + }))) + } +} + +/// Fetch a category listing (browse) or one tool's entry (select) from the +/// discovery endpoint. Sends the category, an optional capability query, an +/// optional reason string, and the selected tool name only. Hard fails on +/// any error: no cache, no fallback, no retry. +async fn fetch_listing( + client: &reqwest::Client, + endpoint: &str, + request_id: &str, + category: &str, + query: Option<&str>, + reason: Option<&str>, + tool: Option<&str>, +) -> std::result::Result<DiscoveryFetchResult, DiscoveryFetchError> { + let endpoint = endpoint.trim_end_matches('/'); + let mut request = client + .get(endpoint) + .query(&[("category", category)]) + .header( + reqwest::header::USER_AGENT, + format!("jcode/{}", env!("CARGO_PKG_VERSION")), + ) + .header(DISCOVERY_REQUEST_ID_HEADER, request_id) + .timeout(DISCOVERY_TIMEOUT); + if let Some(query) = query.filter(|q| !q.trim().is_empty()) { + request = request.query(&[("q", query.trim())]); + } + if let Some(reason) = reason.filter(|r| !r.trim().is_empty()) { + request = request.query(&[("reason", reason.trim())]); + } + if let Some(tool) = tool.filter(|t| !t.trim().is_empty()) { + request = request.query(&[("tool", tool.trim())]); + } + + let response = request.send().await.map_err(|err| DiscoveryFetchError { + message: format!("discovery 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!("discovery 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!("discovery 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!("discovery 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!("discovery 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, + }) +} + +/// Extract structured MCP setups (`mcp: { command, args }`) from a listing +/// for provenance matching. Entries without an `mcp` descriptor are skipped. +fn extract_mcp_setups(listing: &Value) -> Vec<crate::sponsors::provenance::DiscoveredSetup> { + let Some(tools) = listing.get("tools").and_then(|v| v.as_array()) else { + return Vec::new(); + }; + extract_mcp_setups_from(tools) +} + +/// Extract MCP setups from a slice of tool entries. +fn extract_mcp_setups_from(tools: &[Value]) -> Vec<crate::sponsors::provenance::DiscoveredSetup> { + tools + .iter() + .filter_map(|tool| { + let sponsor = tool.get("name")?.as_str()?.trim().to_ascii_lowercase(); + let mcp = tool.get("mcp")?; + let command = mcp.get("command")?.as_str()?.to_string(); + let args = mcp + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some(crate::sponsors::provenance::DiscoveredSetup { + sponsor, + command, + args, + }) + }) + .collect() +} + +/// Render a discovery listing (browse phase) for the model. Expected shape: +/// `{ "tools": [{ "name": "...", "blurb": "...", "url": "..." }] }`. Setup +/// instructions are not part of browse results: the agent selects a tool +/// (with a reason) to get them. +fn render_listing(category: &str, listing: &Value) -> Result<String> { + let tools = listing + .get("tools") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("discovery returned no tool list"))?; + if tools.is_empty() { + return Ok(format!( + "No discoverable tools in category '{category}' right now." + )); + } + let mut out = format!( + "Discoverable tools in '{category}' (sponsored discovery: placement, not preference; \ + details: {}):\n", + crate::sponsors::SPONSORED_DISCOVERY_URL + ); + for tool in tools { + let name = tool.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let blurb = tool.get("blurb").and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&format!("\n- {name}: {blurb}")); + 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 setup: {setup}")); + } + } + out.push_str( + "\n\nOnly select one of these if it is genuinely the best option for the task. \ + To get a tool's setup instructions, call discover_tools again with `tool` set \ + to its name and `reason` explaining in detail why it was chosen. Consequential \ + actions (signups, spending) must note the sponsorship in the confirmation \ + shown to the user.", + ); + Ok(out) +} + +/// Render a selected tool's full entry (select phase). Expected shape: +/// `{ "tool": { "name": "...", "blurb": "...", "url": "...", "setup": "..." } }`. +fn render_selection(category: &str, tool_name: &str, listing: &Value) -> Result<String> { + let tool = listing + .get("tool") + .ok_or_else(|| anyhow::anyhow!("discovery returned no tool entry for '{tool_name}'"))?; + let name = tool + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(tool_name); + let blurb = tool.get("blurb").and_then(|v| v.as_str()).unwrap_or(""); + let mut out = format!( + "Selected '{name}' from '{category}' (sponsored discovery: placement, not \ + preference; details: {}):\n\n{name}: {blurb}", + crate::sponsors::SPONSORED_DISCOVERY_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( + "\n\nConsequential actions (signups, spending) must note the sponsorship in \ + the confirmation shown to the user.", + ); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_listing_includes_disclosure_and_tools() { + let listing = json!({ + "tools": [ + {"name": "agentcard", "blurb": "virtual payment cards", "url": "https://agentcard.example"}, + ] + }); + let out = render_listing("payments", &listing).unwrap(); + assert!(out.contains("agentcard")); + assert!(out.contains("virtual payment cards")); + assert!(out.contains("sponsored discovery")); + assert!(out.contains("placement, not preference")); + } + + #[test] + fn render_listing_rejects_missing_tools() { + assert!(render_listing("payments", &json!({})).is_err()); + } + + #[test] + fn render_listing_handles_empty_category() { + let out = render_listing("payments", &json!({"tools": []})).unwrap(); + assert!(out.contains("No discoverable tools")); + } + + #[test] + fn render_listing_instructs_selection_phase() { + let listing = json!({ + "tools": [{"name": "agentcard", "blurb": "virtual cards", "url": "https://a.example"}] + }); + let out = render_listing("payments", &listing).unwrap(); + assert!(out.contains("call discover_tools again with `tool`")); + } + + #[test] + fn render_selection_includes_setup_and_disclosure() { + let listing = json!({ + "tool": { + "name": "agentcard", + "blurb": "virtual cards", + "url": "https://a.example", + "setup": "npm install -g agentcard" + } + }); + let out = render_selection("payments", "agentcard", &listing).unwrap(); + assert!(out.contains("Selected 'agentcard'")); + assert!(out.contains("Setup: npm install -g agentcard")); + assert!(out.contains("sponsored discovery")); + assert!(render_selection("payments", "ghost", &json!({})).is_err()); + } + + #[test] + fn schema_is_compact_and_self_contained() { + let tool = DiscoverToolsTool::new(); + let description = tool.description(); + assert!(description.starts_with("Use when a third-party tool would help complete")); + assert!(description.contains("Every listing is vetted")); + assert!(description.contains("one-tool-call setup is enforced")); + assert!(description.contains("Sponsors pay only for directory inclusion")); + assert!(description.contains("without sponsorship bias")); + + let schema = serde_json::to_string(&tool.parameters_schema()).unwrap(); + assert!(schema.contains("Capability needed. No private data.")); + assert!(schema.contains("compare alternatives. No private data.")); + assert!( + schema.len() < 1_200, + "discovery schema should stay compact, got {} bytes", + schema.len() + ); + } + + /// Minimal one-shot HTTP server that answers a single request with the + /// given body, returning the request line + headers it received. + async fn one_shot_server( + status_line: &'static str, + body: String, + ) -> (String, tokio::task::JoinHandle<String>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 8192]; + let n = stream.read(&mut buf).await.unwrap(); + let request = String::from_utf8_lossy(&buf[..n]).to_string(); + let response = format!( + "{status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.shutdown().await.ok(); + request + }); + (format!("http://{addr}"), handle) + } + + #[tokio::test] + async fn fetch_listing_round_trips_and_sends_only_expected_params() { + let body = json!({"tools": [{"name": "agentcard", "blurb": "virtual cards", "url": "https://a.example"}]}).to_string(); + let (endpoint, server) = one_shot_server("HTTP/1.1 200 OK", body).await; + let client = reqwest::Client::new(); + let listing = fetch_listing( + &client, + &endpoint, + "request-test-1", + "payments", + Some("virtual card for checkout"), + Some("task needs an online payment"), + None, + ) + .await + .unwrap(); + assert_eq!(listing.listing["tools"][0]["name"], "agentcard"); + assert_eq!(listing.http_status, 200); + assert!(listing.response_bytes > 0); + + let request = server.await.unwrap(); + let request_line = request.lines().next().unwrap(); + // Exactly the three disclosed parameters, nothing else. + assert!(request_line.contains("category=payments"), "{request_line}"); + assert!(request_line.contains("q=virtual"), "{request_line}"); + assert!(request_line.contains("reason=task"), "{request_line}"); + assert!( + request + .to_ascii_lowercase() + .contains("x-jcode-discovery-request-id: request-test-1"), + "{request}" + ); + } + + #[tokio::test] + async fn fetch_listing_hard_fails_on_http_error() { + let (endpoint, _server) = + one_shot_server("HTTP/1.1 500 Internal Server Error", "{}".to_string()).await; + let client = reqwest::Client::new(); + let err = fetch_listing( + &client, + &endpoint, + "request-test-2", + "payments", + None, + None, + None, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("discovery unavailable")); + assert_eq!(err.failure_reason, "http_error"); + assert_eq!(err.http_status, Some(500)); + } + + #[tokio::test] + async fn fetch_listing_hard_fails_when_endpoint_unreachable() { + // Reserved port with no listener: connection refused, no fallback. + let client = reqwest::Client::new(); + let err = fetch_listing( + &client, + "http://127.0.0.1:9", + "request-test-3", + "payments", + None, + None, + None, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("discovery unavailable")); + assert_eq!(err.failure_reason, "connect_error"); + } + + fn test_ctx() -> crate::tool::ToolContext { + crate::tool::ToolContext { + session_id: "test".into(), + message_id: "test".into(), + tool_call_id: "test".into(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } + } + + #[tokio::test] + async fn execute_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": "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; + std::fs::write( + temp.path().join("config.toml"), + format!("[sponsors]\nenabled = true\nendpoint = \"{endpoint}\"\n"), + ) + .unwrap(); + crate::config::Config::invalidate_cache(); + + let tool = DiscoverToolsTool::new(); + let output = tool + .execute( + json!({ + "category": "payments", + "query": "virtual card for checkout", + "reason": "task requires an online card payment" + }), + test_ctx(), + ) + .await + .unwrap(); + + assert!(output.output.contains("agentcard")); + assert!(output.output.contains("sponsored discovery")); + assert!(output.output.contains("placement, not preference")); + let title = output.title.unwrap(); + assert!(title.contains("(sponsored discovery)"), "{title}"); + let meta = output.metadata.unwrap(); + assert_eq!(meta["sponsored_discovery"], true); + + // Opted-out config: execute refuses without any network call. + std::fs::write( + temp.path().join("config.toml"), + "[sponsors]\nenabled = false\n", + ) + .unwrap(); + crate::config::Config::invalidate_cache(); + let err = tool + .execute(json!({"category": "payments", "reason": "x"}), test_ctx()) + .await + .unwrap_err(); + assert!(err.to_string().contains("disabled")); + + 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(); + } +} diff --git a/crates/jcode-app-core/src/tool/edit.rs b/crates/jcode-app-core/src/tool/edit.rs new file mode 100644 index 0000000..17a235b --- /dev/null +++ b/crates/jcode-app-core/src/tool/edit.rs @@ -0,0 +1,435 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use similar::{ChangeTag, TextDiff}; +use std::path::Path; + +const FILE_TOUCH_PREVIEW_MAX_LINES: usize = 6; +const FILE_TOUCH_PREVIEW_MAX_BYTES: usize = 240; + +pub struct EditTool; + +impl EditTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct EditInput { + #[serde(default)] + intent: Option<String>, + file_path: String, + old_string: String, + new_string: String, + #[serde(default)] + replace_all: bool, +} + +#[async_trait] +impl Tool for EditTool { + fn name(&self) -> &str { + "edit" + } + + fn description(&self) -> &str { + "Replace text in a file." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["file_path", "old_string", "new_string"], + "properties": { + "intent": super::intent_schema_property(), + "file_path": { + "type": "string", + "description": "File path." + }, + "old_string": { + "type": "string", + "description": "Text to replace." + }, + "new_string": { + "type": "string", + "description": "Replacement text." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: EditInput = serde_json::from_value(input)?; + + if params.old_string == params.new_string { + return Err(anyhow::anyhow!( + "old_string and new_string must be different" + )); + } + + let path = ctx.resolve_path(Path::new(¶ms.file_path)); + + if !path.exists() { + return Err(anyhow::anyhow!("File not found: {}", params.file_path)); + } + + let content = tokio::fs::read_to_string(&path).await?; + + // Count occurrences + let occurrences = content.matches(¶ms.old_string).count(); + + if occurrences == 0 { + // Try flexible matching + return try_flexible_match(&content, ¶ms.old_string, ¶ms.file_path); + } + + if occurrences > 1 && !params.replace_all { + return Err(anyhow::anyhow!( + "old_string found {} times in the file. Either:\n\ + 1. Provide more context to make it unique, or\n\ + 2. Set replace_all: true to replace all occurrences", + occurrences + )); + } + + // Perform replacement + let new_content = if params.replace_all { + content.replace(¶ms.old_string, ¶ms.new_string) + } else { + content.replacen(¶ms.old_string, ¶ms.new_string, 1) + }; + + // Find line number where edit starts + let start_line = find_line_number(&content, ¶ms.old_string); + + // Write back + tokio::fs::write(&path, &new_content).await?; + + // Generate a diff with line numbers + let diff = generate_diff(¶ms.old_string, ¶ms.new_string, start_line); + + // Publish file touch event for swarm coordination + let end_line = start_line + params.new_string.lines().count().saturating_sub(1); + let detail = build_file_touch_preview(&diff); + Bus::global().publish(BusEvent::FileTouch(FileTouch { + session_id: ctx.session_id.clone(), + path: path.to_path_buf(), + op: FileOp::Edit, + intent: params + .intent + .clone() + .filter(|value| !value.trim().is_empty()), + summary: Some(format!( + "edited lines {}-{} ({} occurrence{})", + start_line, + end_line, + occurrences, + if occurrences == 1 { "" } else { "s" } + )), + detail, + })); + + // Extract context around the edit to help with consecutive edits + 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!( + "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())) + } +} + +/// Find the 1-based line number where a substring starts +fn find_line_number(content: &str, substring: &str) -> usize { + if let Some(pos) = content.find(substring) { + content[..pos].lines().count() + 1 + } else { + 1 + } +} + +/// Generate a compact diff: "42- old" / "42+ new" +fn generate_diff(old: &str, new: &str, start_line: usize) -> String { + let diff = TextDiff::from_lines(old, new); + let mut output = String::new(); + + let mut old_line = start_line; + let mut new_line = start_line; + + for change in diff.iter_all_changes() { + let content = change.value().trim(); + let (prefix, line_num) = match change.tag() { + ChangeTag::Delete => { + let num = old_line; + old_line += 1; + if content.is_empty() { + continue; + } + ("-", num) + } + ChangeTag::Insert => { + let num = new_line; + new_line += 1; + if content.is_empty() { + continue; + } + ("+", num) + } + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + continue; + } + }; + + // Compact format: "42- content" (no spaces) + output.push_str(&format!("{}{} {}\n", line_num, prefix, content)); + } + + if output.is_empty() { + String::new() + } else { + output.trim_end().to_string() + } +} + +fn build_file_touch_preview(diff: &str) -> Option<String> { + let trimmed = diff.trim(); + if trimmed.is_empty() { + return None; + } + + let mut lines = trimmed.lines(); + let mut preview = lines + .by_ref() + .take(FILE_TOUCH_PREVIEW_MAX_LINES) + .collect::<Vec<_>>() + .join("\n"); + let mut truncated = lines.next().is_some(); + + if preview.len() > FILE_TOUCH_PREVIEW_MAX_BYTES { + preview = crate::util::truncate_str(&preview, FILE_TOUCH_PREVIEW_MAX_BYTES) + .trim_end() + .to_string(); + truncated = true; + } + + if truncated { + preview.push_str("\n…"); + } + + Some(preview) +} + +/// Extract lines around the edited region, returns (start_line, end_line, content) +fn extract_context( + content: &str, + edit_start: usize, + edit_end: usize, + padding: usize, +) -> (usize, usize, String) { + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len(); + + // Calculate range with padding (1-indexed to 0-indexed) + let start = edit_start.saturating_sub(padding + 1); + let end = (edit_end + padding).min(total_lines); + + let context_lines: Vec<String> = lines[start..end] + .iter() + .enumerate() + .map(|(i, line)| format!("{:>4}│ {}", start + i + 1, line)) + .collect(); + + (start + 1, end, context_lines.join("\n")) +} + +fn try_flexible_match(content: &str, old_string: &str, file_path: &str) -> Result<ToolOutput> { + // Try trimmed matching + let trimmed = old_string.trim(); + if content.contains(trimmed) && trimmed != old_string { + return Err(anyhow::anyhow!( + "old_string not found exactly, but found after trimming whitespace.\n\ + Try using the exact string from the file, including leading/trailing whitespace." + )); + } + + // Try line-by-line matching with normalized whitespace + let old_lines: Vec<&str> = old_string.lines().collect(); + let content_lines: Vec<&str> = content.lines().collect(); + + for (i, window) in content_lines.windows(old_lines.len()).enumerate() { + let matches = window + .iter() + .zip(old_lines.iter()) + .all(|(a, b)| a.trim() == b.trim()); + + if matches { + return Err(anyhow::anyhow!( + "old_string not found exactly, but found with different indentation around line {}.\n\ + Make sure to preserve the exact whitespace from the file.", + i + 1 + )); + } + } + + Err(anyhow::anyhow!( + "old_string not found in {}.\n\ + Use the read tool to see the current file contents.", + file_path + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_diff_single_line_change() { + let old = "hello world"; + let new = "hello rust"; + let diff = generate_diff(old, new, 10); + + // Compact format: "10- content" / "10+ content" + assert!(diff.contains("10- hello world"), "Should show deleted line"); + assert!(diff.contains("10+ hello rust"), "Should show added line"); + } + + #[test] + fn test_generate_diff_multi_line() { + let old = "line one\nline two\nline three"; + let new = "line one\nmodified two\nline three"; + let diff = generate_diff(old, new, 5); + + // Line 6 should be the changed line (5 + 1 for "line two") + assert!(diff.contains("6- line two"), "Should show deleted line"); + assert!(diff.contains("6+ modified two"), "Should show added line"); + // Equal lines should not appear + assert!( + !diff.contains("line one"), + "Should not show unchanged lines" + ); + assert!( + !diff.contains("line three"), + "Should not show unchanged lines" + ); + } + + #[test] + fn test_generate_diff_addition_only() { + let old = "first\nthird"; + let new = "first\nsecond\nthird"; + let diff = generate_diff(old, new, 1); + + assert!(diff.contains("+ second"), "Should show added line"); + } + + #[test] + fn test_generate_diff_deletion_only() { + let old = "first\nsecond\nthird"; + let new = "first\nthird"; + let diff = generate_diff(old, new, 1); + + assert!(diff.contains("- second"), "Should show deleted line"); + } + + #[test] + fn test_generate_diff_no_changes() { + let old = "same content"; + let new = "same content"; + let diff = generate_diff(old, new, 1); + + assert!(diff.is_empty(), "No changes should produce empty diff"); + } + + #[test] + fn test_generate_diff_line_number_format() { + let old = "old"; + let new = "new"; + let diff = generate_diff(old, new, 42); + + // Compact format: no padding + assert!( + diff.contains("42- old"), + "Should have line number directly before minus" + ); + assert!( + diff.contains("42+ new"), + "Should have line number directly before plus" + ); + } + + #[test] + fn test_find_line_number() { + let content = "line 1\nline 2\nline 3\nline 4"; + + assert_eq!(find_line_number(content, "line 1"), 1); + assert_eq!(find_line_number(content, "line 2"), 2); + assert_eq!(find_line_number(content, "line 3"), 3); + assert_eq!(find_line_number(content, "line 4"), 4); + assert_eq!(find_line_number(content, "not found"), 1); + } + + #[test] + fn test_extract_context() { + let content = + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10"; + + // Edit at line 5, with 2 lines padding + let (start, end, ctx) = extract_context(content, 5, 5, 2); + + assert_eq!(start, 3, "Should start at line 3 (5 - 2)"); + assert_eq!(end, 7, "Should end at line 7 (5 + 2)"); + assert!(ctx.contains("line 3"), "Should include line 3"); + assert!(ctx.contains("line 5"), "Should include edited line 5"); + assert!(ctx.contains("line 7"), "Should include line 7"); + assert!(!ctx.contains("line 2"), "Should not include line 2"); + assert!(!ctx.contains("line 8"), "Should not include line 8"); + } + + #[test] + fn test_extract_context_at_start() { + let content = "line 1\nline 2\nline 3\nline 4\nline 5"; + + // Edit at line 1, with 2 lines padding - shouldn't go negative + let (start, _end, ctx) = extract_context(content, 1, 1, 2); + + assert_eq!(start, 1, "Should start at line 1 (can't go before)"); + assert!(ctx.contains("line 1"), "Should include line 1"); + assert!(ctx.contains("line 3"), "Should include line 3"); + } + + #[test] + fn test_extract_context_at_end() { + let content = "line 1\nline 2\nline 3\nline 4\nline 5"; + + // Edit at line 5, with 2 lines padding - shouldn't go past end + let (_start, end, ctx) = extract_context(content, 5, 5, 2); + + assert_eq!(end, 5, "Should end at line 5 (can't go past)"); + assert!(ctx.contains("line 5"), "Should include line 5"); + assert!(ctx.contains("line 3"), "Should include line 3"); + } + + #[test] + fn test_extract_context_range_past_end() { + let content = "line 1\nline 2\nline 3\nline 4\nline 5"; + + // Edit range extends past the end of the file. + let (start, end, ctx) = extract_context(content, 4, 10, 1); + + assert_eq!(start, 3, "Should start at line 3 (4 - 1)"); + assert_eq!(end, 5, "Should clamp to last line"); + assert!(ctx.contains("line 3"), "Should include line 3"); + assert!(ctx.contains("line 5"), "Should include line 5"); + } +} diff --git a/crates/jcode-app-core/src/tool/gmail.rs b/crates/jcode-app-core/src/tool/gmail.rs new file mode 100644 index 0000000..52c7ce2 --- /dev/null +++ b/crates/jcode-app-core/src/tool/gmail.rs @@ -0,0 +1,534 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::gmail::{self, GmailClient, MessageFormat}; + +pub struct GmailTool { + client: GmailClient, +} + +impl GmailTool { + pub fn new() -> Self { + Self { + client: GmailClient::new(), + } + } +} + +#[derive(Deserialize)] +struct GmailInput { + action: String, + #[serde(default)] + query: Option<String>, + #[serde(default)] + message_id: Option<String>, + #[serde(default)] + thread_id: Option<String>, + #[serde(default)] + draft_id: Option<String>, + #[serde(default)] + to: Option<String>, + #[serde(default)] + subject: Option<String>, + #[serde(default)] + body: Option<String>, + #[serde(default)] + in_reply_to: Option<String>, + #[serde(default)] + max_results: Option<u32>, + #[serde(default)] + label_ids: Option<Vec<String>>, + #[serde(default)] + add_labels: Option<Vec<String>>, + #[serde(default)] + remove_labels: Option<Vec<String>>, + #[serde(default)] + confirmed: Option<bool>, + #[serde(default)] + attachments: Option<Vec<String>>, +} + +#[async_trait] +impl Tool for GmailTool { + fn name(&self) -> &str { + "gmail" + } + + fn description(&self) -> &str { + "Use Gmail." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["action"], + "properties": { + "intent": super::intent_schema_property(), + "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)." + }, + "query": { "type": "string" }, + "message_id": { "type": "string" }, + "thread_id": { "type": "string" }, + "draft_id": { "type": "string" }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "body": { "type": "string" }, + "in_reply_to": { "type": "string" }, + "max_results": { "type": "integer" }, + "label_ids": { "type": "array", "items": { "type": "string" } }, + "add_labels": { "type": "array", "items": { "type": "string" } }, + "remove_labels": { "type": "array", "items": { "type": "string" } }, + "confirmed": { + "type": "boolean", + "description": "Confirm." + }, + "attachments": { + "type": "array", + "items": { "type": "string" }, + "description": "Absolute file paths to attach (for draft/send actions)." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result<ToolOutput> { + let params: GmailInput = serde_json::from_value(input)?; + let max = params.max_results.unwrap_or(10).min(50); + + // The connect action sets up the Composio managed backend by opening a + // browser OAuth screen for the user to approve. It runs before the + // is_configured gate so it can establish the very first connection. + if params.action == "connect" { + if !self.client.supports_connect() { + return Ok(ToolOutput::new( + "The 'connect' action is only available with the Composio Gmail backend. \ + Set JCODE_GMAIL_BACKEND=composio and COMPOSIO_API_KEY, then retry. \ + For the default backend, run `jcode login google` instead.", + )); + } + let no_browser = crate::auth::browser_suppressed(false); + match self.client.connect(!no_browser).await { + Ok(conn) => { + let who = conn + .email + .clone() + .unwrap_or_else(|| "your Gmail account".to_string()); + return Ok(ToolOutput::new(format!( + "Gmail connected via Composio for {}. You can now search, read, draft, and send email.", + who + ))); + } + Err(e) => { + return Ok(ToolOutput::new(format!("Gmail connect failed: {}", e))); + } + } + } + + if !self.client.is_configured() { + return Ok(ToolOutput::new(self.client.not_configured_message())); + } + + if self.client.needs_connection() { + return Ok(ToolOutput::new( + "Gmail (Composio backend) has no connected account yet. Run the gmail tool with \ + action 'connect' to authorize your Gmail account, then retry.", + )); + } + + match params.action.as_str() { + "search" | "list" => { + let query = params.query.as_deref(); + let label_refs: Vec<&str> = params + .label_ids + .as_ref() + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let labels = if label_refs.is_empty() { + None + } else { + Some(label_refs.as_slice()) + }; + + let list = self.client.list_messages(query, labels, max).await?; + let msgs = list.messages.unwrap_or_default(); + + if msgs.is_empty() { + return Ok(ToolOutput::new("No messages found.")); + } + + let mut results = Vec::new(); + for (i, msg_ref) in msgs.iter().enumerate().take(max as usize) { + match self + .client + .get_message(&msg_ref.id, MessageFormat::Metadata) + .await + { + Ok(msg) => { + results.push(format!( + "{}. {}\n From: {}\n Date: {}\n ID: {}", + i + 1, + msg.subject().unwrap_or("(no subject)"), + msg.from().unwrap_or("(unknown)"), + msg.date().unwrap_or(""), + msg.id, + )); + } + Err(e) => { + results.push(format!( + "{}. [error fetching {}: {}]", + i + 1, + msg_ref.id, + e + )); + } + } + } + + let header = if let Some(q) = query { + format!("Search results for \"{}\" ({} found):", q, msgs.len()) + } else { + format!("Recent messages ({} shown):", results.len()) + }; + + Ok(ToolOutput::new(format!( + "{}\n\n{}", + header, + results.join("\n\n") + ))) + } + + "read" => { + let id = params + .message_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("message_id is required for read action"))?; + + let msg = self.client.get_message(id, MessageFormat::Full).await?; + Ok(ToolOutput::new(gmail::format_message_full(&msg))) + } + + "threads" => { + let query = params.query.as_deref(); + let list = self.client.list_threads(query, max).await?; + let threads = list.threads.unwrap_or_default(); + + if threads.is_empty() { + return Ok(ToolOutput::new("No threads found.")); + } + + let mut results = Vec::new(); + for (i, t) in threads.iter().enumerate() { + results.push(format!( + "{}. {}\n ID: {}", + i + 1, + t.snippet.as_deref().unwrap_or("(no snippet)"), + t.id, + )); + } + + Ok(ToolOutput::new(format!( + "Threads ({}):\n\n{}", + threads.len(), + results.join("\n\n") + ))) + } + + "thread" => { + let id = params + .thread_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("thread_id is required for thread action"))?; + + // Accept a message ID too: if the thread lookup fails, try + // resolving the ID as a message and use its containing thread. + let thread = match self.client.get_thread(id).await { + Ok(t) => t, + Err(thread_err) => { + match self.client.get_message(id, MessageFormat::Metadata).await { + Ok(msg) => { + let tid = msg.thread_id.ok_or(thread_err)?; + self.client.get_thread(&tid).await? + } + Err(_) => return Err(thread_err), + } + } + }; + let thread_id = thread.id.clone(); + let messages = thread.messages.unwrap_or_default(); + + if messages.is_empty() { + return Ok(ToolOutput::new("Thread has no messages.")); + } + + let mut results = Vec::new(); + for (i, msg) in messages.iter().enumerate() { + let mut entry = format!( + "--- Message {} ---\nID: {}\nFrom: {}\nDate: {}\nSubject: {}\nSnippet: {}", + i + 1, + msg.id, + msg.from().unwrap_or("(unknown)"), + msg.date().unwrap_or(""), + msg.subject().unwrap_or("(no subject)"), + msg.snippet.as_deref().unwrap_or(""), + ); + let attachments = msg.attachments(); + if !attachments.is_empty() { + entry.push_str(&format!( + "\nAttachments ({}):\n{}", + attachments.len(), + gmail::format_attachment_lines(&attachments) + )); + } + results.push(entry); + } + + Ok(ToolOutput::new(format!( + "Thread {} ({} messages):\n\n{}", + thread_id, + messages.len(), + results.join("\n\n") + ))) + } + + "labels" => { + let labels = self.client.list_labels().await?; + let mut results = Vec::new(); + for label in &labels { + let unread = label + .messages_unread + .map(|u| format!(" ({} unread)", u)) + .unwrap_or_default(); + let total = label + .messages_total + .map(|t| format!(" [{} total]", t)) + .unwrap_or_default(); + results.push(format!( + "- {} (id: {}){}{}", + label.name, label.id, unread, total + )); + } + Ok(ToolOutput::new(format!("Labels:\n{}", results.join("\n")))) + } + + "draft" => { + let to = params + .to + .as_deref() + .ok_or_else(|| anyhow::anyhow!("'to' is required for draft action"))?; + let subject = params.subject.as_deref().unwrap_or(""); + let body = params.body.as_deref().unwrap_or(""); + + let attachments: Vec<std::path::PathBuf> = params + .attachments + .as_deref() + .unwrap_or(&[]) + .iter() + .map(std::path::PathBuf::from) + .collect(); + for path in &attachments { + if !path.is_file() { + return Ok(ToolOutput::new(format!( + "Attachment not found or not a file: {}", + path.display() + ))); + } + } + + let draft = self + .client + .create_draft_with_attachments( + to, + subject, + body, + params.in_reply_to.as_deref(), + params.thread_id.as_deref(), + &attachments, + ) + .await?; + + let attach_line = if attachments.is_empty() { + String::new() + } else { + format!( + "Attachments ({}):\n{}\n", + attachments.len(), + attachments + .iter() + .map(|p| format!(" - {}", p.display())) + .collect::<Vec<_>>() + .join("\n") + ) + }; + Ok(ToolOutput::new(format!( + "Draft created successfully.\nDraft ID: {}\nTo: {}\nSubject: {}\n{}\nTo send this draft, use action 'send_draft' with draft_id '{}' and confirmed: true.", + draft.id, to, subject, attach_line, draft.id + ))) + } + + "send" => { + if !self.client.can_send() { + return Ok(ToolOutput::new( + "Send is not available. Your Gmail access is configured as Read & Draft Only (API-level restriction).\n\ + The draft has been created - open Gmail to send it manually.\n\ + To enable sending, rerun `jcode login google --google-access-tier full`.", + )); + } + + let to = params + .to + .as_deref() + .ok_or_else(|| anyhow::anyhow!("'to' is required for send action"))?; + let subject = params.subject.as_deref().unwrap_or(""); + let body = params.body.as_deref().unwrap_or(""); + + let attachments: Vec<std::path::PathBuf> = params + .attachments + .as_deref() + .unwrap_or(&[]) + .iter() + .map(std::path::PathBuf::from) + .collect(); + for path in &attachments { + if !path.is_file() { + return Ok(ToolOutput::new(format!( + "Attachment not found or not a file: {}", + path.display() + ))); + } + } + + if params.confirmed != Some(true) { + let attach_line = if attachments.is_empty() { + String::new() + } else { + format!( + "Attachments:\n{}\n", + attachments + .iter() + .map(|p| format!(" - {}", p.display())) + .collect::<Vec<_>>() + .join("\n") + ) + }; + return Ok(ToolOutput::new(format!( + "CONFIRMATION REQUIRED: Send this email?\n\n\ + To: {}\n\ + Subject: {}\n\ + {}\ + Body:\n{}\n\n\ + To confirm, call gmail again with the same parameters and confirmed: true.", + to, subject, attach_line, body + ))); + } + + let msg = self + .client + .send_message_with_attachments( + to, + subject, + body, + params.in_reply_to.as_deref(), + params.thread_id.as_deref(), + &attachments, + ) + .await?; + + Ok(ToolOutput::new(format!( + "Email sent successfully.\nMessage ID: {}\nTo: {}\nSubject: {}\nAttachments: {}", + msg.id, + to, + subject, + attachments.len() + ))) + } + + "send_draft" => { + if !self.client.can_send() { + return Ok(ToolOutput::new( + "Send is not available. Your Gmail access is configured as Read & Draft Only (API-level restriction).\n\ + Open Gmail to send the draft manually.\n\ + To enable sending, rerun `jcode login google --google-access-tier full`.", + )); + } + + let draft_id = params.draft_id.as_deref().ok_or_else(|| { + anyhow::anyhow!("'draft_id' is required for send_draft action") + })?; + + if params.confirmed != Some(true) { + return Ok(ToolOutput::new(format!( + "CONFIRMATION REQUIRED: Send draft {}?\n\n\ + To confirm, call gmail again with action 'send_draft', draft_id '{}', and confirmed: true.", + draft_id, draft_id + ))); + } + + let msg = self.client.send_draft(draft_id).await?; + Ok(ToolOutput::new(format!( + "Draft sent successfully.\nMessage ID: {}", + msg.id + ))) + } + + "trash" => { + if !self.client.can_delete() { + return Ok(ToolOutput::new( + "Trash is not available. Your Gmail access is configured as Read & Draft Only (API-level restriction).\n\ + To enable delete, rerun `jcode login google --google-access-tier full`.", + )); + } + + let id = params + .message_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("'message_id' is required for trash action"))?; + + if params.confirmed != Some(true) { + return Ok(ToolOutput::new(format!( + "CONFIRMATION REQUIRED: Move message {} to trash?\n\n\ + To confirm, call gmail again with action 'trash', message_id '{}', and confirmed: true.", + id, id + ))); + } + + self.client.trash_message(id).await?; + Ok(ToolOutput::new(format!("Message {} moved to trash.", id))) + } + + "modify_labels" => { + let id = params + .message_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("'message_id' is required for modify_labels"))?; + + let add: Vec<&str> = params + .add_labels + .as_ref() + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let remove: Vec<&str> = params + .remove_labels + .as_ref() + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + + self.client.modify_labels(id, &add, &remove).await?; + Ok(ToolOutput::new(format!( + "Labels modified on message {}.\nAdded: {:?}\nRemoved: {:?}", + id, add, remove + ))) + } + + other => Ok(ToolOutput::new(format!( + "Unknown gmail action: '{}'. Valid actions: search, read, list, draft, send, send_draft, threads, thread, labels, trash, modify_labels", + other + ))), + } + } +} diff --git a/crates/jcode-app-core/src/tool/goal.rs b/crates/jcode-app-core/src/tool/goal.rs new file mode 100644 index 0000000..0e0e99d --- /dev/null +++ b/crates/jcode-app-core/src/tool/goal.rs @@ -0,0 +1,365 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, SidePanelUpdated}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +pub struct InitiativeTool; + +impl InitiativeTool { + pub fn new() -> Self { + Self + } +} + +fn default_display_for_action(action: &str) -> crate::goal::GoalDisplayMode { + match action { + // The tool must never open (spawn) the side panel on its own; users + // open it explicitly via /goals. UpdateOnly refreshes pages that are + // already open without stealing focus. + "update" | "checkpoint" => crate::goal::GoalDisplayMode::UpdateOnly, + _ => crate::goal::GoalDisplayMode::None, + } +} + +fn publish_side_panel_snapshot(session_id: &str, snapshot: &crate::side_panel::SidePanelSnapshot) { + Bus::global().publish(BusEvent::SidePanelUpdated(SidePanelUpdated { + session_id: session_id.to_string(), + snapshot: snapshot.clone(), + })); +} + +fn maybe_publish_goals_overview_refresh( + session_id: &str, + working_dir: Option<&std::path::Path>, +) -> Result<()> { + if let Some(snapshot) = + crate::goal::refresh_goals_overview_for_session(session_id, working_dir)? + { + publish_side_panel_snapshot(session_id, &snapshot); + } + Ok(()) +} + +fn goal_page_is_open(session_id: &str, goal_id: &str) -> Result<bool> { + let page_id = crate::goal::goal_page_id(goal_id); + let snapshot = crate::side_panel::snapshot_for_session(session_id)?; + Ok(snapshot.pages.iter().any(|page| page.id == page_id)) +} + +#[derive(Debug, Deserialize)] +struct GoalInput { + action: String, + #[serde(default)] + id: Option<String>, + #[serde(default)] + title: Option<String>, + #[serde(default)] + scope: Option<String>, + #[serde(default)] + status: Option<String>, + #[serde(default)] + description: Option<String>, + #[serde(default)] + why: Option<String>, + #[serde(default)] + success_criteria: Option<Vec<String>>, + #[serde(default)] + milestones: Option<Vec<crate::goal::GoalMilestone>>, + #[serde(default)] + next_steps: Option<Vec<String>>, + #[serde(default)] + blockers: Option<Vec<String>>, + #[serde(default)] + current_milestone_id: Option<String>, + #[serde(default)] + progress_percent: Option<u8>, + #[serde(default)] + checkpoint_summary: Option<String>, + #[serde(default)] + display: Option<String>, +} + +fn goal_step_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": true + }) +} + +fn goal_milestone_schema() -> Value { + json!({ + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": goal_step_schema() + } + }, + "additionalProperties": true + }) +} + +#[async_trait] +impl Tool for InitiativeTool { + fn name(&self) -> &str { + "initiative" + } + + fn description(&self) -> &str { + "Manage durable initiatives." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["action"], + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["create", "list", "show", "resume", "update", "checkpoint", "focus"], + "description": "Action." + }, + "id": {"type": "string"}, + "title": {"type": "string"}, + "scope": {"type": "string"}, + "status": {"type": "string"}, + "description": {"type": "string"}, + "why": {"type": "string"}, + "success_criteria": {"type": "array", "items": {"type": "string"}}, + "milestones": {"type": "array", "items": goal_milestone_schema()}, + "next_steps": {"type": "array", "items": {"type": "string"}}, + "blockers": {"type": "array", "items": {"type": "string"}}, + "current_milestone_id": {"type": "string"}, + "progress_percent": {"type": "integer"}, + "checkpoint_summary": {"type": "string"} + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: GoalInput = serde_json::from_value(input)?; + let action_label = params.action.clone(); + let goal_id_label = params.id.clone().unwrap_or_else(|| "<none>".to_string()); + let working_dir = ctx.working_dir.as_deref(); + let display = params + .display + .as_deref() + .and_then(crate::goal::GoalDisplayMode::parse) + .unwrap_or_else(|| default_display_for_action(¶ms.action)); + + match params.action.as_str() { + "list" => { + let goals = crate::goal::list_relevant_goals(working_dir)?; + if display != crate::goal::GoalDisplayMode::None { + let focus = display != crate::goal::GoalDisplayMode::UpdateOnly; + let snapshot = crate::goal::open_goals_overview_for_session( + &ctx.session_id, + working_dir, + focus, + )?; + publish_side_panel_snapshot(&ctx.session_id, &snapshot); + } + Ok(ToolOutput::new(crate::goal::render_goals_overview(&goals)) + .with_title(format!("{} goals", goals.len())) + .with_metadata(serde_json::to_value(&goals)?)) + } + "create" => { + let title = params + .title + .as_deref() + .ok_or_else(|| anyhow::anyhow!("title is required for create"))?; + let scope = params + .scope + .as_deref() + .and_then(crate::goal::GoalScope::parse) + .unwrap_or(crate::goal::GoalScope::Project); + let goal = crate::goal::create_goal( + crate::goal::GoalCreateInput { + id: params.id.clone(), + title: title.to_string(), + scope, + description: params.description.clone(), + why: params.why.clone(), + success_criteria: params.success_criteria.unwrap_or_default(), + milestones: params.milestones.unwrap_or_default(), + next_steps: params.next_steps.unwrap_or_default(), + blockers: params.blockers.unwrap_or_default(), + current_milestone_id: params.current_milestone_id.clone(), + progress_percent: params.progress_percent, + }, + working_dir, + )?; + let metadata = serde_json::to_value(&goal)?; + let output = if display == crate::goal::GoalDisplayMode::None { + ToolOutput::new(format!("Created initiative `{}` ({})", goal.id, goal.title)) + } else { + let snapshot = + crate::goal::write_goal_page(&ctx.session_id, working_dir, &goal, display)?; + publish_side_panel_snapshot(&ctx.session_id, &snapshot); + maybe_publish_goals_overview_refresh(&ctx.session_id, working_dir)?; + ToolOutput::new(format!( + "Created initiative `{}` ({}) and opened it in the side panel.", + goal.id, goal.title + )) + }; + Ok(output + .with_title(goal.title.clone()) + .with_metadata(metadata)) + } + "show" | "focus" => { + let id = params + .id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("id is required for show/focus"))?; + if display == crate::goal::GoalDisplayMode::None { + let Some(goal) = crate::goal::load_goal(id, None, working_dir)? else { + anyhow::bail!("initiative not found: {}", id); + }; + crate::goal::attach_goal_to_session(&ctx.session_id, &goal, working_dir)?; + Ok(ToolOutput::new(crate::goal::render_goal_detail(&goal)) + .with_title(goal.title.clone()) + .with_metadata(serde_json::to_value(&goal)?)) + } else { + let Some(result) = crate::goal::open_goal_for_session( + &ctx.session_id, + working_dir, + id, + params.action == "focus" || display == crate::goal::GoalDisplayMode::Focus, + )? + else { + anyhow::bail!("initiative not found: {}", id); + }; + publish_side_panel_snapshot(&ctx.session_id, &result.snapshot); + Ok( + ToolOutput::new(crate::goal::render_goal_detail(&result.goal)) + .with_title(result.goal.title.clone()) + .with_metadata(serde_json::to_value(&result.goal)?), + ) + } + } + "resume" => { + let goal = if display == crate::goal::GoalDisplayMode::None { + let Some(goal) = crate::goal::resume_goal(&ctx.session_id, working_dir)? else { + return Ok(ToolOutput::new("No resumable goals found.")); + }; + crate::goal::attach_goal_to_session(&ctx.session_id, &goal, working_dir)?; + goal + } else { + let Some(result) = crate::goal::resume_goal_for_session( + &ctx.session_id, + working_dir, + display == crate::goal::GoalDisplayMode::Focus, + )? + else { + return Ok(ToolOutput::new("No resumable goals found.")); + }; + publish_side_panel_snapshot(&ctx.session_id, &result.snapshot); + result.goal + }; + let mut output = format!("Resumed initiative `{}` ({})", goal.id, goal.title); + if let Some(progress) = goal.progress_percent { + output.push_str(&format!(" — {}%", progress)); + } + if let Some(next_step) = goal.next_steps.first() { + output.push_str(&format!("\nNext step: {}", next_step)); + } + Ok(ToolOutput::new(output) + .with_title(goal.title.clone()) + .with_metadata(serde_json::to_value(&goal)?)) + } + "update" | "checkpoint" => { + let id = params + .id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("id is required for update/checkpoint"))?; + let status = params + .status + .as_deref() + .map(|value| { + crate::goal::GoalStatus::parse(value) + .ok_or_else(|| anyhow::anyhow!("invalid goal status: {}", value)) + }) + .transpose()?; + let goal = crate::goal::update_goal( + id, + params + .scope + .as_deref() + .and_then(crate::goal::GoalScope::parse), + working_dir, + crate::goal::GoalUpdateInput { + title: params.title.clone(), + description: params.description.clone(), + why: params.why.clone(), + status, + success_criteria: params.success_criteria.clone(), + milestones: params.milestones.clone(), + next_steps: params.next_steps.clone(), + blockers: params.blockers.clone(), + current_milestone_id: if params.current_milestone_id.is_some() { + Some(params.current_milestone_id.clone()) + } else { + None + }, + progress_percent: if params.progress_percent.is_some() { + Some(params.progress_percent) + } else { + None + }, + checkpoint_summary: if params.action == "checkpoint" { + params + .checkpoint_summary + .clone() + .or(params.description.clone()) + } else { + params.checkpoint_summary.clone() + }, + }, + )? + .ok_or_else(|| anyhow::anyhow!("initiative not found: {}", id))?; + if display != crate::goal::GoalDisplayMode::None { + let should_write_goal_page = match display { + crate::goal::GoalDisplayMode::None => false, + crate::goal::GoalDisplayMode::UpdateOnly => { + goal_page_is_open(&ctx.session_id, &goal.id)? + } + crate::goal::GoalDisplayMode::Auto + | crate::goal::GoalDisplayMode::Focus => true, + }; + if should_write_goal_page { + let snapshot = crate::goal::write_goal_page( + &ctx.session_id, + working_dir, + &goal, + display, + )?; + publish_side_panel_snapshot(&ctx.session_id, &snapshot); + } + maybe_publish_goals_overview_refresh(&ctx.session_id, working_dir)?; + } + Ok( + ToolOutput::new(format!("Updated initiative `{}` ({})", goal.id, goal.title)) + .with_title(goal.title.clone()) + .with_metadata(serde_json::to_value(&goal)?), + ) + } + other => anyhow::bail!("unknown goal action: {}", other), + } + .map_err(|err| { + crate::logging::warn(&format!( + "[tool:goal] action failed action={} goal_id={} session_id={} error={}", + action_label, goal_id_label, ctx.session_id, err + )); + err + }) + } +} + +#[cfg(test)] +#[path = "goal_tests.rs"] +mod goal_tests; diff --git a/crates/jcode-app-core/src/tool/goal_tests.rs b/crates/jcode-app-core/src/tool/goal_tests.rs new file mode 100644 index 0000000..fdfe5a7 --- /dev/null +++ b/crates/jcode-app-core/src/tool/goal_tests.rs @@ -0,0 +1,215 @@ +use super::*; +use tokio::time::{Duration, timeout}; + +#[tokio::test] +async fn initiative_tool_create_and_resume_round_trip() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("repo"); + std::fs::create_dir_all(&project).expect("project dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let tool = InitiativeTool::new(); + let ctx = ToolContext { + session_id: "ses_goal_tool".to_string(), + message_id: "msg1".to_string(), + tool_call_id: "tool1".to_string(), + working_dir: Some(project.clone()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + }; + + let mut bus_rx = Bus::global().subscribe(); + + let create = tool + .execute( + json!({ + "action": "create", + "title": "Ship mobile MVP", + "scope": "project", + "next_steps": ["finish reconnect flow"] + }), + ctx.clone(), + ) + .await + .expect("create goal"); + assert!(create.output.contains("Created initiative")); + assert!(!create.output.contains("side panel")); + + // Creating an initiative must not spawn the side panel. + let update = timeout(Duration::from_millis(200), bus_rx.recv()).await; + if let Ok(Ok(event)) = update { + assert!( + !matches!(event, BusEvent::SidePanelUpdated(_)), + "create must not publish a side panel update, got {:?}", + event + ); + } + + let persisted = + crate::side_panel::snapshot_for_session("ses_goal_tool").expect("side panel snapshot"); + assert!( + !persisted + .pages + .iter() + .any(|page| page.id == "goal.ship-mobile-mvp"), + "create must not write a goal page to the side panel" + ); + + let resume = tool + .execute(json!({"action": "resume"}), ctx) + .await + .expect("resume goal"); + assert!(resume.output.contains("Resumed initiative")); + assert!(resume.output.contains("finish reconnect flow")); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +async fn initiative_tool_list_does_not_open_side_panel_by_default() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("repo"); + std::fs::create_dir_all(&project).expect("project dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + crate::goal::create_goal( + crate::goal::GoalCreateInput { + title: "Ship mobile MVP".to_string(), + scope: crate::goal::GoalScope::Project, + ..crate::goal::GoalCreateInput::default() + }, + Some(&project), + ) + .expect("create goal"); + + let tool = InitiativeTool::new(); + let ctx = ToolContext { + session_id: "ses_goal_list".to_string(), + message_id: "msg1".to_string(), + tool_call_id: "tool1".to_string(), + working_dir: Some(project.clone()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + }; + + let list = tool + .execute(json!({"action": "list"}), ctx) + .await + .expect("list goals"); + + assert!(list.output.contains("# Goals")); + let snapshot = + crate::side_panel::snapshot_for_session("ses_goal_list").expect("side panel snapshot"); + assert!( + !snapshot.pages.iter().any(|page| page.id == "goals"), + "list must not open the goals overview in the side panel" + ); + assert_eq!(snapshot.focused_page_id, None); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[tokio::test] +async fn initiative_tool_update_refreshes_open_overview_without_stealing_focus() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("repo"); + std::fs::create_dir_all(&project).expect("project dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + + let goal = crate::goal::create_goal( + crate::goal::GoalCreateInput { + title: "Ship mobile MVP".to_string(), + scope: crate::goal::GoalScope::Project, + next_steps: vec!["finish reconnect flow".to_string()], + ..crate::goal::GoalCreateInput::default() + }, + Some(&project), + ) + .expect("create goal"); + + let tool = InitiativeTool::new(); + let ctx = ToolContext { + session_id: "ses_goal_update".to_string(), + message_id: "msg1".to_string(), + tool_call_id: "tool1".to_string(), + working_dir: Some(project.clone()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + }; + + // The user opens the overview explicitly (e.g. via /goals); the tool + // itself never spawns the side panel. + crate::goal::open_goals_overview_for_session("ses_goal_update", Some(&project), true) + .expect("open goals overview"); + + tool.execute( + json!({ + "action": "update", + "id": goal.id, + "next_steps": ["ship reconnect flow"] + }), + ctx, + ) + .await + .expect("update goal"); + + let snapshot = + crate::side_panel::snapshot_for_session("ses_goal_update").expect("side panel snapshot"); + assert_eq!(snapshot.focused_page_id.as_deref(), Some("goals")); + let goals_page = snapshot + .pages + .iter() + .find(|page| page.id == "goals") + .expect("goals page"); + assert!(goals_page.content.contains("ship reconnect flow")); + + if let Some(prev_home) = prev_home { + crate::env::set_var("JCODE_HOME", prev_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } +} + +#[test] +fn test_initiative_schema_milestones_define_items() { + let schema = InitiativeTool::new().parameters_schema(); + let milestone_items = &schema["properties"]["milestones"]["items"]; + + assert_eq!(milestone_items["type"], "object"); + assert_eq!(milestone_items["additionalProperties"], json!(true)); + assert_eq!(milestone_items["properties"]["steps"]["type"], "array"); + assert_eq!( + milestone_items["properties"]["steps"]["items"]["additionalProperties"], + json!(true) + ); +} + +#[test] +fn test_initiative_schema_omits_display_override() { + let schema = InitiativeTool::new().parameters_schema(); + assert!(schema["properties"]["display"].is_null()); +} + +#[test] +fn test_initiative_schema_omits_public_enums_for_scope_and_status() { + let schema = InitiativeTool::new().parameters_schema(); + assert!(schema["properties"]["scope"]["enum"].is_null()); + assert!(schema["properties"]["status"]["enum"].is_null()); +} diff --git a/crates/jcode-app-core/src/tool/invalid.rs b/crates/jcode-app-core/src/tool/invalid.rs new file mode 100644 index 0000000..9b3ab3d --- /dev/null +++ b/crates/jcode-app-core/src/tool/invalid.rs @@ -0,0 +1,56 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +pub struct InvalidTool; + +impl InvalidTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct InvalidInput { + tool: String, + error: String, +} + +#[async_trait] +impl Tool for InvalidTool { + fn name(&self) -> &str { + "invalid" + } + + fn description(&self) -> &str { + "Report invalid tool usage. Use only when a tool call is malformed." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["tool", "error"], + "properties": { + "intent": super::intent_schema_property(), + "tool": { + "type": "string", + "description": "Tool name." + }, + "error": { + "type": "string", + "description": "Validation error." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result<ToolOutput> { + let params: InvalidInput = serde_json::from_value(input)?; + Ok(ToolOutput::new(format!( + "Invalid tool invocation for '{}': {}", + params.tool, params.error + ))) + } +} diff --git a/crates/jcode-app-core/src/tool/ls.rs b/crates/jcode-app-core/src/tool/ls.rs new file mode 100644 index 0000000..f3997b6 --- /dev/null +++ b/crates/jcode-app-core/src/tool/ls.rs @@ -0,0 +1,190 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::Path; + +const MAX_ENTRIES: usize = 100; +const DEFAULT_IGNORE: &[&str] = &[ + "node_modules", + "__pycache__", + ".git", + "dist", + "build", + "target", + ".next", + ".nuxt", + "venv", + ".venv", + "coverage", + ".cache", +]; + +pub struct LsTool; + +impl LsTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct LsInput { + #[serde(default)] + path: Option<String>, + #[serde(default)] + ignore: Option<Vec<String>>, +} + +struct DirEntry { + name: String, + is_dir: bool, + depth: usize, +} + +#[async_trait] +impl Tool for LsTool { + fn name(&self) -> &str { + "ls" + } + + fn description(&self) -> &str { + "List directory contents." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "path": { + "type": "string", + "description": "Directory path." + }, + "ignore": { + "type": "array", + "items": { "type": "string" }, + "description": "Ignore patterns." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: LsInput = serde_json::from_value(input)?; + + let base_path = params.path.clone().unwrap_or_else(|| ".".to_string()); + let base = ctx.resolve_path(Path::new(&base_path)); + let ignore_extra = params.ignore.clone(); + + if !base.exists() { + return Err(anyhow::anyhow!("Directory not found: {}", base_path)); + } + + if !base.is_dir() { + return Err(anyhow::anyhow!("Not a directory: {}", base_path)); + } + + let entries = tokio::task::spawn_blocking(move || { + let mut ignore_patterns: Vec<String> = + DEFAULT_IGNORE.iter().map(|s| s.to_string()).collect(); + if let Some(extra) = ignore_extra { + ignore_patterns.extend(extra); + } + + let mut entries: Vec<DirEntry> = Vec::new(); + collect_entries(&base, 0, &ignore_patterns, &mut entries, MAX_ENTRIES)?; + Ok::<_, anyhow::Error>(entries) + }) + .await??; + + let truncated = entries.len() >= MAX_ENTRIES; + + let mut output = String::new(); + output.push_str(&format!("{}/\n", base_path)); + + for entry in &entries { + let indent = " ".repeat(entry.depth); + let suffix = if entry.is_dir { "/" } else { "" }; + output.push_str(&format!("{}{}{}\n", indent, entry.name, suffix)); + } + + if truncated { + output.push_str(&format!("\n... truncated at {} entries", MAX_ENTRIES)); + } + + let file_count = entries.iter().filter(|e| !e.is_dir).count(); + let dir_count = entries.iter().filter(|e| e.is_dir).count(); + output.push_str(&format!( + "\n{} files, {} directories", + file_count, dir_count + )); + + Ok(ToolOutput::new(output)) + } +} + +fn collect_entries( + dir: &Path, + depth: usize, + ignore: &[String], + entries: &mut Vec<DirEntry>, + max: usize, +) -> Result<()> { + if entries.len() >= max { + return Ok(()); + } + + let mut items: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect(); + + // Cache file_type from DirEntry (uses cached readdir data, no extra stat on most platforms) + // Then sort using cached values instead of calling is_dir() in the comparator + let mut typed_items: Vec<(std::fs::DirEntry, bool)> = items + .drain(..) + .map(|e| { + let is_dir = e.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + (e, is_dir) + }) + .collect(); + + typed_items.sort_by(|(a, a_dir), (b, b_dir)| match (*a_dir, *b_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.file_name().cmp(&b.file_name()), + }); + + for (item, is_dir) in typed_items { + if entries.len() >= max { + break; + } + + let name = item.file_name().to_string_lossy().to_string(); + + if ignore.iter().any(|p| { + glob::Pattern::new(p) + .map(|pat| pat.matches(&name)) + .unwrap_or(false) + || name == *p + }) { + continue; + } + + if name.starts_with('.') && name != "." && name != ".." { + continue; + } + + entries.push(DirEntry { + name: name.clone(), + is_dir, + depth: depth + 1, + }); + + if is_dir && depth < 5 { + let path = item.path(); + collect_entries(&path, depth + 1, ignore, entries, max)?; + } + } + + Ok(()) +} diff --git a/crates/jcode-app-core/src/tool/mcp.rs b/crates/jcode-app-core/src/tool/mcp.rs new file mode 100644 index 0000000..fe1908b --- /dev/null +++ b/crates/jcode-app-core/src/tool/mcp.rs @@ -0,0 +1,669 @@ +//! MCP management tool - connect, disconnect, list, reload MCP servers + +use crate::mcp::{McpManager, McpServerConfig}; +use crate::tool::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Deserialize)] +struct McpToolInput { + action: String, + #[serde(default)] + server: Option<String>, + #[serde(default)] + command: Option<String>, + #[serde(default)] + args: Option<Vec<String>>, + #[serde(default)] + env: Option<HashMap<String, String>>, +} + +pub struct McpManagementTool { + manager: Arc<RwLock<McpManager>>, + registry: Option<crate::tool::Registry>, +} + +impl McpManagementTool { + pub fn new(manager: Arc<RwLock<McpManager>>) -> Self { + Self { + manager, + registry: None, + } + } + + pub fn with_registry(mut self, registry: crate::tool::Registry) -> Self { + self.registry = Some(registry); + self + } +} + +#[async_trait] +impl Tool for McpManagementTool { + fn name(&self) -> &str { + "mcp" + } + + fn description(&self) -> &str { + "Manage MCP (Model Context Protocol) servers." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["list", "connect", "disconnect", "reload"], + "description": "Action." + }, + "server": { + "type": "string", + "description": "Server name." + }, + "command": { + "type": "string", + "description": "Server command." + }, + "args": { + "type": "array", + "items": {"type": "string"}, + "description": "Command args." + }, + "env": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": "Server env." + } + }, + "required": ["action"] + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: McpToolInput = serde_json::from_value(input)?; + let started = std::time::Instant::now(); + let action = params.action.clone(); + let server = params.server.clone().unwrap_or_else(|| "none".to_string()); + crate::logging::event_info( + "MCP_LIFECYCLE", + vec![ + ("phase", "management_start".to_string()), + ("action", action.clone()), + ("server", server.clone()), + ("session_id", ctx.session_id.clone()), + ("tool_call_id", ctx.tool_call_id.clone()), + ], + ); + + let result = match params.action.as_str() { + "list" => self.list_servers().await, + "connect" => self.connect_server(params, &ctx.session_id).await, + "disconnect" => self.disconnect_server(params).await, + "reload" => self.reload_config(&ctx.session_id).await, + _ => Ok(ToolOutput::new(format!( + "Unknown action: {}. Use 'list', 'connect', 'disconnect', or 'reload'.", + params.action + ))), + }; + + match &result { + Ok(_) => crate::logging::event_info( + "MCP_LIFECYCLE", + vec![ + ("phase", "management_done".to_string()), + ("action", action), + ("server", server), + ("session_id", ctx.session_id), + ("tool_call_id", ctx.tool_call_id), + ("status", "ok".to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ), + Err(error) => crate::logging::event_warn( + "MCP_LIFECYCLE", + vec![ + ("phase", "management_done".to_string()), + ("action", action), + ("server", server), + ("session_id", ctx.session_id), + ("tool_call_id", ctx.tool_call_id), + ("status", "error".to_string()), + ("error", error.to_string()), + ("elapsed_ms", started.elapsed().as_millis().to_string()), + ], + ), + } + + result + } +} + +// Helper for tests to update cached server names +impl McpManagementTool { + pub fn manager(&self) -> &Arc<RwLock<McpManager>> { + &self.manager + } +} + +impl McpManagementTool { + async fn list_servers(&self) -> Result<ToolOutput> { + let manager = self.manager.read().await; + let servers = manager.connected_servers().await; + let all_tools = manager.all_tools().await; + // Configured-but-not-connected servers, including disabled ones + // (issue #436), so the full config state is visible. + let mut configured: Vec<(String, bool)> = manager + .config() + .servers + .iter() + .filter(|(name, _)| !servers.contains(name)) + .map(|(name, cfg)| (name.clone(), cfg.is_enabled())) + .collect(); + configured.sort(); + + if servers.is_empty() && configured.is_empty() { + return Ok(ToolOutput::new( + "No MCP servers connected.\n\n\ + To connect a server, use:\n\ + {\"action\": \"connect\", \"server\": \"name\", \"command\": \"/path/to/server\", \"args\": []}\n\n\ + Or add servers to ~/.jcode/mcp.json or .jcode/mcp.json and use {\"action\": \"reload\"}.\n\ + .claude/mcp.json is also supported for compatibility." + ).with_title("MCP: No servers")); + } + + let mut output = String::new(); + output.push_str(&format!("Connected MCP servers: {}\n\n", servers.len())); + + for server in &servers { + output.push_str(&format!("## {}\n", server)); + let server_tools: Vec<_> = all_tools.iter().filter(|(s, _)| s == server).collect(); + + if server_tools.is_empty() { + output.push_str(" (no tools)\n"); + } else { + for (_, tool) in server_tools { + output.push_str(&format!( + " - mcp__{}__{}: {}\n", + server, + tool.name, + tool.description.as_deref().unwrap_or("(no description)") + )); + } + } + output.push('\n'); + } + + if !configured.is_empty() { + output.push_str("Configured but not connected:\n"); + for (name, enabled) in &configured { + if *enabled { + output.push_str(&format!( + " - {} (enabled; connect with {{\"action\": \"connect\", \"server\": \"{}\"}})\n", + name, name + )); + } else { + output.push_str(&format!( + " - {} (disabled in config; connect on demand with {{\"action\": \"connect\", \"server\": \"{}\"}})\n", + name, name + )); + } + } + } + + Ok(ToolOutput::new(output).with_title("MCP: Server list")) + } + + async fn connect_server(&self, params: McpToolInput, session_id: &str) -> Result<ToolOutput> { + let server_name = params + .server + .ok_or_else(|| anyhow::anyhow!("'server' is required for connect action"))?; + + // With an explicit command this is an ad-hoc connect. Without one, fall + // back to the configured server of that name, which also lets disabled + // configured servers be connected on demand, session-scoped, without + // rewriting config (issue #436). + let config = if let Some(command) = params.command { + McpServerConfig { + command, + args: params.args.unwrap_or_default(), + env: params.env.unwrap_or_default(), + shared: true, + transport: None, + url: None, + enabled: None, + disabled: None, + } + } else { + let manager = self.manager.read().await; + let configured = manager.config().servers.get(&server_name).cloned(); + drop(manager); + configured.ok_or_else(|| { + anyhow::anyhow!( + "'command' is required for connect action ('{}' is not in the MCP config)", + server_name + ) + })? + }; + + let manager = self.manager.read().await; + + // Check if already connected + let connected = manager.connected_servers().await; + if connected.contains(&server_name) { + return Ok(ToolOutput::new(format!( + "Server '{}' is already connected. Use 'disconnect' first to reconnect.", + server_name + )) + .with_title("MCP: Already connected")); + } + drop(manager); + + // Connect + let manager = self.manager.read().await; + match manager.connect(&server_name, &config).await { + Ok(()) => { + let tools = manager.all_tools().await; + let server_tools: Vec<_> = + tools.iter().filter(|(s, _)| s == &server_name).collect(); + + let mut output = format!( + "Connected to MCP server '{}'\n\nAvailable tools ({}):\n", + server_name, + server_tools.len() + ); + for (_, tool) in &server_tools { + output.push_str(&format!( + " - mcp__{}__{}: {}\n", + 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 { + let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await; + for (name, tool) in mcp_tools { + if name.starts_with(&format!("mcp__{}__", server_name)) { + registry.register(name, tool).await; + } + } + } + + Ok(ToolOutput::new(output).with_title(format!("MCP: Connected {}", server_name))) + } + Err(e) => { + crate::logging::event_warn( + "MCP_LIFECYCLE", + vec![ + ("phase", "connect_failed".to_string()), + ("server", server_name.clone()), + ("session_id", session_id.to_string()), + ("error", e.to_string()), + ], + ); + Ok( + ToolOutput::new(format!("Failed to connect to '{}': {}", server_name, e)) + .with_title("MCP: Connection failed"), + ) + } + } + } + + async fn disconnect_server(&self, params: McpToolInput) -> Result<ToolOutput> { + let server_name = params + .server + .ok_or_else(|| anyhow::anyhow!("'server' is required for disconnect action"))?; + + let manager = self.manager.read().await; + let connected = manager.connected_servers().await; + + if !connected.contains(&server_name) { + return Ok(ToolOutput::new(format!( + "Server '{}' is not connected.\n\nConnected servers: {}", + server_name, + if connected.is_empty() { + "(none)".to_string() + } else { + connected.join(", ") + } + )) + .with_title("MCP: Not connected")); + } + drop(manager); + + let manager = self.manager.read().await; + manager.disconnect(&server_name).await?; + drop(manager); + + // Unregister tools for this server + if let Some(ref registry) = self.registry { + let removed = registry + .unregister_prefix(&format!("mcp__{}__", server_name)) + .await; + crate::logging::event_info( + "MCP_LIFECYCLE", + vec![ + ("phase", "tools_unregistered".to_string()), + ("server", server_name.clone()), + ("removed_tool_count", removed.len().to_string()), + ], + ); + } + + Ok( + ToolOutput::new(format!("Disconnected from MCP server '{}'", server_name)) + .with_title(format!("MCP: Disconnected {}", server_name)), + ) + } + + async fn reload_config(&self, session_id: &str) -> Result<ToolOutput> { + // Load fresh config, resolved against the session's project directory + // rather than the server process cwd (issue #420). + let config = self.manager.read().await.load_fresh_config(); + + if config.servers.is_empty() { + // Unregister all existing MCP tools before reporting empty + if let Some(ref registry) = self.registry { + registry.unregister_prefix("mcp__").await; + } + return Ok(ToolOutput::new( + "No servers found in config.\n\n\ + Add servers to ~/.jcode/mcp.json (global) or .jcode/mcp.json (project):\n\ + {\n \"servers\": {\n \"server-name\": {\n \"command\": \"/path/to/server\",\n \"args\": [],\n \"env\": {},\n \"shared\": true\n }\n }\n}\n\n\ + .claude/mcp.json is also supported for compatibility." + ).with_title("MCP: Empty config")); + } + + // Unregister all existing MCP server tools before reload + if let Some(ref registry) = self.registry { + registry.unregister_prefix("mcp__").await; + } + + let mut manager = self.manager.write().await; + let (successes, failures) = manager.reload().await?; + + let servers = manager.connected_servers().await; + let all_tools = manager.all_tools().await; + drop(manager); + + // Re-register tools from fresh connections + if let Some(ref registry) = self.registry { + let mcp_tools = crate::mcp::create_mcp_tools(Arc::clone(&self.manager)).await; + for (name, tool) in mcp_tools { + registry.register(name, tool).await; + } + } + + let enabled_count = config + .servers + .values() + .filter(|cfg| cfg.is_enabled()) + .count(); + let disabled_count = config.servers.len() - enabled_count; + let mut output = format!( + "Reloaded MCP config. Connected: {}/{}\n\n", + successes, enabled_count + ); + if disabled_count > 0 { + output.push_str(&format!( + "{} server(s) disabled in config (kept, not spawned).\n\n", + disabled_count + )); + } + + // Show failures first + if !failures.is_empty() { + crate::logging::event_warn( + "MCP_LIFECYCLE", + vec![ + ("phase", "reload_connect_failures".to_string()), + ("session_id", session_id.to_string()), + ("failure_count", failures.len().to_string()), + ( + "servers", + failures + .iter() + .map(|(name, _)| name.clone()) + .collect::<Vec<_>>() + .join(","), + ), + ], + ); + output.push_str("## Connection Failures\n"); + for (name, error) in &failures { + output.push_str(&format!(" - {}: {}\n", name, error)); + } + output.push('\n'); + } + + for server in &servers { + output.push_str(&format!("## {}\n", server)); + let server_tools: Vec<_> = all_tools.iter().filter(|(s, _)| s == server).collect(); + + for (_, tool) in server_tools { + output.push_str(&format!(" - {}\n", tool.name)); + } + output.push('\n'); + } + + Ok(ToolOutput::new(output).with_title("MCP: Reloaded")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::Tool; + use std::fs; + use std::path::PathBuf; + + fn create_test_tool() -> McpManagementTool { + // Use an explicit empty config so tests are hermetic: McpManager::new() + // would load the developer's real ~/.jcode/mcp.json, and list output + // now includes configured-but-not-connected servers (issue #436). + let manager = Arc::new(RwLock::new(McpManager::with_config( + crate::mcp::McpConfig::default(), + ))); + McpManagementTool::new(manager) + } + + fn create_test_context() -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-message".to_string(), + tool_call_id: "test-tool-call".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } + } + + struct LocalMcpConfigGuard { + path: PathBuf, + backup: Option<String>, + created_dir: bool, + } + + impl LocalMcpConfigGuard { + fn new(content: &str) -> std::io::Result<Self> { + let path = PathBuf::from(".jcode/mcp.json"); + let dir = path + .parent() + .ok_or_else(|| std::io::Error::other("missing parent"))?; + let created_dir = if !dir.exists() { + fs::create_dir_all(dir)?; + true + } else { + false + }; + let backup = if path.exists() { + Some(fs::read_to_string(&path)?) + } else { + None + }; + fs::write(&path, content)?; + Ok(Self { + path, + backup, + created_dir, + }) + } + } + + impl Drop for LocalMcpConfigGuard { + fn drop(&mut self) { + match &self.backup { + Some(content) => { + let _ = fs::write(&self.path, content); + } + None => { + let _ = fs::remove_file(&self.path); + if self.created_dir + && let Some(dir) = self.path.parent() + { + let _ = fs::remove_dir(dir); + } + } + } + } + } + + #[test] + fn test_tool_name() { + let tool = create_test_tool(); + assert_eq!(tool.name(), "mcp"); + } + + #[test] + fn test_tool_description() { + let tool = create_test_tool(); + assert!(tool.description().contains("MCP")); + assert!(tool.description().contains("Model Context Protocol")); + } + + #[test] + fn test_parameters_schema() { + let tool = create_test_tool(); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"]["action"].is_object()); + assert!(schema["properties"]["server"].is_object()); + assert!(schema["properties"]["command"].is_object()); + } + + #[tokio::test] + async fn test_list_empty() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "list"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("No MCP servers connected")); + } + + #[tokio::test] + async fn test_list_shows_disabled_configured_server() { + // Issue #436: disabled servers stay visible in the list with their + // state, so users can see and enable them on demand. + let mut config = crate::mcp::McpConfig::default(); + config.servers.insert( + "off-server".to_string(), + McpServerConfig { + command: "some-bin".to_string(), + args: vec![], + env: HashMap::new(), + shared: true, + transport: None, + url: None, + enabled: Some(false), + disabled: None, + }, + ); + let manager = Arc::new(RwLock::new(McpManager::with_config(config))); + let tool = McpManagementTool::new(manager); + let ctx = create_test_context(); + + let result = tool.execute(json!({"action": "list"}), ctx).await.unwrap(); + assert!( + result.output.contains("off-server"), + "disabled server must be listed: {}", + result.output + ); + assert!( + result.output.contains("disabled in config"), + "disabled state must be visible: {}", + result.output + ); + } + + #[tokio::test] + async fn test_connect_missing_server() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "connect", "command": "/bin/test"}); + + let result = tool.execute(input, ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("server")); + } + + #[tokio::test] + async fn test_connect_missing_command() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "connect", "server": "test"}); + + let result = tool.execute(input, ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("command")); + } + + #[tokio::test] + async fn test_disconnect_not_connected() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "disconnect", "server": "nonexistent"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("not connected")); + } + + #[tokio::test] + async fn test_unknown_action() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "invalid_action"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("Unknown action")); + } + + #[tokio::test] + async fn test_reload_empty_config() { + let _guard = + LocalMcpConfigGuard::new("{\"servers\":{}}").expect("create temporary .jcode/mcp.json"); + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "reload"}); + + let result = tool.execute(input, ctx).await.unwrap(); + // With config merging, global config may have servers. + // If both are empty: "No servers found in config" + // If global has servers: "Reloaded MCP config" (may show connection failures) + assert!( + result.output.contains("No servers") + || result.output.contains("Empty config") + || result.output.contains("Connected servers: 0") + || result.output.contains("Reloaded MCP config") + ); + } +} diff --git a/crates/jcode-app-core/src/tool/memory.rs b/crates/jcode-app-core/src/tool/memory.rs new file mode 100644 index 0000000..bf89759 --- /dev/null +++ b/crates/jcode-app-core/src/tool/memory.rs @@ -0,0 +1,473 @@ +//! Memory tool for storing and recalling information across sessions + +use super::{Tool, ToolContext, ToolOutput}; +use crate::memory::{MemoryCategory, MemoryEntry, MemoryManager, MemoryScope}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +pub struct MemoryTool { + manager: MemoryManager, +} + +impl MemoryTool { + pub fn new() -> Self { + Self { + manager: MemoryManager::new(), + } + } + + /// Create a memory tool in test mode (isolated storage) + pub fn new_test() -> Self { + Self { + manager: MemoryManager::new_test(), + } + } + + fn parse_scope(scope: Option<&str>, default: MemoryScope) -> Result<MemoryScope> { + match scope.unwrap_or(match default { + MemoryScope::Project => "project", + MemoryScope::Global => "global", + MemoryScope::All => "all", + }) { + "project" => Ok(MemoryScope::Project), + "global" => Ok(MemoryScope::Global), + "all" => Ok(MemoryScope::All), + other => Err(anyhow::anyhow!( + "Unknown scope: {}. Use project, global, or all", + other + )), + } + } +} + +#[derive(Debug, Deserialize)] +struct MemoryInput { + action: String, + #[serde(default)] + content: Option<String>, + #[serde(default)] + category: Option<String>, + #[serde(default)] + query: Option<String>, + #[serde(default)] + id: Option<String>, + #[serde(default)] + tags: Option<Vec<String>>, + #[serde(default)] + scope: Option<String>, + /// For link action: source memory ID + #[serde(default)] + from_id: Option<String>, + /// For link action: target memory ID + #[serde(default)] + to_id: Option<String>, + /// For link action: relationship weight (0.0-1.0) + #[serde(default)] + weight: Option<f32>, + /// For related action: traversal depth (default: 2) + #[serde(default)] + depth: Option<usize>, + /// For recall action: max results (default: 10) + #[serde(default)] + limit: Option<usize>, + /// For recall action: retrieval mode + #[serde(default)] + mode: Option<String>, +} + +#[async_trait] +impl Tool for MemoryTool { + fn name(&self) -> &str { + "memory" + } + + fn description(&self) -> &str { + "Manage memory." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["remember", "recall", "search", "list", "forget", "tag", "link", "related"], + "description": "Action." + }, + "content": { "type": "string" }, + "category": { + "type": "string", + "enum": ["fact", "preference", "entity", "correction"] + }, + "query": { "type": "string" }, + "id": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "scope": { "type": "string", "enum": ["project", "global", "all"] }, + "from_id": { "type": "string" }, + "to_id": { "type": "string" }, + "limit": { "type": "integer", "description": "Max results." } + }, + "required": ["action"] + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + use crate::memory; + use crate::memory_types::{MemoryEventKind, MemoryState}; + + let input: MemoryInput = serde_json::from_value(input)?; + let action_label = input.action.clone(); + let session_id = ctx.session_id.clone(); + + match input.action.as_str() { + "remember" => { + let content = input + .content + .ok_or_else(|| anyhow::anyhow!("content required"))?; + let category: MemoryCategory = input + .category + .as_deref() + .unwrap_or("fact") + .parse() + .map_err(|err| anyhow::anyhow!("invalid memory category: {}", err))?; + let scope = input.scope.as_deref().unwrap_or("project"); + memory::set_state(MemoryState::ToolAction { + action: "remember".into(), + detail: truncate_for_widget(&content, 40), + }); + let mut entry = + MemoryEntry::new(category.clone(), &content).with_source(ctx.session_id); + if let Some(tags) = input.tags { + entry = entry.with_tags(tags); + } + let id = if scope == "global" { + self.manager.remember_global(entry)? + } else { + self.manager.remember_project(entry)? + }; + // The agent just wrote this memory itself; the content is in + // the transcript (tool call + result), so auto-recall should + // not inject it back into this session. + memory::mark_memories_known( + &session_id, + std::slice::from_ref(&id), + "stored via memory tool in this session", + ); + memory::add_event(MemoryEventKind::ToolRemembered { + content: truncate_for_widget(&content, 60), + scope: scope.to_string(), + category: category.to_string(), + }); + memory::set_state(MemoryState::Idle); + Ok(ToolOutput::new(format!( + "Remembered {} ({}): \"{}\" [id: {}]", + category, scope, content, id + ))) + } + "recall" => { + let limit = input.limit.unwrap_or(10); + let scope = Self::parse_scope(input.scope.as_deref(), MemoryScope::All)?; + let mode = input.mode.as_deref().unwrap_or_else(|| { + if input.query.is_some() { + "cascade" + } else { + "recent" + } + }); + + match mode { + "recent" => { + memory::set_state(MemoryState::ToolAction { + action: "recall".into(), + detail: "recent".into(), + }); + let result = match self.manager.get_prompt_memories_scoped(limit, scope) { + Some(memories) => { + let count = + memories.lines().filter(|l| l.starts_with("- ")).count(); + memory::add_event(MemoryEventKind::ToolRecalled { + query: "(recent)".into(), + count, + }); + Ok(ToolOutput::new(format!("Recent memories:\n{}", memories))) + } + None => { + memory::add_event(MemoryEventKind::ToolRecalled { + query: "(recent)".into(), + count: 0, + }); + Ok(ToolOutput::new("No memories stored yet.")) + } + }; + memory::set_state(MemoryState::Idle); + result + } + "semantic" | "cascade" => { + let query = match &input.query { + Some(q) => q.clone(), + None => { + return Err(anyhow::anyhow!( + "query required for semantic/cascade mode" + )); + } + }; + memory::set_state(MemoryState::ToolAction { + action: "recall".into(), + detail: truncate_for_widget(&query, 40), + }); + + let results = if mode == "cascade" { + self.manager + .find_similar_with_cascade_scoped(&query, 0.5, limit, scope)? + } else { + self.manager + .find_similar_scoped(&query, 0.5, limit, scope)? + }; + + memory::add_event(MemoryEventKind::ToolRecalled { + query: truncate_for_widget(&query, 40), + count: results.len(), + }); + memory::set_state(MemoryState::Idle); + + if results.is_empty() { + Ok(ToolOutput::new(format!( + "No memories found matching '{}'. Try recall without query to see recent memories.", + query + ))) + } else { + let mut out = format!( + "Found {} relevant memories for '{}':\n\n", + results.len(), + query + ); + for (entry, score) in results { + let tags_str = if entry.tags.is_empty() { + String::new() + } else { + format!(" [{}]", entry.tags.join(", ")) + }; + out.push_str(&format!( + "- [{}] {}{}\n id: {} (relevance: {:.0}%)\n\n", + entry.category, + entry.content, + tags_str, + entry.id, + score * 100.0 + )); + } + Ok(ToolOutput::new(out)) + } + } + other => Err(anyhow::anyhow!( + "Unknown mode: {}. Use recent, semantic, or cascade", + other + )), + } + } + "search" => { + let query = input + .query + .ok_or_else(|| anyhow::anyhow!("query required"))?; + let scope = Self::parse_scope(input.scope.as_deref(), MemoryScope::All)?; + memory::set_state(MemoryState::ToolAction { + action: "search".into(), + detail: truncate_for_widget(&query, 40), + }); + let results = self.manager.search_scoped(&query, scope)?; + memory::add_event(MemoryEventKind::ToolRecalled { + query: truncate_for_widget(&query, 40), + count: results.len(), + }); + memory::set_state(MemoryState::Idle); + if results.is_empty() { + Ok(ToolOutput::new(format!("No memories matching '{}'", query))) + } else { + let mut out = format!("Found {} memories:\n\n", results.len()); + for e in results { + out.push_str(&format!( + "- [{}] {}\n id: {}\n\n", + e.category, e.content, e.id + )); + } + Ok(ToolOutput::new(out)) + } + } + "list" => { + let scope = Self::parse_scope(input.scope.as_deref(), MemoryScope::All)?; + memory::set_state(MemoryState::ToolAction { + action: "list".into(), + detail: String::new(), + }); + let all = self.manager.list_all_scoped(scope)?; + memory::add_event(MemoryEventKind::ToolListed { count: all.len() }); + memory::set_state(MemoryState::Idle); + if all.is_empty() { + Ok(ToolOutput::new("No memories stored.")) + } else { + let mut out = format!("All memories ({}):\n\n", all.len()); + for e in all { + out.push_str(&format!( + "- [{}] {}\n id: {}\n\n", + e.category, e.content, e.id + )); + } + Ok(ToolOutput::new(out)) + } + } + "forget" => { + let id = input.id.ok_or_else(|| anyhow::anyhow!("id required"))?; + memory::set_state(MemoryState::ToolAction { + action: "forget".into(), + detail: truncate_for_widget(&id, 30), + }); + let found = self.manager.forget(&id)?; + memory::add_event(MemoryEventKind::ToolForgot { id: id.clone() }); + memory::set_state(MemoryState::Idle); + if found { + Ok(ToolOutput::new(format!("Forgot: {}", id))) + } else { + Ok(ToolOutput::new(format!("Not found: {}", id))) + } + } + "tag" => { + let id = input.id.ok_or_else(|| anyhow::anyhow!("id required"))?; + let tags = input.tags.ok_or_else(|| anyhow::anyhow!("tags required"))?; + + if tags.is_empty() { + return Err(anyhow::anyhow!("At least one tag required")); + } + + memory::set_state(MemoryState::ToolAction { + action: "tag".into(), + detail: format!("{} +{}", truncate_for_widget(&id, 20), tags.join(",")), + }); + for tag in &tags { + self.manager.tag_memory(&id, tag)?; + } + let tags_str = tags.join(", "); + memory::add_event(MemoryEventKind::ToolTagged { + id: id.clone(), + tags: tags_str.clone(), + }); + memory::set_state(MemoryState::Idle); + + Ok(ToolOutput::new(format!( + "Tagged memory {} with: {}", + id, tags_str + ))) + } + "link" => { + let from_id = input + .from_id + .ok_or_else(|| anyhow::anyhow!("from_id required"))?; + let to_id = input + .to_id + .ok_or_else(|| anyhow::anyhow!("to_id required"))?; + let weight = input.weight.unwrap_or(0.5); + + memory::set_state(MemoryState::ToolAction { + action: "link".into(), + detail: format!( + "{} -> {}", + truncate_for_widget(&from_id, 15), + truncate_for_widget(&to_id, 15) + ), + }); + self.manager.link_memories(&from_id, &to_id, weight)?; + memory::add_event(MemoryEventKind::ToolLinked { + from: from_id.clone(), + to: to_id.clone(), + }); + memory::set_state(MemoryState::Idle); + Ok(ToolOutput::new(format!( + "Linked memories {} -> {} (weight {:.2})", + from_id, to_id, weight + ))) + } + "related" => { + let id = input.id.ok_or_else(|| anyhow::anyhow!("id required"))?; + let depth = input.depth.unwrap_or(2); + + memory::set_state(MemoryState::ToolAction { + action: "related".into(), + detail: truncate_for_widget(&id, 30), + }); + let related = self.manager.get_related(&id, depth)?; + memory::add_event(MemoryEventKind::ToolRecalled { + query: format!("related:{}", truncate_for_widget(&id, 20)), + count: related.len(), + }); + memory::set_state(MemoryState::Idle); + + if related.is_empty() { + Ok(ToolOutput::new(format!( + "No related memories found for {}", + id + ))) + } else { + let mut out = format!( + "Found {} memories related to {} (depth {}):\n\n", + related.len(), + id, + depth + ); + for e in related { + out.push_str(&format!( + "- [{}] {}\n id: {}\n\n", + e.category, e.content, e.id + )); + } + Ok(ToolOutput::new(out)) + } + } + other => Err(anyhow::anyhow!("Unknown action: {}", other)), + } + .map_err(|err| { + crate::logging::warn(&format!( + "[tool:memory] action failed action={} session_id={} error={}", + action_label, session_id, err + )); + err + }) + } +} + +fn truncate_for_widget(s: &str, max: usize) -> String { + if s.chars().count() > max { + let truncated: String = s.chars().take(max).collect(); + format!("{}…", truncated) + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_only_advertises_core_memory_fields() { + let schema = MemoryTool::new().parameters_schema(); + let props = schema["properties"] + .as_object() + .expect("memory schema should have properties"); + + assert!(props.contains_key("action")); + assert!(props.contains_key("content")); + assert!(props.contains_key("category")); + assert!(props.contains_key("query")); + assert!(props.contains_key("id")); + assert!(props.contains_key("tags")); + assert!(props.contains_key("scope")); + assert!(props.contains_key("from_id")); + assert!(props.contains_key("to_id")); + assert!(props.contains_key("limit")); + assert!(!props.contains_key("weight")); + assert!(!props.contains_key("depth")); + assert!(!props.contains_key("mode")); + } +} diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs new file mode 100644 index 0000000..f76b7fb --- /dev/null +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -0,0 +1,1094 @@ +mod agentgrep; +pub mod ambient; +mod apply_patch; +mod bash; +mod batch; +mod bg; +mod browser; +mod communicate; +#[cfg(target_os = "macos")] +mod computer; +mod conversation_search; +mod debug_socket; +mod discover; +mod edit; +mod gmail; +mod goal; +mod invalid; +mod ls; +pub mod mcp; +mod memory; +mod multiedit; +mod open; +mod patch; +mod read; +pub mod selfdev; +pub(crate) mod serde_coerce; +mod session_search; +pub(crate) mod session_search_index; +mod side_panel; +mod skill; +mod todo; +mod webfetch; +mod websearch; +mod write; + +use crate::compaction::CompactionManager; +use crate::provider::Provider; +use crate::skill::SkillRegistry; +use anyhow::Result; +use jcode_message_types::ToolDefinition; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::sync::{LazyLock, RwLock as StdRwLock}; +use tokio::sync::RwLock; + +pub(crate) use jcode_tool_core::intent_schema_property; +pub use jcode_tool_core::{StdinInputRequest, Tool, ToolContext, ToolExecutionMode}; +pub use jcode_tool_types::{ToolImage, ToolOutput}; +pub(crate) use session_search::spawn_recent_index_warmup; + +#[derive(Clone, Debug, Default)] +struct SessionToolPolicy { + allowed_tools: Option<HashSet<String>>, + disabled_tools: HashSet<String>, +} + +static SESSION_TOOL_POLICIES: LazyLock<StdRwLock<HashMap<String, SessionToolPolicy>>> = + LazyLock::new(|| StdRwLock::new(HashMap::new())); + +pub(crate) fn set_session_tool_policy( + session_id: &str, + allowed_tools: Option<HashSet<String>>, + disabled_tools: HashSet<String>, +) { + 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, + }, + ); +} + +pub(crate) fn clear_session_tool_policy(session_id: &str) { + let mut policies = SESSION_TOOL_POLICIES + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + policies.remove(session_id); +} + +fn session_tool_policy(session_id: &str) -> Option<SessionToolPolicy> { + SESSION_TOOL_POLICIES + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(session_id) + .cloned() +} + +/// Registry of available tools (Arc-wrapped for sharing) +/// +/// Clone creates a fresh CompactionManager so each subagent gets independent +/// message history tracking. Tools and skills are shared via Arc. +pub struct Registry { + tools: Arc<RwLock<HashMap<String, Arc<dyn Tool>>>>, + skills: Arc<RwLock<SkillRegistry>>, + compaction: Arc<RwLock<CompactionManager>>, +} + +impl Clone for Registry { + fn clone(&self) -> Self { + Self { + tools: self.tools.clone(), + skills: self.skills.clone(), + // Each clone gets a fresh CompactionManager to prevent parallel + // subagents from corrupting each other's message history + compaction: Arc::new(RwLock::new(CompactionManager::new())), + } + } +} + +impl Registry { + fn shared_skills_registry() -> Arc<RwLock<SkillRegistry>> { + SkillRegistry::shared_registry() + } + + fn insert_tool<T>(tools: &mut HashMap<String, Arc<dyn Tool>>, name: &str, tool: T) + where + T: Tool + 'static, + { + tools.insert(name.into(), Arc::new(tool) as Arc<dyn Tool>); + } + + fn insert_tool_timed<T>( + tools: &mut HashMap<String, Arc<dyn Tool>>, + timings: &mut Vec<(String, u128)>, + name: &str, + make_tool: impl FnOnce() -> T, + ) where + T: Tool + 'static, + { + let start = std::time::Instant::now(); + Self::insert_tool(tools, name, make_tool()); + timings.push((name.to_string(), start.elapsed().as_millis())); + } + + /// Create a lightweight empty registry (no tools, no skill loading). + /// Used by remote-mode clients that don't execute tools locally. + pub fn empty() -> Self { + Self { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(SkillRegistry::default())), + compaction: Arc::new(RwLock::new(CompactionManager::new())), + } + } + + /// Base tools that are stateless and can be shared across sessions. + /// Created once and cached in a OnceLock, then cloned (cheap Arc bumps) per session. + fn base_tools(skills: &Arc<RwLock<SkillRegistry>>) -> HashMap<String, Arc<dyn Tool>> { + use std::sync::OnceLock; + static BASE: OnceLock<HashMap<String, Arc<dyn Tool>>> = OnceLock::new(); + let base = BASE.get_or_init(|| { + let init_start = std::time::Instant::now(); + let mut timings = Vec::new(); + let mut m = HashMap::new(); + Self::insert_tool_timed(&mut m, &mut timings, "read", read::ReadTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "write", write::WriteTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "agentgrep", + agentgrep::AgentGrepTool::new, + ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "side_panel", + side_panel::SidePanelTool::new, + ); + Self::insert_tool_timed(&mut m, &mut timings, "edit", edit::EditTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "multiedit", + multiedit::MultiEditTool::new, + ); + Self::insert_tool_timed(&mut m, &mut timings, "patch", patch::PatchTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "apply_patch", + apply_patch::ApplyPatchTool::new, + ); + Self::insert_tool_timed(&mut m, &mut timings, "ls", ls::LsTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "bash", bash::BashTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "browser", browser::BrowserTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "open", open::OpenTool::new); + #[cfg(target_os = "macos")] + Self::insert_tool_timed( + &mut m, + &mut timings, + "macos_computer_use", + computer::ComputerTool::new, + ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "webfetch", + webfetch::WebFetchTool::new, + ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "websearch", + 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, + ); + Self::insert_tool_timed( + &mut m, + &mut timings, + "session_search", + session_search::SessionSearchTool::new, + ); + Self::insert_tool_timed(&mut m, &mut timings, "memory", memory::MemoryTool::new); + Self::insert_tool_timed( + &mut m, + &mut timings, + "initiative", + goal::InitiativeTool::new, + ); + Self::insert_tool_timed(&mut m, &mut timings, "gmail", gmail::GmailTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "schedule", ambient::ScheduleTool::new); + Self::insert_tool_timed(&mut m, &mut timings, "selfdev", selfdev::SelfDevTool::new); + let nonzero: Vec<String> = timings + .iter() + .filter(|(_, ms)| *ms > 0) + .map(|(name, ms)| format!("{name}={ms}ms")) + .collect(); + crate::logging::info(&format!( + "[TIMING] registry_base_tools_init: total={}ms, nonzero=[{}]", + init_start.elapsed().as_millis(), + nonzero.join(", ") + )); + m + }); + // Clone the Arc entries (cheap refcount bumps, not deep copies) + let mut tools = base.clone(); + // SkillTool needs the skills registry reference (shared across sessions) + Self::insert_tool( + &mut tools, + "skill_manage", + skill::SkillTool::new(skills.clone()), + ); + tools + } + + pub async fn new(_provider: Arc<dyn Provider>) -> Self { + let start = std::time::Instant::now(); + let skills_start = std::time::Instant::now(); + let skills = Self::shared_skills_registry(); + let skills_ms = skills_start.elapsed().as_millis(); + let compaction_start = std::time::Instant::now(); + let compaction = Arc::new(RwLock::new(CompactionManager::new())); + let compaction_ms = compaction_start.elapsed().as_millis(); + let registry_struct_start = std::time::Instant::now(); + let registry = Self { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: skills.clone(), + compaction: compaction.clone(), + }; + let registry_struct_ms = registry_struct_start.elapsed().as_millis(); + + let base_start = std::time::Instant::now(); + let mut tools_map = Self::base_tools(&skills); + let base_ms = base_start.elapsed().as_millis(); + + // Per-session tools that need provider/registry references + let session_tools_start = std::time::Instant::now(); + Self::insert_tool( + &mut tools_map, + "batch", + batch::BatchTool::new(registry.clone()), + ); + Self::insert_tool( + &mut tools_map, + "conversation_search", + conversation_search::ConversationSearchTool::new(compaction), + ); + // Sponsored 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", + discover::DiscoverToolsTool::new(), + ); + } + let session_tools_ms = session_tools_start.elapsed().as_millis(); + + let write_start = std::time::Instant::now(); + *registry.tools.write().await = tools_map; + let write_ms = write_start.elapsed().as_millis(); + crate::logging::info(&format!( + "[TIMING] registry_new: skills={}ms, compaction={}ms, registry_struct={}ms, base_tools={}ms, session_tools={}ms, write={}ms, total={}ms", + skills_ms, + compaction_ms, + registry_struct_ms, + base_ms, + session_tools_ms, + write_ms, + start.elapsed().as_millis() + )); + registry + } + + /// Get all tool definitions for the API + pub async fn definitions( + &self, + allowed_tools: Option<&HashSet<String>>, + ) -> Vec<ToolDefinition> { + let tools = self.tools.read().await; + let mut defs: Vec<ToolDefinition> = tools + .iter() + .filter(|(name, _)| allowed_tools.map(|set| set.contains(*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 + // the registry key is "mcp__server__tool" but Tool::name() returns + // just the raw tool name) + if def.name != *name { + def.name = name.clone(); + } + def + }) + .collect(); + + // Sort by name for deterministic ordering - critical for prompt cache hits + defs.sort_by(|a, b| a.name.cmp(&b.name)); + defs + } + + pub async fn tool_names(&self) -> Vec<String> { + let tools = self.tools.read().await; + tools.keys().cloned().collect() + } + + /// Enable test mode for memory tools (isolated storage) + /// Called when session is marked as debug + pub async fn enable_memory_test_mode(&self) { + let mut tools = self.tools.write().await; + + // Replace memory tool with test version + tools.insert( + "memory".to_string(), + Arc::new(memory::MemoryTool::new_test()) as Arc<dyn Tool>, + ); + + crate::logging::info("Memory test mode enabled - using isolated storage"); + } + + /// Resolve tool name aliases. + /// + /// When using OAuth, the API presents tools with Claude Code names + /// (e.g. `file_grep`, `shell_exec`). The model uses those names in + /// sub-tool calls (e.g. inside `batch`), but our registry uses internal + /// names (`grep`, `bash`). This mapping ensures both forms resolve + /// correctly. + /// + /// The canonical mapping lives in `jcode-tool-types::resolve_tool_name` so + /// lower-level crates (e.g. config) can normalize tool names without + /// depending on the tool subsystem; this method delegates to it. + pub(crate) fn resolve_tool_name(name: &str) -> &str { + jcode_tool_types::resolve_tool_name(name) + } + + /// Suggest up to 3 available tool names that look similar to `name`. + /// Uses cheap, dependency-free heuristics: case-insensitive equality, + /// prefix/substring containment, then bounded edit distance. Helps the + /// model recover from hallucinated tool names (#104). + fn closest_tool_names(name: &str, available: &[&str]) -> Vec<String> { + let needle = name.trim().to_ascii_lowercase(); + if needle.is_empty() { + return Vec::new(); + } + let mut scored: Vec<(usize, &str)> = available + .iter() + .filter_map(|candidate| { + let hay = candidate.to_ascii_lowercase(); + let score = if hay == needle { + 0 + } else if hay.starts_with(&needle) || needle.starts_with(&hay) { + 1 + } else if hay.contains(&needle) || needle.contains(&hay) { + 2 + } else { + let dist = levenshtein(&needle, &hay); + // Only suggest near-misses, scaled to the longer name. + let threshold = (hay.len().max(needle.len()) / 3).max(2); + if dist <= threshold { + 3 + dist + } else { + return None; + } + }; + Some((score, *candidate)) + }) + .collect(); + scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + scored + .into_iter() + .take(3) + .map(|(_, name)| name.to_string()) + .collect() + } + + /// Estimate token count for a string (chars / 4, matching compaction heuristic) + fn estimate_tokens(s: &str) -> usize { + crate::util::estimate_tokens(s) + } + + fn tool_lifecycle_fields( + phase: &str, + requested_name: &str, + resolved_name: &str, + input: &Value, + ctx: &ToolContext, + ) -> Vec<(String, String)> { + let cwd = ctx + .working_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()); + let input_json = serde_json::to_string(input).unwrap_or_default(); + let mut fields = vec![ + ("phase".to_string(), phase.to_string()), + ("tool_name".to_string(), requested_name.to_string()), + ("resolved_tool_name".to_string(), resolved_name.to_string()), + ("session_id".to_string(), ctx.session_id.clone()), + ("message_id".to_string(), ctx.message_id.clone()), + ("tool_call_id".to_string(), ctx.tool_call_id.clone()), + ( + "execution_mode".to_string(), + format!("{:?}", ctx.execution_mode), + ), + ("cwd".to_string(), cwd), + ("input_json_bytes".to_string(), input_json.len().to_string()), + ]; + + if let Some(object) = input.as_object() { + let mut keys = object.keys().cloned().collect::<Vec<_>>(); + keys.sort(); + fields.push(("input_keys".to_string(), keys.join(","))); + + let path_fields = [ + "file_path", + "path", + "target", + "target_path", + "old_path", + "new_path", + ]; + let mut touched_paths = Vec::new(); + for key in path_fields { + if let Some(path) = object.get(key).and_then(Value::as_str) { + touched_paths.push(format!( + "{key}:{}", + ctx.resolve_path(std::path::Path::new(path)).display() + )); + } + } + if let Some(paths) = object.get("paths").and_then(Value::as_array) { + for path in paths.iter().filter_map(Value::as_str).take(8) { + touched_paths.push(format!( + "paths:{}", + ctx.resolve_path(std::path::Path::new(path)).display() + )); + } + } + if !touched_paths.is_empty() { + fields.push(("touched_paths".to_string(), touched_paths.join(","))); + fields.push(( + "touched_path_count".to_string(), + touched_paths.len().to_string(), + )); + } + + for text_key in ["command", "prompt", "task", "query", "content"] { + if let Some(text) = object.get(text_key).and_then(Value::as_str) { + fields.push((format!("{text_key}_bytes"), text.len().to_string())); + fields.push(( + format!("{text_key}_chars"), + text.chars().count().to_string(), + )); + } + } + } + + fields + } + + /// Maximum fraction of context budget a single tool output may consume. + /// Outputs that would push total context beyond this are truncated. + const CONTEXT_GUARD_THRESHOLD: f32 = 0.90; + + /// Fire the `post_tool` observer hook with tool outcome metadata. + /// No-op (without building the payload) when the hook is not configured. + fn fire_post_tool_hook( + resolved_name: &str, + ctx: &ToolContext, + result: &Result<ToolOutput>, + latency_ms: u64, + ) { + if !crate::hooks::hook_configured("post_tool") { + return; + } + let mut event = crate::hooks::HookEvent::new("post_tool") + .session_id(ctx.session_id.clone()) + .field("TOOL_NAME", resolved_name) + .field("STATUS", if result.is_ok() { "ok" } else { "error" }) + .field("DURATION_MS", latency_ms.to_string()); + if let Some(dir) = &ctx.working_dir { + event = event.cwd(dir.display().to_string()); + } + match result { + Ok(output) => { + event = event.field("OUTPUT_BYTES", output.output.len().to_string()); + } + Err(error) => { + const ERROR_LIMIT: usize = 1000; + let message: String = error.to_string().chars().take(ERROR_LIMIT).collect(); + event = event.field("ERROR", message); + } + } + crate::hooks::dispatch_observer(event); + } + + /// Maximum fraction of context budget a single tool output may occupy. + /// Even if we have room, a single output shouldn't dominate the context. + const SINGLE_OUTPUT_MAX_FRACTION: f32 = 0.30; + + /// Execute a tool by name + pub async fn execute(&self, name: &str, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + 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) + { + return Err(anyhow::anyhow!("Tool '{}' is not allowed", resolved_name)); + } + if policy.disabled_tools.contains(resolved_name) { + return Err(anyhow::anyhow!("Tool '{}' is disabled", resolved_name)); + } + } + let tool = match tools.get(resolved_name) { + Some(tool) => tool.clone(), + None => { + // List available tools so the model can recover instead of + // spiraling through hallucinated names like "ToolSearch" (#104). + let mut available: Vec<&str> = tools.keys().map(|k| k.as_str()).collect(); + available.sort_unstable(); + let suggestions = Self::closest_tool_names(name, &available); + let mut msg = format!("Unknown tool: {name}."); + if !suggestions.is_empty() { + msg.push_str(&format!(" Did you mean: {}?", suggestions.join(", "))); + } + msg.push_str(&format!(" Available tools: {}.", available.join(", "))); + return Err(anyhow::anyhow!(msg)); + } + }; + + // Drop the lock before executing + drop(tools); + + // User-configured pre_tool gate: external policy hook that can block + // this call (exit 2). Skipped entirely when not configured. + if crate::hooks::hook_configured("pre_tool") { + let input_json = input.to_string(); + let working_dir = ctx + .working_dir + .as_ref() + .map(|dir| dir.display().to_string()); + let decision = crate::hooks::run_pre_tool_gate( + &ctx.session_id, + working_dir.as_deref(), + resolved_name, + &input_json, + ) + .await; + if let crate::hooks::GateDecision::Block { reason } = decision { + let mut fields = + Self::tool_lifecycle_fields("blocked", name, resolved_name, &input, &ctx); + fields.push(("block_reason".to_string(), reason.clone())); + crate::logging::event_warn("TOOL_LIFECYCLE", fields); + return Err(anyhow::anyhow!( + "Tool call blocked by pre_tool hook: {reason}" + )); + } + } + + crate::logging::event_info( + "TOOL_LIFECYCLE", + Self::tool_lifecycle_fields("start", name, resolved_name, &input, &ctx), + ); + + let started_at = std::time::Instant::now(); + let result = tool.execute(input.clone(), ctx.clone()).await; + let latency_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + + crate::telemetry::record_tool_execution(resolved_name, &input, result.is_ok(), latency_ms); + Self::fire_post_tool_hook(resolved_name, &ctx, &result, latency_ms); + + let mut output = match result { + Ok(output) => output, + Err(error) => { + let mut fields = + Self::tool_lifecycle_fields("error", name, resolved_name, &input, &ctx); + fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); + fields.push(("error".to_string(), crate::util::format_error_chain(&error))); + crate::logging::event_warn("TOOL_LIFECYCLE", fields); + return Err(error); + } + }; + + // Context overflow guard: check if this output would push us over the limit + output = self.guard_context_overflow(name, output).await; + + let mut fields = Self::tool_lifecycle_fields("done", name, resolved_name, &input, &ctx); + fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); + fields.push(("output_bytes".to_string(), output.output.len().to_string())); + fields.push(( + "output_chars".to_string(), + output.output.chars().count().to_string(), + )); + fields.push(("image_count".to_string(), output.images.len().to_string())); + crate::logging::event_info("TOOL_LIFECYCLE", fields); + + Ok(output) + } + + /// 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 { + let compaction = self.compaction.read().await; + let budget = compaction.token_budget(); + if budget == 0 { + return output; + } + + let current_tokens = compaction.effective_token_count(); + let output_tokens = Self::estimate_tokens(&output.output); + + // Check 1: Would adding this output push us over the safety threshold? + 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; + + let needs_truncation = projected > threshold_tokens || output_tokens > single_max_tokens; + + if !needs_truncation { + 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); + + // Convert token limit back to approximate character limit + let max_chars = max_tokens * 4; + + if output.output.len() <= max_chars { + return output; + } + + crate::logging::info(&format!( + "Context guard: truncating {} output from ~{}k to ~{}k tokens \ + (context: {}k/{}k, {:.0}% used)", + tool_name, + output_tokens / 1000, + max_tokens / 1000, + current_tokens / 1000, + budget / 1000, + (current_tokens as f32 / budget as f32) * 100.0, + )); + + // 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, + ) + }; + + ToolOutput { + output: truncated, + title: output.title, + metadata: output.metadata, + images: output.images, + } + } + + /// Register a tool dynamically (for MCP tools, etc.) + pub async fn register(&self, name: String, tool: Arc<dyn Tool>) { + let mut tools = self.tools.write().await; + tools.insert(name, tool); + } + + /// Register MCP tools (MCP management and server tools) + /// Connections happen in background to avoid blocking startup. + /// If `event_tx` is provided, sends an McpStatus event when connections complete. + /// If `shared_pool` is provided, shared servers reuse processes from the pool. + pub async fn register_mcp_tools( + &self, + event_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::protocol::ServerEvent>>, + shared_pool: Option<std::sync::Arc<crate::mcp::SharedMcpPool>>, + session_id: Option<String>, + ) { + self.register_mcp_tools_for_dir(event_tx, shared_pool, session_id, None) + .await + } + + /// Like [`Self::register_mcp_tools`], but resolves project-local MCP config + /// (`.mcp.json`, `.jcode/mcp.json`, `.claude/mcp.json`) against + /// `working_dir` instead of the server process cwd. Remote/client sessions + /// must pass their session working directory here (issue #420). + pub async fn register_mcp_tools_for_dir( + &self, + event_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::protocol::ServerEvent>>, + shared_pool: Option<std::sync::Arc<crate::mcp::SharedMcpPool>>, + session_id: Option<String>, + working_dir: Option<std::path::PathBuf>, + ) { + use crate::mcp::McpManager; + use std::sync::Arc; + use tokio::sync::RwLock; + + let mcp_manager = if let Some(pool) = shared_pool { + let sid = session_id.unwrap_or_else(|| "unknown".to_string()); + Arc::new(RwLock::new(McpManager::with_shared_pool_for_dir( + pool, + sid, + working_dir, + ))) + } else { + Arc::new(RwLock::new(McpManager::new())) + }; + + // Register MCP management tool immediately (with registry for dynamic tool registration) + let mcp_tool = + mcp::McpManagementTool::new(Arc::clone(&mcp_manager)).with_registry(self.clone()); + self.register("mcp".to_string(), Arc::new(mcp_tool) as Arc<dyn Tool>) + .await; + + // Check if we have enabled servers to connect to. Disabled servers stay + // configured (visible to the mcp management tool, connectable by name) + // but are not spawned, advertised, or shown as connecting (issue #436). + let (enabled_count, disabled_count) = { + let manager = mcp_manager.read().await; + let enabled = manager + .config() + .servers + .values() + .filter(|cfg| cfg.is_enabled()) + .count(); + (enabled, manager.config().servers.len() - enabled) + }; + + if disabled_count > 0 { + crate::logging::info(&format!( + "MCP: {} disabled server(s) in config (kept, not spawned)", + disabled_count + )); + } + + if enabled_count > 0 { + crate::logging::info(&format!("MCP: Found {} server(s) in config", enabled_count)); + + // Send immediate "connecting" status so the TUI shows loading state + // Server names with count 0 means "connecting..." + if let Some(ref tx) = event_tx { + let server_names: Vec<String> = { + let manager = mcp_manager.read().await; + manager + .config() + .servers + .iter() + .filter(|(_, cfg)| cfg.is_enabled()) + .map(|(name, _)| format!("{}:0", name)) + .collect() + }; + let _ = tx.send(crate::protocol::ServerEvent::McpStatus { + servers: server_names, + }); + } + + // Advertise-early: register proxy tools for each configured server + // from the on-disk schema cache *before* connections settle, so the + // first locked tool snapshot already contains MCP tools and we avoid + // the intentional prompt-cache miss entirely (#206 Phase 2). The + // proxies connect-on-first-call. Servers with no cached schemas yet + // (cold start, or reconfigured) fall back to the post-connect + // registration + one-shot late-register rebuild below. + let schema_cache = crate::mcp::McpSchemaCache::load(); + let mut advertised_servers: std::collections::BTreeSet<String> = + std::collections::BTreeSet::new(); + { + let config_servers: Vec<(String, crate::mcp::McpServerConfig)> = { + let manager = mcp_manager.read().await; + manager + .config() + .servers + .iter() + .filter(|(_, cfg)| cfg.is_enabled()) + .map(|(name, cfg)| (name.clone(), cfg.clone())) + .collect() + }; + let mut advertised_tool_count = 0usize; + for (server, cfg) in &config_servers { + if let Some(cached) = schema_cache.tools_for(server, cfg) { + let tools = crate::mcp::create_mcp_tools_from_cached( + server, + cached, + Arc::clone(&mcp_manager), + ); + advertised_tool_count += tools.len(); + for (name, tool) in tools { + self.register(name, tool).await; + } + advertised_servers.insert(server.clone()); + } + } + if advertised_tool_count > 0 { + crate::logging::info(&format!( + "MCP: advertised {} cached tool(s) from {} server(s) at spawn \ + (connect-on-first-call); zero prompt-cache miss expected (#206)", + advertised_tool_count, + advertised_servers.len() + )); + // Reflect the advertised tools in the status indicator + // immediately so the UI shows them before connections settle. + if let Some(ref tx) = event_tx { + let mut counts: std::collections::BTreeMap<String, usize> = + std::collections::BTreeMap::new(); + for (server, cfg) in &config_servers { + if let Some(cached) = schema_cache.tools_for(server, cfg) { + counts.insert(server.clone(), cached.len()); + } + } + let servers: Vec<String> = counts + .into_iter() + .map(|(name, count)| format!("{}:{}", name, count)) + .collect(); + let _ = tx.send(crate::protocol::ServerEvent::McpStatus { servers }); + } + } + } + + // Spawn connection and tool registration in background + let registry = self.clone(); + tokio::spawn(async move { + let (successes, failures) = { + let manager = mcp_manager.write().await; + manager.connect_all().await.unwrap_or((0, Vec::new())) + }; + + if successes > 0 { + crate::logging::info(&format!("MCP: Connected to {} server(s)", successes)); + } + if !failures.is_empty() { + for (name, error) in &failures { + crate::logging::event_rate_limited( + crate::logging::LogLevel::Error, + &format!("mcp_register_failed:{name}"), + std::time::Duration::from_secs(60), + "MCP_REGISTER_FAILED", + vec![("server", name.to_string()), ("error", error.to_string())], + ); + } + } + + // Register MCP server tools and collect server info + let tools = crate::mcp::create_mcp_tools(Arc::clone(&mcp_manager)).await; + let mut server_counts: std::collections::BTreeMap<String, usize> = + std::collections::BTreeMap::new(); + for (name, tool) in &tools { + if let Some(rest) = name.strip_prefix("mcp__") + && let Some((server, _)) = rest.split_once("__") + { + *server_counts.entry(server.to_string()).or_default() += 1; + } + // Idempotent: advertise-early may have already registered an + // identical proxy. Re-registering refreshes it with the live + // schema, which is correct (handles schema drift). + registry.register(name.clone(), tool.clone()).await; + } + + // Reconcile the on-disk schema cache with the live schemas so the + // next spawn can advertise the up-to-date tools with zero cache + // miss. Group live tool defs by server and update each entry + // under the current config fingerprint; prune servers that are + // no longer configured. (#206 Phase 2) + { + // Live tool defs grouped by server, plus a snapshot of the + // configured servers, captured under one read lock. + type LiveToolsByServer = + std::collections::BTreeMap<String, Vec<crate::mcp::McpToolDef>>; + type ConfigSnapshot = Vec<(String, crate::mcp::McpServerConfig)>; + let (live_by_server, config_snapshot): (LiveToolsByServer, ConfigSnapshot) = { + let manager = mcp_manager.read().await; + let mut grouped: std::collections::BTreeMap< + String, + Vec<crate::mcp::McpToolDef>, + > = std::collections::BTreeMap::new(); + for (server, def) in manager.all_tools().await { + grouped.entry(server).or_default().push(def); + } + let configs = manager + .config() + .servers + .iter() + .map(|(name, cfg)| (name.clone(), cfg.clone())) + .collect(); + (grouped, configs) + }; + + let mut cache = crate::mcp::McpSchemaCache::load(); + let mut dirty = false; + for (server, cfg) in &config_snapshot { + if let Some(defs) = live_by_server.get(server) { + // Only cache servers that actually exposed tools. + if cache.update(server, cfg, defs.clone()) { + dirty = true; + } + } + } + let configured_names: Vec<String> = + config_snapshot.iter().map(|(n, _)| n.clone()).collect(); + if cache.retain_servers(&configured_names) { + dirty = true; + } + if dirty { + cache.save(); + crate::logging::info( + "MCP: updated on-disk tool-schema cache from live connection (#206)", + ); + } + } + + // Notify client of MCP status + if let Some(tx) = event_tx { + let servers: Vec<String> = server_counts + .into_iter() + .map(|(name, count)| format!("{}:{}", name, count)) + .collect(); + let _ = tx.send(crate::protocol::ServerEvent::McpStatus { servers }); + } + }); + } + } + + /// Register self-dev tools (only for canary/self-dev sessions) + pub async fn register_selfdev_tools(&self) { + // Self-dev management tool + let selfdev_tool = selfdev::SelfDevTool::new(); + self.register( + "selfdev".to_string(), + Arc::new(selfdev_tool) as Arc<dyn Tool>, + ) + .await; + + // Debug socket tool for direct debug socket access + let debug_socket_tool = debug_socket::DebugSocketTool::new(); + self.register( + "debug_socket".to_string(), + Arc::new(debug_socket_tool) as Arc<dyn Tool>, + ) + .await; + } + + /// Register ambient-mode tools (only for ambient sessions) + pub async fn register_ambient_tools(&self) { + self.register( + "end_ambient_cycle".to_string(), + Arc::new(ambient::EndAmbientCycleTool::new()) as Arc<dyn Tool>, + ) + .await; + + self.register( + "schedule_ambient".to_string(), + Arc::new(ambient::ScheduleAmbientTool::new()) as Arc<dyn Tool>, + ) + .await; + + self.register( + "request_permission".to_string(), + Arc::new(ambient::RequestPermissionTool::new()) as Arc<dyn Tool>, + ) + .await; + + self.register( + "send_message".to_string(), + Arc::new(ambient::SendChannelMessageTool::new()) as Arc<dyn Tool>, + ) + .await; + } + + /// Unregister a tool + pub async fn unregister(&self, name: &str) -> Option<Arc<dyn Tool>> { + let mut tools = self.tools.write().await; + tools.remove(name) + } + + /// Unregister all tools matching a prefix + pub async fn unregister_prefix(&self, prefix: &str) -> Vec<String> { + let mut tools = self.tools.write().await; + let to_remove: Vec<String> = tools + .keys() + .filter(|k| k.starts_with(prefix)) + .cloned() + .collect(); + for name in &to_remove { + tools.remove(name); + } + to_remove + } + + /// Get shared access to the skill registry + pub fn skills(&self) -> Arc<RwLock<SkillRegistry>> { + self.skills.clone() + } + + /// Get shared access to the compaction manager + pub fn compaction(&self) -> Arc<RwLock<CompactionManager>> { + self.compaction.clone() + } +} + +/// Classic Levenshtein edit distance over Unicode scalar values. +/// Used only for tool-name "did you mean" suggestions, so the simple +/// O(n*m) two-row implementation is more than sufficient. +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec<char> = a.chars().collect(); + let b: Vec<char> = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + let mut prev: Vec<usize> = (0..=b.len()).collect(); + let mut curr: Vec<usize> = vec![0; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + curr[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = if ca == cb { 0 } else { 1 }; + curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[b.len()] +} + +#[cfg(test)] +mod tests; diff --git a/crates/jcode-app-core/src/tool/multiedit.rs b/crates/jcode-app-core/src/tool/multiedit.rs new file mode 100644 index 0000000..7d856f9 --- /dev/null +++ b/crates/jcode-app-core/src/tool/multiedit.rs @@ -0,0 +1,283 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use similar::{ChangeTag, TextDiff}; +use std::path::Path; + +pub struct MultiEditTool; + +impl MultiEditTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct MultiEditInput { + file_path: String, + edits: Vec<EditOperation>, +} + +#[derive(Deserialize)] +struct EditOperation { + old_string: String, + new_string: String, + #[serde(default)] + replace_all: bool, +} + +#[async_trait] +impl Tool for MultiEditTool { + fn name(&self) -> &str { + "multiedit" + } + + fn description(&self) -> &str { + "Apply multiple edits to one file." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["file_path", "edits"], + "properties": { + "intent": super::intent_schema_property(), + "file_path": { + "type": "string", + "description": "The path to the file to edit" + }, + "edits": { + "type": "array", + "description": "Array of edit operations to apply sequentially", + "items": { + "type": "object", + "required": ["old_string", "new_string"], + "properties": { + "old_string": { + "type": "string", + "description": "The text to find and replace" + }, + "new_string": { + "type": "string", + "description": "The replacement text" + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences (default: false)" + } + } + }, + "minItems": 1 + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: MultiEditInput = serde_json::from_value(input)?; + + let path = ctx.resolve_path(Path::new(¶ms.file_path)); + + if !path.exists() { + return Err(anyhow::anyhow!("File not found: {}", params.file_path)); + } + + let original_content = tokio::fs::read_to_string(&path).await?; + let mut content = original_content.clone(); + let mut applied = Vec::new(); + let mut failed = Vec::new(); + + for (i, edit) in params.edits.iter().enumerate() { + if edit.old_string == edit.new_string { + failed.push(format!("Edit {}: old_string equals new_string", i + 1)); + continue; + } + + let occurrences = content.matches(&edit.old_string).count(); + + if occurrences == 0 { + failed.push(format!("Edit {}: old_string not found", i + 1)); + continue; + } + + if occurrences > 1 && !edit.replace_all { + failed.push(format!( + "Edit {}: found {} occurrences, use replace_all or be more specific", + i + 1, + occurrences + )); + continue; + } + + // Apply the edit + if edit.replace_all { + content = content.replace(&edit.old_string, &edit.new_string); + applied.push(format!( + "Edit {}: replaced {} occurrences", + i + 1, + occurrences + )); + } else { + content = content.replacen(&edit.old_string, &edit.new_string, 1); + applied.push(format!("Edit {}: replaced 1 occurrence", i + 1)); + } + } + + // Write the result + tokio::fs::write(&path, &content).await?; + + // Format output + let mut output = format!("Edited {}\n\n", params.file_path); + + if !applied.is_empty() { + output.push_str("Applied:\n"); + for msg in &applied { + output.push_str(&format!(" ✓ {}\n", msg)); + } + } + + if !failed.is_empty() { + output.push_str("\nFailed:\n"); + for msg in &failed { + output.push_str(&format!(" ✗ {}\n", msg)); + } + } + + output.push_str(&format!( + "\nTotal: {} applied, {} failed\n", + applied.len(), + failed.len() + )); + + // Generate diff summary + if !applied.is_empty() { + output.push_str("\nDiff:\n"); + output.push_str(&generate_diff_summary(&original_content, &content)); + } + + Ok(ToolOutput::new(output).with_title(params.file_path.clone())) + } +} + +/// Generate a compact diff: "42- old" / "42+ new" (max 30 lines) +fn generate_diff_summary(old: &str, new: &str) -> String { + let diff = TextDiff::from_lines(old, new); + let mut output = String::new(); + let mut lines_shown = 0; + const MAX_LINES: usize = 30; + + let mut old_line = 1usize; + let mut new_line = 1usize; + + for change in diff.iter_all_changes() { + match change.tag() { + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + continue; + } + ChangeTag::Delete => { + let content = change.value().trim(); + old_line += 1; + if content.is_empty() { + continue; + } + if lines_shown >= MAX_LINES { + output.push_str("...\n"); + break; + } + output.push_str(&format!("{}- {}\n", old_line - 1, content)); + lines_shown += 1; + } + ChangeTag::Insert => { + let content = change.value().trim(); + new_line += 1; + if content.is_empty() { + continue; + } + if lines_shown >= MAX_LINES { + output.push_str("...\n"); + break; + } + output.push_str(&format!("{}+ {}\n", new_line - 1, content)); + lines_shown += 1; + } + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_diff_summary_single_change() { + let old = "hello world"; + let new = "hello rust"; + let diff = generate_diff_summary(old, new); + + // Compact format: "1- content" / "1+ content" + assert!(diff.contains("1- hello world"), "Should show deleted line"); + assert!(diff.contains("1+ hello rust"), "Should show added line"); + } + + #[test] + fn test_generate_diff_summary_multi_line() { + let old = "line one\nline two\nline three"; + let new = "line one\nchanged two\nline three"; + let diff = generate_diff_summary(old, new); + + assert!(diff.contains("2- line two"), "Should show deleted line"); + assert!(diff.contains("2+ changed two"), "Should show added line"); + } + + #[test] + fn test_generate_diff_summary_multiple_edits() { + let old = "line 1\nline 2\nline 3\nline 4\nline 5"; + let new = "line 1\nmodified 2\nline 3\nmodified 4\nline 5"; + let diff = generate_diff_summary(old, new); + + // Should show both changed lines with correct line numbers + assert!(diff.contains("2- line 2"), "Should show line 2 deleted"); + assert!(diff.contains("2+ modified 2"), "Should show line 2 added"); + assert!(diff.contains("4- line 4"), "Should show line 4 deleted"); + assert!(diff.contains("4+ modified 4"), "Should show line 4 added"); + } + + #[test] + fn test_generate_diff_summary_truncation() { + // Create old and new with more than 30 changed lines + let old = (1..=35) + .map(|i| format!("old line {}", i)) + .collect::<Vec<_>>() + .join("\n"); + let new = (1..=35) + .map(|i| format!("new line {}", i)) + .collect::<Vec<_>>() + .join("\n"); + let diff = generate_diff_summary(&old, &new); + + assert!(diff.contains("..."), "Should truncate after 30 lines"); + } + + #[test] + fn test_generate_diff_summary_line_number_format() { + let old = "old"; + let new = "new"; + let diff = generate_diff_summary(old, new); + + // Compact format: no padding + assert!( + diff.contains("1- old"), + "Should have line number directly before minus" + ); + assert!( + diff.contains("1+ new"), + "Should have line number directly before plus" + ); + } +} diff --git a/crates/jcode-app-core/src/tool/open.rs b/crates/jcode-app-core/src/tool/open.rs new file mode 100644 index 0000000..23def9a --- /dev/null +++ b/crates/jcode-app-core/src/tool/open.rs @@ -0,0 +1,740 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashSet; +#[cfg(all(unix, not(target_os = "macos")))] +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +const OPEN_GRACE_PERIOD_MS: u64 = 800; +const URL_SCHEMES: &[&str] = &["http", "https", "mailto", "file"]; + +pub struct OpenTool; + +impl OpenTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +struct OpenInput { + #[serde(default)] + action: Option<String>, + target: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenAction { + Open, + Reveal, +} + +impl OpenAction { + fn parse(raw: Option<&str>) -> Result<Self> { + match raw.unwrap_or("open") { + "open" => Ok(Self::Open), + "reveal" => Ok(Self::Reveal), + other => anyhow::bail!( + "Unknown open action: {}. Valid actions: open, reveal", + other + ), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Open => "open", + Self::Reveal => "reveal", + } + } +} + +#[derive(Debug, Clone)] +enum ParsedTarget { + Local(PathBuf), + Url(String), +} + +#[derive(Debug, Clone)] +enum ResolvedTarget { + Local { + path: PathBuf, + kind: LocalTargetKind, + }, + Url(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalTargetKind { + File, + Directory, +} + +impl LocalTargetKind { + fn as_str(self) -> &'static str { + match self { + Self::File => "file", + Self::Directory => "directory", + } + } +} + +struct OpenOutcome { + _backend: String, + message: String, + metadata: Value, +} + +#[async_trait] +impl Tool for OpenTool { + fn name(&self) -> &str { + "open" + } + + fn description(&self) -> &str { + "Open or reveal a file, folder, or URL for the user." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["target"], + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["open", "reveal"], + "description": "Open action. Use 'open' to open the target or 'reveal' to show it in the file manager." + }, + "target": { + "type": "string", + "description": "Open target." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + if input.get("mode").is_some() { + anyhow::bail!("open.mode was removed. Use action='open' or action='reveal'."); + } + let params: OpenInput = serde_json::from_value(input)?; + let requested_target = params.target.clone(); + let action = OpenAction::parse(params.action.as_deref())?; + let action_name = action.as_str(); + let target = match resolve_target(¶ms.target, &ctx) + .with_context(|| format!("Invalid open target: {}", params.target)) + { + Ok(target) => target, + Err(err) => { + crate::logging::warn(&format!( + "[tool:open] failed to resolve target action={} session_id={} target={} error={}", + action_name, ctx.session_id, requested_target, err + )); + return Err(err); + } + }; + + let outcome = match action { + OpenAction::Open => perform_open(&target).await, + OpenAction::Reveal => perform_reveal(&target).await, + } + .map_err(|err| { + crate::logging::warn(&format!( + "[tool:open] action failed action={} session_id={} target={} error={}", + action_name, ctx.session_id, requested_target, err + )); + err + })?; + + Ok(ToolOutput::new(outcome.message) + .with_title(format!("open {}", action_name)) + .with_metadata(outcome.metadata)) + } +} + +fn resolve_target(target: &str, ctx: &ToolContext) -> Result<ResolvedTarget> { + let trimmed = target.trim(); + if trimmed.is_empty() { + anyhow::bail!("target cannot be empty"); + } + + if let Some(parsed_target) = parse_target(trimmed)? { + return match parsed_target { + ParsedTarget::Url(url) => Ok(ResolvedTarget::Url(url)), + ParsedTarget::Local(path) => resolve_local_target(path), + }; + } + + let expanded = expand_home(trimmed)?; + let resolved = ctx.resolve_path(Path::new(&expanded)); + resolve_local_target(resolved) +} + +fn resolve_local_target(resolved: PathBuf) -> Result<ResolvedTarget> { + if !resolved.exists() { + anyhow::bail!("Target path does not exist: {}", resolved.display()); + } + + let kind = if resolved.is_dir() { + LocalTargetKind::Directory + } else { + LocalTargetKind::File + }; + + Ok(ResolvedTarget::Local { + path: resolved, + kind, + }) +} + +fn parse_target(target: &str) -> Result<Option<ParsedTarget>> { + let Some(colon_index) = target.find(':') else { + return Ok(None); + }; + + let scheme = &target[..colon_index]; + if scheme.len() == 1 && cfg!(windows) { + return Ok(None); + } + if !scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return Ok(None); + } + + let lower = scheme.to_ascii_lowercase(); + if !URL_SCHEMES.iter().any(|allowed| *allowed == lower) { + anyhow::bail!( + "Unsupported URL scheme: {}. Allowed schemes: {}", + scheme, + URL_SCHEMES.join(", ") + ); + } + + let parsed = + url::Url::parse(target).with_context(|| format!("Failed to parse URL: {}", target))?; + + if lower == "file" { + let path = parsed.to_file_path().map_err(|_| { + anyhow::anyhow!( + "Failed to convert file URL to a local path: {}. Use a local path or a valid file:// URL.", + target + ) + })?; + return Ok(Some(ParsedTarget::Local(path))); + } + + Ok(Some(ParsedTarget::Url(parsed.to_string()))) +} + +fn expand_home(path: &str) -> Result<PathBuf> { + if path == "~" { + return dirs::home_dir().context("Could not determine home directory for '~'"); + } + + let rest = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")); + if let Some(rest) = rest { + let home = dirs::home_dir().context("Could not determine home directory for '~'")?; + return Ok(home.join(rest)); + } + + Ok(PathBuf::from(path)) +} + +async fn perform_open(target: &ResolvedTarget) -> Result<OpenOutcome> { + // On Wayland compositors that prevent focus stealing (e.g. niri), launching + // a URL via the system opener forwards it to the default browser, but the + // existing browser window is not raised. Snapshot the browser windows before + // opening so we can detect/raise the right one afterwards. + let is_url = matches!(target, ResolvedTarget::Url(_)); + let focus_ctx = if is_url { + capture_browser_windows_before_open() + } else { + None + }; + let backend = open_target(target).await?; + if is_url { + focus_browser_window_after_open(focus_ctx).await; + } + let (message, metadata) = match target { + ResolvedTarget::Url(url) => ( + format!("Opened {} in the default browser via {}.", url, backend), + json!({ + "action": "open", + "target_kind": "url", + "target": url, + "backend": backend, + }), + ), + ResolvedTarget::Local { path, kind } => { + let noun = match kind { + LocalTargetKind::File => "file", + LocalTargetKind::Directory => "folder", + }; + ( + format!( + "Opened {} {} in the default application via {}.", + noun, + path.display(), + backend, + ), + json!({ + "action": "open", + "target_kind": kind.as_str(), + "target": path.to_string_lossy(), + "backend": backend, + }), + ) + } + }; + + Ok(OpenOutcome { + _backend: backend, + message, + metadata, + }) +} + +async fn perform_reveal(target: &ResolvedTarget) -> Result<OpenOutcome> { + let ResolvedTarget::Local { path, kind } = target else { + anyhow::bail!("The reveal action only supports local filesystem paths"); + }; + + let (backend, selection_supported) = reveal_target(path, *kind).await?; + let message = if *kind == LocalTargetKind::Directory { + format!( + "Opened folder {} in the file manager via {}.", + path.display(), + backend + ) + } else if selection_supported { + format!( + "Revealed {} in the file manager via {}.", + path.display(), + backend + ) + } else { + format!( + "Opened the containing folder for {} via {}. File selection is not supported on this platform.", + path.display(), + backend, + ) + }; + + Ok(OpenOutcome { + _backend: backend.clone(), + message, + metadata: json!({ + "action": "reveal", + "target_kind": kind.as_str(), + "target": path.to_string_lossy(), + "backend": backend, + "selection_supported": selection_supported, + }), + }) +} + +async fn open_target(target: &ResolvedTarget) -> Result<String> { + // Never open real windows from test binaries, and honor + // NO_BROWSER/JCODE_NO_BROWSER. Without this, agent-loop tests that + // exercise the open tool pop browsers/viewers on the developer's desktop. + if crate::auth::browser_suppressed(false) { + anyhow::bail!( + "opening files/URLs is suppressed (NO_BROWSER/JCODE_NO_BROWSER or test harness)" + ); + } + + #[cfg(target_os = "macos")] + { + let mut cmd = Command::new("open"); + match target { + ResolvedTarget::Local { path, .. } => { + cmd.arg(path); + } + ResolvedTarget::Url(url) => { + cmd.arg(url); + } + } + spawn_with_grace(cmd, "open").await?; + return Ok("open".to_string()); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + let arg = match target { + ResolvedTarget::Local { path, .. } => OsString::from(path.as_os_str()), + ResolvedTarget::Url(url) => OsString::from(url), + }; + try_unix_openers(vec![vec![arg.clone()], vec![OsString::from("open"), arg]]).await + } + + #[cfg(windows)] + { + match target { + ResolvedTarget::Local { path, .. } => open::that_detached(path), + ResolvedTarget::Url(url) => open::that_detached(url), + } + .context("Failed to open with the system opener")?; + Ok("system opener".to_string()) + } +} + +async fn reveal_target(path: &Path, kind: LocalTargetKind) -> Result<(String, bool)> { + // Same suppression as open_target: no real windows from tests/NO_BROWSER. + if crate::auth::browser_suppressed(false) { + anyhow::bail!( + "revealing files is suppressed (NO_BROWSER/JCODE_NO_BROWSER or test harness)" + ); + } + + #[cfg(target_os = "macos")] + { + let mut cmd = Command::new("open"); + if kind == LocalTargetKind::Directory { + cmd.arg(path); + } else { + cmd.arg("-R").arg(path); + } + spawn_with_grace(cmd, "open").await?; + return Ok(("open".to_string(), true)); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + let to_open = if kind == LocalTargetKind::Directory { + path.to_path_buf() + } else { + path.parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| path.to_path_buf()) + }; + let backend = try_unix_openers(vec![ + vec![OsString::from(to_open.as_os_str())], + vec![OsString::from("open"), OsString::from(to_open.as_os_str())], + ]) + .await?; + Ok((backend, false)) + } + + #[cfg(windows)] + { + let mut cmd = Command::new("explorer.exe"); + if kind == LocalTargetKind::Directory { + cmd.arg(path); + } else { + cmd.arg(format!("/select,{}", path.display())); + } + spawn_with_grace(cmd, "explorer").await?; + return Ok(("explorer".to_string(), true)); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +async fn try_unix_openers(arg_sets: Vec<Vec<OsString>>) -> Result<String> { + let candidates = [("xdg-open", 0usize), ("gio", 1usize)]; + let mut not_found = 0usize; + let mut failures: Vec<String> = Vec::new(); + + for (program, arg_index) in candidates { + let args = arg_sets.get(arg_index).cloned().unwrap_or_else(Vec::new); + let mut cmd = Command::new(program); + cmd.args(args); + match spawn_with_grace(cmd, program).await { + Ok(()) => return Ok(program.to_string()), + Err(e) => { + let is_missing = e + .downcast_ref::<std::io::Error>() + .map(|io| io.kind() == std::io::ErrorKind::NotFound) + .unwrap_or(false); + if is_missing { + not_found += 1; + } else { + failures.push(format!("{}: {}", program, e)); + } + } + } + } + + if not_found == candidates.len() { + anyhow::bail!("No system opener found. Tried xdg-open and gio."); + } + + anyhow::bail!( + "Failed to open with the system opener: {}", + failures.join("; ") + ) +} + +async fn spawn_with_grace(mut cmd: Command, backend: &str) -> Result<()> { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = crate::platform::spawn_detached(&mut cmd) + .with_context(|| format!("Failed to open via {}", backend))?; + + tokio::time::sleep(Duration::from_millis(OPEN_GRACE_PERIOD_MS)).await; + if let Some(status) = child.try_wait()? + && !status.success() + { + match status.code() { + Some(code) => { + anyhow::bail!("Opener '{}' exited immediately with code {}", backend, code) + } + None => anyhow::bail!("Opener '{}' exited immediately", backend), + } + } + + Ok(()) +} + +/// Snapshot/context used to raise the browser window after opening a URL. +/// +/// `stems` are normalized application identifiers for the default browser(s) +/// (e.g. `firefox`), and `pre_ids` are the matching window ids that existed +/// before the open so we can prefer a freshly created window afterwards. +#[allow(dead_code)] +struct BrowserFocusContext { + stems: Vec<String>, + pre_ids: HashSet<u64>, +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn capture_browser_windows_before_open() -> Option<BrowserFocusContext> { + // Only the niri compositor is wired up for explicit window raising today. + std::env::var_os("NIRI_SOCKET")?; + let stems = browser_app_stems(); + if stems.is_empty() { + return None; + } + let pre_ids = match query_niri_windows() { + Ok(windows) => windows + .iter() + .filter(|w| app_id_matches(w.app_id.as_deref(), &stems)) + .map(|w| w.id) + .collect(), + Err(_) => HashSet::new(), + }; + Some(BrowserFocusContext { stems, pre_ids }) +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn capture_browser_windows_before_open() -> Option<BrowserFocusContext> { + None +} + +async fn focus_browser_window_after_open(ctx: Option<BrowserFocusContext>) { + #[cfg(all(unix, not(target_os = "macos")))] + { + let Some(ctx) = ctx else { + return; + }; + // niri IPC is synchronous subprocess work; keep it off the async runtime. + let _ = tokio::task::spawn_blocking(move || focus_browser_window_niri(&ctx)).await; + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + { + let _ = ctx; + } +} + +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +#[derive(Debug, Clone, Deserialize)] +struct NiriWindow { + id: u64, + #[serde(rename = "app_id")] + app_id: Option<String>, + #[serde(default)] + focus_timestamp: Option<NiriTimestamp>, +} + +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +#[derive(Debug, Clone, Copy, Deserialize)] +struct NiriTimestamp { + secs: u64, + nanos: u32, +} + +/// Decide which browser window niri should raise after a URL open. +/// +/// Prefer a window that appeared after the open (a brand new browser window). +/// Otherwise raise the most recently focused matching window, which is where +/// browsers add a new tab by default. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn select_window_to_focus( + windows: &[NiriWindow], + stems: &[String], + pre_ids: &HashSet<u64>, +) -> Option<u64> { + let matching: Vec<&NiriWindow> = windows + .iter() + .filter(|w| app_id_matches(w.app_id.as_deref(), stems)) + .collect(); + if matching.is_empty() { + return None; + } + + if let Some(newest) = matching + .iter() + .filter(|w| !pre_ids.contains(&w.id)) + .max_by_key(|w| w.id) + { + return Some(newest.id); + } + + matching + .iter() + .max_by_key(|w| { + w.focus_timestamp + .map(|t| (t.secs, t.nanos)) + .unwrap_or((0, 0)) + }) + .map(|w| w.id) +} + +/// Case-insensitive match between a window `app_id` and known browser stems. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn app_id_matches(app_id: Option<&str>, stems: &[String]) -> bool { + let Some(app_id) = app_id else { + return false; + }; + let app_id = app_id.to_ascii_lowercase(); + if app_id.len() < 3 { + return false; + } + stems.iter().any(|stem| { + let stem = stem.to_ascii_lowercase(); + stem.len() >= 3 && (app_id == stem || app_id.contains(&stem) || stem.contains(&app_id)) + }) +} + +/// Normalize a desktop entry (e.g. `org.mozilla.firefox.desktop`) into the +/// application id stems a compositor is likely to report. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn normalize_desktop_entry_to_stems(entry: &str) -> Vec<String> { + let entry = entry.trim(); + let stem = entry + .strip_suffix(".desktop") + .unwrap_or(entry) + .to_ascii_lowercase(); + if stem.is_empty() { + return Vec::new(); + } + let mut stems = vec![stem.clone()]; + if let Some(last) = stem.rsplit('.').next() + && last != stem + && !last.is_empty() + { + stems.push(last.to_string()); + } + stems +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn browser_app_stems() -> Vec<String> { + let mut stems: Vec<String> = Vec::new(); + let mut push_unique = |new: Vec<String>| { + for s in new { + if !s.is_empty() && !stems.contains(&s) { + stems.push(s); + } + } + }; + + if let Some(entry) = run_capture("xdg-settings", &["get", "default-web-browser"]) { + push_unique(normalize_desktop_entry_to_stems(&entry)); + } + if let Some(entry) = run_capture("xdg-mime", &["query", "default", "x-scheme-handler/https"]) { + push_unique(normalize_desktop_entry_to_stems(&entry)); + } + + // Fall back to well-known browser application ids so raising still works + // even when the xdg helpers are unavailable. + push_unique( + [ + "firefox", + "librewolf", + "waterfox", + "floorp", + "zen", + "chromium", + "google-chrome", + "brave-browser", + "vivaldi", + "opera", + "epiphany", + ] + .iter() + .map(|s| s.to_string()) + .collect(), + ); + + stems +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn run_capture(program: &str, args: &[&str]) -> Option<String> { + let output = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { None } else { Some(text) } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn query_niri_windows() -> Result<Vec<NiriWindow>> { + let output = Command::new("niri") + .args(["msg", "-j", "windows"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .context("failed to run `niri msg -j windows`")?; + if !output.status.success() { + anyhow::bail!("`niri msg -j windows` exited unsuccessfully"); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let windows: Vec<NiriWindow> = serde_json::from_str(stdout.trim()) + .context("failed to parse `niri msg -j windows` output")?; + Ok(windows) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn focus_browser_window_niri(ctx: &BrowserFocusContext) { + // The browser may need a moment to create/update its window after the open; + // retry a few times before giving up. + for attempt in 0..3 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(300)); + } + let Ok(windows) = query_niri_windows() else { + return; + }; + if let Some(id) = select_window_to_focus(&windows, &ctx.stems, &ctx.pre_ids) { + let _ = Command::new("niri") + .args(["msg", "action", "focus-window", "--id"]) + .arg(id.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + return; + } + } +} + +#[cfg(test)] +#[path = "open_tests.rs"] +mod open_tests; diff --git a/crates/jcode-app-core/src/tool/open_tests.rs b/crates/jcode-app-core/src/tool/open_tests.rs new file mode 100644 index 0000000..ad1413a --- /dev/null +++ b/crates/jcode-app-core/src/tool/open_tests.rs @@ -0,0 +1,162 @@ +use super::*; + +fn make_ctx() -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-msg".to_string(), + tool_call_id: "test-call".to_string(), + working_dir: Some(std::env::temp_dir()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } +} + +#[test] +fn parse_target_accepts_supported_schemes() { + let parsed = parse_target("https://example.com/docs").unwrap(); + assert!(matches!(parsed, Some(ParsedTarget::Url(url)) if url == "https://example.com/docs")); + + let parsed_mailto = parse_target("mailto:test@example.com").unwrap(); + assert!( + matches!(parsed_mailto, Some(ParsedTarget::Url(url)) if url == "mailto:test@example.com") + ); +} + +#[test] +fn parse_target_rejects_custom_scheme() { + let err = parse_target("javascript:alert(1)").unwrap_err(); + assert!( + err.to_string() + .contains("Unsupported URL scheme: javascript") + ); +} + +#[test] +fn resolve_target_treats_file_url_as_local_path() { + let ctx = make_ctx(); + let temp_file = std::env::temp_dir().join("jcode-open-tool-file-url.txt"); + std::fs::write(&temp_file, "test").unwrap(); + + let file_url = url::Url::from_file_path(&temp_file).unwrap().to_string(); + let resolved = resolve_target(&file_url, &ctx).unwrap(); + + assert!(matches!( + resolved, + ResolvedTarget::Local { path, kind: LocalTargetKind::File } + if path == temp_file + )); + + let _ = std::fs::remove_file(&temp_file); +} + +#[test] +fn resolve_target_rejects_missing_local_path() { + let ctx = make_ctx(); + let err = resolve_target("./definitely-missing-jcode-open-target", &ctx).unwrap_err(); + assert!(err.to_string().contains("Target path does not exist")); +} + +#[tokio::test] +async fn execute_rejects_reveal_for_url() { + let tool = OpenTool::new(); + let err = tool + .execute( + json!({"action": "reveal", "target": "https://example.com"}), + make_ctx(), + ) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("The reveal action only supports local filesystem paths") + ); +} + +#[tokio::test] +async fn execute_rejects_removed_mode_parameter() { + let tool = OpenTool::new(); + let err = tool + .execute( + json!({"mode": "reveal", "target": "https://example.com"}), + make_ctx(), + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains("open.mode was removed"), + "err={err}" + ); +} + +#[test] +fn expand_home_handles_plain_non_tilde_paths() { + let path = expand_home("docs/spec.pdf").unwrap(); + assert_eq!(path, PathBuf::from("docs/spec.pdf")); +} + +fn window(id: u64, app_id: &str, ts: Option<(u64, u32)>) -> NiriWindow { + NiriWindow { + id, + app_id: Some(app_id.to_string()), + focus_timestamp: ts.map(|(secs, nanos)| NiriTimestamp { secs, nanos }), + } +} + +#[test] +fn normalize_desktop_entry_strips_suffix_and_adds_stem() { + let stems = normalize_desktop_entry_to_stems("firefox.desktop"); + assert_eq!(stems, vec!["firefox".to_string()]); + + let stems = normalize_desktop_entry_to_stems("org.mozilla.firefox.desktop"); + assert_eq!( + stems, + vec!["org.mozilla.firefox".to_string(), "firefox".to_string()] + ); +} + +#[test] +fn app_id_matches_is_case_insensitive_and_guards_short_ids() { + let stems = vec!["firefox".to_string()]; + assert!(app_id_matches(Some("firefox"), &stems)); + assert!(app_id_matches(Some("Firefox"), &stems)); + assert!(!app_id_matches(Some("kitty"), &stems)); + assert!(!app_id_matches(None, &stems)); + // Very short ids should never match to avoid accidental substring hits. + assert!(!app_id_matches(Some("fi"), &["fi".to_string()])); +} + +#[test] +fn select_window_prefers_newly_created_browser_window() { + let windows = vec![ + window(10, "kitty", Some((100, 0))), + window(11, "firefox", Some((50, 0))), + window(20, "firefox", Some((40, 0))), + ]; + let stems = vec!["firefox".to_string()]; + let pre_ids: std::collections::HashSet<u64> = [11].into_iter().collect(); + // Window 20 is the new firefox window (not in pre_ids), so it wins even + // though window 11 was focused more recently. + assert_eq!(select_window_to_focus(&windows, &stems, &pre_ids), Some(20)); +} + +#[test] +fn select_window_falls_back_to_most_recently_focused() { + let windows = vec![ + window(11, "firefox", Some((50, 0))), + window(20, "firefox", Some((90, 0))), + ]; + let stems = vec!["firefox".to_string()]; + // Both windows already existed: raise the most recently focused one (20), + // which is where browsers add a new tab. + let pre_ids: std::collections::HashSet<u64> = [11, 20].into_iter().collect(); + assert_eq!(select_window_to_focus(&windows, &stems, &pre_ids), Some(20)); +} + +#[test] +fn select_window_returns_none_without_browser_windows() { + let windows = vec![window(10, "kitty", Some((100, 0)))]; + let stems = vec!["firefox".to_string()]; + let pre_ids = std::collections::HashSet::new(); + assert_eq!(select_window_to_focus(&windows, &stems, &pre_ids), None); +} diff --git a/crates/jcode-app-core/src/tool/patch.rs b/crates/jcode-app-core/src/tool/patch.rs new file mode 100644 index 0000000..b39c4b7 --- /dev/null +++ b/crates/jcode-app-core/src/tool/patch.rs @@ -0,0 +1,319 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use similar::{ChangeTag, TextDiff}; +use std::path::Path; + +pub struct PatchTool; + +impl PatchTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct PatchInput { + patch_text: String, +} + +#[derive(Debug)] +struct FilePatch { + path: String, + hunks: Vec<Hunk>, + is_new: bool, + is_delete: bool, +} + +#[derive(Debug)] +struct Hunk { + old_start: usize, + old_lines: Vec<String>, + new_lines: Vec<String>, +} + +#[async_trait] +impl Tool for PatchTool { + fn name(&self) -> &str { + "patch" + } + + fn description(&self) -> &str { + "Apply a standard unified diff patch using ---/+++ headers. Prefer apply_patch for Codex-style patches." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["patch_text"], + "properties": { + "intent": super::intent_schema_property(), + "patch_text": { + "type": "string", + "description": "Patch text." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: PatchInput = serde_json::from_value(input)?; + + let patches = parse_patch(¶ms.patch_text)?; + + if patches.is_empty() { + return Err(anyhow::anyhow!("No valid patches found in input")); + } + + let mut results = Vec::new(); + + for patch in patches { + let resolved_path = ctx.resolve_path(Path::new(&patch.path)); + let result = apply_patch_with_diff(&patch, &resolved_path).await; + match result { + Ok((msg, diff)) => { + if diff.is_empty() { + results.push(format!("✓ {}: {}", patch.path, msg)); + } else { + results.push(format!("✓ {}: {}\n{}", patch.path, msg, diff)); + } + } + Err(e) => results.push(format!("✗ {}: {}", patch.path, e)), + } + } + + Ok(ToolOutput::new(results.join("\n\n"))) + } +} + +fn parse_patch(text: &str) -> Result<Vec<FilePatch>> { + let mut patches = Vec::new(); + let lines: Vec<&str> = text.lines().collect(); + let mut i = 0; + + while i < lines.len() { + // Look for --- line + if lines[i].starts_with("---") { + let old_file = lines[i] + .strip_prefix("--- ") + .unwrap_or("") + .split('\t') + .next() + .unwrap_or(""); + + i += 1; + if i >= lines.len() || !lines[i].starts_with("+++") { + continue; + } + + let new_file = lines[i] + .strip_prefix("+++ ") + .unwrap_or("") + .split('\t') + .next() + .unwrap_or(""); + + // Determine the actual file path + let path = if new_file == "/dev/null" { + old_file.strip_prefix("a/").unwrap_or(old_file).to_string() + } else { + new_file.strip_prefix("b/").unwrap_or(new_file).to_string() + }; + + let is_new = old_file == "/dev/null"; + let is_delete = new_file == "/dev/null"; + + i += 1; + + // Parse hunks + let mut hunks = Vec::new(); + while i < lines.len() && !lines[i].starts_with("---") { + if lines[i].starts_with("@@") { + if let Some(hunk) = parse_hunk(&lines, &mut i) { + hunks.push(hunk); + } + } else { + i += 1; + } + } + + if !hunks.is_empty() || is_new || is_delete { + patches.push(FilePatch { + path, + hunks, + is_new, + is_delete, + }); + } + } else { + i += 1; + } + } + + Ok(patches) +} + +fn parse_hunk(lines: &[&str], i: &mut usize) -> Option<Hunk> { + // Parse @@ -start,count +start,count @@ + let header = lines[*i]; + let parts: Vec<&str> = header.split_whitespace().collect(); + + if parts.len() < 3 { + *i += 1; + return None; + } + + let old_range = parts[1].strip_prefix('-').unwrap_or(parts[1]); + let old_start: usize = old_range + .split(',') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + + *i += 1; + + let mut old_lines = Vec::new(); + let mut new_lines = Vec::new(); + + while *i < lines.len() { + let line = lines[*i]; + + if line.starts_with("@@") || line.starts_with("---") { + break; + } + + if let Some(content) = line.strip_prefix('-') { + old_lines.push(content.to_string()); + } else if let Some(content) = line.strip_prefix('+') { + new_lines.push(content.to_string()); + } else if let Some(content) = line.strip_prefix(' ') { + old_lines.push(content.to_string()); + new_lines.push(content.to_string()); + } else if line.is_empty() || line == "\\ No newline at end of file" { + // Context line or special marker + } + + *i += 1; + } + + Some(Hunk { + old_start, + old_lines, + new_lines, + }) +} + +/// Apply a patch and return (status_message, diff_output) +async fn apply_patch_with_diff(patch: &FilePatch, path: &Path) -> Result<(String, String)> { + // Handle deletion + if patch.is_delete { + if path.exists() { + let old_content = tokio::fs::read_to_string(path).await.unwrap_or_default(); + tokio::fs::remove_file(path).await?; + let diff = generate_diff(&old_content, "", 1); + return Ok(("deleted".to_string(), diff)); + } else { + return Err(anyhow::anyhow!("file does not exist")); + } + } + + // Handle new file + if patch.is_new { + if path.exists() { + return Err(anyhow::anyhow!("file already exists")); + } + + // Create parent directories + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Collect all new lines from hunks + let content: String = patch + .hunks + .iter() + .flat_map(|h| h.new_lines.iter()) + .map(|l| format!("{}\n", l)) + .collect(); + + tokio::fs::write(path, &content).await?; + let diff = generate_diff("", &content, 1); + return Ok(("created".to_string(), diff)); + } + + // Handle modification + if !path.exists() { + return Err(anyhow::anyhow!("file does not exist")); + } + + let old_content = tokio::fs::read_to_string(path).await?; + let mut lines: Vec<String> = old_content.lines().map(|s| s.to_string()).collect(); + + // Find the first affected line for diff context + let first_line = patch.hunks.iter().map(|h| h.old_start).min().unwrap_or(1); + + // Apply hunks in reverse order to preserve line numbers + for hunk in patch.hunks.iter().rev() { + let start = hunk.old_start.saturating_sub(1); + let end = (start + hunk.old_lines.len()).min(lines.len()); + + // Remove old lines and insert new ones + lines.splice(start..end, hunk.new_lines.iter().cloned()); + } + + let new_content = lines.join("\n") + "\n"; + tokio::fs::write(path, &new_content).await?; + + let diff = generate_diff(&old_content, &new_content, first_line); + Ok((format!("modified ({} hunks)", patch.hunks.len()), diff)) +} + +/// Generate a compact diff with line numbers (max 30 lines) +fn generate_diff(old: &str, new: &str, start_line: usize) -> String { + let diff = TextDiff::from_lines(old, new); + let mut output = String::new(); + let mut line_count = 0; + const MAX_LINES: usize = 30; + + let mut old_line = start_line; + let mut new_line = start_line; + + for change in diff.iter_all_changes() { + if line_count >= MAX_LINES { + output.push_str("... (diff truncated)\n"); + break; + } + + let content = change.value().trim_end_matches('\n'); + let (prefix, line_num) = match change.tag() { + ChangeTag::Delete => { + let num = old_line; + old_line += 1; + if content.trim().is_empty() { + continue; + } + ("-", num) + } + ChangeTag::Insert => { + let num = new_line; + new_line += 1; + if content.trim().is_empty() { + continue; + } + ("+", num) + } + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + continue; + } + }; + + output.push_str(&format!("{}{} {}\n", line_num, prefix, content)); + line_count += 1; + } + + output.trim_end().to_string() +} diff --git a/crates/jcode-app-core/src/tool/read.rs b/crates/jcode-app-core/src/tool/read.rs new file mode 100644 index 0000000..d35b432 --- /dev/null +++ b/crates/jcode-app-core/src/tool/read.rs @@ -0,0 +1,549 @@ +#![cfg_attr(test, allow(clippy::items_after_test_module))] + +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use anyhow::Result; +use async_trait::async_trait; +use jcode_terminal_image::{ImageDisplayParams, ImageProtocol, display_image}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::Path; + +const DEFAULT_LIMIT: usize = 5000; +const MAX_LINE_LEN: usize = 2000; + +pub struct ReadTool; + +impl ReadTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct ReadInput { + file_path: String, + #[serde(default)] + start_line: Option<usize>, + #[serde(default)] + end_line: Option<usize>, + #[serde(default)] + offset: Option<usize>, + #[serde(default)] + limit: Option<usize>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReadRangeStyle { + OffsetLimit, + StartEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct NormalizedReadRange { + offset: usize, + limit: usize, + style: ReadRangeStyle, +} + +impl NormalizedReadRange { + fn next_offset(self) -> usize { + self.offset + self.limit + } + + fn next_start_line(self) -> usize { + self.next_offset() + 1 + } +} + +fn normalize_read_range(params: &ReadInput) -> Result<NormalizedReadRange> { + let has_start_end = params.start_line.is_some() || params.end_line.is_some(); + let has_mixed_offset = match (params.start_line, params.end_line, params.offset) { + (Some(start_line), _, Some(offset)) => { + if start_line == 0 { + true + } else { + offset.checked_add(1) != Some(start_line) + } + } + (None, Some(_), Some(offset)) => offset != 0, + _ => params.offset.is_some(), + }; + + if has_start_end && has_mixed_offset { + return Err(anyhow::anyhow!( + "Use either start_line/end_line (1-based) or offset (0-based), not both. `limit` may be used with either style." + )); + } + + if has_start_end { + let start_line = params.start_line.unwrap_or(1); + if start_line == 0 { + return Err(anyhow::anyhow!( + "start_line must be 1 or greater (it is 1-based)." + )); + } + + let limit = if let Some(end_line) = params.end_line { + if end_line == 0 { + return Err(anyhow::anyhow!( + "end_line must be 1 or greater (it is 1-based)." + )); + } + if end_line < start_line { + return Err(anyhow::anyhow!( + "end_line ({}) must be greater than or equal to start_line ({}).", + end_line, + start_line + )); + } + end_line - start_line + 1 + } else { + params.limit.unwrap_or(DEFAULT_LIMIT) + }; + + return Ok(NormalizedReadRange { + offset: start_line - 1, + limit, + style: ReadRangeStyle::StartEnd, + }); + } + + Ok(NormalizedReadRange { + offset: params.offset.unwrap_or(0), + limit: params.limit.unwrap_or(DEFAULT_LIMIT), + style: ReadRangeStyle::OffsetLimit, + }) +} + +#[async_trait] +impl Tool for ReadTool { + fn name(&self) -> &str { + "read" + } + + fn description(&self) -> &str { + "Read a file. Supports text files, image files, and PDFs." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["file_path"], + "properties": { + "intent": super::intent_schema_property(), + "file_path": { + "type": "string", + "description": "Path to a file." + }, + "start_line": { + "type": "integer", + "description": "1-based start line for text files." + }, + "limit": { + "type": "integer", + "description": "Max text lines to read. Default 5000." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: ReadInput = serde_json::from_value(input)?; + let range = normalize_read_range(¶ms)?; + + let path = ctx.resolve_path(Path::new(¶ms.file_path)); + + // Check if file exists + if !path.exists() { + // Try to find similar files + let suggestions = find_similar_files(&path); + if suggestions.is_empty() { + return Err(anyhow::anyhow!("File not found: {}", params.file_path)); + } else { + return Err(anyhow::anyhow!( + "File not found: {}\nDid you mean: {}", + params.file_path, + suggestions.join(", ") + )); + } + } + + // Check for image files and display in terminal if supported + if is_image_file(&path) { + return handle_image_file(&path, ¶ms.file_path); + } + + // Check for PDF files and extract text + if is_pdf_file(&path) { + return handle_pdf_file(&path, ¶ms.file_path); + } + + // Check for binary files + if is_binary_file(&path) { + return Ok(ToolOutput::new(format!( + "Binary file detected: {}\nUse appropriate tools to handle binary files.", + params.file_path + ))); + } + + // Read file + let content = tokio::fs::read_to_string(&path).await?; + + // Single-pass: count lines while building output + let mut output = String::with_capacity(range.limit.min(2000) * 80); + let mut total_lines = 0usize; + let mut truncated_line_count = 0usize; + let end_exclusive = range.offset + range.limit; + { + use std::fmt::Write; + for (i, line) in content.lines().enumerate() { + total_lines = i + 1; + if i < range.offset { + continue; + } + if i >= end_exclusive { + // Still need to count remaining lines + continue; + } + let line_num = i + 1; + if line.len() > MAX_LINE_LEN { + truncated_line_count += 1; + let _ = writeln!( + output, + "{:>5}\t{}...", + line_num, + crate::util::truncate_str(line, MAX_LINE_LEN) + ); + } else { + let _ = writeln!(output, "{:>5}\t{}", line_num, line); + } + } + } + + let end = end_exclusive.min(total_lines); + + // Publish file touch event for swarm coordination + Bus::global().publish(BusEvent::FileTouch(FileTouch { + session_id: ctx.session_id.clone(), + path: path.to_path_buf(), + op: FileOp::Read, + intent: None, + summary: Some(format!( + "read lines {}-{} of {}", + range.offset + 1, + end, + total_lines + )), + detail: None, + })); + + if truncated_line_count > 0 || end < total_lines { + crate::logging::warn(&format!( + "[tool:read] returned truncated output for {} in session {} (tool_call={} range={}..{} total_lines={} truncated_lines={})", + params.file_path, + ctx.session_id, + ctx.tool_call_id, + range.offset + 1, + end, + total_lines, + truncated_line_count + )); + } + + // Add metadata + if end < total_lines { + let continuation_hint = match range.style { + ReadRangeStyle::OffsetLimit => format!("offset={}", range.next_offset()), + ReadRangeStyle::StartEnd => format!("start_line={}", range.next_start_line()), + }; + output.push_str(&format!( + "\n... {} more lines (use {} to continue)\n", + total_lines - end, + continuation_hint + )); + } + + if output.is_empty() { + Ok(ToolOutput::new("(empty file)")) + } else { + Ok(ToolOutput::new(output)) + } + } +} + +#[cfg(test)] +mod tests; + +fn is_binary_file(path: &Path) -> bool { + // Check by extension first (no I/O needed) + if let Some(ext) = path.extension() { + let ext = ext.to_string_lossy().to_lowercase(); + let binary_exts = [ + "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "zip", "tar", "gz", "bz2", "xz", + "7z", "rar", "exe", "dll", "so", "dylib", "o", "a", "class", "pyc", "wasm", "mp3", + "mp4", "avi", "mov", "mkv", "flac", "ogg", "wav", + ]; + if binary_exts.contains(&ext.as_str()) { + return true; + } + } + + // Read only the first 8KB to check for binary content (not the entire file) + use std::io::Read; + if let Ok(mut file) = std::fs::File::open(path) { + let mut buf = [0u8; 8192]; + if let Ok(n) = file.read(&mut buf) + && n > 0 + { + let null_count = buf[..n].iter().filter(|&&b| b == 0).count(); + return null_count > n / 10; + } + } + + false +} + +fn find_similar_files(path: &Path) -> Vec<String> { + let parent = path.parent().unwrap_or(Path::new(".")); + let filename = path.file_name().map(|s| s.to_string_lossy().to_lowercase()); + + let mut suggestions = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.filter_map(|e| e.ok()) { + let name = entry.file_name().to_string_lossy().to_lowercase(); + if let Some(ref target) = filename { + // Simple similarity check + let target_str: &str = target.as_ref(); + if name.contains(target_str) || target_str.contains(&name as &str) { + suggestions.push(entry.path().display().to_string()); + if suggestions.len() >= 3 { + break; + } + } + } + } + } + + suggestions +} + +/// Check if a file is an image based on extension +fn is_image_file(path: &Path) -> bool { + if let Some(ext) = path.extension() { + let ext = ext.to_string_lossy().to_lowercase(); + matches!( + ext.as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" + ) + } else { + false + } +} + +/// Handle reading an image file - display in terminal if supported AND return base64 for model vision +fn handle_image_file(path: &Path, file_path: &str) -> Result<ToolOutput> { + let protocol = ImageProtocol::detect(); + + let data = std::fs::read(path)?; + let file_size = data.len() as u64; + + let dimensions = get_image_dimensions_from_data(&data); + + let dim_str = dimensions + .map(|(w, h)| format!("{}x{}", w, h)) + .unwrap_or_else(|| "unknown".to_string()); + + let size_str = if file_size < 1024 { + format!("{} bytes", file_size) + } else if file_size < 1024 * 1024 { + format!("{:.1} KB", file_size as f64 / 1024.0) + } else { + format!("{:.1} MB", file_size as f64 / 1024.0 / 1024.0) + }; + + let mut terminal_displayed = false; + if protocol.is_supported() { + let params = ImageDisplayParams::from_terminal(); + match display_image(path, ¶ms) { + Ok(true) => { + terminal_displayed = true; + } + Ok(false) => {} + Err(e) => { + crate::logging::info(&format!("Warning: Failed to display image: {}", e)); + } + } + } + + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let media_type = match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + _ => "image/png", + }; + + const MAX_IMAGE_SIZE: u64 = 20 * 1024 * 1024; + let mut output = if file_size <= MAX_IMAGE_SIZE { + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data); + let display_note = if terminal_displayed { + "Displayed in terminal. " + } else { + "" + }; + ToolOutput::new(format!( + "Image: {} ({})\nDimensions: {}\n{}Image sent to model for vision analysis.", + file_path, size_str, dim_str, display_note + )) + .with_labeled_image(media_type, b64, file_path.to_string()) + } else { + let display_note = if terminal_displayed { + "\nDisplayed in terminal." + } else { + "" + }; + ToolOutput::new(format!( + "Image: {} ({})\nDimensions: {}\nImage too large for vision (max 20MB).{}", + file_path, size_str, dim_str, display_note + )) + }; + + output = output.with_title(format!("📷 {}", file_path)); + Ok(output) +} + +/// Get image dimensions from raw data (duplicated from tui::image for convenience) +fn get_image_dimensions_from_data(data: &[u8]) -> Option<(u32, u32)> { + // PNG: check signature and parse IHDR chunk + if data.len() > 24 && &data[0..8] == b"\x89PNG\r\n\x1a\n" { + let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]); + let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]); + return Some((width, height)); + } + + // JPEG: look for SOF0/SOF2 markers + if data.len() > 2 && data[0] == 0xFF && data[1] == 0xD8 { + let mut i = 2; + while i + 9 < data.len() { + if data[i] != 0xFF { + i += 1; + continue; + } + let marker = data[i + 1]; + // SOF0 (baseline) or SOF2 (progressive) + if marker == 0xC0 || marker == 0xC2 { + let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32; + let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32; + return Some((width, height)); + } + // Skip to next marker + if i + 3 < data.len() { + let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize; + i += 2 + len; + } else { + break; + } + } + } + + // GIF: parse header + if data.len() > 10 && (&data[0..6] == b"GIF87a" || &data[0..6] == b"GIF89a") { + let width = u16::from_le_bytes([data[6], data[7]]) as u32; + let height = u16::from_le_bytes([data[8], data[9]]) as u32; + return Some((width, height)); + } + + None +} + +/// Check if a file is a PDF based on extension +fn is_pdf_file(path: &Path) -> bool { + if let Some(ext) = path.extension() { + ext.to_string_lossy().to_lowercase() == "pdf" + } else { + false + } +} + +/// Handle reading a PDF file - extract text content +#[cfg(feature = "pdf")] +fn handle_pdf_file(path: &Path, file_path: &str) -> Result<ToolOutput> { + // Get file metadata + let metadata = std::fs::metadata(path)?; + let file_size = metadata.len(); + + let size_str = if file_size < 1024 { + format!("{} bytes", file_size) + } else if file_size < 1024 * 1024 { + format!("{:.1} KB", file_size as f64 / 1024.0) + } else { + format!("{:.1} MB", file_size as f64 / 1024.0 / 1024.0) + }; + + // Extract text from PDF + match jcode_pdf::extract_text(path) { + Ok(text) => { + let mut output = String::new(); + output.push_str(&format!("PDF: {} ({})\n", file_path, size_str)); + output.push_str(&format!("{}\n", "=".repeat(60))); + + // Split into pages (pdf_extract uses form feed \x0c as page separator) + let pages: Vec<&str> = text.split('\x0c').collect(); + let page_count = pages.len(); + + output.push_str(&format!("Pages: {}\n\n", page_count)); + + for (i, page) in pages.iter().enumerate() { + let page_text = page.trim(); + if !page_text.is_empty() { + output.push_str(&format!("--- Page {} ---\n", i + 1)); + // Limit each page to reasonable length + if page_text.len() > 10000 { + output.push_str(crate::util::truncate_str(page_text, 10000)); + output.push_str("\n... (page truncated)\n"); + } else { + output.push_str(page_text); + } + output.push_str("\n\n"); + } + } + + Ok(ToolOutput::new(output)) + } + Err(e) => { + // Fall back to metadata only if text extraction fails + Ok(ToolOutput::new(format!( + "PDF: {} ({})\nCould not extract text: {}\nThis may be a scanned/image-based PDF.", + file_path, size_str, e + ))) + } + } +} + +/// Handle reading a PDF file when PDF support is not compiled in. +#[cfg(not(feature = "pdf"))] +fn handle_pdf_file(path: &Path, file_path: &str) -> Result<ToolOutput> { + let metadata = std::fs::metadata(path)?; + let file_size = metadata.len(); + + let size_str = if file_size < 1024 { + format!("{} bytes", file_size) + } else if file_size < 1024 * 1024 { + format!("{:.1} KB", file_size as f64 / 1024.0) + } else { + format!("{:.1} MB", file_size as f64 / 1024.0 / 1024.0) + }; + + Ok(ToolOutput::new(format!( + "PDF: {} ({})\nPDF text extraction is not available in this build. Rebuild with the `pdf` feature enabled to extract text.", + file_path, size_str + ))) +} diff --git a/crates/jcode-app-core/src/tool/read/tests.rs b/crates/jcode-app-core/src/tool/read/tests.rs new file mode 100644 index 0000000..36305a8 --- /dev/null +++ b/crates/jcode-app-core/src/tool/read/tests.rs @@ -0,0 +1,344 @@ +use super::*; +use crate::tool::{ToolContext, ToolExecutionMode}; +use serde_json::json; + +fn make_ctx(working_dir: std::path::PathBuf) -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-message".to_string(), + tool_call_id: "test-call".to_string(), + working_dir: Some(working_dir), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + } +} + +#[test] +fn normalize_read_range_supports_start_and_end_lines() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "start_line": 10, + "end_line": 20 + })) + .expect("deserialize params"); + + let range = normalize_read_range(¶ms).expect("normalize range"); + assert_eq!( + range, + NormalizedReadRange { + offset: 9, + limit: 11, + style: ReadRangeStyle::StartEnd, + } + ); +} + +#[test] +fn normalize_read_range_supports_start_line_and_limit() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "start_line": 10, + "limit": 20 + })) + .expect("deserialize params"); + + let range = normalize_read_range(¶ms).expect("start_line + limit should work"); + assert_eq!( + range, + NormalizedReadRange { + offset: 9, + limit: 20, + style: ReadRangeStyle::StartEnd, + } + ); +} + +#[test] +fn normalize_read_range_prefers_end_line_over_limit() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "start_line": 10, + "end_line": 20, + "limit": 999 + })) + .expect("deserialize params"); + + let range = normalize_read_range(¶ms).expect("end_line should take precedence"); + assert_eq!( + range, + NormalizedReadRange { + offset: 9, + limit: 11, + style: ReadRangeStyle::StartEnd, + } + ); +} + +#[test] +fn normalize_read_range_rejects_start_line_and_offset() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "start_line": 10, + "offset": 20 + })) + .expect("deserialize params"); + + let err = normalize_read_range(¶ms).expect_err("mixed range styles should fail"); + assert!( + err.to_string().contains("Use either start_line/end_line") + || err.to_string().contains("not both"), + "unexpected error: {err}" + ); +} + +#[test] +fn normalize_read_range_accepts_matching_start_line_and_offset() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "start_line": 10, + "offset": 9, + "limit": 20 + })) + .expect("deserialize params"); + + let range = normalize_read_range(¶ms).expect("matching range styles should work"); + assert_eq!( + range, + NormalizedReadRange { + offset: 9, + limit: 20, + style: ReadRangeStyle::StartEnd, + } + ); +} + +#[test] +fn normalize_read_range_accepts_end_line_with_zero_offset() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "end_line": 20, + "offset": 0 + })) + .expect("deserialize params"); + + let range = normalize_read_range(¶ms).expect("redundant zero offset should work"); + assert_eq!( + range, + NormalizedReadRange { + offset: 0, + limit: 20, + style: ReadRangeStyle::StartEnd, + } + ); +} + +#[test] +fn normalize_read_range_rejects_invalid_end_before_start() { + let params: ReadInput = serde_json::from_value(json!({ + "file_path": "src/lib.rs", + "start_line": 20, + "end_line": 10 + })) + .expect("deserialize params"); + + let err = normalize_read_range(¶ms).expect_err("invalid range should fail"); + assert!( + err.to_string() + .contains("greater than or equal to start_line"), + "unexpected error: {err}" + ); +} + +#[test] +fn read_tool_schema_avoids_openai_incompatible_combinators() { + let schema = ReadTool::new().parameters_schema(); + + assert_eq!(schema.get("type"), Some(&json!("object"))); + assert!(schema.get("allOf").is_none()); + assert!(schema.get("not").is_none()); +} + +#[test] +fn read_tool_schema_advertises_only_canonical_public_fields() { + let schema = ReadTool::new().parameters_schema(); + let properties = schema["properties"] + .as_object() + .expect("read schema properties should be an object"); + + assert!(properties.contains_key("file_path")); + assert!(properties.contains_key("start_line")); + assert!(properties.contains_key("limit")); + assert!(!properties.contains_key("end_line")); + assert!(!properties.contains_key("offset")); +} + +#[test] +fn read_tool_description_advertises_supported_file_types() { + let tool = ReadTool::new(); + let description = tool.description().to_lowercase(); + assert!(description.contains("text"), "description={description}"); + assert!(description.contains("image"), "description={description}"); + assert!(description.contains("pdf"), "description={description}"); + + let schema = tool.parameters_schema(); + let file_path_description = schema["properties"]["file_path"]["description"] + .as_str() + .expect("file_path should have a description"); + assert_eq!(file_path_description, "Path to a file."); +} + +#[tokio::test] +async fn read_tool_supports_start_line_and_end_line() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sample.txt"); + std::fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").expect("write sample file"); + + let tool = ReadTool::new(); + let output = tool + .execute( + json!({ + "file_path": "sample.txt", + "start_line": 2, + "end_line": 4 + }), + make_ctx(temp.path().to_path_buf()), + ) + .await + .expect("read execution should succeed"); + + assert!( + output.output.contains("2\ttwo"), + "output={:?}", + output.output + ); + assert!( + output.output.contains("3\tthree"), + "output={:?}", + output.output + ); + assert!( + output.output.contains("4\tfour"), + "output={:?}", + output.output + ); + assert!( + !output.output.contains("1\tone"), + "output={:?}", + output.output + ); + assert!( + !output.output.contains("5\tfive"), + "output={:?}", + output.output + ); +} + +#[tokio::test] +async fn read_tool_continuation_hint_matches_start_line_style() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sample.txt"); + std::fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").expect("write sample file"); + + let tool = ReadTool::new(); + let output = tool + .execute( + json!({ + "file_path": "sample.txt", + "start_line": 2, + "end_line": 3 + }), + make_ctx(temp.path().to_path_buf()), + ) + .await + .expect("read execution should succeed"); + + assert!( + output.output.contains("use start_line=4 to continue"), + "output={:?}", + output.output + ); +} + +#[tokio::test] +async fn read_tool_supports_start_line_with_limit() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sample.txt"); + std::fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").expect("write sample file"); + + let tool = ReadTool::new(); + let output = tool + .execute( + json!({ + "file_path": "sample.txt", + "start_line": 2, + "limit": 2 + }), + make_ctx(temp.path().to_path_buf()), + ) + .await + .expect("read execution should succeed"); + + assert!( + output.output.contains("2\ttwo"), + "output={:?}", + output.output + ); + assert!( + output.output.contains("3\tthree"), + "output={:?}", + output.output + ); + assert!( + !output.output.contains("4\tfour"), + "output={:?}", + output.output + ); + assert!( + output.output.contains("use start_line=4 to continue"), + "output={:?}", + output.output + ); +} + +#[tokio::test] +async fn read_tool_prefers_end_line_over_limit() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sample.txt"); + std::fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").expect("write sample file"); + + let tool = ReadTool::new(); + let output = tool + .execute( + json!({ + "file_path": "sample.txt", + "start_line": 2, + "end_line": 3, + "limit": 50 + }), + make_ctx(temp.path().to_path_buf()), + ) + .await + .expect("read execution should succeed"); + + assert!( + output.output.contains("2\ttwo"), + "output={:?}", + output.output + ); + assert!( + output.output.contains("3\tthree"), + "output={:?}", + output.output + ); + assert!( + !output.output.contains("4\tfour"), + "output={:?}", + output.output + ); + assert!( + output.output.contains("use start_line=4 to continue"), + "output={:?}", + output.output + ); +} diff --git a/crates/jcode-app-core/src/tool/selfdev/build_queue.rs b/crates/jcode-app-core/src/tool/selfdev/build_queue.rs new file mode 100644 index 0000000..863bc07 --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/build_queue.rs @@ -0,0 +1,1087 @@ +use super::*; + +impl SelfDevTool { + async fn append_output_line(file: &mut tokio::fs::File, line: impl AsRef<str>) { + let _ = file.write_all(line.as_ref().as_bytes()).await; + let _ = file.write_all(b"\n").await; + let _ = file.flush().await; + } + + async fn wait_for_turn( + request_id: &str, + worktree_scope: &str, + file: &mut tokio::fs::File, + ) -> Result<BuildLockGuard> { + let mut last_note: Option<String> = None; + // Tolerate transient lookup misses: a concurrent save() of this (or + // any) request file can momentarily make it unreadable, and load_all + // silently skips unreadable entries. Only a *persistent* absence means + // the request was actually pruned/cancelled. + let mut missing_streak = 0u32; + loop { + let pending = BuildRequest::pending_requests_for_scope(worktree_scope)?; + let my_index = match pending + .iter() + .position(|request| request.request_id == request_id) + { + Some(idx) => { + missing_streak = 0; + idx + } + None => { + missing_streak += 1; + if missing_streak >= 4 { + anyhow::bail!("Queued build request {} disappeared", request_id); + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + continue; + } + }; + + if my_index == 0 + && let Some(lock) = Self::try_acquire_build_lock(worktree_scope)? + { + return Ok(lock); + } + + let note = if my_index == 0 { + Some("Waiting for the self-dev build lock to become available".to_string()) + } else { + pending.get(my_index - 1).map(|request| { + format!( + "Waiting in queue behind {} — {}", + request.display_owner(), + request.reason + ) + }) + }; + if note.as_ref() != last_note.as_ref() { + if let Some(note) = note.as_ref() { + Self::append_output_line(file, note).await; + } + last_note = note; + } + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + + async fn stream_build_command( + repo_dir: PathBuf, + command: SelfDevBuildCommand, + output_path: PathBuf, + ) -> Result<TaskResult> { + let mut cmd = tokio::process::Command::new(&command.program); + cmd.args(&command.args) + .current_dir(repo_dir) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn build command: {}", e))?; + + let mut file = tokio::fs::File::create(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?; + Self::append_output_line( + &mut file, + format!("Starting build with {}", command.display), + ) + .await; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let mut stdout_lines = stdout.map(|s| BufReader::new(s).lines()); + let mut stderr_lines = stderr.map(|s| BufReader::new(s).lines()); + let mut stdout_done = stdout_lines.is_none(); + let mut stderr_done = stderr_lines.is_none(); + + while !stdout_done || !stderr_done { + tokio::select! { + line = async { + match stdout_lines.as_mut() { + Some(r) => r.next_line().await, + None => std::future::pending().await, + } + }, if !stdout_done => { + match line { + Ok(Some(line)) => Self::append_output_line(&mut file, line).await, + _ => stdout_done = true, + } + } + line = async { + match stderr_lines.as_mut() { + Some(r) => r.next_line().await, + None => std::future::pending().await, + } + }, if !stderr_done => { + match line { + Ok(Some(line)) => Self::append_output_line(&mut file, format!("[stderr] {}", line)).await, + _ => stderr_done = true, + } + } + } + } + + let status = child.wait().await?; + let exit_code = status.code(); + Self::append_output_line( + &mut file, + format!( + "--- Command finished with exit code: {} ---", + exit_code.unwrap_or(-1) + ), + ) + .await; + + if status.success() { + Ok(TaskResult::completed(exit_code)) + } else { + Ok(TaskResult::failed( + exit_code, + format!("Command exited with code {}", exit_code.unwrap_or(-1)), + )) + } + } + + async fn run_test_build(output_path: PathBuf, reason: &str) -> Result<TaskResult> { + let mut file = tokio::fs::File::create(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?; + Self::append_output_line( + &mut file, + format!("[test mode] Simulated selfdev build for reason: {}", reason), + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Self::append_output_line(&mut file, "--- Command finished with exit code: 0 ---").await; + Ok(TaskResult::completed(Some(0))) + } + + async fn run_test_request( + request_id: String, + repo_dir: PathBuf, + command: SelfDevBuildCommand, + reason: String, + output_path: PathBuf, + ) -> Result<TaskResult> { + let mut request = BuildRequest::load(&request_id)? + .ok_or_else(|| anyhow::anyhow!("Missing queued test request {}", request_id))?; + let mut queue_file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to open output file: {}", e))?; + + let worktree_scope = request.worktree_scope.clone(); + let _lock = Self::wait_for_turn(&request_id, &worktree_scope, &mut queue_file).await?; + request.state = BuildRequestState::Building; + request.started_at = Some(Utc::now().to_rfc3339()); + request.last_progress = Some("testing".to_string()); + request.save()?; + Self::append_output_line(&mut queue_file, format!("Test starting now: {}", reason)).await; + drop(queue_file); + + let result = if Self::is_test_session() { + let mut file = tokio::fs::File::create(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?; + Self::append_output_line( + &mut file, + format!("[test mode] Simulated selfdev test: {}", command.display), + ) + .await; + Self::append_output_line(&mut file, "--- Command finished with exit code: 0 ---").await; + TaskResult::completed(Some(0)) + } else { + Self::stream_build_command(repo_dir, command, output_path.clone()).await? + }; + + let mut request = BuildRequest::load(&request_id)? + .ok_or_else(|| anyhow::anyhow!("Missing queued test request {}", request_id))?; + request.completed_at = Some(Utc::now().to_rfc3339()); + request.state = match result + .status + .as_ref() + .unwrap_or(&BackgroundTaskStatus::Failed) + { + BackgroundTaskStatus::Completed => BuildRequestState::Completed, + BackgroundTaskStatus::Superseded => BuildRequestState::Superseded, + BackgroundTaskStatus::Failed => BuildRequestState::Failed, + BackgroundTaskStatus::Running => BuildRequestState::Building, + }; + request.error = result.error.clone(); + request.last_progress = match request.state { + BuildRequestState::Completed => Some("test completed".to_string()), + BuildRequestState::Superseded => Some("test superseded".to_string()), + BuildRequestState::Failed => Some("test failed".to_string()), + BuildRequestState::Building => Some("testing".to_string()), + BuildRequestState::Queued => Some("queued".to_string()), + BuildRequestState::Attached => Some("attached".to_string()), + BuildRequestState::Cancelled => Some("cancelled".to_string()), + }; + request.save()?; + Ok(result) + } + + async fn follow_existing_build( + request_id: String, + original_request_id: String, + output_path: PathBuf, + ) -> Result<TaskResult> { + let mut file = tokio::fs::File::create(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to create output file: {}", e))?; + Self::append_output_line( + &mut file, + format!( + "Attached to existing selfdev build request {} instead of spawning a duplicate build.", + original_request_id + ), + ) + .await; + + loop { + let Some(original) = BuildRequest::load(&original_request_id)? else { + anyhow::bail!("Original build request {} disappeared", original_request_id); + }; + match original.state { + BuildRequestState::Queued | BuildRequestState::Building => { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + BuildRequestState::Completed => { + let mut request = BuildRequest::load(&request_id)?.ok_or_else(|| { + anyhow::anyhow!("Attached build request {} disappeared", request_id) + })?; + request.state = BuildRequestState::Completed; + request.completed_at = Some(Utc::now().to_rfc3339()); + request.error = None; + request.save()?; + Self::append_output_line( + &mut file, + format!( + "Original build {} completed successfully.", + original_request_id + ), + ) + .await; + return Ok(TaskResult::completed(Some(0))); + } + BuildRequestState::Superseded => { + let mut request = BuildRequest::load(&request_id)?.ok_or_else(|| { + anyhow::anyhow!("Attached build request {} disappeared", request_id) + })?; + request.state = BuildRequestState::Superseded; + request.completed_at = Some(Utc::now().to_rfc3339()); + request.error = original.error.clone(); + request.save()?; + let detail = original.error.clone().unwrap_or_else(|| { + format!( + "Original build {} completed but was superseded before activation", + original_request_id + ) + }); + Self::append_output_line(&mut file, &detail).await; + return Ok(TaskResult::superseded(Some(0), detail)); + } + BuildRequestState::Failed | BuildRequestState::Cancelled => { + let mut request = BuildRequest::load(&request_id)?.ok_or_else(|| { + anyhow::anyhow!("Attached build request {} disappeared", request_id) + })?; + request.state = original.state.clone(); + request.completed_at = Some(Utc::now().to_rfc3339()); + request.error = original.error.clone(); + request.save()?; + let error = original.error.clone().unwrap_or_else(|| { + format!("Original build {} did not complete", original_request_id) + }); + Self::append_output_line(&mut file, &error).await; + return Ok(TaskResult::failed(None, error)); + } + BuildRequestState::Attached => { + anyhow::bail!( + "Original build request {} is attached, not build-producing", + original_request_id + ); + } + } + } + } + + async fn run_build_request( + request_id: String, + repo_dir: PathBuf, + command: SelfDevBuildCommand, + reason: String, + output_path: PathBuf, + ) -> Result<TaskResult> { + let mut request = BuildRequest::load(&request_id)? + .ok_or_else(|| anyhow::anyhow!("Missing queued build request {}", request_id))?; + let mut queue_file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to open output file: {}", e))?; + + let worktree_scope = request.worktree_scope.clone(); + let _lock = Self::wait_for_turn(&request_id, &worktree_scope, &mut queue_file).await?; + let expected_source = request + .requested_source + .clone() + .ok_or_else(|| anyhow::anyhow!("Missing requested source state for {}", request_id))?; + let actual_source = if Self::is_test_session() { + expected_source.clone() + } else { + build::ensure_source_state_matches(&repo_dir, &expected_source)? + }; + request.state = BuildRequestState::Building; + request.started_at = Some(Utc::now().to_rfc3339()); + request.version = Some(expected_source.version_label.clone()); + request.built_source = Some(actual_source.clone()); + request.last_progress = Some("building".to_string()); + request.save()?; + Self::append_output_line(&mut queue_file, format!("Build starting now: {}", reason)).await; + drop(queue_file); + + let result = if Self::is_test_session() { + Self::run_test_build(output_path.clone(), &reason).await? + } else { + let result = + Self::stream_build_command(repo_dir.clone(), command.clone(), output_path.clone()) + .await?; + if result.error.is_none() { + match build::ensure_source_state_matches(&repo_dir, &expected_source) { + Ok(source_after_build) => { + build::write_current_dev_binary_source_metadata( + &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 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.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.save()?; + result + } + Err(err) => { + let detail = format!( + "Build completed successfully, but the source changed before activation. Marking this result as superseded instead of failed. {}", + err + ); + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&output_path) + .await + .map_err(|e| anyhow::anyhow!("Failed to append output note: {}", e))?; + Self::append_output_line(&mut file, &detail).await; + TaskResult::superseded(result.exit_code.or(Some(0)), detail) + } + } + } else { + result + } + }; + + let mut request = BuildRequest::load(&request_id)? + .ok_or_else(|| anyhow::anyhow!("Missing queued build request {}", request_id))?; + request.completed_at = Some(Utc::now().to_rfc3339()); + request.state = match result + .status + .as_ref() + .unwrap_or(&BackgroundTaskStatus::Failed) + { + BackgroundTaskStatus::Completed => BuildRequestState::Completed, + BackgroundTaskStatus::Superseded => BuildRequestState::Superseded, + BackgroundTaskStatus::Failed => BuildRequestState::Failed, + BackgroundTaskStatus::Running => BuildRequestState::Building, + }; + request.error = result.error.clone(); + request.last_progress = match request.state { + BuildRequestState::Completed => request + .last_progress + .take() + .or_else(|| Some("completed".to_string())), + BuildRequestState::Superseded => Some("superseded by newer source state".to_string()), + BuildRequestState::Failed => Some("failed".to_string()), + BuildRequestState::Building => Some("building".to_string()), + BuildRequestState::Queued => Some("queued".to_string()), + BuildRequestState::Attached => Some("attached".to_string()), + BuildRequestState::Cancelled => Some("cancelled".to_string()), + }; + request.save()?; + 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<String>, + target: Option<String>, + notify: Option<bool>, + wake: Option<bool>, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + let reason = reason + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "`selfdev build` requires a non-empty `reason` so other queued agents can see why the build is needed." + ) + })?; + let repo_dir = + SelfDevTool::resolve_repo_dir(ctx.working_dir.as_deref()).ok_or_else(|| { + anyhow::anyhow!("Could not find the jcode repository directory for selfdev build") + })?; + + let requested_source = SelfDevTool::requested_source_state(&repo_dir)?; + let target = build::SelfDevBuildTarget::parse(target.as_deref())?; + let command = SelfDevTool::build_command(&repo_dir, target); + let dedupe_key = SelfDevTool::build_dedupe_key(&requested_source, &command); + let blocker = SelfDevTool::newest_active_request(&requested_source.worktree_scope)?; + let duplicate = + BuildRequest::find_duplicate_pending(&requested_source.worktree_scope, &dedupe_key)?; + let (session_short_name, session_title) = SelfDevTool::load_session_labels(&ctx.session_id); + let request_id = SelfDevTool::next_request_id(); + let wake = wake.unwrap_or(true); + let notify = notify.unwrap_or(true) || wake; + + if let Some(existing) = duplicate { + let mut request = BuildRequest { + request_id: request_id.clone(), + background_task_id: None, + session_id: ctx.session_id.clone(), + session_short_name, + session_title, + reason: reason.clone(), + repo_dir: repo_dir.display().to_string(), + repo_scope: requested_source.repo_scope.clone(), + worktree_scope: requested_source.worktree_scope.clone(), + command: command.display.clone(), + requested_at: Utc::now().to_rfc3339(), + started_at: None, + completed_at: None, + state: BuildRequestState::Attached, + version: Some(requested_source.version_label.clone()), + dedupe_key: Some(dedupe_key.clone()), + requested_source: Some(requested_source.clone()), + built_source: None, + published_version: None, + last_progress: Some("attached to existing build".to_string()), + validated: false, + error: None, + output_file: None, + status_file: None, + attached_to_request_id: Some(existing.request_id.clone()), + }; + request.save()?; + + let request_id_for_task = request_id.clone(); + let existing_request_id = existing.request_id.clone(); + let info = background::global() + .spawn_with_notify( + "selfdev-build-watch", + Some("build watch".to_string()), + &ctx.session_id, + notify, + wake, + move |output_path| async move { + SelfDevTool::follow_existing_build( + request_id_for_task, + existing_request_id, + output_path, + ) + .await + }, + ) + .await; + + request.background_task_id = Some(info.task_id.clone()); + request.output_file = Some(info.output_file.display().to_string()); + request.status_file = Some(info.status_file.display().to_string()); + request.save()?; + + let delivery = if wake { + "The requesting agent will be woken when the existing build finishes." + } else if notify { + "You will be notified when the existing build finishes." + } else { + "Completion delivery is disabled for this watcher." + }; + let output = format!( + "Matching self-dev build already queued/running, so this request was attached instead of spawning a duplicate build.\n\n- Your request ID: `{}`\n- Watcher task ID: `{}`\n- Existing request: `{}`\n- Requested by: {}\n- Reason: {}\n- Target version: `{}`\n- Source fingerprint: `{}`\n\n{}", + request_id, + info.task_id, + existing.request_id, + existing.display_owner(), + existing.reason, + requested_source.version_label, + requested_source.fingerprint, + delivery + ); + + return Ok(ToolOutput::new(output).with_metadata(json!({ + "background": true, + "deduped": true, + "request_id": request_id, + "task_id": info.task_id, + "output_file": info.output_file.to_string_lossy(), + "status_file": info.status_file.to_string_lossy(), + "duplicate_of": { + "request_id": existing.request_id, + "task_id": existing.background_task_id, + "session_id": existing.session_id, + "session_short_name": existing.session_short_name, + "session_title": existing.session_title, + "reason": existing.reason, + "version": existing.version, + "source_fingerprint": existing + .requested_source + .as_ref() + .map(|source| source.fingerprint.clone()), + } + }))); + } + + let mut request = BuildRequest { + request_id: request_id.clone(), + background_task_id: None, + session_id: ctx.session_id.clone(), + session_short_name, + session_title, + reason: reason.clone(), + repo_dir: repo_dir.display().to_string(), + repo_scope: requested_source.repo_scope.clone(), + worktree_scope: requested_source.worktree_scope.clone(), + command: command.display.clone(), + requested_at: Utc::now().to_rfc3339(), + started_at: None, + completed_at: None, + state: BuildRequestState::Queued, + version: Some(requested_source.version_label.clone()), + dedupe_key: Some(dedupe_key), + requested_source: Some(requested_source.clone()), + built_source: None, + published_version: None, + last_progress: Some("queued".to_string()), + validated: false, + error: None, + output_file: None, + status_file: None, + attached_to_request_id: None, + }; + request.save()?; + + let queue_position = + SelfDevTool::current_queue_position(&request_id, &requested_source.worktree_scope)? + .unwrap_or(1); + + let request_id_for_task = request_id.clone(); + let repo_dir_for_task = repo_dir.clone(); + let command_for_task = command.clone(); + let reason_for_task = reason.clone(); + let info = background::global() + .spawn_with_notify( + "selfdev-build", + Some("selfdev build".to_string()), + &ctx.session_id, + notify, + wake, + move |output_path| async move { + SelfDevTool::run_build_request( + request_id_for_task, + repo_dir_for_task, + command_for_task, + reason_for_task, + output_path, + ) + .await + }, + ) + .await; + + request.background_task_id = Some(info.task_id.clone()); + request.output_file = Some(info.output_file.display().to_string()); + request.status_file = Some(info.status_file.display().to_string()); + request.save()?; + let delivery = if wake { + "The requesting agent will be woken when the build completes." + } else if notify { + "You will be notified when the build completes." + } else { + "Completion delivery is disabled for this build request." + }; + let mut output = format!( + "Self-dev build queued in background.\n\n- Request ID: `{}`\n- Task ID: `{}`\n- Reason: {}\n- Target version: `{}`\n- Source fingerprint: `{}`\n- Command: `{}`\n- Queue position: {}\n- Output file: `{}`\n- Status file: `{}`\n\n{}", + request_id, + info.task_id, + reason, + requested_source.version_label, + requested_source.fingerprint, + command.display, + queue_position, + info.output_file.display(), + info.status_file.display(), + delivery + ); + + if let Some(ref blocker) = blocker { + output.push_str(&format!( + "\n\nCurrently blocked by: {}\nReason: {}", + blocker.display_owner(), + blocker.reason + )); + } + + output.push_str(&format!( + "\n\nUse `bg action=\"wait\" task_id=\"{}\"` to wait for completion/checkpoints, `bg action=\"status\" task_id=\"{}\"` to check progress immediately, or `selfdev status` to inspect the build queue.\nAfter it finishes, use `selfdev reload` when you want to restart onto the new binary.", + info.task_id, + info.task_id + )); + + Ok(ToolOutput::new(output).with_metadata(json!({ + "background": true, + "request_id": request_id, + "task_id": info.task_id, + "output_file": info.output_file.to_string_lossy(), + "status_file": info.status_file.to_string_lossy(), + "queue_position": queue_position, + "version": requested_source.version_label, + "source_fingerprint": requested_source.fingerprint, + "blocked_by": blocker.as_ref().map(|request| json!({ + "session_id": request.session_id, + "session_short_name": request.session_short_name, + "session_title": request.session_title, + "reason": request.reason, + "version": request.version, + "source_fingerprint": request + .requested_source + .as_ref() + .map(|source| source.fingerprint.clone()), + })) + }))) + } + + /// Queue a build and, once it finishes successfully, reload onto the new + /// binary in a single step. This is a convenience wrapper that chains + /// `do_build` -> wait-for-completion -> `do_reload` so the agent does not + /// have to manually poll the build and then issue a separate reload. + pub(super) async fn do_build_reload( + &self, + reason: Option<String>, + target: Option<String>, + context: Option<String>, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + // Queue the build. Disable per-task notify/wake delivery: this action + // waits inline for completion, so a separate completion notification + // would be redundant noise. + let build_output = self + .do_build(reason, target, Some(false), Some(false), ctx) + .await?; + + let metadata = build_output.metadata.clone().unwrap_or_else(|| json!({})); + let task_id = metadata + .get("task_id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + let request_id = metadata + .get("request_id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + + let Some(task_id) = task_id else { + // Should not happen for a freshly queued build, but degrade + // gracefully: surface the build output and let the agent reload. + return Ok(ToolOutput::new(format!( + "{}\n\nCould not determine the build task id, so the automatic reload was skipped. Reload manually with `selfdev reload` once the build finishes.", + build_output.output + ))); + }; + + // Wait inline for the build (and anything queued ahead of it) to finish. + let wait = std::time::Duration::from_secs(SelfDevTool::build_reload_wait_secs()); + let wait_result = background::global().wait(&task_id, wait, false).await; + + let finished = matches!( + wait_result + .as_ref() + .map(|result| result.task.status.clone()), + Some(BackgroundTaskStatus::Completed) + | Some(BackgroundTaskStatus::Superseded) + | Some(BackgroundTaskStatus::Failed) + ); + + if !finished { + return Ok(ToolOutput::new(format!( + "{}\n\nThe build is still running after waiting {}s, so the automatic reload was not started yet. Use `bg action=\"wait\" task_id=\"{}\"` to keep waiting, then `selfdev reload` once it finishes.", + build_output.output, + wait.as_secs(), + task_id + )) + .with_metadata(json!({ + "phase": "build", + "build_finished": false, + "request_id": request_id, + "task_id": task_id, + }))); + } + + // Resolve the final build outcome from the persisted request when + // available (it carries published version / validation), falling back + // to the background task status otherwise. + let build_request = request_id + .as_deref() + .and_then(|id| BuildRequest::load(id).ok().flatten()); + let task_status = wait_result + .as_ref() + .map(|result| result.task.status.clone()); + + let build_succeeded = match build_request.as_ref().map(|request| &request.state) { + Some(BuildRequestState::Completed) => true, + Some(_) => false, + None => matches!(task_status, Some(BackgroundTaskStatus::Completed)), + }; + + if !build_succeeded { + let detail = build_request + .as_ref() + .and_then(|request| request.error.clone()) + .or_else(|| { + wait_result + .as_ref() + .and_then(|result| result.task.error.clone()) + }) + .unwrap_or_else(|| "see build output for details".to_string()); + let state_label = build_request + .as_ref() + .map(|request| match request.state { + BuildRequestState::Superseded => "superseded", + BuildRequestState::Failed => "failed", + BuildRequestState::Cancelled => "cancelled", + BuildRequestState::Queued => "queued", + BuildRequestState::Building => "building", + BuildRequestState::Attached => "attached", + BuildRequestState::Completed => "completed", + }) + .unwrap_or("unknown"); + return Ok(ToolOutput::new(format!( + "Build did not complete successfully (state: {state_label}), so the automatic reload was skipped.\n\nReason: {detail}\n\nInspect the build with `selfdev status` or the build output, fix the issue, and retry." + )) + .with_metadata(json!({ + "phase": "build", + "build_finished": true, + "build_succeeded": false, + "state": state_label, + "request_id": request_id, + "task_id": task_id, + }))); + } + + // Build succeeded: reload onto the freshly published binary. + let reload_output = self + .do_reload( + context, + &ctx.session_id, + ctx.execution_mode, + ctx.working_dir.as_deref(), + ) + .await?; + + let published_version = build_request + .as_ref() + .and_then(|request| request.published_version.clone()); + let mut combined = String::from("Build completed successfully"); + if let Some(version) = published_version.as_deref() { + combined.push_str(&format!(" (version `{version}`)")); + } + combined.push_str(", now reloading.\n\n"); + combined.push_str(&reload_output.output); + + let mut reload_metadata = reload_output.metadata.unwrap_or_else(|| json!({})); + if let Some(map) = reload_metadata.as_object_mut() { + map.insert("phase".to_string(), json!("reload")); + map.insert("build_finished".to_string(), json!(true)); + map.insert("build_succeeded".to_string(), json!(true)); + if let Some(request_id) = request_id.as_deref() { + map.insert("request_id".to_string(), json!(request_id)); + } + map.insert("task_id".to_string(), json!(task_id)); + if let Some(version) = published_version { + map.insert("published_version".to_string(), json!(version)); + } + } + + Ok(ToolOutput::new(combined).with_metadata(reload_metadata)) + } + + pub(super) async fn do_test( + &self, + command: Option<String>, + reason: Option<String>, + notify: Option<bool>, + wake: Option<bool>, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + let command = command + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("`selfdev test` requires a non-empty shell `command`.") + })?; + let reason = reason + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| command.clone()); + let repo_dir = + SelfDevTool::resolve_repo_dir(ctx.working_dir.as_deref()).ok_or_else(|| { + anyhow::anyhow!("Could not find the jcode repository directory for selfdev test") + })?; + let requested_source = SelfDevTool::requested_source_state(&repo_dir)?; + let shell_command = SelfDevBuildCommand { + program: "bash".to_string(), + args: vec!["-lc".to_string(), command.clone()], + display: command.clone(), + }; + let dedupe_key = format!( + "test:{}:{}:{}", + requested_source.worktree_scope, requested_source.fingerprint, shell_command.display + ); + let blocker = SelfDevTool::newest_active_request(&requested_source.worktree_scope)?; + let (session_short_name, session_title) = SelfDevTool::load_session_labels(&ctx.session_id); + let request_id = SelfDevTool::next_request_id(); + let wake = wake.unwrap_or(true); + let notify = notify.unwrap_or(true) || wake; + + let mut request = BuildRequest { + request_id: request_id.clone(), + background_task_id: None, + session_id: ctx.session_id.clone(), + session_short_name, + session_title, + reason: reason.clone(), + repo_dir: repo_dir.display().to_string(), + repo_scope: requested_source.repo_scope.clone(), + worktree_scope: requested_source.worktree_scope.clone(), + command: shell_command.display.clone(), + requested_at: Utc::now().to_rfc3339(), + started_at: None, + completed_at: None, + state: BuildRequestState::Queued, + version: Some(requested_source.version_label.clone()), + dedupe_key: Some(dedupe_key), + requested_source: Some(requested_source.clone()), + built_source: None, + published_version: None, + last_progress: Some("queued".to_string()), + validated: false, + error: None, + output_file: None, + status_file: None, + attached_to_request_id: None, + }; + request.save()?; + let queue_position = + SelfDevTool::current_queue_position(&request_id, &requested_source.worktree_scope)? + .unwrap_or(1); + + let request_id_for_task = request_id.clone(); + let repo_dir_for_task = repo_dir.clone(); + let command_for_task = shell_command.clone(); + let reason_for_task = reason.clone(); + let info = background::global() + .spawn_with_notify( + "selfdev-test", + Some("selfdev test".to_string()), + &ctx.session_id, + notify, + wake, + move |output_path| async move { + SelfDevTool::run_test_request( + request_id_for_task, + repo_dir_for_task, + command_for_task, + reason_for_task, + output_path, + ) + .await + }, + ) + .await; + + request.background_task_id = Some(info.task_id.clone()); + request.output_file = Some(info.output_file.display().to_string()); + request.status_file = Some(info.status_file.display().to_string()); + request.save()?; + let delivery = if wake { + "The requesting agent will be woken when the test completes." + } else if notify { + "You will be notified when the test completes." + } else { + "Completion delivery is disabled for this test request." + }; + let mut output = format!( + "Self-dev test queued in background.\n\n- Request ID: `{}`\n- Task ID: `{}`\n- Reason: {}\n- Command: `{}`\n- Queue position: {}\n- Output file: `{}`\n- Status file: `{}`\n\n{}", + request_id, + info.task_id, + reason, + shell_command.display, + queue_position, + info.output_file.display(), + info.status_file.display(), + delivery + ); + if let Some(ref blocker) = blocker { + output.push_str(&format!( + "\n\nCurrently blocked by: {}\nReason: {}", + blocker.display_owner(), + blocker.reason + )); + } + output.push_str(&format!( + "\n\nUse `bg action=\"wait\" task_id=\"{}\"` to wait for completion/checkpoints, or `selfdev status` to inspect the queue.", + info.task_id + )); + + Ok(ToolOutput::new(output).with_metadata(json!({ + "background": true, + "request_id": request_id, + "task_id": info.task_id, + "output_file": info.output_file.to_string_lossy(), + "status_file": info.status_file.to_string_lossy(), + "queue_position": queue_position, + "command": shell_command.display, + }))) + } + + pub(super) async fn do_cancel_build( + &self, + request_id: Option<String>, + task_id: Option<String>, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + let Some(mut request) = + BuildRequest::find_by_request_or_task(request_id.as_deref(), task_id.as_deref())? + else { + return Ok(ToolOutput::new( + "No self-dev build request matched the provided request_id/task_id.", + )); + }; + + if request.session_id != ctx.session_id { + return Ok(ToolOutput::new(format!( + "That self-dev build request belongs to {}, not this session ({}).", + request.display_owner(), + ctx.session_id + ))); + } + + if matches!( + request.state, + BuildRequestState::Completed | BuildRequestState::Failed | BuildRequestState::Cancelled + ) { + return Ok(ToolOutput::new(format!( + "Build request `{}` is already in terminal state `{}`.", + request.request_id, + match request.state { + BuildRequestState::Completed => "completed", + BuildRequestState::Failed => "failed", + BuildRequestState::Cancelled => "cancelled", + _ => unreachable!(), + } + ))); + } + + let cancelled_task = if let Some(task_id) = request.background_task_id.as_deref() { + background::global().cancel(task_id).await? + } else { + false + }; + + request.state = BuildRequestState::Cancelled; + request.completed_at = Some(Utc::now().to_rfc3339()); + request.error = Some("Cancelled by user".to_string()); + request.save()?; + + Ok(ToolOutput::new(format!( + "Cancelled self-dev build request `{}`.\n\n- Task cancelled: {}\n- Reason: {}\n- Target version: {}", + request.request_id, + if cancelled_task { "yes" } else { "no (task may have already finished)" }, + request.reason, + request.version.as_deref().unwrap_or("unknown") + )) + .with_metadata(json!({ + "request_id": request.request_id, + "task_id": request.background_task_id, + "cancelled": true, + "cancelled_task": cancelled_task, + }))) + } +} diff --git a/crates/jcode-app-core/src/tool/selfdev/launch.rs b/crates/jcode-app-core/src/tool/selfdev/launch.rs new file mode 100644 index 0000000..17194eb --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/launch.rs @@ -0,0 +1,225 @@ +use super::*; + +pub fn enter_selfdev_session( + parent_session_id: Option<&str>, + working_dir: Option<&Path>, +) -> Result<SelfDevLaunchResult> { + let repo_dir = SelfDevTool::resolve_repo_dir(working_dir).ok_or_else(|| { + anyhow::anyhow!("Could not find the jcode repository to enter self-dev mode") + })?; + + let mut inherited_context = false; + let mut session = if let Some(parent_session_id) = parent_session_id { + match session::Session::load(parent_session_id) { + Ok(parent) => { + let mut child = session::Session::create( + Some(parent_session_id.to_string()), + Some("Self-development session".to_string()), + ); + child.replace_messages(parent.messages.clone()); + child.compaction = parent.compaction.clone(); + child.model = parent.model.clone(); + child.provider_key = parent.provider_key.clone(); + child.route_api_method = parent.route_api_method.clone(); + child.subagent_model = parent.subagent_model.clone(); + child.improve_mode = parent.improve_mode; + child.autoreview_enabled = parent.autoreview_enabled; + child.autojudge_enabled = parent.autojudge_enabled; + child.memory_injections = parent.memory_injections.clone(); + child.replay_events = parent.replay_events.clone(); + inherited_context = true; + child + } + Err(err) => { + crate::logging::warn(&format!( + "Failed to load parent session {} for self-dev enter; starting fresh session: {}", + parent_session_id, err + )); + session::Session::create(None, Some("Self-development session".to_string())) + } + } + } else { + session::Session::create(None, Some("Self-development session".to_string())) + }; + session.set_canary("self-dev"); + session.working_dir = Some(repo_dir.display().to_string()); + session.status = session::SessionStatus::Closed; + session.save()?; + + let session_id = session.id.clone(); + + if SelfDevTool::is_test_session() { + return Ok(SelfDevLaunchResult { + session_id, + repo_dir, + launched: false, + test_mode: true, + exe: None, + inherited_context, + }); + } + + let exe = SelfDevTool::launch_binary()?; + let launched = session_launch::spawn_selfdev_in_new_terminal(&exe, &session_id, &repo_dir)?; + + Ok(SelfDevLaunchResult { + session_id, + repo_dir, + launched, + test_mode: false, + exe: Some(exe), + inherited_context, + }) +} + +pub fn schedule_selfdev_prompt_delivery(session_id: String, prompt: String) { + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + match runtime { + Ok(runtime) => { + if let Err(err) = + runtime.block_on(SelfDevTool::send_prompt_to_session(&session_id, &prompt)) + { + crate::logging::warn(&format!( + "Failed to auto-deliver prompt to spawned self-dev session {}: {}", + session_id, err + )); + } + } + Err(err) => crate::logging::warn(&format!( + "Failed to initialize runtime for self-dev prompt delivery: {}", + err + )), + } + }); +} + +impl SelfDevTool { + async fn send_prompt_to_session(session_id: &str, prompt: &str) -> Result<()> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let mut last_error: Option<String> = None; + + while std::time::Instant::now() < deadline { + match Self::try_send_prompt_once(session_id, prompt).await { + Ok(()) => return Ok(()), + Err(err) => { + last_error = Some(err.to_string()); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + } + + Err(anyhow::anyhow!( + "Timed out delivering prompt to spawned self-dev session {}: {}", + session_id, + last_error.unwrap_or_else(|| "unknown error".to_string()) + )) + } + + async fn try_send_prompt_once(session_id: &str, prompt: &str) -> Result<()> { + let mut client = server::Client::connect_debug().await?; + let request_id = client + .send_transcript(prompt, TranscriptMode::Send, Some(session_id.to_string())) + .await?; + + loop { + match client.read_event().await? { + ServerEvent::Ack { id } if id == request_id => {} + ServerEvent::Done { id } if id == request_id => return Ok(()), + ServerEvent::Error { id, message, .. } if id == request_id => { + anyhow::bail!(message) + } + _ => {} + } + } + } + + pub(super) async fn do_enter( + &self, + prompt: Option<String>, + ctx: &ToolContext, + ) -> Result<ToolOutput> { + let launch = enter_selfdev_session(Some(&ctx.session_id), ctx.working_dir.as_deref())?; + + if launch.test_mode { + let mut output = format!( + "Created self-dev session {} in {}.\n\nTest mode skipped launching a new terminal.", + launch.session_id, + launch.repo_dir.display() + ); + if let Some(prompt) = prompt { + output.push_str(&format!( + "\n\nSeed prompt captured ({} chars) but not delivered in test mode.", + prompt.chars().count() + )); + } + return Ok(ToolOutput::new(output).with_metadata(json!({ + "session_id": launch.session_id, + "repo_dir": launch.repo_dir, + "launched": false, + "test_mode": true, + "inherited_context": launch.inherited_context + }))); + } + + if !launch.launched { + let command_preview = launch + .command_preview() + .unwrap_or_else(|| format!("jcode --resume {} self-dev", launch.session_id)); + return Ok(ToolOutput::new(format!( + "Created self-dev session {} but could not find a supported terminal to spawn automatically.\n\nRun manually:\n`{} --resume {} self-dev`", + launch.session_id, + launch.exe.as_ref().map(|exe| exe.display().to_string()).unwrap_or_else(|| "jcode".to_string()), + launch.session_id + )) + .with_metadata(json!({ + "session_id": launch.session_id, + "repo_dir": launch.repo_dir, + "launched": false, + "inherited_context": launch.inherited_context + })) + .with_title(format!("selfdev enter: {}", command_preview))); + } + + let mut output = format!( + "Spawned a new self-dev session in a separate terminal.\n\n- Session: `{}`\n- Repo: `{}`\n- Command: `{} --resume {} self-dev`", + launch.session_id, + launch.repo_dir.display(), + launch + .exe + .as_ref() + .map(|exe| exe.display().to_string()) + .unwrap_or_else(|| "jcode".to_string()), + launch.session_id + ); + + let prompt_delivery = if let Some(prompt_text) = prompt { + match SelfDevTool::send_prompt_to_session(&launch.session_id, &prompt_text).await { + Ok(()) => { + output.push_str("\n- Prompt: delivered to the spawned self-dev session"); + Some(true) + } + Err(err) => { + output.push_str(&format!("\n- Prompt: failed to auto-deliver ({})", err)); + Some(false) + } + } + } else { + None + }; + + if launch.inherited_context { + output.push_str("\n- Context: cloned from the current session"); + } + + Ok(ToolOutput::new(output).with_metadata(json!({ + "session_id": launch.session_id, + "repo_dir": launch.repo_dir, + "launched": true, + "prompt_delivered": prompt_delivery, + "inherited_context": launch.inherited_context + }))) + } +} diff --git a/crates/jcode-app-core/src/tool/selfdev/mod.rs b/crates/jcode-app-core/src/tool/selfdev/mod.rs new file mode 100644 index 0000000..54f514f --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/mod.rs @@ -0,0 +1,767 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +//! Self-development tool - manage canary builds when working on jcode itself + +use crate::background::{self, TaskResult}; +use crate::build; +use crate::bus::BackgroundTaskStatus; +use crate::protocol::{ServerEvent, TranscriptMode}; +use crate::server; +use crate::session; +use crate::session_launch; +use crate::storage; +use crate::tool::{Tool, ToolContext, ToolExecutionMode, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +mod build_queue; +mod launch; +mod reload; +mod setup; +mod status; +#[cfg(test)] +mod tests; + +pub use launch::{enter_selfdev_session, schedule_selfdev_prompt_delivery}; +pub use reload::{ReloadRecoveryDirective, persisted_background_tasks_note}; +pub use status::selfdev_status_output; + +/// Public GitHub source used when cloning the jcode repository for self-dev. +pub const JCODE_REPO_URL: &str = "https://github.com/1jehuang/jcode.git"; + +#[derive(Debug, Deserialize)] +struct SelfDevInput { + action: String, + /// Optional prompt to seed the spawned self-dev session. + #[serde(default)] + prompt: Option<String>, + /// Optional context for reload - what the agent is working on + #[serde(default)] + context: Option<String>, + /// Why this build is needed; shown to other queued/blocked agents. + #[serde(default)] + reason: Option<String>, + /// Build target for selfdev build: auto, tui, desktop, or all. + #[serde(default)] + target: Option<String>, + /// Shell command for selfdev test/check action. + #[serde(default)] + command: Option<String>, + /// Whether to notify the requesting agent when the queued background build completes. + #[serde(default)] + notify: Option<bool>, + /// Whether to wake the requesting agent when the queued background build completes. + #[serde(default)] + wake: Option<bool>, + /// Build request id for actions like cancel-build. + #[serde(default)] + request_id: Option<String>, + /// Background task id for actions like cancel-build. + #[serde(default)] + task_id: Option<String>, +} + +/// Context saved before reload, restored after restart +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReloadContext { + /// What the agent was working on (user-provided or auto-detected) + pub task_context: Option<String>, + /// Version before reload + pub version_before: String, + /// New version (target) + pub version_after: String, + /// Session ID + pub session_id: String, + /// Timestamp + pub timestamp: String, +} + +#[derive(Debug, Clone)] +pub struct SelfDevLaunchResult { + pub session_id: String, + pub repo_dir: PathBuf, + pub launched: bool, + pub test_mode: bool, + pub exe: Option<PathBuf>, + pub inherited_context: bool, +} + +impl SelfDevLaunchResult { + pub fn command_preview(&self) -> Option<String> { + self.exe + .as_ref() + .map(|exe| format!("{} --resume {} self-dev", exe.display(), self.session_id)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum BuildRequestState { + Queued, + Building, + Attached, + Completed, + Superseded, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BuildRequest { + request_id: String, + background_task_id: Option<String>, + session_id: String, + session_short_name: Option<String>, + session_title: Option<String>, + reason: String, + repo_dir: String, + #[serde(default)] + repo_scope: String, + #[serde(default)] + worktree_scope: String, + command: String, + requested_at: String, + started_at: Option<String>, + completed_at: Option<String>, + state: BuildRequestState, + version: Option<String>, + #[serde(default)] + dedupe_key: Option<String>, + #[serde(default)] + requested_source: Option<build::SourceState>, + #[serde(default)] + built_source: Option<build::SourceState>, + #[serde(default)] + published_version: Option<String>, + #[serde(default)] + last_progress: Option<String>, + #[serde(default)] + validated: bool, + error: Option<String>, + output_file: Option<String>, + status_file: Option<String>, + attached_to_request_id: Option<String>, +} + +impl BuildRequest { + fn requests_dir() -> Result<PathBuf> { + let dir = storage::jcode_dir()?.join("selfdev-build-requests"); + storage::ensure_dir(&dir)?; + Ok(dir) + } + + fn path_for_request(request_id: &str) -> Result<PathBuf> { + Ok(Self::requests_dir()?.join(format!("{}.json", request_id))) + } + + fn save(&self) -> Result<()> { + storage::write_json(&Self::path_for_request(&self.request_id)?, self) + } + + fn load(request_id: &str) -> Result<Option<Self>> { + let path = Self::path_for_request(request_id)?; + if path.exists() { + Ok(Some(storage::read_json(&path)?)) + } else { + Ok(None) + } + } + + fn load_all() -> Result<Vec<Self>> { + let dir = Self::requests_dir()?; + let mut requests = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + if let Ok(request) = storage::read_json::<Self>(&path) { + requests.push(request); + } + } + requests.sort_by(|a, b| { + a.requested_at + .cmp(&b.requested_at) + .then_with(|| a.request_id.cmp(&b.request_id)) + }); + Ok(requests) + } + + fn pending_requests() -> Result<Vec<Self>> { + let mut pending = Vec::new(); + + for mut request in Self::load_all()? { + if !matches!( + request.state, + BuildRequestState::Queued | BuildRequestState::Building + ) { + continue; + } + + if request.reconcile_pending_state()? { + pending.push(request); + } + } + + Ok(pending) + } + + fn pending_requests_for_scope(worktree_scope: &str) -> Result<Vec<Self>> { + Ok(Self::pending_requests()? + .into_iter() + .filter(|request| request.worktree_scope == worktree_scope) + .collect()) + } + + fn attached_watchers(parent_request_id: &str) -> Result<Vec<Self>> { + Ok(Self::load_all()? + .into_iter() + .filter(|request| { + request.attached_to_request_id.as_deref() == Some(parent_request_id) + && request.state == BuildRequestState::Attached + }) + .collect()) + } + + fn find_duplicate_pending(worktree_scope: &str, dedupe_key: &str) -> Result<Option<Self>> { + Ok(Self::pending_requests_for_scope(worktree_scope)? + .into_iter() + .find(|request| request.dedupe_key.as_deref() == Some(dedupe_key))) + } + + fn find_by_request_or_task( + request_id: Option<&str>, + task_id: Option<&str>, + ) -> Result<Option<Self>> { + if let Some(request_id) = request_id { + return Self::load(request_id); + } + let Some(task_id) = task_id else { + return Ok(None); + }; + Ok(Self::load_all()? + .into_iter() + .find(|request| request.background_task_id.as_deref() == Some(task_id))) + } + + fn display_owner(&self) -> String { + if let Some(short_name) = self.session_short_name.as_deref() { + return format!("{} ({})", short_name, self.session_id); + } + if let Some(title) = self.session_title.as_deref() { + return format!("{} ({})", title, self.session_id); + } + self.session_id.clone() + } + + fn status_path(&self) -> Option<PathBuf> { + self.status_file.as_ref().map(PathBuf::from).or_else(|| { + self.background_task_id.as_ref().map(|task_id| { + std::env::temp_dir() + .join("jcode-bg-tasks") + .join(format!("{}.status.json", task_id)) + }) + }) + } + + fn mark_stale(&mut self, detail: impl Into<String>) -> Result<()> { + self.state = BuildRequestState::Failed; + self.completed_at = Some(Utc::now().to_rfc3339()); + self.error = Some(detail.into()); + self.save() + } + + /// Freshly enqueued requests are saved *before* their background task id + /// and status file exist (the handler saves once, spawns the task, then + /// saves again with the task metadata). The spawned task itself - or any + /// concurrent `selfdev status` / queue poll - can reconcile in that window + /// and must not prune the request as stale, or the build dies instantly + /// with "Queued build request disappeared". + fn within_bootstrap_grace(&self) -> bool { + const BOOTSTRAP_GRACE_SECS: i64 = 30; + chrono::DateTime::parse_from_rfc3339(&self.requested_at) + .map(|requested| { + Utc::now().signed_duration_since(requested.with_timezone(&Utc)) + < chrono::Duration::seconds(BOOTSTRAP_GRACE_SECS) + }) + .unwrap_or(false) + } + + fn reconcile_pending_state(&mut self) -> Result<bool> { + let Some(task_id) = self.background_task_id.as_deref() else { + if self.within_bootstrap_grace() { + return Ok(true); + } + self.mark_stale("Self-dev build request is missing its background task id.")?; + return Ok(false); + }; + + let Some(status_path) = self.status_path() else { + if self.within_bootstrap_grace() { + return Ok(true); + } + self.mark_stale("Self-dev build request is missing its task status path.")?; + return Ok(false); + }; + + let Some(task_status) = (if status_path.exists() && status_path.is_file() { + storage::read_json::<background::TaskStatusFile>(&status_path).ok() + } else { + None + }) else { + if self.within_bootstrap_grace() { + return Ok(true); + } + self.mark_stale( + "Background task status file is missing; pruning stale self-dev build request.", + )?; + return Ok(false); + }; + + match task_status.status { + BackgroundTaskStatus::Running => { + if task_status.detached || background::global().is_live_task(task_id) { + Ok(true) + } else if self.within_bootstrap_grace() { + // The status file is written and the build future spawned + // *before* the task is registered in the in-process task + // map. The freshly spawned build task can reach this check + // (via wait_for_turn) ahead of that registration, and + // is_live_task also returns false while the map's write + // lock is held. Without this grace the request marks + // itself stale and the build fails with "queued build + // request disappeared". + Ok(true) + } else { + self.mark_stale( + "Background task is no longer live; pruning stale self-dev build request.", + )?; + Ok(false) + } + } + BackgroundTaskStatus::Completed => { + self.state = BuildRequestState::Completed; + self.completed_at = task_status + .completed_at + .clone() + .or_else(|| Some(Utc::now().to_rfc3339())); + self.error = None; + self.save()?; + Ok(false) + } + BackgroundTaskStatus::Superseded => { + self.state = BuildRequestState::Superseded; + self.completed_at = task_status + .completed_at + .clone() + .or_else(|| Some(Utc::now().to_rfc3339())); + self.error = task_status.error.clone(); + self.save()?; + Ok(false) + } + BackgroundTaskStatus::Failed => { + self.state = BuildRequestState::Failed; + self.completed_at = task_status + .completed_at + .clone() + .or_else(|| Some(Utc::now().to_rfc3339())); + self.error = task_status.error.clone().or_else(|| { + Some("Background task failed without an error message.".to_string()) + }); + self.save()?; + Ok(false) + } + } + } +} + +struct BuildLockGuard { + file: Option<std::fs::File>, + path: PathBuf, +} + +type SelfDevBuildCommand = build::SelfDevBuildCommand; + +impl Drop for BuildLockGuard { + fn drop(&mut self) { + // Windows does not allow deleting an open lock file. Close the handle + // before unlinking so self-dev builds do not leave a permanent lock. + self.file.take(); + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Default)] +pub struct SelfDevTool; + +impl SelfDevTool { + pub fn new() -> Self { + Self + } + + /// Description shown to the model, tailored to whether this is a self-dev + /// session. Outside self-dev mode the tool is an on-ramp (enter/setup/ + /// reload/find-config); inside self-dev it manages builds and reloads. + pub fn description_for(is_selfdev: bool) -> &'static str { + 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." + } + } + + /// JSON schema advertised to the model, tailored to the session mode. + /// + /// Outside self-dev mode only the on-ramp actions are exposed + /// (`enter`, `setup`, `reload`, `find-config`, `status`). Inside a self-dev + /// session the full build/test/reload/socket surface is exposed. + pub fn schema_for(is_selfdev: bool) -> Value { + if is_selfdev { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": [ + "enter", + "setup", + "build", + "build-reload", + "test", + "cancel-build", + "reload", + "status", + "find-config", + "socket-info", + "socket-help" + ], + "description": "Action. `build-reload` queues a build and, once it finishes successfully, reloads onto the new binary in one step." + }, + "prompt": { "type": "string" }, + "context": { "type": "string" }, + "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." + }, + "command": { + "type": "string", + "description": "Shell command for action=test. Runs under the selfdev worktree compile lock." + }, + "request_id": { "type": "string" }, + "task_id": { "type": "string" } + }, + "required": ["action"] + }) + } else { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": [ + "enter", + "setup", + "reload", + "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." + }, + "prompt": { + "type": "string", + "description": "Optional task to seed the spawned self-dev session when action=enter." + } + }, + "required": ["action"] + }) + } + } +} + +#[async_trait] +impl Tool for SelfDevTool { + fn name(&self) -> &str { + "selfdev" + } + + fn description(&self) -> &str { + // Default to the non-self-dev (on-ramp) description. The agent's tool + // definition builder substitutes the self-dev description for canary + // sessions via `SelfDevTool::description_for`. + SelfDevTool::description_for(false) + } + + fn parameters_schema(&self) -> Value { + // Default to the non-self-dev (on-ramp) schema. The agent's tool + // definition builder substitutes the full self-dev schema for canary + // sessions via `SelfDevTool::schema_for`. + SelfDevTool::schema_for(false) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: SelfDevInput = serde_json::from_value(input)?; + let action = params.action.clone(); + + let title = format!("selfdev {}", action); + let is_selfdev = SelfDevTool::session_is_selfdev(&ctx.session_id); + + let result = match action.as_str() { + // Available in every session. + "enter" => self.do_enter(params.prompt, &ctx).await, + "setup" => self.do_setup(&ctx).await, + "status" => self.do_status().await, + "find-config" => self.do_find_config(&ctx).await, + "reload" => { + if is_selfdev { + self.do_reload( + params.context, + &ctx.session_id, + ctx.execution_mode, + ctx.working_dir.as_deref(), + ) + .await + } else { + self.do_reload_to_newer_build(&ctx).await + } + } + + // Self-dev-only actions: building, testing, and low-level socket + // access only make sense once you are working on jcode itself. + "build" => { + self.do_build( + params.reason, + params.target, + params.notify, + params.wake, + &ctx, + ) + .await + } + "build-reload" | "build_reload" => { + if is_selfdev { + self.do_build_reload(params.reason, params.target, params.context, &ctx) + .await + } else { + Ok(ToolOutput::new(SelfDevTool::selfdev_only_action_message( + "build-reload", + ))) + } + } + "test" => { + self.do_test( + params.command, + params.reason, + params.notify, + params.wake, + &ctx, + ) + .await + } + "cancel-build" => { + self.do_cancel_build(params.request_id, params.task_id, &ctx) + .await + } + "socket-info" => { + if is_selfdev { + self.do_socket_info().await + } else { + Ok(ToolOutput::new(SelfDevTool::selfdev_only_action_message( + "socket-info", + ))) + } + } + "socket-help" => { + if is_selfdev { + self.do_socket_help().await + } else { + Ok(ToolOutput::new(SelfDevTool::selfdev_only_action_message( + "socket-help", + ))) + } + } + _ => Ok(ToolOutput::new(format!( + "Unknown action: {}. In a self-dev session use 'enter', 'setup', 'build', \ + 'build-reload', 'test', 'cancel-build', 'reload', 'status', 'find-config', \ + 'socket-info', or 'socket-help'. Outside self-dev mode use 'enter', 'setup', \ + 'reload', 'status', or 'find-config'.", + action + ))), + }; + + result.map(|output| output.with_title(title)) + } +} + +impl SelfDevTool { + fn is_test_session() -> bool { + std::env::var("JCODE_TEST_SESSION") + .map(|value| { + let trimmed = value.trim(); + !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false") + }) + .unwrap_or(false) + } + + fn reload_timeout_secs() -> u64 { + std::env::var("JCODE_SELFDEV_RELOAD_TIMEOUT_SECS") + .ok() + .and_then(|raw| raw.trim().parse::<u64>().ok()) + .filter(|secs| *secs > 0) + .unwrap_or(15) + } + + /// How long `build-reload` waits inline for the queued build (and any + /// builds ahead of it in the queue) to finish before giving up and telling + /// the agent to reload manually. + fn build_reload_wait_secs() -> u64 { + std::env::var("JCODE_SELFDEV_BUILD_WAIT_SECS") + .ok() + .and_then(|raw| raw.trim().parse::<u64>().ok()) + .filter(|secs| *secs > 0) + .unwrap_or(1800) + } + + fn session_is_selfdev(session_id: &str) -> bool { + session::Session::load(session_id) + .map(|session| session.is_canary) + .unwrap_or(false) + } + + /// Guidance returned when a self-dev-only action is requested from a regular + /// session. Points the agent at `selfdev enter` to get the full toolset. + fn selfdev_only_action_message(action: &str) -> String { + format!( + "`selfdev {action}` is only available inside a self-dev session. \ + Run `selfdev enter` first (optionally with a `prompt`) to open a \ + self-dev session, which exposes builds, tests, reloads, and the \ + debug socket." + ) + } + + fn resolve_repo_dir(working_dir: Option<&std::path::Path>) -> Option<std::path::PathBuf> { + if let Some(dir) = working_dir { + for ancestor in dir.ancestors() { + if build::is_jcode_repo(ancestor) { + return Some(ancestor.to_path_buf()); + } + } + } + + build::get_repo_dir() + } + + fn launch_binary() -> Result<std::path::PathBuf> { + build::client_update_candidate(true) + .map(|(path, _label)| path) + .or_else(|| std::env::current_exe().ok()) + .ok_or_else(|| anyhow::anyhow!("Could not resolve jcode executable to launch")) + } + + fn build_command(repo_dir: &Path, target: build::SelfDevBuildTarget) -> SelfDevBuildCommand { + build::selfdev_build_command_for_target(repo_dir, target) + } + + fn build_lock_path(worktree_scope: &str) -> Result<PathBuf> { + let dir = storage::jcode_dir()?.join("selfdev-build-locks"); + storage::ensure_dir(&dir)?; + Ok(dir.join(format!("{}.lock", worktree_scope))) + } + + #[cfg(unix)] + fn try_acquire_build_lock(worktree_scope: &str) -> Result<Option<BuildLockGuard>> { + use std::fs::OpenOptions; + use std::os::fd::AsRawFd; + + let path = Self::build_lock_path(worktree_scope)?; + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&path)?; + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if ret == 0 { + Ok(Some(BuildLockGuard { + file: Some(file), + path, + })) + } else { + Ok(None) + } + } + + #[cfg(not(unix))] + fn try_acquire_build_lock(worktree_scope: &str) -> Result<Option<BuildLockGuard>> { + use std::fs::OpenOptions; + + let path = Self::build_lock_path(worktree_scope)?; + match OpenOptions::new().create_new(true).write(true).open(&path) { + Ok(file) => Ok(Some(BuildLockGuard { + file: Some(file), + path, + })), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(None), + Err(err) => Err(err.into()), + } + } + + fn load_session_labels(session_id: &str) -> (Option<String>, Option<String>) { + session::Session::load(session_id) + .map(|session| { + let title = session.display_title().map(ToOwned::to_owned); + (session.short_name, title) + }) + .unwrap_or((None, None)) + } + + fn requested_source_state(repo_dir: &Path) -> Result<build::SourceState> { + if Self::is_test_session() { + return Ok(build::SourceState { + repo_scope: "test-repo-scope".to_string(), + worktree_scope: "test-worktree-scope".to_string(), + short_hash: "test-build".to_string(), + full_hash: "test-build-full".to_string(), + dirty: true, + fingerprint: "test-fingerprint".to_string(), + version_label: "test-build".to_string(), + changed_paths: 0, + }); + } + build::current_source_state(repo_dir) + } + + fn newest_active_request(worktree_scope: &str) -> Result<Option<BuildRequest>> { + Ok(BuildRequest::pending_requests_for_scope(worktree_scope)? + .into_iter() + .find(|request| request.state == BuildRequestState::Building)) + } + + fn build_dedupe_key(source: &build::SourceState, command: &SelfDevBuildCommand) -> String { + format!( + "{}:{}:{}", + source.worktree_scope, source.fingerprint, command.display + ) + } + + fn next_request_id() -> String { + format!("selfdev-build-{}", uuid::Uuid::new_v4().simple()) + } + + fn current_queue_position(request_id: &str, worktree_scope: &str) -> Result<Option<usize>> { + Ok(BuildRequest::pending_requests_for_scope(worktree_scope)? + .into_iter() + .position(|request| request.request_id == request_id) + .map(|index| index + 1)) + } +} diff --git a/crates/jcode-app-core/src/tool/selfdev/reload.rs b/crates/jcode-app-core/src/tool/selfdev/reload.rs new file mode 100644 index 0000000..e02ecc9 --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/reload.rs @@ -0,0 +1,427 @@ +use super::*; +pub use jcode_selfdev_types::ReloadRecoveryDirective; + +impl ReloadContext { + fn sanitize_session_id(session_id: &str) -> String { + session_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() + } + + pub fn path_for_session(session_id: &str) -> Result<std::path::PathBuf> { + let sanitized = Self::sanitize_session_id(session_id); + Ok(storage::jcode_dir()?.join(format!("reload-context-{}.json", sanitized))) + } + + fn legacy_path() -> Result<std::path::PathBuf> { + Ok(storage::jcode_dir()?.join("reload-context.json")) + } + + pub fn save(&self) -> Result<()> { + let path = Self::path_for_session(&self.session_id)?; + storage::write_json(&path, self)?; + Ok(()) + } + + pub fn load() -> Result<Option<Self>> { + let legacy = Self::legacy_path()?; + if !legacy.exists() { + return Ok(None); + } + let ctx: Self = storage::read_json(&legacy)?; + let _ = std::fs::remove_file(&legacy); + Ok(Some(ctx)) + } + + /// Peek at context for a specific session without consuming it. + pub fn peek_for_session(session_id: &str) -> Result<Option<Self>> { + let session_path = Self::path_for_session(session_id)?; + if session_path.exists() { + let ctx: Self = storage::read_json(&session_path)?; + return Ok(Some(ctx)); + } + + let legacy = Self::legacy_path()?; + if !legacy.exists() { + return Ok(None); + } + + let ctx: Self = storage::read_json(&legacy)?; + if ctx.session_id == session_id { + Ok(Some(ctx)) + } else { + Ok(None) + } + } + + /// Load context only if it belongs to the given session; consumes on success. + pub fn load_for_session(session_id: &str) -> Result<Option<Self>> { + let session_path = Self::path_for_session(session_id)?; + if session_path.exists() { + let ctx: Self = storage::read_json(&session_path)?; + let _ = std::fs::remove_file(&session_path); + return Ok(Some(ctx)); + } + + let legacy = Self::legacy_path()?; + if !legacy.exists() { + return Ok(None); + } + + let ctx: Self = storage::read_json(&legacy)?; + if ctx.session_id == session_id { + let _ = std::fs::remove_file(&legacy); + Ok(Some(ctx)) + } else { + Ok(None) + } + } + + fn task_info_suffix(&self) -> String { + self.task_context + .as_ref() + .map(|task| format!("\nTask context: {}", task)) + .unwrap_or_default() + } + + pub fn reconnect_notice_line(&self) -> String { + format!("Reloaded with build {}", self.version_after) + } + + pub fn continuation_message( + &self, + background_task_note: &str, + restored_turns: Option<usize>, + ) -> String { + let task_info = self.task_info_suffix(); + let turns_note = restored_turns + .map(|turns| format!(" Session restored with {} turns.", turns)) + .unwrap_or_default(); + format!( + "Reload succeeded ({} → {}).{}{}{} Continue immediately from where you left off. Do not ask the user what to do next. Do not summarize the reload.", + self.version_before, self.version_after, task_info, background_task_note, turns_note + ) + } + + pub fn interrupted_session_continuation_message() -> String { + "Your session was interrupted by a server reload while a tool was running. The tool was aborted and results may be incomplete. Continue exactly where you left off and do not ask the user what to do next.".to_string() + } + + pub fn recovery_continuation_message( + reload_ctx: Option<&Self>, + background_task_note: &str, + restored_turns: Option<usize>, + ) -> String { + reload_ctx + .map(|ctx| ctx.continuation_message(background_task_note, restored_turns)) + .unwrap_or_else(Self::interrupted_session_continuation_message) + } + + pub fn recovery_directive( + reload_ctx: Option<&Self>, + was_interrupted: bool, + background_task_note: &str, + restored_turns: Option<usize>, + ) -> Option<ReloadRecoveryDirective> { + if let Some(ctx) = reload_ctx { + return Some(ReloadRecoveryDirective { + reconnect_notice: Some(ctx.reconnect_notice_line()), + continuation_message: ctx + .continuation_message(background_task_note, restored_turns), + }); + } + + if was_interrupted { + return Some(ReloadRecoveryDirective { + reconnect_notice: None, + continuation_message: Self::interrupted_session_continuation_message(), + }); + } + + None + } + + pub fn recovery_directive_for_session( + session_id: &str, + reload_ctx: Option<&Self>, + was_interrupted: bool, + restored_turns: Option<usize>, + ) -> Option<ReloadRecoveryDirective> { + Self::recovery_directive( + reload_ctx, + was_interrupted, + &persisted_background_tasks_note(session_id), + restored_turns, + ) + } + + pub fn log_recovery_outcome(flow: &str, session_id: &str, outcome: &str, detail: &str) { + crate::logging::info(&format!( + "reload recovery flow={} session_id={} outcome={} detail={}", + flow, session_id, outcome, detail + )); + } +} + +pub fn persisted_background_tasks_note(session_id: &str) -> String { + let mut notes = String::new(); + + let tasks = + crate::background::global().persisted_detached_running_tasks_for_session(session_id); + if !tasks.is_empty() { + let task_list = tasks + .iter() + .map(|task| format!("{} ({})", task.task_id, task.tool_name)) + .collect::<Vec<_>>() + .join(", "); + + notes.push_str(&format!( + "\nPersisted background task(s) for this session are still running: {}. Do not rerun those commands. Check them first with the `bg` tool (`bg action=\"list\"`, `bg action=\"status\" task_id=...`, or `bg action=\"output\" task_id=...`).", + task_list + )); + } + + // Background awaits auto-resume on the new server and report via + // notify/wake, so they need no agent action. Only blocking awaits, whose + // socket waiter dies with the old process, must be rerun by the agent. + let pending_awaits: Vec<_> = crate::server::pending_await_members_for_session(session_id) + .into_iter() + .filter(|state| !state.background) + .collect(); + if !pending_awaits.is_empty() { + let await_list = pending_awaits + .iter() + .map(|state| { + let watch = if state.requested_ids.is_empty() { + "entire swarm".to_string() + } else { + state.requested_ids.join(", ") + }; + let remaining_secs = state.remaining_timeout().as_secs(); + format!( + "{} -> [{}], {}s remaining", + watch, + state.target_status.join(", "), + remaining_secs + ) + }) + .collect::<Vec<_>>() + .join("; "); + + notes.push_str(&format!( + "\nPersisted blocking `swarm await_members` wait(s) are still pending: {}. If you still need those coordination points after reload, rerun the same `swarm` call with action `await_members` to resume them with the remaining timeout instead of starting over. (Background awaits resume automatically and will notify you.)", + await_list + )); + } + + notes +} + +pub(super) fn resolve_selfdev_reload_repo_dir( + working_dir: Option<&std::path::Path>, +) -> Option<std::path::PathBuf> { + resolve_selfdev_reload_repo_dir_from(build::get_repo_dir(), working_dir) +} + +pub(super) fn resolve_selfdev_reload_repo_dir_from( + primary: Option<std::path::PathBuf>, + working_dir: Option<&std::path::Path>, +) -> Option<std::path::PathBuf> { + primary.or_else(|| working_dir.and_then(build::find_repo_in_ancestors)) +} + +impl SelfDevTool { + pub(super) async fn do_reload( + &self, + context: Option<String>, + session_id: &str, + execution_mode: ToolExecutionMode, + working_dir: Option<&std::path::Path>, + ) -> Result<ToolOutput> { + let repo_dir = resolve_selfdev_reload_repo_dir(working_dir) + .ok_or_else(|| anyhow::anyhow!("Could not find jcode repository directory"))?; + + let target_binary = build::find_dev_binary(&repo_dir) + .unwrap_or_else(|| build::release_binary_path(&repo_dir)); + // In a test session the rest of this method fakes the build source, + // hash and published state, so the real on-disk binary check is both + // inconsistent and environment-dependent (CI builds a release binary; + // a local debug/selfdev checkout may not have target/release/jcode). + // Skipping it lets the reload-signal/ack contract be exercised + // deterministically regardless of which profile was built locally. + if !SelfDevTool::is_test_session() && !target_binary.exists() { + return Ok(ToolOutput::new( + format!( + "No binary found at {}.\n\ + Run 'jcode self-dev --build' first, or build with 'scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode' and then try reload again.", + target_binary.display() + ) + .to_string(), + )); + } + + let source = if SelfDevTool::is_test_session() { + build::SourceState { + repo_scope: "test-repo-scope".to_string(), + worktree_scope: "test-worktree-scope".to_string(), + short_hash: "test-reload-hash".to_string(), + full_hash: "test-reload-hash-full".to_string(), + dirty: true, + fingerprint: "test-reload-fingerprint".to_string(), + version_label: "test-reload-hash".to_string(), + changed_paths: 0, + } + } else { + build::current_source_state(&repo_dir)? + }; + let hash = source.version_label.clone(); + let version_before = jcode_build_meta::VERSION.to_string(); + let published = if SelfDevTool::is_test_session() { + None + } else { + Some(build::publish_local_current_build_for_source( + &repo_dir, &source, + )?) + }; + let previous_shared_server_version = if SelfDevTool::is_test_session() { + None + } else { + let published_build = published.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "published build metadata was missing after publish_local_current_build_for_source" + ) + })?; + build::smoke_test_server_binary(&published_build.versioned_path)?; + build::read_shared_server_version()? + }; + + // Update manifest - track what we're testing + let mut manifest = build::BuildManifest::load()?; + manifest.canary = Some(hash.clone()); + manifest.canary_status = Some(build::CanaryStatus::Testing); + manifest.set_pending_activation(build::PendingActivation { + session_id: session_id.to_string(), + new_version: hash.clone(), + previous_current_version: published + .as_ref() + .and_then(|published| published.previous_current_version.clone()), + previous_shared_server_version, + source_fingerprint: Some(source.fingerprint.clone()), + requested_at: chrono::Utc::now(), + })?; + manifest.save()?; + + if !SelfDevTool::is_test_session() + && let Err(error) = build::update_shared_server_symlink(&hash) + { + let _ = build::rollback_pending_activation_for_session(session_id); + return Err(error); + } + + // Save reload context for continuation after restart + let reload_ctx = ReloadContext { + task_context: context, + version_before, + version_after: hash.clone(), + session_id: session_id.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }; + crate::logging::info(&format!( + "Saving reload context to {:?}", + ReloadContext::path_for_session(session_id) + )); + if let Err(e) = reload_ctx.save() { + crate::logging::error(&format!("Failed to save reload context: {}", e)); + let _ = build::rollback_pending_activation_for_session(session_id); + return Err(e); + } + crate::logging::info("Reload context saved successfully"); + + // Signal the server via in-process channel (replaces filesystem-based rebuild-signal) + let request_id = + server::send_reload_signal(hash.clone(), Some(session_id.to_string()), true); + crate::logging::info(&format!( + "selfdev reload: request={} session_id={} hash={} execution_mode={:?}", + request_id, session_id, hash, execution_mode + )); + + let timeout = std::time::Duration::from_secs(SelfDevTool::reload_timeout_secs()); + let ack_wait_started = std::time::Instant::now(); + let ack = server::wait_for_reload_ack(&request_id, timeout) + .await + .map_err(|error| { + let _ = build::rollback_pending_activation_for_session(session_id); + anyhow::anyhow!( + "Timed out waiting for the server to begin reload after {}s: {}. The reload signal may not have been picked up; check that the connected server is running a build with unified self-dev reload support and try restarting the shared server.", + timeout.as_secs(), + error + ) + })?; + + crate::logging::info(&format!( + "selfdev reload: acked request={} hash={} after {}ms state={}", + ack.request_id, + ack.hash, + ack_wait_started.elapsed().as_millis(), + server::reload_state_summary(std::time::Duration::from_secs(60)) + )); + + match execution_mode { + ToolExecutionMode::Direct => { + if SelfDevTool::is_test_session() { + return Ok(ToolOutput::new(format!( + "Reload acknowledged for build {}. Server is restarting now.", + ack.hash + ))); + } + match server::await_reload_handoff(&server::socket_path(), timeout).await { + server::ReloadWaitStatus::Ready => { + let _ = build::complete_pending_activation_for_session(session_id); + Ok(ToolOutput::new(format!( + "Reload completed successfully for build {}. Server reported ready.", + ack.hash + ))) + } + server::ReloadWaitStatus::Failed(detail) => { + let _ = build::rollback_pending_activation_for_session(session_id); + Err(anyhow::anyhow!( + "Reload was acknowledged for build {}, but the replacement server failed before becoming ready on {}: {}; recent_state={}", + ack.hash, + server::socket_path().display(), + detail.unwrap_or_else(|| "unknown reload failure".to_string()), + server::reload_state_summary(std::time::Duration::from_secs(60)) + )) + } + server::ReloadWaitStatus::Idle | server::ReloadWaitStatus::Waiting { .. } => { + let _ = build::rollback_pending_activation_for_session(session_id); + Err(anyhow::anyhow!( + "Reload was acknowledged for build {}, but readiness could not be confirmed within {}s.", + ack.hash, + timeout.as_secs() + )) + } + } + } + ToolExecutionMode::AgentTurn => { + // In normal agent turns the reload will intentionally terminate this + // process shortly after the server acknowledges the request. Return a + // tool result immediately so the harness can persist/deliver the tool + // output before the process exits. Previously this branch waited to be + // interrupted by shutdown; depending on timing, the process could exit + // before the in-flight tool result reached the client, producing + // "Tool output missing" even though reload succeeded. + Ok(ToolOutput::new(format!( + "Reload initiated for build {}. Process restarting...", + ack.hash + ))) + } + } + } +} diff --git a/crates/jcode-app-core/src/tool/selfdev/setup.rs b/crates/jcode-app-core/src/tool/selfdev/setup.rs new file mode 100644 index 0000000..496329d --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/setup.rs @@ -0,0 +1,354 @@ +use super::*; + +use std::process::Command; + +/// A single dependency check performed by `selfdev setup`. +struct SetupCheck { + name: &'static str, + ok: bool, + detail: String, + /// Hint shown when the check fails. + fix: Option<String>, +} + +impl SetupCheck { + fn ok(name: &'static str, detail: impl Into<String>) -> Self { + Self { + name, + ok: true, + detail: detail.into(), + fix: None, + } + } + + fn missing(name: &'static str, detail: impl Into<String>, fix: impl Into<String>) -> Self { + Self { + name, + ok: false, + detail: detail.into(), + fix: Some(fix.into()), + } + } + + fn marker(&self) -> &'static str { + if self.ok { "✅" } else { "❌" } + } +} + +/// Run a command and capture trimmed stdout if it succeeds. +fn command_version(program: &str, args: &[&str]) -> Option<String> { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let first = text.lines().next().unwrap_or("").trim(); + if first.is_empty() { + None + } else { + Some(first.to_string()) + } +} + +impl SelfDevTool { + /// `selfdev setup`: verify (and where safe, bootstrap) the prerequisites for + /// building jcode from source: the rust toolchain, git, and a local repo + /// checkout. This never installs anything irreversible automatically; it + /// reports what is missing and how to fix it, and clones the source when no + /// checkout exists yet. + pub(super) async fn do_setup(&self, ctx: &ToolContext) -> Result<ToolOutput> { + let mut checks: Vec<SetupCheck> = Vec::new(); + + // Rust toolchain: cargo + rustc are required to build jcode. + match command_version("cargo", &["--version"]) { + Some(version) => checks.push(SetupCheck::ok("cargo", version)), + None => checks.push(SetupCheck::missing( + "cargo", + "not found on PATH", + "Install the Rust toolchain via rustup: \ + `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` \ + (or your distro's rustup package), then restart your shell.", + )), + } + match command_version("rustc", &["--version"]) { + Some(version) => checks.push(SetupCheck::ok("rustc", version)), + None => checks.push(SetupCheck::missing( + "rustc", + "not found on PATH", + "Install the Rust toolchain via rustup (see cargo above).", + )), + } + + // git is required to clone/update the repository. + match command_version("git", &["--version"]) { + Some(version) => checks.push(SetupCheck::ok("git", version)), + None => checks.push(SetupCheck::missing( + "git", + "not found on PATH", + "Install git from your system package manager (e.g. `pacman -S git`, \ + `apt install git`, `brew install git`).", + )), + } + + // Repository checkout: locate an existing repo or clone the source. + let mut repo_dir: Option<std::path::PathBuf> = + SelfDevTool::resolve_repo_dir(ctx.working_dir.as_deref()); + let mut clone_note: Option<String> = None; + + if repo_dir.is_none() { + // Only attempt a clone when git is available and we're not in a + // synthetic test session. + let git_available = checks.iter().any(|check| check.name == "git" && check.ok); + if SelfDevTool::is_test_session() { + clone_note = Some("Test mode: skipped cloning the jcode source.".to_string()); + } else if git_available { + match Self::clone_selfdev_source() { + Ok(path) => { + clone_note = Some(format!("Cloned jcode source into {}.", path.display())); + repo_dir = Some(path); + } + Err(err) => { + clone_note = + Some(format!("Could not clone jcode source automatically: {err}",)); + } + } + } + } + + match &repo_dir { + Some(path) => checks.push(SetupCheck::ok("repository", path.display().to_string())), + None => { + let target = Self::selfdev_clone_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "~/.jcode/source/jcode".to_string()); + checks.push(SetupCheck::missing( + "repository", + "no local jcode checkout found", + format!( + "Clone the source manually: `git clone {} {}`.", + super::JCODE_REPO_URL, + target + ), + )); + } + } + + // A reloadable/built binary, so the agent knows whether it still needs a + // build before `selfdev reload`/`enter` can hand off into a dev binary. + if let Some(repo) = repo_dir.as_deref() { + match build::find_dev_binary(repo) { + Some(binary) => { + checks.push(SetupCheck::ok("dev binary", binary.display().to_string())) + } + None => checks.push(SetupCheck::missing( + "dev binary", + "no built binary in target/selfdev or target/release", + "Build it once with `jcode self-dev --build`, or inside a \ + self-dev session run `selfdev build`.", + )), + } + } + + let all_ok = checks.iter().all(|check| check.ok); + + let mut output = String::from("## Self-dev setup\n\n"); + for check in &checks { + output.push_str(&format!( + "{} **{}** — {}\n", + check.marker(), + check.name, + check.detail + )); + } + if let Some(note) = &clone_note { + output.push_str(&format!("\n{}\n", note)); + } + + let failing: Vec<&SetupCheck> = checks.iter().filter(|c| !c.ok).collect(); + if !failing.is_empty() { + output.push_str("\n### Next steps\n\n"); + for check in &failing { + if let Some(fix) = &check.fix { + output.push_str(&format!("- **{}**: {}\n", check.name, fix)); + } + } + } else { + output.push_str( + "\nAll prerequisites satisfied. Use `selfdev enter` to start working on jcode.\n", + ); + } + + let metadata = json!({ + "ready": all_ok, + "repo_dir": repo_dir.as_ref().map(|p| p.display().to_string()), + "checks": checks + .iter() + .map(|c| json!({ + "name": c.name, + "ok": c.ok, + "detail": c.detail, + })) + .collect::<Vec<_>>(), + }); + + Ok(ToolOutput::new(output).with_metadata(metadata)) + } + + /// `selfdev find-config`: report the key jcode paths (config file, home, + /// logs, build channels, sockets, and repo checkout) so the agent can locate + /// configuration without guessing platform-specific locations. + pub(super) async fn do_find_config(&self, ctx: &ToolContext) -> Result<ToolOutput> { + let jcode_home = storage::jcode_dir().ok(); + let config_path = jcode_home.as_ref().map(|home| home.join("config.toml")); + let logs_dir = storage::logs_dir().ok(); + let repo_dir = SelfDevTool::resolve_repo_dir(ctx.working_dir.as_deref()); + + let format_path = |path: Option<&std::path::Path>| match path { + Some(p) => { + let exists = p.exists(); + format!( + "{} {}", + p.display(), + if exists { "(exists)" } else { "(missing)" } + ) + } + None => "unavailable".to_string(), + }; + + let mut output = String::from("## jcode config & paths\n\n"); + output.push_str(&format!( + "**Config file:** {}\n", + format_path(config_path.as_deref()) + )); + output.push_str(&format!( + "**jcode home:** {}\n", + format_path(jcode_home.as_deref()) + )); + output.push_str(&format!( + "**Logs dir:** {}\n", + format_path(logs_dir.as_deref()) + )); + output.push_str(&format!( + "**Repository:** {}\n", + repo_dir + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "not found (run `selfdev setup`)".to_string()) + )); + + output.push_str("\n### Build channels\n\n"); + output.push_str(&format!( + "**current:** {}\n", + format_path(build::current_binary_path().ok().as_deref()) + )); + output.push_str(&format!( + "**stable:** {}\n", + format_path(build::stable_binary_path().ok().as_deref()) + )); + output.push_str(&format!( + "**shared-server:** {}\n", + format_path(build::shared_server_binary_path().ok().as_deref()) + )); + output.push_str(&format!( + "**launcher:** {}\n", + format_path(build::launcher_binary_path().ok().as_deref()) + )); + + output.push_str("\n### Sockets\n\n"); + output.push_str(&format!( + "**main socket:** {}\n", + server::socket_path().display() + )); + output.push_str(&format!( + "**debug socket:** {}\n", + server::debug_socket_path().display() + )); + + let metadata = json!({ + "config_path": config_path.as_ref().map(|p| p.display().to_string()), + "jcode_home": jcode_home.as_ref().map(|p| p.display().to_string()), + "logs_dir": logs_dir.as_ref().map(|p| p.display().to_string()), + "repo_dir": repo_dir.as_ref().map(|p| p.display().to_string()), + "config_exists": config_path.as_ref().map(|p| p.exists()).unwrap_or(false), + }); + + Ok(ToolOutput::new(output).with_metadata(metadata)) + } + + /// `selfdev reload` from a non-self-dev session: a plain upgrade-in-place. + /// Unlike the self-dev reload, this does not publish a freshly-built binary; + /// it asks the shared server to exec into the newest installed build (if one + /// is strictly newer than the running process). + pub(super) async fn do_reload_to_newer_build(&self, _ctx: &ToolContext) -> Result<ToolOutput> { + if SelfDevTool::is_test_session() { + return Ok(ToolOutput::new("Test mode: skipped reload-to-newer-build.")); + } + + if !server::server_has_newer_binary() { + return Ok(ToolOutput::new( + "Already running the newest installed jcode build; no reload needed.", + )); + } + + let hash = jcode_build_meta::GIT_HASH.to_string(); + let request_id = server::send_reload_signal(hash.clone(), None, false); + let timeout = std::time::Duration::from_secs(SelfDevTool::reload_timeout_secs()); + + match server::wait_for_reload_ack(&request_id, timeout).await { + Ok(ack) => Ok(ToolOutput::new(format!( + "Reload initiated into build {}. The server is restarting into the newer binary.", + ack.hash + )) + .with_metadata(json!({ + "request_id": request_id, + "hash": ack.hash, + }))), + Err(err) => Ok(ToolOutput::new(format!( + "Sent a reload request, but the server did not acknowledge within {}s: {}. \ + It may still reload; check `selfdev status`.", + timeout.as_secs(), + err + ))), + } + } + + /// Resolve the default location for a cloned self-dev source checkout. + fn selfdev_clone_dir() -> Result<std::path::PathBuf> { + Ok(storage::jcode_dir()?.join("source").join("jcode")) + } + + /// Clone the jcode source into the default self-dev source directory. + fn clone_selfdev_source() -> Result<std::path::PathBuf> { + let repo_dir = Self::selfdev_clone_dir()?; + if repo_dir.exists() { + if build::is_jcode_repo(&repo_dir) { + return Ok(repo_dir); + } + anyhow::bail!( + "{} exists but is not a jcode repository; move it aside and retry", + repo_dir.display() + ); + } + + if let Some(parent) = repo_dir.parent() { + std::fs::create_dir_all(parent)?; + } + + let status = Command::new("git") + .arg("clone") + .arg(super::JCODE_REPO_URL) + .arg(&repo_dir) + .status() + .map_err(|e| anyhow::anyhow!("failed to run git clone: {e}"))?; + if !status.success() { + anyhow::bail!("git clone exited with {status}"); + } + if !build::is_jcode_repo(&repo_dir) { + anyhow::bail!( + "cloned source at {} is not a valid jcode repository", + repo_dir.display() + ); + } + Ok(repo_dir) + } +} diff --git a/crates/jcode-app-core/src/tool/selfdev/status.rs b/crates/jcode-app-core/src/tool/selfdev/status.rs new file mode 100644 index 0000000..19eede3 --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/status.rs @@ -0,0 +1,301 @@ +use super::*; + +pub fn selfdev_status_output() -> Result<ToolOutput> { + let manifest = build::BuildManifest::load()?; + + let mut status = String::new(); + + status.push_str("## Current Version\n\n"); + status.push_str(&format!( + "**Running:** jcode {}\n", + jcode_build_meta::VERSION + )); + + if let Some(repo_dir) = build::get_repo_dir() { + let output = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&repo_dir) + .output() + .ok(); + + if let Some(output) = output { + let changes: Vec<&str> = std::str::from_utf8(&output.stdout) + .unwrap_or("") + .lines() + .collect(); + if changes.is_empty() { + status.push_str("**Working tree:** clean\n"); + } else { + status.push_str(&format!( + "**Working tree:** {} uncommitted change{}\n", + changes.len(), + if changes.len() == 1 { "" } else { "s" } + )); + } + } + } + + status.push_str("\n## Build Channels\n\n"); + + if let Ok(Some(current)) = build::read_current_version() { + status.push_str(&format!("**Current:** {}\n", current)); + } else { + status.push_str("**Current:** none\n"); + } + + if let Ok(Some(shared_server)) = build::read_shared_server_version() { + status.push_str(&format!("**Shared server:** {}\n", shared_server)); + } else { + status.push_str("**Shared server:** none\n"); + } + + if let Some(ref stable) = manifest.stable { + status.push_str(&format!("**Stable:** {}\n", stable)); + } else { + status.push_str("**Stable:** none\n"); + } + + if let Some(ref canary) = manifest.canary { + let status_str = match &manifest.canary_status { + Some(build::CanaryStatus::Testing) => "testing", + Some(build::CanaryStatus::Passed) => "passed", + Some(build::CanaryStatus::Failed) => "failed", + None => "unknown", + }; + status.push_str(&format!("**Canary:** {} ({})\n", canary, status_str)); + } else { + status.push_str("**Canary:** none\n"); + } + + if let Some(pending) = manifest.pending_activation.as_ref() { + status.push_str(&format!( + "**Pending activation:** {} for session `{}`\n", + pending.new_version, pending.session_id + )); + if let Some(previous) = pending.previous_current_version.as_deref() { + status.push_str(&format!("**Rollback target:** {}\n", previous)); + } + if let Some(previous) = pending.previous_shared_server_version.as_deref() { + status.push_str(&format!( + "**Shared server rollback target:** {}\n", + previous + )); + } + if let Some(fingerprint) = pending.source_fingerprint.as_deref() { + status.push_str(&format!( + "**Pending source fingerprint:** `{}`\n", + fingerprint + )); + } + } + + status.push_str("\n## Debug Socket\n\n"); + status.push_str(&format!( + "**Path:** {}\n", + server::debug_socket_path().display() + )); + + if let Some(reload_state) = server::ReloadState::load() { + status.push_str("\n## Reload State\n\n"); + status.push_str(&format!( + "**Phase:** {:?}\n**Request:** {}\n**Hash:** {}\n**PID:** {}\n**Timestamp:** {}\n", + reload_state.phase, + reload_state.request_id, + reload_state.hash, + reload_state.pid, + reload_state.timestamp, + )); + if let Some(detail) = reload_state.detail { + status.push_str(&format!("**Detail:** {}\n", detail)); + } + } + + let pending_requests = BuildRequest::pending_requests()?; + if !pending_requests.is_empty() { + status.push_str("\n## Build Queue\n\n"); + for (index, request) in pending_requests.iter().enumerate() { + let watchers = BuildRequest::attached_watchers(&request.request_id)?; + let state = match request.state { + BuildRequestState::Queued => "queued", + BuildRequestState::Building => "building", + BuildRequestState::Attached => "attached", + BuildRequestState::Completed => "completed", + BuildRequestState::Superseded => "superseded", + BuildRequestState::Failed => "failed", + BuildRequestState::Cancelled => "cancelled", + }; + status.push_str(&format!( + "{}. **{}** — {}\n Reason: {}\n Requested: {}\n", + index + 1, + state, + request.display_owner(), + request.reason, + request.requested_at, + )); + if let Some(version) = request.version.as_deref() { + status.push_str(&format!(" Target version: `{}`\n", version)); + } + if let Some(source) = request.requested_source.as_ref() { + status.push_str(&format!( + " Source fingerprint: `{}` (dirty={}, changed_paths={})\n", + source.fingerprint, source.dirty, source.changed_paths + )); + } + if let Some(progress) = request.last_progress.as_deref() { + status.push_str(&format!(" Progress: {}\n", progress)); + } + if let Some(task_id) = request.background_task_id.as_deref() { + status.push_str(&format!(" Task: `{}`\n", task_id)); + } + if let Some(started_at) = request.started_at.as_deref() { + status.push_str(&format!(" Started: {}\n", started_at)); + } + if let Some(published) = request.published_version.as_deref() { + status.push_str(&format!(" Published version: `{}`\n", published)); + } + status.push_str(&format!(" Validated: {}\n", request.validated)); + if !watchers.is_empty() { + let watcher_names = watchers + .iter() + .map(BuildRequest::display_owner) + .collect::<Vec<_>>() + .join(", "); + status.push_str(&format!( + " Attached watchers: {} ({})\n", + watchers.len(), + watcher_names + )); + } + } + } + + if let Some(ref crash) = manifest.last_crash { + status.push_str(&format!( + "\n## Last Crash\n\n\ + Build: {}\n\ + Exit code: {}\n\ + Time: {}\n", + crash.build_hash, + crash.exit_code, + crash.crashed_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + + if !crash.stderr.is_empty() { + let stderr_preview = if crash.stderr.len() > 500 { + format!("{}...", crate::util::truncate_str(&crash.stderr, 500)) + } else { + crash.stderr.clone() + }; + status.push_str(&format!("\nStderr:\n```\n{}\n```\n", stderr_preview)); + } + } + + if !manifest.history.is_empty() { + status.push_str("\n## Recent Builds\n\n"); + for (i, info) in manifest.history.iter().take(5).enumerate() { + let dirty_marker = if info.dirty { " (dirty)" } else { "" }; + let msg = info + .commit_message + .as_deref() + .unwrap_or("No commit message"); + status.push_str(&format!( + "{}. `{}`{} - {}\n Built: {}\n", + i + 1, + info.hash, + dirty_marker, + msg, + info.built_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + } + } + + Ok(ToolOutput::new(status)) +} + +impl SelfDevTool { + pub(super) async fn do_status(&self) -> Result<ToolOutput> { + selfdev_status_output() + } + + pub(super) async fn do_socket_info(&self) -> Result<ToolOutput> { + let debug_socket = server::debug_socket_path(); + let main_socket = server::socket_path(); + + let info = json!({ + "debug_socket": debug_socket.to_string_lossy(), + "main_socket": main_socket.to_string_lossy(), + "debug_enabled": crate::config::config().display.debug_socket || + std::env::var("JCODE_DEBUG_CONTROL").is_ok() || + crate::storage::jcode_dir().map(|d| d.join("debug_control").exists()).unwrap_or(false), + "connect_example": format!( + "echo '{{\"type\":\"debug_command\",\"id\":1,\"command\":\"help\"}}' | nc -U {}", + debug_socket.display() + ), + }); + + Ok(ToolOutput::new(format!( + "## Debug Socket Info\n\n\ + **Debug socket:** {}\n\ + **Main socket:** {}\n\n\ + Use the `debug_socket` tool to send commands, or connect directly:\n\ + ```bash\n\ + echo '{{\"type\":\"debug_command\",\"id\":1,\"command\":\"help\"}}' | nc -U {}\n\ + ```\n\n\ + For programmatic access, use the `debug_socket` tool with the command parameter.", + debug_socket.display(), + main_socket.display(), + debug_socket.display() + )) + .with_metadata(info)) + } + + pub(super) async fn do_socket_help(&self) -> Result<ToolOutput> { + Ok(ToolOutput::new( + r#"## Debug Socket Commands + +Commands are namespaced with `server:`, `client:`, or `tester:` prefixes. +Unnamespaced commands default to `server:`. + +### Server Commands (agent/tools) +| Command | Description | +|---------|-------------| +| `state` | Agent state (session, model, canary) | +| `history` | Conversation history as JSON | +| `tools` | List available tools | +| `last_response` | Last assistant response | +| `message:<text>` | Send message, get LLM response | +| `tool:<name> <json>` | Execute tool directly | +| `sessions` | List all sessions | +| `create_session` | Create headless session | +| `help` | Full help text | + +### Client Commands (TUI/visual debug) +| Command | Description | +|---------|-------------| +| `client:frame` | Get latest visual debug frame (JSON) | +| `client:frame-normalized` | Normalized frame for diffs | +| `client:screen` | Dump frames to file | +| `client:enable` | Enable visual debug capture | +| `client:disable` | Disable visual debug capture | +| `client:status` | Client debug status | +| `client:scroll-test[:<json>]` | Run offscreen scroll+diagram test | +| `client:scroll-suite[:<json>]` | Run scroll+diagram test suite | + +### Tester Commands (spawn test instances) +| Command | Description | +|---------|-------------| +| `tester:spawn` | Spawn new tester instance | +| `tester:spawn {"cwd":"/path"}` | Spawn with options | +| `tester:list` | List active testers | +| `tester:<id>:frame` | Get frame from tester | +| `tester:<id>:state` | Get tester state | +| `tester:<id>:message:<text>` | Send message to tester | +| `tester:<id>:scroll-test[:<json>]` | Run offscreen scroll+diagram test | +| `tester:<id>:scroll-suite[:<json>]` | Run scroll+diagram test suite | +| `tester:<id>:stop` | Stop tester | + +Use the `debug_socket` tool to execute these commands directly."# + .to_string(), + )) + } +} diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs new file mode 100644 index 0000000..723c57e --- /dev/null +++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs @@ -0,0 +1,1341 @@ +use super::*; +use crate::bus::BackgroundTaskStatus; +use std::ffi::OsStr; +use std::sync::{LazyLock, Mutex}; + +static ENV_LOCK: LazyLock<Mutex<()>> = 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, + original: Option<std::ffi::OsString>, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self { + let original = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, original } + } + + fn remove(key: &'static str) -> Self { + let original = std::env::var_os(key); + crate::env::remove_var(key); + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => crate::env::set_var(self.key, value), + None => crate::env::remove_var(self.key), + } + } +} + +fn create_test_context(session_id: &str, working_dir: Option<std::path::PathBuf>) -> ToolContext { + ToolContext { + session_id: session_id.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, + } +} + +fn create_repo_fixture() -> tempfile::TempDir { + let temp = tempfile::TempDir::new().expect("temp repo"); + std::fs::create_dir_all(temp.path().join(".git")).expect("git dir"); + std::fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname = \"jcode\"\nversion = \"0.1.0\"\n", + ) + .expect("cargo toml"); + temp +} + +fn test_source_state(repo_dir: &std::path::Path) -> build::SourceState { + build::SourceState { + repo_scope: "test-repo-scope".to_string(), + worktree_scope: build::worktree_scope_key(repo_dir) + .unwrap_or_else(|_| "test-worktree".to_string()), + short_hash: "test-build".to_string(), + full_hash: "test-build-full".to_string(), + dirty: true, + fingerprint: "test-fingerprint".to_string(), + version_label: "test-build".to_string(), + changed_paths: 0, + } +} + +#[test] +fn build_lock_is_removed_on_drop_and_can_be_reacquired() { + let _env_lock = lock_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()); + let path = SelfDevTool::build_lock_path(&scope).expect("lock path"); + + let first = SelfDevTool::try_acquire_build_lock(&scope) + .expect("first lock attempt") + .expect("first lock acquired"); + assert!(path.exists(), "lock file should exist while held"); + drop(first); + assert!(!path.exists(), "lock file should be removed on drop"); + + let second = SelfDevTool::try_acquire_build_lock(&scope) + .expect("second lock attempt") + .expect("lock should be reacquirable after drop"); + drop(second); + assert!(!path.exists(), "reacquired lock should also clean up"); +} + +async fn wait_for_task_completion(task_id: &str) -> background::TaskStatusFile { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Some(status) = background::global().status(task_id).await + && status.status != BackgroundTaskStatus::Running + { + return status; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for background task {}", + task_id + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +#[test] +fn test_reload_context_serialization() { + // Create test context with task info + let ctx = ReloadContext { + task_context: Some("Testing the reload feature".to_string()), + version_before: "v0.1.100".to_string(), + version_after: "abc1234".to_string(), + session_id: "test-session-123".to_string(), + timestamp: "2025-01-20T00:00:00Z".to_string(), + }; + + // Serialize and deserialize + let json = serde_json::to_string(&ctx).unwrap(); + let loaded: ReloadContext = serde_json::from_str(&json).unwrap(); + + assert_eq!( + loaded.task_context, + Some("Testing the reload feature".to_string()) + ); + assert_eq!(loaded.version_before, "v0.1.100"); + assert_eq!(loaded.version_after, "abc1234"); + assert_eq!(loaded.session_id, "test-session-123"); +} + +#[test] +fn test_reload_context_path() { + // Just verify the session-scoped path function works + let path = ReloadContext::path_for_session("test-session-123"); + assert!(path.is_ok()); + let path = path.unwrap(); + let path_str = path.to_string_lossy(); + assert!(path_str.contains("reload-context-test-session-123.json")); +} + +#[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()); + + let ctx = ReloadContext { + task_context: Some("Testing scoped reload context".to_string()), + version_before: "v0.1.100".to_string(), + version_after: "abc1234".to_string(), + session_id: "test-session-123".to_string(), + timestamp: "2025-01-20T00:00:00Z".to_string(), + }; + + ctx.save().expect("save reload context"); + + let path = ReloadContext::path_for_session("test-session-123").expect("context path"); + assert!( + path.exists(), + "session-scoped reload context file should exist" + ); + + let peeked = ReloadContext::peek_for_session("test-session-123") + .expect("peek should succeed") + .expect("context should exist"); + assert_eq!(peeked.session_id, "test-session-123"); + + let loaded = ReloadContext::load_for_session("test-session-123") + .expect("load should succeed") + .expect("context should exist"); + assert_eq!(loaded.session_id, "test-session-123"); + assert!( + !path.exists(), + "load_for_session should consume the context file" + ); +} + +#[test] +fn test_recovery_directive_prefers_reload_context_when_present() { + let ctx = ReloadContext { + task_context: Some("Resume a self-dev reload".to_string()), + version_before: "old-build".to_string(), + version_after: "new-build".to_string(), + session_id: "session-123".to_string(), + timestamp: "2026-04-19T00:00:00Z".to_string(), + }; + + let directive = ReloadContext::recovery_directive( + Some(&ctx), + true, + "\nPersisted background task(s) detected.", + Some(12), + ) + .expect("directive should exist"); + + assert_eq!( + directive.reconnect_notice.as_deref(), + Some("Reloaded with build new-build") + ); + assert!(directive.continuation_message.contains("Reload succeeded")); + assert!( + directive + .continuation_message + .contains("Persisted background task(s)") + ); + assert!( + directive + .continuation_message + .contains("Session restored with 12 turns") + ); +} + +#[test] +fn test_recovery_directive_uses_interrupted_message_without_reload_context() { + let directive = ReloadContext::recovery_directive(None, true, "", None) + .expect("interrupted sessions should get a directive"); + + assert!(directive.reconnect_notice.is_none()); + assert!( + directive + .continuation_message + .contains("interrupted by a server reload while a tool was running") + ); +} + +#[test] +fn test_recovery_directive_returns_none_when_no_reload_recovery_needed() { + assert!(ReloadContext::recovery_directive(None, false, "", None).is_none()); +} + +#[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); +} + +#[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); +} + +#[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); + + let _guard = EnvVarGuard::set("JCODE_SELFDEV_RELOAD_TIMEOUT_SECS", "abc"); + assert_eq!(SelfDevTool::reload_timeout_secs(), 15); + drop(_guard); + + let _guard = EnvVarGuard::set("JCODE_SELFDEV_RELOAD_TIMEOUT_SECS", "0"); + assert_eq!(SelfDevTool::reload_timeout_secs(), 15); +} + +#[test] +fn schema_only_advertises_core_selfdev_fields() { + // The full (self-dev) schema exposes the build/test/reload surface. + let schema = SelfDevTool::schema_for(true); + let props = schema["properties"] + .as_object() + .expect("selfdev schema should have properties"); + + assert!(props.contains_key("action")); + assert!(props.contains_key("prompt")); + assert!(props.contains_key("context")); + assert!(props.contains_key("reason")); + assert!(props.contains_key("target")); + assert!(props.contains_key("command")); + assert!(props.contains_key("request_id")); + assert!(props.contains_key("task_id")); + assert!(!props.contains_key("notify")); + assert!(!props.contains_key("wake")); + + let actions: Vec<&str> = schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + for expected in [ + "enter", + "setup", + "build", + "build-reload", + "test", + "cancel-build", + "reload", + "status", + "find-config", + "socket-info", + "socket-help", + ] { + assert!(actions.contains(&expected), "missing action {expected}"); + } +} + +#[test] +fn non_selfdev_schema_only_exposes_onramp_actions() { + // The default schema (what a regular session advertises) is the on-ramp + // surface: no build/test/socket actions, only enter/setup/reload/status/ + // find-config. + let default_schema = SelfDevTool::new().parameters_schema(); + let onramp_schema = SelfDevTool::schema_for(false); + assert_eq!(default_schema, onramp_schema); + + let props = onramp_schema["properties"] + .as_object() + .expect("schema properties"); + assert!(props.contains_key("action")); + assert!(props.contains_key("prompt")); + // Build/test-only fields are hidden outside self-dev mode. + assert!(!props.contains_key("reason")); + assert!(!props.contains_key("target")); + assert!(!props.contains_key("command")); + assert!(!props.contains_key("request_id")); + assert!(!props.contains_key("task_id")); + + let actions: Vec<&str> = onramp_schema["properties"]["action"]["enum"] + .as_array() + .expect("action enum") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + let mut sorted = actions.clone(); + sorted.sort_unstable(); + assert_eq!( + sorted, + vec!["enter", "find-config", "reload", "setup", "status"] + ); + for hidden in [ + "build", + "build-reload", + "test", + "cancel-build", + "socket-info", + "socket-help", + ] { + assert!( + !actions.contains(&hidden), + "on-ramp schema should not expose {hidden}" + ); + } +} + +#[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"); + let repo = create_repo_fixture(); + + let tool = SelfDevTool::new(); + let ctx = create_test_context( + "session-selfdev-test-action", + Some(repo.path().to_path_buf()), + ); + let output = tool + .execute( + json!({ + "action": "test", + "command": "cargo test -p jcode selfdev_build_command", + "reason": "verify selfdev test queue" + }), + ctx, + ) + .await + .expect("selfdev test should queue"); + + assert!(output.output.contains("Self-dev test queued")); + assert!( + output + .output + .contains("cargo test -p jcode selfdev_build_command") + ); +} + +#[tokio::test] +async fn do_reload_returns_after_ack_in_direct_mode() { + let request_id = server::send_reload_signal("direct-hash".to_string(), None, true); + let waiter = tokio::spawn({ + let request_id = request_id.clone(); + async move { server::wait_for_reload_ack(&request_id, std::time::Duration::from_secs(1)).await } + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + server::acknowledge_reload_signal(&crate::server::ReloadSignal { + hash: "direct-hash".to_string(), + triggering_session: None, + prefer_selfdev_binary: true, + request_id: "ignored".to_string(), + }); + + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + server::acknowledge_reload_signal(&crate::server::ReloadSignal { + hash: "direct-hash".to_string(), + triggering_session: None, + prefer_selfdev_binary: true, + request_id, + }); + + let ack = waiter + .await + .expect("waiter task should complete") + .expect("ack should be received"); + assert_eq!(ack.hash, "direct-hash"); +} + +#[test] +fn reload_repo_resolver_uses_working_dir_when_primary_detection_fails() { + let repo = create_repo_fixture(); + let nested = repo.path().join("crates").join("jcode-build-support"); + std::fs::create_dir_all(&nested).expect("nested dir"); + + let resolved = reload::resolve_selfdev_reload_repo_dir_from(None, Some(&nested)); + assert_eq!(resolved.as_deref(), Some(repo.path())); +} + +#[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"); + let repo = create_repo_fixture(); + + let mut parent = session::Session::create(None, Some("Origin Session".to_string())); + parent.working_dir = Some("/tmp/origin-project".to_string()); + parent.model = Some("gpt-test".to_string()); + parent.provider_key = Some("openai".to_string()); + parent.subagent_model = Some("gpt-subagent".to_string()); + parent.add_message( + crate::message::Role::User, + vec![crate::message::ContentBlock::Text { + text: "hello from parent".to_string(), + cache_control: None, + }], + ); + parent.compaction = Some(session::StoredCompactionState { + summary_text: "summary".to_string(), + openai_encrypted_content: None, + covers_up_to_turn: 1, + original_turn_count: 1, + compacted_count: 1, + }); + parent.record_replay_display_message("system", None, "remember this context"); + parent.save().expect("save parent session"); + + let tool = SelfDevTool::new(); + let ctx = create_test_context(&parent.id, Some(repo.path().to_path_buf())); + let output = tool + .execute( + json!({"action": "enter", "prompt": "Work on jcode itself"}), + ctx, + ) + .await + .expect("selfdev enter should succeed in test mode"); + + assert!(output.output.contains("Created self-dev session")); + assert!( + output + .output + .contains("Test mode skipped launching a new terminal") + ); + assert!( + output.output.contains("Seed prompt captured"), + "test-mode enter should still report captured prompt" + ); + + let metadata = output.metadata.expect("metadata"); + let session_id = metadata["session_id"] + .as_str() + .expect("session id metadata"); + assert_eq!(metadata["inherited_context"].as_bool(), Some(true)); + let session = session::Session::load(session_id).expect("load spawned session"); + assert!( + session.is_canary, + "spawned session should be canary/self-dev" + ); + assert_eq!(session.testing_build.as_deref(), Some("self-dev")); + assert_eq!( + session.working_dir.as_deref(), + Some(repo.path().to_string_lossy().as_ref()) + ); + assert_eq!(session.parent_id.as_deref(), Some(parent.id.as_str())); + assert_eq!(session.messages.len(), parent.messages.len()); + assert_eq!(session.messages[0].content_preview(), "hello from parent"); + assert_eq!(session.compaction, parent.compaction); + assert_eq!(session.model, parent.model); + assert_eq!(session.provider_key, parent.provider_key); + assert_eq!(session.subagent_model, parent.subagent_model); + assert_eq!(session.replay_events, parent.replay_events); +} + +#[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"); + let repo = create_repo_fixture(); + + let tool = SelfDevTool::new(); + let ctx = create_test_context("missing-parent", Some(repo.path().to_path_buf())); + let output = tool + .execute(json!({"action": "enter"}), ctx) + .await + .expect("selfdev enter should succeed without a persisted parent session"); + + let metadata = output.metadata.expect("metadata"); + let session_id = metadata["session_id"] + .as_str() + .expect("session id metadata"); + assert_eq!(metadata["inherited_context"].as_bool(), Some(false)); + + let session = session::Session::load(session_id).expect("load spawned session"); + assert!(session.messages.is_empty()); + assert!(session.parent_id.is_none()); + assert_eq!( + session.working_dir.as_deref(), + Some(repo.path().to_string_lossy().as_ref()) + ); +} + +#[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. + let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); + + let mut session = session::Session::create(None, Some("Normal Session".to_string())); + session.save().expect("save session"); + + let tool = SelfDevTool::new(); + let ctx = create_test_context(&session.id, session.working_dir.clone().map(Into::into)); + let output = tool + .execute(json!({"action": "reload"}), ctx) + .await + .expect("reload should route to upgrade-in-place"); + + // It must NOT be the old "only available inside a self-dev session" error; + // a regular session can reload into a newer installed build. + assert!( + !output + .output + .contains("only available inside a self-dev session") + ); + assert!(output.output.contains("Test mode")); +} + +#[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()); + + let mut session = session::Session::create(None, Some("Normal Session".to_string())); + session.save().expect("save session"); + + let tool = SelfDevTool::new(); + for action in ["socket-info", "socket-help"] { + let ctx = create_test_context(&session.id, session.working_dir.clone().map(Into::into)); + let output = tool + .execute(json!({"action": action}), ctx) + .await + .expect("socket action should return guidance instead of failing"); + assert!( + output + .output + .contains("only available inside a self-dev session"), + "{action} should be gated" + ); + assert!(output.output.contains("selfdev enter")); + } +} + +#[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()); + + let mut session = session::Session::create(None, Some("Normal Session".to_string())); + session.save().expect("save session"); + + let tool = SelfDevTool::new(); + let ctx = create_test_context(&session.id, None); + let output = tool + .execute(json!({"action": "find-config"}), ctx) + .await + .expect("find-config should succeed"); + + assert!(output.output.contains("Config file:")); + assert!(output.output.contains("config.toml")); + assert!(output.output.contains("Build channels")); + let metadata = output.metadata.expect("find-config metadata"); + assert!(metadata["config_path"].as_str().is_some()); +} + +#[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. + let _test_guard = EnvVarGuard::set("JCODE_TEST_SESSION", "1"); + let repo = create_repo_fixture(); + + let mut session = session::Session::create(None, Some("Normal Session".to_string())); + session.save().expect("save session"); + + let tool = SelfDevTool::new(); + let ctx = create_test_context(&session.id, Some(repo.path().to_path_buf())); + let output = tool + .execute(json!({"action": "setup"}), ctx) + .await + .expect("setup should succeed"); + + assert!(output.output.contains("Self-dev setup")); + assert!(output.output.contains("**cargo**") || output.output.contains("cargo")); + assert!(output.output.contains("repository")); + let metadata = output.metadata.expect("setup metadata"); + assert!(metadata["checks"].as_array().is_some()); + // The fixture repo should be detected as the repository. + assert_eq!( + metadata["repo_dir"].as_str(), + Some(repo.path().to_string_lossy().as_ref()) + ); +} + +#[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"); + let repo = create_repo_fixture(); + + let tool = SelfDevTool::new(); + let ctx = create_test_context("build-session", Some(repo.path().to_path_buf())); + let err = tool + .execute(json!({"action": "build"}), ctx) + .await + .expect_err("build without reason should fail"); + + assert!(err.to_string().contains("requires a non-empty `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"); + let repo = create_repo_fixture(); + + let mut session_one = session::Session::create(None, Some("First build session".to_string())); + session_one.short_name = Some("alpha".to_string()); + session_one.save().expect("save session one"); + + let mut session_two = session::Session::create(None, Some("Second build session".to_string())); + session_two.short_name = Some("beta".to_string()); + session_two.save().expect("save session two"); + + let tool = SelfDevTool::new(); + let first = tool + .execute( + json!({"action": "build", "reason": "first reason"}), + create_test_context(&session_one.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("first build should queue"); + let second = tool + .execute( + json!({"action": "build", "reason": "second reason"}), + create_test_context(&session_two.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("second build should queue"); + + let first_meta = first.metadata.expect("first metadata"); + let second_meta = second.metadata.expect("second metadata"); + let first_task_id = first_meta["task_id"].as_str().expect("first task id"); + let second_task_id = second_meta["task_id"].as_str().expect("second task id"); + + assert_eq!(first_meta["queue_position"].as_u64(), Some(1)); + assert_eq!(second_meta["deduped"].as_bool(), Some(true)); + assert!( + second + .output + .contains("attached instead of spawning a duplicate build") + ); + + let status_output = selfdev_status_output().expect("status output"); + assert!(status_output.output.contains("## Build Queue")); + assert!(status_output.output.contains("first reason")); + assert!(status_output.output.contains("Attached watchers: 1")); + assert!( + status_output + .output + .contains("Target version: `test-build`") + ); + + let first_status = wait_for_task_completion(first_task_id).await; + let second_status = wait_for_task_completion(second_task_id).await; + assert_eq!(first_status.status, BackgroundTaskStatus::Completed); + assert_eq!(second_status.status, BackgroundTaskStatus::Completed); + + let request_one = + BuildRequest::load(first_meta["request_id"].as_str().expect("first request id")) + .expect("load request one") + .expect("request one exists"); + let request_two = BuildRequest::load( + second_meta["request_id"] + .as_str() + .expect("second request id"), + ) + .expect("load request two") + .expect("request two exists"); + assert_eq!(request_one.state, BuildRequestState::Completed); + assert_eq!(request_two.state, BuildRequestState::Completed); +} + +#[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"); + let repo = create_repo_fixture(); + + let mut session = session::Session::create(None, Some("Build+reload session".to_string())); + session.is_canary = true; + session.short_name = Some("gamma".to_string()); + session.save().expect("save session"); + + // The reload phase blocks on a server ack. Spawn a watcher that mirrors the + // server: it observes reload signals and acknowledges them so the inline + // reload can complete deterministically in test mode. It keeps acking every + // signal it sees (the RELOAD_SIGNAL channel is a process-global shared by + // parallel tests, and `wait_for_reload_ack` matches by request id, so acking + // unrelated/stale signals is harmless). + let mut signal_rx = server::subscribe_reload_signal_for_tests(); + let acker = tokio::spawn(async move { + if let Some(signal) = signal_rx.borrow_and_update().clone() { + server::acknowledge_reload_signal(&signal); + } + while signal_rx.changed().await.is_ok() { + if let Some(signal) = signal_rx.borrow_and_update().clone() { + server::acknowledge_reload_signal(&signal); + } + } + }); + + let tool = SelfDevTool::new(); + let output = tool + .execute( + json!({"action": "build-reload", "reason": "combined build and reload"}), + create_test_context(&session.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("build-reload should succeed"); + + acker.abort(); + + assert!( + output.output.contains("Build completed successfully"), + "unexpected output: {}", + output.output + ); + let meta = output.metadata.expect("build-reload metadata"); + assert_eq!(meta["phase"].as_str(), Some("reload")); + assert_eq!(meta["build_finished"].as_bool(), Some(true)); + assert_eq!(meta["build_succeeded"].as_bool(), Some(true)); + + let request_id = meta["request_id"].as_str().expect("request id in metadata"); + let request = BuildRequest::load(request_id) + .expect("load request") + .expect("request exists"); + assert_eq!(request.state, BuildRequestState::Completed); +} + +#[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"); + let repo = create_repo_fixture(); + + let mut session_one = session::Session::create(None, Some("Build A".to_string())); + session_one.short_name = Some("alpha".to_string()); + session_one.save().expect("save session one"); + + let mut session_two = session::Session::create(None, Some("Build B".to_string())); + session_two.short_name = Some("beta".to_string()); + session_two.save().expect("save session two"); + + let tool = SelfDevTool::new(); + let first = tool + .execute( + json!({"action": "build", "reason": "same reason"}), + create_test_context(&session_one.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("first build should queue"); + let second = tool + .execute( + json!({"action": "build", "reason": "same reason"}), + create_test_context(&session_two.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("second build should attach"); + + let first_meta = first.metadata.expect("first metadata"); + let second_meta = second.metadata.expect("second metadata"); + assert_eq!(second_meta["deduped"].as_bool(), Some(true)); + assert_eq!( + second_meta["duplicate_of"]["request_id"].as_str(), + first_meta["request_id"].as_str() + ); + + let status_output = selfdev_status_output().expect("status output"); + assert!(status_output.output.contains("Attached watchers: 1")); + assert!(status_output.output.contains("alpha")); + assert!(status_output.output.contains("beta")); + + let first_status = wait_for_task_completion(first_meta["task_id"].as_str().unwrap()).await; + let second_status = wait_for_task_completion(second_meta["task_id"].as_str().unwrap()).await; + assert_eq!(first_status.status, BackgroundTaskStatus::Completed); + assert_eq!(second_status.status, BackgroundTaskStatus::Completed); + + let watcher_request = BuildRequest::load(second_meta["request_id"].as_str().unwrap()) + .expect("load watcher request") + .expect("watcher request exists"); + assert_eq!(watcher_request.state, BuildRequestState::Completed); + assert_eq!( + watcher_request.attached_to_request_id.as_deref(), + first_meta["request_id"].as_str() + ); +} + +#[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"); + let repo = create_repo_fixture(); + + let mut session_one = session::Session::create(None, Some("Build A".to_string())); + session_one.short_name = Some("alpha".to_string()); + session_one.save().expect("save session one"); + + let mut session_two = session::Session::create(None, Some("Build B".to_string())); + session_two.short_name = Some("beta".to_string()); + session_two.save().expect("save session two"); + + let tool = SelfDevTool::new(); + let first = tool + .execute( + json!({"action": "build", "reason": "keep building"}), + create_test_context(&session_one.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("first build should queue"); + let second = tool + .execute( + json!({"action": "build", "reason": "cancel me"}), + create_test_context(&session_two.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("second build should queue"); + + let second_meta = second.metadata.expect("second metadata"); + let cancel = tool + .execute( + json!({ + "action": "cancel-build", + "request_id": second_meta["request_id"].as_str().unwrap() + }), + create_test_context(&session_two.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("cancel should succeed"); + + assert!(cancel.output.contains("Cancelled self-dev build request")); + + let second_status = wait_for_task_completion(second_meta["task_id"].as_str().unwrap()).await; + assert_eq!(second_status.status, BackgroundTaskStatus::Failed); + + let cancelled_request = BuildRequest::load(second_meta["request_id"].as_str().unwrap()) + .expect("load cancelled request") + .expect("cancelled request exists"); + assert_eq!(cancelled_request.state, BuildRequestState::Cancelled); + + let status_output = selfdev_status_output().expect("status output"); + assert!(status_output.output.contains("keep building")); + assert!(!status_output.output.contains("cancel me")); + + let first_meta = first.metadata.expect("first metadata"); + let first_status = wait_for_task_completion(first_meta["task_id"].as_str().unwrap()).await; + assert_eq!(first_status.status, BackgroundTaskStatus::Completed); +} + +#[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()); + + let mut session = session::Session::create(None, Some("Stale Build".to_string())); + session.short_name = Some("ghost".to_string()); + session.save().expect("save session"); + + let stale_status_path = temp_home.path().join("missing-selfdev.status.json"); + let source = test_source_state(std::path::Path::new("/tmp/jcode")); + let request = BuildRequest { + request_id: "stale-request".to_string(), + background_task_id: Some("missing-task".to_string()), + session_id: session.id.clone(), + session_short_name: session.short_name.clone(), + session_title: Some("Stale Build".to_string()), + reason: "stale reason".to_string(), + repo_dir: "/tmp/jcode".to_string(), + repo_scope: source.repo_scope.clone(), + worktree_scope: source.worktree_scope.clone(), + command: "scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode".to_string(), + // Outside the bootstrap grace window: a request with a missing status + // file is only pruned once it is old enough that the queue handler + // cannot still be mid-spawn. + requested_at: (Utc::now() - chrono::Duration::minutes(10)).to_rfc3339(), + started_at: Some(Utc::now().to_rfc3339()), + completed_at: None, + state: BuildRequestState::Building, + version: Some("stale-build".to_string()), + dedupe_key: Some("stale-dedupe".to_string()), + requested_source: Some(source), + built_source: None, + published_version: None, + last_progress: Some("building".to_string()), + validated: false, + error: None, + output_file: None, + status_file: Some(stale_status_path.display().to_string()), + attached_to_request_id: None, + }; + request.save().expect("save stale request"); + + let status_output = selfdev_status_output().expect("status output"); + assert!( + !status_output.output.contains("stale reason"), + "stale request should be pruned from queue output" + ); + + let request = BuildRequest::load("stale-request") + .expect("load stale request") + .expect("stale request exists"); + assert_eq!(request.state, BuildRequestState::Failed); + assert!( + request + .error + .as_deref() + .unwrap_or_default() + .contains("pruning stale self-dev build request"), + "stale request should record why it was pruned" + ); +} + +#[test] +fn freshly_queued_request_survives_reconcile_before_task_metadata_exists() { + // Regression: the queue handler saves the request *before* spawning its + // background task, so for a moment it has no task id / status file. A + // concurrent reconcile (status output, another agent's queue poll, or the + // 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()); + + let mut session = session::Session::create(None, Some("Fresh Build".to_string())); + session.save().expect("save session"); + + let source = test_source_state(std::path::Path::new("/tmp/jcode")); + let request = BuildRequest { + request_id: "fresh-request".to_string(), + // No background task metadata yet: mid-bootstrap. + background_task_id: None, + session_id: session.id.clone(), + session_short_name: session.short_name.clone(), + session_title: Some("Fresh Build".to_string()), + reason: "fresh reason".to_string(), + repo_dir: "/tmp/jcode".to_string(), + repo_scope: source.repo_scope.clone(), + worktree_scope: source.worktree_scope.clone(), + command: "scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode".to_string(), + requested_at: Utc::now().to_rfc3339(), + started_at: None, + completed_at: None, + state: BuildRequestState::Queued, + version: Some("fresh-build".to_string()), + dedupe_key: Some("fresh-dedupe".to_string()), + requested_source: Some(source.clone()), + built_source: None, + published_version: None, + last_progress: Some("queued".to_string()), + validated: false, + error: None, + output_file: None, + status_file: None, + attached_to_request_id: None, + }; + request.save().expect("save fresh request"); + + let pending = + BuildRequest::pending_requests_for_scope(&source.worktree_scope).expect("pending requests"); + assert!( + pending + .iter() + .any(|request| request.request_id == "fresh-request"), + "freshly queued request must stay pending during the bootstrap grace window" + ); + + let reloaded = BuildRequest::load("fresh-request") + .expect("load fresh request") + .expect("fresh request exists"); + assert_eq!(reloaded.state, BuildRequestState::Queued); + assert!(reloaded.error.is_none()); +} + +#[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"); + let repo = create_repo_fixture(); + + let mut stale_session = session::Session::create(None, Some("Stale Build".to_string())); + stale_session.short_name = Some("ghost".to_string()); + stale_session.save().expect("save stale session"); + + let stale_status_path = temp_home.path().join("stale-running.status.json"); + storage::write_json( + &stale_status_path, + &background::TaskStatusFile { + task_id: "stale-task".to_string(), + tool_name: "selfdev-build".to_string(), + display_name: Some("selfdev build".to_string()), + session_id: stale_session.id.clone(), + status: BackgroundTaskStatus::Running, + exit_code: None, + error: None, + started_at: Utc::now().to_rfc3339(), + completed_at: None, + duration_secs: None, + pid: None, + owner_pid: None, + owner_instance: None, + detached: false, + notify: true, + wake: true, + progress: None, + event_history: Vec::new(), + }, + ) + .expect("write stale status file"); + + let source = test_source_state(repo.path()); + let stale_request = BuildRequest { + request_id: "stale-queued-request".to_string(), + background_task_id: Some("stale-task".to_string()), + session_id: stale_session.id.clone(), + session_short_name: stale_session.short_name.clone(), + session_title: Some("Stale Build".to_string()), + reason: "stale blocker".to_string(), + repo_dir: repo.path().display().to_string(), + repo_scope: source.repo_scope.clone(), + worktree_scope: source.worktree_scope.clone(), + command: "scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode".to_string(), + // Backdated beyond the 30s bootstrap grace so reconciliation treats the + // dead-task request as genuinely stale (a fresh timestamp would keep it + // alive and Queued, which is the bootstrap-race protection, not the + // staleness path under test). + requested_at: (Utc::now() - chrono::Duration::seconds(120)).to_rfc3339(), + started_at: Some(Utc::now().to_rfc3339()), + completed_at: None, + state: BuildRequestState::Queued, + version: Some("test-build".to_string()), + dedupe_key: Some("stale-dedupe".to_string()), + requested_source: Some(source), + built_source: None, + published_version: None, + last_progress: Some("queued".to_string()), + validated: false, + error: None, + output_file: None, + status_file: Some(stale_status_path.display().to_string()), + attached_to_request_id: None, + }; + stale_request.save().expect("save stale queued request"); + + let mut live_session = session::Session::create(None, Some("Live Build".to_string())); + live_session.short_name = Some("alpha".to_string()); + live_session.save().expect("save live session"); + + let tool = SelfDevTool::new(); + let output = tool + .execute( + json!({"action": "build", "reason": "fresh build"}), + create_test_context(&live_session.id, Some(repo.path().to_path_buf())), + ) + .await + .expect("build should queue"); + + let metadata = output.metadata.expect("build metadata"); + assert_eq!(metadata["queue_position"].as_u64(), Some(1)); + assert!( + !output.output.contains("Currently blocked by"), + "stale queued requests should not block new builds" + ); + + let stale_request = BuildRequest::load("stale-queued-request") + .expect("load stale queued request") + .expect("stale queued request exists"); + assert_eq!(stale_request.state, BuildRequestState::Failed); + + let task_id = metadata["task_id"].as_str().expect("task id"); + let status = wait_for_task_completion(task_id).await; + assert_eq!(status.status, BackgroundTaskStatus::Completed); +} + +#[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()); + + let mut session = session::Session::create(None, Some("Superseded Build".to_string())); + session.short_name = Some("alpha".to_string()); + session.save().expect("save session"); + + let status_path = temp_home.path().join("superseded.status.json"); + storage::write_json( + &status_path, + &background::TaskStatusFile { + task_id: "superseded-task".to_string(), + tool_name: "selfdev-build".to_string(), + display_name: Some("selfdev build".to_string()), + session_id: session.id.clone(), + status: BackgroundTaskStatus::Superseded, + exit_code: Some(0), + error: Some("Build completed, but source changed before activation".to_string()), + started_at: Utc::now().to_rfc3339(), + completed_at: Some(Utc::now().to_rfc3339()), + duration_secs: Some(1.0), + pid: None, + owner_pid: None, + owner_instance: None, + detached: false, + notify: true, + wake: true, + progress: None, + event_history: Vec::new(), + }, + ) + .expect("write superseded status file"); + + let source = test_source_state(std::path::Path::new("/tmp/jcode")); + let request = BuildRequest { + request_id: "superseded-request".to_string(), + background_task_id: Some("superseded-task".to_string()), + session_id: session.id.clone(), + session_short_name: session.short_name.clone(), + session_title: Some("Superseded Build".to_string()), + reason: "superseded reason".to_string(), + repo_dir: "/tmp/jcode".to_string(), + repo_scope: source.repo_scope.clone(), + worktree_scope: source.worktree_scope.clone(), + command: "scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode".to_string(), + requested_at: Utc::now().to_rfc3339(), + started_at: Some(Utc::now().to_rfc3339()), + completed_at: None, + state: BuildRequestState::Building, + version: Some("superseded-build".to_string()), + dedupe_key: Some("superseded-dedupe".to_string()), + requested_source: Some(source), + built_source: None, + published_version: None, + last_progress: Some("building".to_string()), + validated: false, + error: None, + output_file: None, + status_file: Some(status_path.display().to_string()), + attached_to_request_id: None, + }; + request.save().expect("save superseded request"); + + let mut request = BuildRequest::load("superseded-request") + .expect("load superseded request") + .expect("request exists"); + assert!( + !request + .reconcile_pending_state() + .expect("reconcile superseded request") + ); + assert_eq!(request.state, BuildRequestState::Superseded); + assert!( + request + .error + .as_deref() + .unwrap_or_default() + .contains("source changed before activation") + ); +} + +#[test] +fn reconcile_keeps_running_request_not_yet_registered_in_live_task_map() { + // Regression: spawn_with_notify writes the Running status file and starts + // the build future *before* inserting the task into the in-process task + // map. The build's own first wait_for_turn iteration (or another agent's + // queue poll) could then see status=Running + is_live_task=false and prune + // 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()); + + let mut session = session::Session::create(None, Some("Racing Build".to_string())); + session.save().expect("save session"); + + let status_path = temp_home.path().join("racing.status.json"); + storage::write_json( + &status_path, + &background::TaskStatusFile { + task_id: "racing-task-not-in-live-map".to_string(), + tool_name: "selfdev-build".to_string(), + display_name: Some("selfdev build".to_string()), + session_id: session.id.clone(), + status: BackgroundTaskStatus::Running, + exit_code: None, + error: None, + started_at: Utc::now().to_rfc3339(), + completed_at: None, + duration_secs: None, + pid: None, + owner_pid: None, + owner_instance: None, + detached: false, + notify: true, + wake: true, + progress: None, + event_history: Vec::new(), + }, + ) + .expect("write running status file"); + + let source = test_source_state(std::path::Path::new("/tmp/jcode")); + let request = BuildRequest { + request_id: "racing-request".to_string(), + background_task_id: Some("racing-task-not-in-live-map".to_string()), + session_id: session.id.clone(), + session_short_name: session.short_name.clone(), + session_title: Some("Racing Build".to_string()), + reason: "racing reason".to_string(), + repo_dir: "/tmp/jcode".to_string(), + repo_scope: source.repo_scope.clone(), + worktree_scope: source.worktree_scope.clone(), + command: "scripts/dev_cargo.sh build --profile selfdev -p jcode --bin jcode".to_string(), + requested_at: Utc::now().to_rfc3339(), + started_at: None, + completed_at: None, + state: BuildRequestState::Queued, + version: Some("racing-build".to_string()), + dedupe_key: Some("racing-dedupe".to_string()), + requested_source: Some(source.clone()), + built_source: None, + published_version: None, + last_progress: Some("queued".to_string()), + validated: false, + error: None, + output_file: None, + status_file: Some(status_path.display().to_string()), + attached_to_request_id: None, + }; + request.save().expect("save racing request"); + + let pending = + BuildRequest::pending_requests_for_scope(&source.worktree_scope).expect("pending requests"); + assert!( + pending + .iter() + .any(|request| request.request_id == "racing-request"), + "running-but-unregistered request must stay pending during bootstrap grace" + ); + + let reloaded = BuildRequest::load("racing-request") + .expect("load racing request") + .expect("racing request exists"); + assert_eq!(reloaded.state, BuildRequestState::Queued); + assert!(reloaded.error.is_none()); +} diff --git a/crates/jcode-app-core/src/tool/serde_coerce.rs b/crates/jcode-app-core/src/tool/serde_coerce.rs new file mode 100644 index 0000000..b780a01 --- /dev/null +++ b/crates/jcode-app-core/src/tool/serde_coerce.rs @@ -0,0 +1,193 @@ +//! Lenient serde deserializers for tool-input fields. +//! +//! Some providers (notably Claude's tool calling) emit numeric and boolean +//! tool arguments as JSON *strings* — e.g. `{"compactions": "0"}` instead of +//! `{"compactions": 0}` — even when the tool's JSON schema declares the field +//! as `integer`/`boolean`. `serde_json` is strict by default and rejects these +//! with errors like `invalid type: string "0", expected u32`, which causes the +//! whole tool call to fail (see issue #106 for `end_ambient_cycle`). +//! +//! These helpers accept either the native JSON type or a string representation +//! and coerce to the target type, so tool inputs survive that provider quirk. +//! Apply them per-field with `#[serde(deserialize_with = ...)]` on fields whose +//! schema declares a numeric or boolean type. + +use serde::{Deserialize, Deserializer, de}; +use std::fmt; + +struct U32OrString; + +impl<'de> de::Visitor<'de> for U32OrString { + type Value = u32; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a u32 or a string representing a u32") + } + + fn visit_u64<E: de::Error>(self, v: u64) -> Result<u32, E> { + u32::try_from(v).map_err(|_| E::custom(format!("number {v} out of range for u32"))) + } + + fn visit_i64<E: de::Error>(self, v: i64) -> Result<u32, E> { + u32::try_from(v).map_err(|_| E::custom(format!("number {v} out of range for u32"))) + } + + fn visit_f64<E: de::Error>(self, v: f64) -> Result<u32, E> { + if v.fract() == 0.0 && v >= 0.0 && v <= f64::from(u32::MAX) { + Ok(v as u32) + } else { + Err(E::custom(format!("number {v} is not a valid u32"))) + } + } + + fn visit_str<E: de::Error>(self, v: &str) -> Result<u32, E> { + let trimmed = v.trim(); + trimmed + .parse::<u32>() + .map_err(|_| E::custom(format!("string {trimmed:?} is not a valid u32"))) + } +} + +/// Deserialize a `u32` from either a JSON number or a numeric string. +pub fn u32_from_string_or_number<'de, D>(deserializer: D) -> Result<u32, D::Error> +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(U32OrString) +} + +/// Deserialize an `Option<u32>` from either a JSON number, a numeric string, +/// or null/missing. Empty strings deserialize to `None`. +pub fn opt_u32_from_string_or_number<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error> +where + D: Deserializer<'de>, +{ + // Accept null, missing, number, or string. + let value: Option<serde_json::Value> = Option::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => s + .trim() + .parse::<u32>() + .map(Some) + .map_err(|_| de::Error::custom(format!("string {:?} is not a valid u32", s.trim()))), + Some(serde_json::Value::Number(n)) => { + if let Some(u) = n.as_u64() { + u32::try_from(u) + .map(Some) + .map_err(|_| de::Error::custom(format!("number {u} out of range for u32"))) + } else if let Some(f) = n.as_f64() { + if f.fract() == 0.0 && f >= 0.0 && f <= f64::from(u32::MAX) { + Ok(Some(f as u32)) + } else { + Err(de::Error::custom(format!("number {f} is not a valid u32"))) + } + } else { + Err(de::Error::custom("number is not a valid u32")) + } + } + Some(other) => Err(de::Error::custom(format!( + "expected u32 or numeric string, got {other}" + ))), + } +} + +struct BoolOrString; + +impl<'de> de::Visitor<'de> for BoolOrString { + type Value = bool; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a bool or a string representing a bool") + } + + fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> { + Ok(v) + } + + fn visit_str<E: de::Error>(self, v: &str) -> Result<bool, E> { + match v.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "y" => Ok(true), + "false" | "0" | "no" | "n" | "" => Ok(false), + other => Err(E::custom(format!("string {other:?} is not a valid bool"))), + } + } + + fn visit_u64<E: de::Error>(self, v: u64) -> Result<bool, E> { + Ok(v != 0) + } + + fn visit_i64<E: de::Error>(self, v: i64) -> Result<bool, E> { + Ok(v != 0) + } +} + +/// Deserialize a `bool` from either a JSON bool or a string/number representation. +pub fn bool_from_string_or_bool<'de, D>(deserializer: D) -> Result<bool, D::Error> +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(BoolOrString) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize)] + struct Demo { + #[serde(deserialize_with = "u32_from_string_or_number")] + n: u32, + #[serde(default, deserialize_with = "opt_u32_from_string_or_number")] + maybe: Option<u32>, + #[serde(default, deserialize_with = "bool_from_string_or_bool")] + flag: bool, + } + + #[test] + fn accepts_native_number() { + let d: Demo = serde_json::from_value(serde_json::json!({"n": 5})).unwrap(); + assert_eq!(d.n, 5); + } + + #[test] + fn accepts_string_number() { + // The #106 case: Claude sends {"compactions": "0"}. + let d: Demo = serde_json::from_value(serde_json::json!({"n": "0"})).unwrap(); + assert_eq!(d.n, 0); + let d: Demo = serde_json::from_value(serde_json::json!({"n": "42"})).unwrap(); + assert_eq!(d.n, 42); + } + + #[test] + fn rejects_garbage_string() { + let r: Result<Demo, _> = serde_json::from_value(serde_json::json!({"n": "abc"})); + assert!(r.is_err()); + } + + #[test] + fn optional_handles_null_empty_string_and_values() { + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1})).unwrap(); + assert_eq!(d.maybe, None); + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "maybe": null})).unwrap(); + assert_eq!(d.maybe, None); + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "maybe": ""})).unwrap(); + assert_eq!(d.maybe, None); + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "maybe": "7"})).unwrap(); + assert_eq!(d.maybe, Some(7)); + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "maybe": 9})).unwrap(); + assert_eq!(d.maybe, Some(9)); + } + + #[test] + fn bool_accepts_string_and_native() { + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "flag": true})).unwrap(); + assert!(d.flag); + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "flag": "true"})).unwrap(); + assert!(d.flag); + let d: Demo = serde_json::from_value(serde_json::json!({"n": 1, "flag": "false"})).unwrap(); + assert!(!d.flag); + } +} diff --git a/crates/jcode-app-core/src/tool/session_search.rs b/crates/jcode-app-core/src/tool/session_search.rs new file mode 100644 index 0000000..7ca2dc0 --- /dev/null +++ b/crates/jcode-app-core/src/tool/session_search.rs @@ -0,0 +1,1892 @@ +//! Cross-session search tool - RAG across all past sessions +//! +//! The tool is optimized for agent recall rather than raw grep output: +//! - current session, system reminders, and tool-only messages are hidden by default +//! - session metadata is searchable and returned as first-class results +//! - snapshot + journal persistence is searched so recent messages are visible +//! - results are grouped by session by default to avoid duplicate floods + +use super::session_search_index::{self, IndexFileSpec}; +use super::{Tool, ToolContext, ToolOutput}; +use crate::message::ContentBlock; +use crate::session::{Session, StoredMessage, session_journal_path_from_snapshot}; +use crate::storage; +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, NaiveDate, Utc}; +use jcode_import_core::{ + ExternalMessageRecord, ExternalSessionRecord, ImportCoreResult, collect_recent_files_recursive, + load_claude_external_messages, load_codex_external_session, load_cursor_external_session, + load_opencode_external_session, load_pi_external_session, +}; +use jcode_session_types::{ + SessionSearchContextLine as ResultContextLine, SessionSearchQueryProfile as QueryProfile, + SessionSearchRenderOptions, SessionSearchReport as SearchReport, + SessionSearchResult as SearchResult, SessionSearchResultKind as SearchResultKind, + format_session_search_no_results, format_session_search_results, + score_session_search_text_match as score_message_match, + session_search_datetime_matches as session_datetime_matches, + session_search_field_filter_matches as field_filter_matches, + session_search_format_datetime as format_datetime, + session_search_path_matches_query as path_matches_query, + session_search_raw_matches_query as raw_matches_query, + session_search_truncate_title_text as truncate_title_text, + session_search_working_dir_matches as working_dir_matches, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// Max session snapshots/journals to deserialize after raw pre-filtering. +const MAX_DESERIALIZE: usize = 500; + +/// Number of parallel threads for file scanning/loading. +const SCAN_THREADS: usize = 8; + +const DEFAULT_LIMIT: usize = 10; +const MAX_LIMIT: usize = 50; +const DEFAULT_MAX_PER_SESSION: usize = 1; +const MAX_MAX_PER_SESSION: usize = 20; +const DEFAULT_MAX_SCAN_SESSIONS: usize = 1000; +const MAX_MAX_SCAN_SESSIONS: usize = 10_000; +const MAX_CONTEXT_MESSAGES: usize = 5; +const INDEX_SCORE_CANDIDATE_MULTIPLIER: usize = 2; +/// Legacy JSON index file superseded by the binary token-hash indexes. +const LEGACY_INDEX_FILE_NAME: &str = "session_search_recent_index_v1.json"; + +#[derive(Debug, Deserialize)] +struct SearchInput { + query: String, + #[serde(default)] + working_dir: Option<String>, + #[serde(default)] + limit: Option<i64>, + /// Include the active session in results. Defaults to false because this tool + /// is meant for recalling past sessions and otherwise tends to find itself. + #[serde(default)] + include_current: Option<bool>, + /// Include raw tool calls/results. Defaults to false because they usually + /// crowd out the conclusions the agent is trying to recall. + #[serde(default)] + include_tools: Option<bool>, + /// Include system/display messages and system reminders. Defaults to false. + #[serde(default)] + include_system: Option<bool>, + /// Maximum number of hits from a single session. Defaults to 1 for diversity. + #[serde(default)] + max_per_session: Option<i64>, + /// Restrict matches to user, assistant, metadata results, or all transcript roles. + #[serde(default)] + role: Option<String>, + /// Restrict sessions by provider key/source label substring. + #[serde(default)] + provider: Option<String>, + /// Restrict sessions by model substring. + #[serde(default)] + model: Option<String>, + /// Restrict to sessions updated/messages at or after this RFC3339 timestamp or YYYY-MM-DD date. + #[serde(default)] + after: Option<String>, + /// Restrict to sessions updated/messages at or before this RFC3339 timestamp or YYYY-MM-DD date. + #[serde(default)] + before: Option<String>, + /// Restrict Jcode sessions by saved/bookmarked flag. + #[serde(default)] + saved: Option<bool>, + /// Restrict Jcode sessions by debug flag. + #[serde(default)] + debug: Option<bool>, + /// Restrict Jcode sessions by canary flag. + #[serde(default)] + canary: Option<bool>, + /// Restrict source: jcode, claude, codex, pi, opencode, cursor, or all. + #[serde(default)] + source: Option<String>, + /// Include external session sources discovered by the session picker. Defaults to true. + #[serde(default)] + include_external: Option<bool>, + /// Number of preceding messages to include around each hit. + #[serde(default)] + context_before: Option<i64>, + /// Number of following messages to include around each hit. + #[serde(default)] + context_after: Option<i64>, + /// Bound the number of recent sessions scanned per source. + #[serde(default)] + max_scan_sessions: Option<i64>, + /// Scan every available Jcode session instead of the recent indexed subset. + #[serde(default)] + exhaustive: Option<bool>, +} + +pub struct SessionSearchTool; + +impl SessionSearchTool { + pub fn new() -> Self { + Self + } +} + +impl Default for SessionSearchTool { + fn default() -> Self { + Self::new() + } +} + +/// Warm the recent-session search indexes in the background so the first +/// interactive `session_search` call does not pay the cold indexing cost. +/// Covers the jcode store plus the external stores (claude/codex/pi/cursor). +pub fn spawn_recent_index_warmup() { + tokio::task::spawn_blocking(|| { + let start = std::time::Instant::now(); + remove_legacy_index(); + let empty_query = QueryProfile::new("__jcode_index_warmup__"); + + let jcode_count = (|| -> Result<usize> { + let sessions_dir = storage::jcode_dir()?.join("sessions"); + let collection = collect_session_files(&sessions_dir, DEFAULT_MAX_SCAN_SESSIONS)?; + if collection.files.is_empty() { + return Ok(0); + } + let _ = jcode_index_candidates(&collection.files, &empty_query)?; + Ok(collection.files.len()) + })() + .unwrap_or_else(|err| { + crate::logging::info(&format!("jcode session index warmup skipped: {err}")); + 0 + }); + + let mut external_count = 0usize; + for (source, root_relative) in [ + ("codex", ".codex/sessions"), + ("pi", ".pi/agent/sessions"), + ("cursor", ".cursor/projects"), + ] { + let Ok(root) = crate::storage::user_home_path(root_relative) else { + continue; + }; + if !root.exists() { + continue; + } + let paths = collect_recent_files_recursive(&root, "jsonl", DEFAULT_MAX_SCAN_SESSIONS); + external_count += paths.len(); + let _ = external_index_candidate_paths(source, &paths, &empty_query); + } + if let Ok(sessions) = + crate::import::list_claude_code_sessions_lazy(DEFAULT_MAX_SCAN_SESSIONS) + { + external_count += sessions.len(); + let _ = claude_index_candidates(&sessions, &empty_query); + } + + crate::logging::info(&format!( + "Session search index warmup completed for {jcode_count} jcode + {external_count} external session(s) in {}ms", + start.elapsed().as_millis() + )); + }); +} + +#[derive(Debug, Clone)] +struct SearchOptions { + current_session_id: String, + working_dir_filter: Option<String>, + limit: usize, + max_per_session: usize, + include_current: bool, + include_tools: bool, + include_system: bool, + include_external: bool, + role_filter: Option<RoleFilter>, + provider_filter: Option<String>, + model_filter: Option<String>, + source_filter: Option<String>, + saved_filter: Option<bool>, + debug_filter: Option<bool>, + canary_filter: Option<bool>, + after: Option<DateTime<Utc>>, + before: Option<DateTime<Utc>>, + context_before: usize, + context_after: usize, + max_scan_sessions: usize, + exhaustive: bool, +} + +impl SearchOptions { + #[cfg(test)] + fn for_test(current_session_id: impl Into<String>) -> Self { + Self { + current_session_id: current_session_id.into(), + working_dir_filter: None, + limit: DEFAULT_LIMIT, + max_per_session: DEFAULT_MAX_PER_SESSION, + include_current: false, + include_tools: false, + include_system: false, + include_external: true, + role_filter: None, + provider_filter: None, + model_filter: None, + source_filter: None, + saved_filter: None, + debug_filter: None, + canary_filter: None, + after: None, + before: None, + context_before: 0, + context_after: 0, + max_scan_sessions: DEFAULT_MAX_SCAN_SESSIONS, + exhaustive: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RoleFilter { + User, + Assistant, + Metadata, +} + +impl RoleFilter { + fn parse(raw: &str) -> Option<Self> { + match raw.trim().to_ascii_lowercase().as_str() { + "user" => Some(Self::User), + "assistant" => Some(Self::Assistant), + "metadata" | "session" => Some(Self::Metadata), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +struct SessionFileCandidate { + snapshot_path: PathBuf, + journal_path: PathBuf, + session_id_hint: String, + mtime: SystemTime, +} + +#[derive(Default)] +struct RawFilterOutcome { + candidates: Vec<SessionFileCandidate>, + read_errors: usize, +} + +#[derive(Default)] +struct SearchWorkerOutcome { + results: Vec<SearchResult>, + parse_errors: usize, +} + +#[derive(Default)] +struct SessionFileCollection { + files: Vec<SessionFileCandidate>, + truncated: bool, +} + +#[async_trait] +impl Tool for SessionSearchTool { + fn name(&self) -> &str { + "session_search" + } + + fn description(&self) -> &str { + "Search past chat sessions. Current session, tool-only messages, and system reminders are hidden by default." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "query": { + "type": "string", + "description": "Search query. Use distinctive keywords; stop-word-only queries are rejected." + }, + "working_dir": { + "type": "string", + "description": "Restrict results to sessions whose working directory matches this path or path prefix. Matching is normalized and case-insensitive." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_LIMIT, + "description": "Max results." + }, + "include_current": { + "type": "boolean", + "description": "Include the current active session. Defaults to false." + }, + "include_tools": { + "type": "boolean", + "description": "Include raw tool calls and tool results. Defaults to false to reduce log noise." + }, + "include_system": { + "type": "boolean", + "description": "Include system reminders and display/system messages. Defaults to false." + }, + "max_per_session": { + "type": "integer", + "minimum": 1, + "maximum": MAX_MAX_PER_SESSION, + "description": "Maximum hits to return from one session. Defaults to 1 for result diversity." + }, + "role": { + "type": "string", + "enum": ["all", "user", "assistant", "metadata"], + "description": "Restrict results to a role or to metadata-only hits. Defaults to all transcript roles plus metadata." + }, + "provider": { + "type": "string", + "description": "Restrict by provider/source substring, e.g. openai, claude, codex, pi, opencode, cursor." + }, + "model": { + "type": "string", + "description": "Restrict by model substring." + }, + "after": { + "type": "string", + "description": "Only include sessions/messages at or after this RFC3339 timestamp or YYYY-MM-DD date." + }, + "before": { + "type": "string", + "description": "Only include sessions/messages at or before this RFC3339 timestamp or YYYY-MM-DD date." + }, + "saved": { + "type": "boolean", + "description": "Restrict Jcode sessions by saved/bookmarked flag." + }, + "debug": { + "type": "boolean", + "description": "Restrict Jcode sessions by debug/test flag." + }, + "canary": { + "type": "boolean", + "description": "Restrict Jcode sessions by canary flag." + }, + "source": { + "type": "string", + "enum": ["all", "jcode", "claude", "codex", "pi", "opencode", "cursor"], + "description": "Restrict session source. Defaults to all available sources." + }, + "include_external": { + "type": "boolean", + "description": "Include external session sources discovered by the session picker. Defaults to true." + }, + "context_before": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTEXT_MESSAGES, + "description": "Number of preceding messages to include around each hit." + }, + "context_after": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTEXT_MESSAGES, + "description": "Number of following messages to include around each hit." + }, + "max_scan_sessions": { + "type": "integer", + "minimum": 1, + "maximum": MAX_MAX_SCAN_SESSIONS, + "description": "Bound the number of recent sessions scanned per source." + }, + "exhaustive": { + "type": "boolean", + "description": "Search every available Jcode session instead of the recent indexed subset. Slower, but useful for deep recall." + } + }, + "required": ["query"] + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: SearchInput = serde_json::from_value(input)?; + let limit = match validate_bounded_usize(params.limit, DEFAULT_LIMIT, 1, MAX_LIMIT, "limit") + { + Ok(limit) => limit, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let max_per_session = match validate_bounded_usize( + params.max_per_session, + DEFAULT_MAX_PER_SESSION, + 1, + MAX_MAX_PER_SESSION, + "max_per_session", + ) { + Ok(max_per_session) => max_per_session.min(limit), + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let context_before = match validate_bounded_usize( + params.context_before, + 0, + 0, + MAX_CONTEXT_MESSAGES, + "context_before", + ) { + Ok(value) => value, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let context_after = match validate_bounded_usize( + params.context_after, + 0, + 0, + MAX_CONTEXT_MESSAGES, + "context_after", + ) { + Ok(value) => value, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let exhaustive = params.exhaustive.unwrap_or(false); + let max_scan_sessions = match validate_bounded_usize( + params.max_scan_sessions, + DEFAULT_MAX_SCAN_SESSIONS, + 1, + MAX_MAX_SCAN_SESSIONS, + "max_scan_sessions", + ) { + Ok(value) => { + if exhaustive { + usize::MAX + } else { + value + } + } + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let role_filter = match parse_role_filter(params.role.as_deref()) { + Ok(value) => value, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let source_filter = match normalize_source_filter(params.source.as_deref()) { + Ok(value) => value, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let after = match parse_datetime_filter(params.after.as_deref(), "after") { + Ok(value) => value, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + let before = match parse_datetime_filter(params.before.as_deref(), "before") { + Ok(value) => value, + Err(message) => return Ok(ToolOutput::new(message).with_title("session_search")), + }; + + let query = QueryProfile::new(¶ms.query); + if query.is_empty() { + return Ok(ToolOutput::new("Query cannot be empty.").with_title("session_search")); + } + if !query.is_actionable() { + return Ok(ToolOutput::new(format!( + "Query '{}' is too generic after removing common stop words. Add at least one distinctive keyword.", + params.query.trim() + )) + .with_title("session_search")); + } + + let sessions_dir = storage::jcode_dir()?.join("sessions"); + + let options = SearchOptions { + current_session_id: ctx.session_id.clone(), + working_dir_filter: params.working_dir.clone(), + limit, + max_per_session, + include_current: params.include_current.unwrap_or(false), + include_tools: params.include_tools.unwrap_or(false), + include_system: params.include_system.unwrap_or(false), + include_external: params.include_external.unwrap_or(true), + role_filter, + provider_filter: normalize_optional_filter(params.provider), + model_filter: normalize_optional_filter(params.model), + source_filter, + saved_filter: params.saved, + debug_filter: params.debug, + canary_filter: params.canary, + after, + before, + context_before, + context_after, + max_scan_sessions, + exhaustive, + }; + + let report = tokio::task::spawn_blocking({ + let session_id = ctx.session_id.clone(); + let query = query.clone(); + let options = options.clone(); + move || search_sessions_blocking(&sessions_dir, &query, &options, &session_id) + }) + .await??; + + if report.results.is_empty() { + return Ok(ToolOutput::new(no_results_message(¶ms.query, &options)) + .with_title("session_search")); + } + + Ok( + ToolOutput::new(format_results(¶ms.query, &report, &options)) + .with_title("session_search"), + ) + } +} + +fn validate_bounded_usize( + value: Option<i64>, + default: usize, + min: usize, + max: usize, + name: &str, +) -> std::result::Result<usize, String> { + let Some(value) = value else { + return Ok(default); + }; + if value < min as i64 || value > max as i64 { + return Err(format!( + "{name} must be between {min} and {max}; received {value}." + )); + } + Ok(value as usize) +} + +fn parse_role_filter(raw: Option<&str>) -> std::result::Result<Option<RoleFilter>, String> { + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + if raw.eq_ignore_ascii_case("all") { + return Ok(None); + } + RoleFilter::parse(raw).map(Some).ok_or_else(|| { + format!("role must be one of all, user, assistant, or metadata; received {raw}.") + }) +} + +fn normalize_optional_filter(raw: Option<String>) -> Option<String> { + raw.map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) +} + +fn normalize_source_filter(raw: Option<&str>) -> std::result::Result<Option<String>, String> { + let Some(source) = raw.map(str::trim).filter(|source| !source.is_empty()) else { + return Ok(None); + }; + let normalized = source.to_ascii_lowercase(); + match normalized.as_str() { + "all" => Ok(None), + "jcode" | "claude" | "claude-code" | "codex" | "pi" | "opencode" | "cursor" => { + Ok(Some(normalized.replace("claude-code", "claude"))) + } + _ => Err(format!( + "source must be one of all, jcode, claude, codex, pi, opencode, or cursor; received {source}." + )), + } +} + +fn parse_datetime_filter( + raw: Option<&str>, + name: &str, +) -> std::result::Result<Option<DateTime<Utc>>, String> { + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Ok(Some(dt.with_timezone(&Utc))); + } + if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") { + let Some(naive) = date.and_hms_opt(0, 0, 0) else { + return Err(format!("{name} has an invalid date: {raw}.")); + }; + return Ok(Some(DateTime::from_naive_utc_and_offset(naive, Utc))); + } + Err(format!( + "{name} must be an RFC3339 timestamp or YYYY-MM-DD date; received {raw}." + )) +} + +/// Synchronous search across session files with parallel raw pre-filtering and +/// journal-aware session loading. +fn search_sessions_blocking( + sessions_dir: &Path, + query: &QueryProfile, + options: &SearchOptions, + log_session_id: &str, +) -> Result<SearchReport> { + let mut report = SearchReport::default(); + if !query.is_actionable() { + return Ok(report); + } + + if source_matches_filter("jcode", options) { + let collection = collect_session_files(sessions_dir, options.max_scan_sessions)?; + report.truncated |= collection.truncated; + let mut files = collection.files; + if !files.is_empty() { + files.sort_unstable_by(|a, b| b.mtime.cmp(&a.mtime)); + report.scanned_jcode_sessions = files.len(); + + if !options.include_current { + files.retain(|candidate| candidate.session_id_hint != options.current_session_id); + } + + if !files.is_empty() { + let using_index = !options.exhaustive; + let mut candidates = if options.exhaustive { + let raw_filter_outcomes = filter_candidates_parallel(&files, query); + report.read_errors += raw_filter_outcomes + .iter() + .map(|outcome| outcome.read_errors) + .sum::<usize>(); + raw_filter_outcomes + .into_iter() + .flat_map(|outcome| outcome.candidates) + .collect() + } else { + match jcode_index_candidates(&files, query) { + Ok(candidates) => candidates, + Err(err) => { + crate::logging::warn(&format!( + "session_search index unavailable; falling back to raw scan: {err}" + )); + let raw_filter_outcomes = filter_candidates_parallel(&files, query); + report.read_errors += raw_filter_outcomes + .iter() + .map(|outcome| outcome.read_errors) + .sum::<usize>(); + raw_filter_outcomes + .into_iter() + .flat_map(|outcome| outcome.candidates) + .collect() + } + } + }; + candidates.sort_unstable_by(|a, b| b.mtime.cmp(&a.mtime)); + report.candidate_jcode_sessions = candidates.len(); + if using_index { + let indexed_budget = indexed_candidate_budget(options); + if candidates.len() > indexed_budget { + candidates.truncate(indexed_budget); + report.truncated = true; + } + } + if candidates.len() > MAX_DESERIALIZE { + candidates.truncate(MAX_DESERIALIZE); + report.truncated = true; + } + + let search_outcomes = score_candidates_parallel(&candidates, query, options); + report.parse_errors += search_outcomes + .iter() + .map(|outcome| outcome.parse_errors) + .sum::<usize>(); + report.results.extend( + search_outcomes + .into_iter() + .flat_map(|outcome| outcome.results), + ); + } + } + } + + if options.include_external { + let external_report = search_external_sessions(query, options); + report.scanned_external_sessions += external_report.scanned_external_sessions; + report + .external_sources + .extend(external_report.external_sources); + report.read_errors += external_report.read_errors; + report.parse_errors += external_report.parse_errors; + report.truncated |= external_report.truncated; + report.results.extend(external_report.results); + } + + if report.read_errors > 0 || report.parse_errors > 0 { + crate::logging::warn(&format!( + "[tool:session_search] skipped unreadable or invalid session files in session {} (read_errors={} parse_errors={})", + log_session_id, report.read_errors, report.parse_errors + )); + } + + report.results.sort_unstable_by(compare_results); + report.results = group_and_limit_results(report.results, options); + Ok(report) +} + +fn collect_session_files( + sessions_dir: &Path, + max_scan_sessions: usize, +) -> Result<SessionFileCollection> { + let mut timestamped: BinaryHeap<Reverse<(u64, PathBuf, String)>> = BinaryHeap::new(); + let mut untimestamped = Vec::new(); + let mut truncated = false; + if !sessions_dir.exists() { + return Ok(SessionFileCollection::default()); + } + for entry in std::fs::read_dir(sessions_dir)?.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + let Some(stem) = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + else { + continue; + }; + if let Some(timestamp_ms) = session_id_timestamp_ms(&stem) { + timestamped.push(Reverse((timestamp_ms, path, stem))); + if timestamped.len() > max_scan_sessions { + timestamped.pop(); + truncated = true; + } + } else { + untimestamped.push((path, stem)); + } + } + + let mut files = + Vec::with_capacity(timestamped.len() + untimestamped.len().min(max_scan_sessions)); + for Reverse((timestamp_ms, path, stem)) in timestamped.into_sorted_vec() { + let journal_path = session_journal_path_from_snapshot(&path); + files.push(SessionFileCandidate { + snapshot_path: path, + journal_path, + session_id_hint: stem, + mtime: system_time_from_unix_millis(timestamp_ms), + }); + } + + // Legacy or imported snapshot names may not contain a timestamp. They are + // uncommon, so only stat these fallback paths instead of every session file. + for (path, stem) in untimestamped { + let journal_path = session_journal_path_from_snapshot(&path); + let snapshot_mtime = modified_time_or_epoch(&path); + let journal_mtime = modified_time_or_epoch(&journal_path); + files.push(SessionFileCandidate { + snapshot_path: path, + journal_path, + session_id_hint: stem, + mtime: snapshot_mtime.max(journal_mtime), + }); + } + + if files.len() > max_scan_sessions { + files.sort_unstable_by(|a, b| b.mtime.cmp(&a.mtime)); + files.truncate(max_scan_sessions); + truncated = true; + } + + Ok(SessionFileCollection { files, truncated }) +} + +fn session_id_timestamp_ms(session_id: &str) -> Option<u64> { + session_id.split('_').find_map(|part| { + (part.len() == 13) + .then(|| part.parse::<u64>().ok()) + .flatten() + }) +} + +fn system_time_from_unix_millis(timestamp_ms: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(timestamp_ms) +} + +fn index_dir() -> Result<PathBuf> { + Ok(storage::jcode_dir()?.join("cache")) +} + +/// How many index candidates are worth fully parsing/scoring for one search. +fn indexed_candidate_budget(options: &SearchOptions) -> usize { + options + .limit + .saturating_mul(options.max_per_session) + .saturating_mul(INDEX_SCORE_CANDIDATE_MULTIPLIER) + .max(options.limit) + .max(1) +} + +/// Remove the superseded JSON index once so it stops taking up space. +fn remove_legacy_index() { + if let Ok(dir) = index_dir() { + let _ = std::fs::remove_file(dir.join(LEGACY_INDEX_FILE_NAME)); + } +} + +/// Build/update the incremental jcode session index and return the candidate +/// subset of `files` that plausibly match `query`. +fn jcode_index_candidates( + files: &[SessionFileCandidate], + query: &QueryProfile, +) -> Result<Vec<SessionFileCandidate>> { + let index_path = index_dir()?.join("session_search_jcode_index_v2.bin"); + let specs: Vec<IndexFileSpec> = files + .iter() + .map(|file| { + // Journals grow after the snapshot is written, so identity covers + // both files. + let (snap_mtime, snap_size) = session_search_index::stat_ms_size(&file.snapshot_path); + let (journal_mtime, journal_size) = + session_search_index::stat_ms_size(&file.journal_path); + IndexFileSpec { + key: file.session_id_hint.clone(), + mtime_ms: snap_mtime.max(journal_mtime), + size: snap_size.wrapping_add(journal_size), + } + }) + .collect(); + + let index = session_search_index::build_or_update(&index_path, &specs, &|slot| { + let file = &files[slot]; + let mut read_errors = 0; + read_candidate_raw(file, &mut read_errors).map(|raw| { + let mut text = String::with_capacity(file.session_id_hint.len() + 1 + raw.len()); + text.push_str(&file.session_id_hint); + text.push(' '); + text.push_str(&String::from_utf8_lossy(&raw)); + text + }) + })?; + + Ok(index + .candidate_slots(&query.terms, query.min_term_matches) + .into_iter() + .filter_map(|slot| files.get(slot).cloned()) + .collect()) +} + +fn modified_time_or_epoch(path: &Path) -> SystemTime { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) +} + +fn filter_candidates_parallel( + files: &[SessionFileCandidate], + query: &QueryProfile, +) -> Vec<RawFilterOutcome> { + if files.is_empty() { + return Vec::new(); + } + let thread_count = SCAN_THREADS.min(files.len()); + let chunk_size = files.len().div_ceil(thread_count); + + std::thread::scope(|scope| { + let mut handles = Vec::new(); + for chunk in files.chunks(chunk_size) { + handles.push(scope.spawn(move || { + let mut outcome = RawFilterOutcome::default(); + for candidate in chunk { + if path_matches_query(&candidate.session_id_hint, query) { + outcome.candidates.push(candidate.clone()); + continue; + } + + let Some(raw) = read_candidate_raw(candidate, &mut outcome.read_errors) else { + continue; + }; + if raw_matches_query(&raw, query) { + outcome.candidates.push(candidate.clone()); + } + } + outcome + })); + } + handles + .into_iter() + .map(|handle| match handle.join() { + Ok(outcome) => outcome, + Err(_) => { + crate::logging::warn( + "session_search raw pre-filter worker panicked; skipping that worker's candidates", + ); + RawFilterOutcome::default() + } + }) + .collect() + }) +} + +fn read_candidate_raw( + candidate: &SessionFileCandidate, + read_errors: &mut usize, +) -> Option<Vec<u8>> { + let mut raw = match std::fs::read(&candidate.snapshot_path) { + Ok(data) => data, + Err(_) => { + *read_errors += 1; + return None; + } + }; + + if candidate.journal_path.exists() { + match std::fs::read(&candidate.journal_path) { + Ok(journal) => { + raw.push(b'\n'); + raw.extend_from_slice(&journal); + } + Err(_) => *read_errors += 1, + } + } + + Some(raw) +} + +fn score_candidates_parallel( + candidates: &[SessionFileCandidate], + query: &QueryProfile, + options: &SearchOptions, +) -> Vec<SearchWorkerOutcome> { + if candidates.is_empty() { + return Vec::new(); + } + let thread_count = SCAN_THREADS.min(candidates.len()); + let chunk_size = candidates.len().div_ceil(thread_count); + + std::thread::scope(|scope| { + let mut handles = Vec::new(); + for chunk in candidates.chunks(chunk_size) { + handles.push(scope.spawn(move || { + let mut outcome = SearchWorkerOutcome::default(); + for candidate in chunk { + match Session::load_from_path(&candidate.snapshot_path) { + Ok(session) => { + append_session_results(&mut outcome.results, &session, query, options) + } + Err(_) => outcome.parse_errors += 1, + } + } + outcome + })); + } + handles + .into_iter() + .map(|handle| match handle.join() { + Ok(outcome) => outcome, + Err(_) => { + crate::logging::warn( + "session_search scoring worker panicked; skipping that worker's results", + ); + SearchWorkerOutcome::default() + } + }) + .collect() + }) +} + +fn search_external_sessions(query: &QueryProfile, options: &SearchOptions) -> SearchReport { + let mut report = SearchReport::default(); + let mut records = Vec::new(); + + if source_matches_filter("claude", options) + && let Ok(sessions) = + crate::import::list_claude_code_sessions_lazy(options.max_scan_sessions) + { + report.external_sources.push("claude"); + let sessions: Vec<_> = sessions + .into_iter() + .take(options.max_scan_sessions) + .collect(); + let mut candidates = claude_index_candidates(&sessions, query); + if !options.exhaustive { + let budget = indexed_candidate_budget(options); + if candidates.len() > budget { + candidates.truncate(budget); + report.truncated = true; + } + } + records.extend(load_claude_candidates_parallel(&candidates, query, options)); + } + + collect_external_jsonl_source( + &mut records, + &mut report, + "codex", + ".codex/sessions", + query, + options, + load_codex_external_session, + ); + collect_external_jsonl_source( + &mut records, + &mut report, + "pi", + ".pi/agent/sessions", + query, + options, + load_pi_external_session, + ); + collect_opencode_external_sessions(&mut records, &mut report, options); + collect_external_jsonl_source( + &mut records, + &mut report, + "cursor", + ".cursor/projects", + query, + options, + load_cursor_external_session, + ); + + if records.len() > options.max_scan_sessions.saturating_mul(5) { + records.truncate(options.max_scan_sessions.saturating_mul(5)); + report.truncated = true; + } + + report.scanned_external_sessions = records.len(); + for record in records { + append_external_session_results(&mut report.results, &record, query, options); + } + report.external_sources.sort_unstable(); + report.external_sources.dedup(); + report +} + +fn collect_external_jsonl_source( + records: &mut Vec<ExternalSessionRecord>, + report: &mut SearchReport, + source: &'static str, + root_relative: &str, + query: &QueryProfile, + options: &SearchOptions, + loader: fn(&Path, bool) -> ImportCoreResult<Option<ExternalSessionRecord>>, +) { + if !source_matches_filter(source, options) { + return; + } + let Ok(root) = crate::storage::user_home_path(root_relative) else { + return; + }; + if !root.exists() { + return; + } + report.external_sources.push(source); + let paths = collect_recent_files_recursive(&root, "jsonl", options.max_scan_sessions); + let mut candidates = external_index_candidate_paths(source, &paths, query); + // Like the jcode path, cap how many candidate files get fully parsed. + // Candidates arrive most-recent-first; exhaustive mode skips the cap. + if !options.exhaustive { + let budget = indexed_candidate_budget(options); + if candidates.len() > budget { + candidates.truncate(budget); + report.truncated = true; + } + } + let outcomes = load_external_candidates_parallel(&candidates, query, options, loader); + for outcome in outcomes { + report.parse_errors += outcome.parse_errors; + records.extend(outcome.records); + } +} + +/// Narrow `paths` to plausible matches using the per-source incremental +/// index. Falls back to scanning everything if the index cannot be built. +/// Candidates are still re-verified against the real file contents, so index +/// hash collisions cannot produce wrong results. +fn external_index_candidate_paths( + source: &'static str, + paths: &[PathBuf], + query: &QueryProfile, +) -> Vec<PathBuf> { + let index = index_dir() + .map(|dir| dir.join(format!("session_search_{source}_index_v2.bin"))) + .and_then(|index_path| { + let specs: Vec<IndexFileSpec> = paths + .iter() + .map(|path| { + let (mtime_ms, size) = session_search_index::stat_ms_size(path); + IndexFileSpec { + key: path.to_string_lossy().into_owned(), + mtime_ms, + size, + } + }) + .collect(); + session_search_index::build_or_update(&index_path, &specs, &|slot| { + let path = &paths[slot]; + std::fs::read(path).ok().map(|raw| { + let path_text = path.to_string_lossy(); + let mut text = String::with_capacity(path_text.len() + 1 + raw.len()); + text.push_str(&path_text); + text.push(' '); + text.push_str(&String::from_utf8_lossy(&raw)); + text + }) + }) + }); + + match index { + Ok(index) => index + .candidate_slots(&query.terms, query.min_term_matches) + .into_iter() + .filter_map(|slot| paths.get(slot).cloned()) + .collect(), + Err(err) => { + crate::logging::warn(&format!( + "session_search {source} index unavailable; scanning all files: {err}" + )); + paths.to_vec() + } + } +} + +#[derive(Default)] +struct ExternalLoadOutcome { + records: Vec<ExternalSessionRecord>, + parse_errors: usize, +} + +/// Pre-filter and load external session files in parallel. External stores +/// like `~/.codex/sessions` can hold gigabytes of JSONL, so scanning them on a +/// single thread dominates search latency. +fn load_external_candidates_parallel( + paths: &[PathBuf], + query: &QueryProfile, + options: &SearchOptions, + loader: fn(&Path, bool) -> ImportCoreResult<Option<ExternalSessionRecord>>, +) -> Vec<ExternalLoadOutcome> { + if paths.is_empty() { + return Vec::new(); + } + let thread_count = SCAN_THREADS.min(paths.len()); + let chunk_size = paths.len().div_ceil(thread_count); + + std::thread::scope(|scope| { + let mut handles = Vec::new(); + for chunk in paths.chunks(chunk_size) { + handles.push(scope.spawn(move || { + let mut outcome = ExternalLoadOutcome::default(); + for path in chunk { + if !external_path_or_raw_matches_query(path, query) { + continue; + } + match loader(path, options.include_tools) { + Ok(Some(record)) => outcome.records.push(record), + Ok(None) => {} + Err(_) => outcome.parse_errors += 1, + } + } + outcome + })); + } + handles + .into_iter() + .map(|handle| match handle.join() { + Ok(outcome) => outcome, + Err(_) => { + crate::logging::warn( + "session_search external scan worker panicked; skipping that worker's sessions", + ); + ExternalLoadOutcome::default() + } + }) + .collect() + }) +} + +/// Narrow claude sessions to plausible matches with the incremental index. +/// Indexed text includes the session metadata (id, prompt, summary, project) +/// so metadata-only matches keep working. +fn claude_index_candidates( + sessions: &[jcode_import_core::ClaudeCodeSessionInfo], + query: &QueryProfile, +) -> Vec<jcode_import_core::ClaudeCodeSessionInfo> { + let index = index_dir() + .map(|dir| dir.join("session_search_claude_index_v2.bin")) + .and_then(|index_path| { + let specs: Vec<IndexFileSpec> = sessions + .iter() + .map(|session| { + let (mtime_ms, size) = + session_search_index::stat_ms_size(Path::new(&session.full_path)); + IndexFileSpec { + key: session.full_path.clone(), + mtime_ms, + size, + } + }) + .collect(); + session_search_index::build_or_update(&index_path, &specs, &|slot| { + let session = &sessions[slot]; + let raw = std::fs::read(&session.full_path).unwrap_or_default(); + let mut text = String::with_capacity(raw.len() + 256); + text.push_str(&session.full_path); + text.push(' '); + text.push_str(&session.session_id); + text.push(' '); + text.push_str(&session.first_prompt); + if let Some(summary) = &session.summary { + text.push(' '); + text.push_str(summary); + } + if let Some(project) = &session.project_path { + text.push(' '); + text.push_str(project); + } + text.push(' '); + text.push_str(&String::from_utf8_lossy(&raw)); + Some(text) + }) + }); + + match index { + Ok(index) => index + .candidate_slots(&query.terms, query.min_term_matches) + .into_iter() + .filter_map(|slot| sessions.get(slot).cloned()) + .collect(), + Err(err) => { + crate::logging::warn(&format!( + "session_search claude index unavailable; scanning all files: {err}" + )); + sessions.to_vec() + } + } +} + +/// Pre-filter and load Claude Code session files in parallel, mirroring the +/// JSONL source scan above. +fn load_claude_candidates_parallel( + sessions: &[jcode_import_core::ClaudeCodeSessionInfo], + query: &QueryProfile, + options: &SearchOptions, +) -> Vec<ExternalSessionRecord> { + if sessions.is_empty() { + return Vec::new(); + } + let thread_count = SCAN_THREADS.min(sessions.len()); + let chunk_size = sessions.len().div_ceil(thread_count); + + std::thread::scope(|scope| { + let mut handles = Vec::new(); + for chunk in sessions.chunks(chunk_size) { + handles.push(scope.spawn(move || { + let mut records = Vec::new(); + for session in chunk { + let path = PathBuf::from(&session.full_path); + if !external_path_or_raw_matches_query(&path, query) + && !external_text_matches_query(&session.session_id, query) + && !external_text_matches_query(&session.first_prompt, query) + && !session + .summary + .as_deref() + .is_some_and(|summary| external_text_matches_query(summary, query)) + && !session + .project_path + .as_deref() + .is_some_and(|project| external_text_matches_query(project, query)) + { + continue; + } + let messages = load_claude_external_messages(&path, options.include_tools); + let created_at = session.created.unwrap_or_else(Utc::now); + let updated_at = session.modified.or(session.created).unwrap_or(created_at); + let title = session + .summary + .clone() + .filter(|summary| !summary.trim().is_empty()) + .unwrap_or_else(|| truncate_title_text(&session.first_prompt, 72)); + records.push(ExternalSessionRecord { + source: "claude", + session_id: session.session_id.clone(), + short_name: Some(format!( + "claude {}", + jcode_core::util::truncate_str(&session.session_id, 8) + )), + title: Some(title), + working_dir: session.project_path.clone(), + provider_key: Some("claude-code".to_string()), + model: None, + created_at, + updated_at, + path, + messages, + }); + } + records + })); + } + handles + .into_iter() + .flat_map(|handle| match handle.join() { + Ok(records) => records, + Err(_) => { + crate::logging::warn( + "session_search claude scan worker panicked; skipping that worker's sessions", + ); + Vec::new() + } + }) + .collect() + }) +} + +fn external_path_or_raw_matches_query(path: &Path, query: &QueryProfile) -> bool { + if path_matches_query(&path.to_string_lossy(), query) { + return true; + } + std::fs::read(path) + .map(|raw| raw_matches_query(&raw, query)) + .unwrap_or(false) +} + +fn external_text_matches_query(text: &str, query: &QueryProfile) -> bool { + jcode_session_types::normalized_session_search_text_matches(&text.to_lowercase(), query) +} + +fn collect_opencode_external_sessions( + records: &mut Vec<ExternalSessionRecord>, + report: &mut SearchReport, + options: &SearchOptions, +) { + if !source_matches_filter("opencode", options) { + return; + } + let Ok(root) = crate::storage::user_home_path(".local/share/opencode/storage/session") else { + return; + }; + if !root.exists() { + return; + } + report.external_sources.push("opencode"); + let Ok(messages_base) = crate::storage::user_home_path(".local/share/opencode/storage/message") + else { + return; + }; + let Ok(parts_base) = crate::storage::user_home_path(".local/share/opencode/storage/part") + else { + return; + }; + for path in collect_recent_files_recursive(&root, "json", options.max_scan_sessions) { + match load_opencode_external_session( + &path, + &messages_base, + &parts_base, + options.include_tools, + options.max_scan_sessions, + ) { + Ok(Some(record)) => records.push(record), + Ok(None) => {} + Err(_) => report.parse_errors += 1, + } + } +} + +fn append_external_session_results( + results: &mut Vec<SearchResult>, + session: &ExternalSessionRecord, + query: &QueryProfile, + options: &SearchOptions, +) { + if !external_session_matches_filters(session, options) { + return; + } + if let Some(filter) = options.working_dir_filter.as_deref() + && !session + .working_dir + .as_deref() + .is_some_and(|working_dir| working_dir_matches(working_dir, filter)) + { + return; + } + + if role_filter_allows_metadata(options) + && session_datetime_matches(session.updated_at, options.after, options.before) + && let Some(match_score) = score_message_match(&external_metadata_text(session), query) + { + results.push(SearchResult { + source: session.source.to_string(), + session_id: format!("{}:{}", session.source, session.session_id), + short_name: session.short_name.clone(), + title: session.title.clone(), + working_dir: session.working_dir.clone(), + provider_key: session.provider_key.clone(), + model: session.model.clone(), + updated_at: session.updated_at, + kind: SearchResultKind::Metadata, + role: "metadata".to_string(), + message_index: None, + message_id: None, + message_timestamp: None, + snippet: match_score.snippet, + score: match_score.score + 1.5, + matched_terms: match_score.matched_terms, + exact_match: match_score.exact_match, + context: Vec::new(), + }); + } + + for (message_index, msg) in session.messages.iter().enumerate() { + if !role_filter_allows_external_message(&msg.role, options) { + continue; + } + if !session_datetime_matches( + msg.timestamp.unwrap_or(session.updated_at), + options.after, + options.before, + ) { + continue; + } + let Some(match_score) = score_message_match(&msg.text, query) else { + continue; + }; + results.push(SearchResult { + source: session.source.to_string(), + session_id: format!("{}:{}", session.source, session.session_id), + short_name: session.short_name.clone(), + title: session.title.clone(), + working_dir: session.working_dir.clone(), + provider_key: session.provider_key.clone(), + model: session.model.clone(), + updated_at: session.updated_at, + kind: SearchResultKind::Message, + role: msg.role.clone(), + message_index: Some(message_index), + message_id: msg.id.clone(), + message_timestamp: msg.timestamp, + snippet: match_score.snippet, + score: match_score.score, + matched_terms: match_score.matched_terms, + exact_match: match_score.exact_match, + context: build_external_context(&session.messages, message_index, options), + }); + } +} + +fn external_metadata_text(session: &ExternalSessionRecord) -> String { + let mut fields = vec![ + format!("Source: {}", session.source), + format!("Session ID: {}:{}", session.source, session.session_id), + format!("Created: {}", format_datetime(session.created_at)), + format!("Updated: {}", format_datetime(session.updated_at)), + format!("Path: {}", session.path.display()), + ]; + if let Some(title) = &session.title { + fields.push(format!("Title: {title}")); + } + if let Some(working_dir) = &session.working_dir { + fields.push(format!("Working directory: {working_dir}")); + } + if let Some(provider_key) = &session.provider_key { + fields.push(format!("Provider: {provider_key}")); + } + if let Some(model) = &session.model { + fields.push(format!("Model: {model}")); + } + fields.join("\n") +} + +fn build_external_context( + messages: &[ExternalMessageRecord], + hit_index: usize, + options: &SearchOptions, +) -> Vec<ResultContextLine> { + if options.context_before == 0 && options.context_after == 0 { + return Vec::new(); + } + let start = hit_index.saturating_sub(options.context_before); + let end = (hit_index + options.context_after + 1).min(messages.len()); + (start..end) + .filter(|&idx| idx != hit_index) + .filter_map(|idx| { + let msg = &messages[idx]; + (!msg.text.trim().is_empty()).then(|| ResultContextLine { + message_index: idx, + role: msg.role.clone(), + timestamp: msg.timestamp, + text: truncate_context_text(&msg.text), + }) + }) + .collect() +} + +fn append_session_results( + results: &mut Vec<SearchResult>, + session: &Session, + query: &QueryProfile, + options: &SearchOptions, +) { + if !options.include_current && session.id == options.current_session_id { + return; + } + if !jcode_session_matches_filters(session, options) { + return; + } + + if let Some(filter) = options.working_dir_filter.as_deref() + && !session + .working_dir + .as_deref() + .is_some_and(|working_dir| working_dir_matches(working_dir, filter)) + { + return; + } + + if role_filter_allows_metadata(options) + && session_datetime_matches(session.updated_at, options.after, options.before) + && let Some(match_score) = score_message_match(&metadata_text(session), query) + { + results.push(SearchResult { + source: "jcode".to_string(), + session_id: session.id.clone(), + short_name: session.short_name.clone(), + title: session.display_title().map(ToOwned::to_owned), + working_dir: session.working_dir.clone(), + provider_key: session.provider_key.clone(), + model: session.model.clone(), + updated_at: session.updated_at, + kind: SearchResultKind::Metadata, + role: "metadata".to_string(), + message_index: None, + message_id: None, + message_timestamp: None, + snippet: match_score.snippet, + score: match_score.score + 2.0, + matched_terms: match_score.matched_terms, + exact_match: match_score.exact_match, + context: Vec::new(), + }); + } + + for (message_index, msg) in session.messages.iter().enumerate() { + if !options.include_system && is_system_like_message(msg) { + continue; + } + if is_tool_only_message(msg) && !options.include_tools { + continue; + } + if !role_filter_allows_message(msg, options) { + continue; + } + if !session_datetime_matches( + msg.timestamp.unwrap_or(session.updated_at), + options.after, + options.before, + ) { + continue; + } + + let text = searchable_message_text(msg, options.include_tools); + if text.is_empty() { + continue; + } + + let Some(match_score) = score_message_match(&text, query) else { + continue; + }; + + let mut score = match_score.score; + if is_tool_only_message(msg) { + score *= 0.4; + } + + results.push(SearchResult { + source: "jcode".to_string(), + session_id: session.id.clone(), + short_name: session.short_name.clone(), + title: session.display_title().map(ToOwned::to_owned), + working_dir: session.working_dir.clone(), + provider_key: session.provider_key.clone(), + model: session.model.clone(), + updated_at: session.updated_at, + kind: SearchResultKind::Message, + role: role_label(msg).to_string(), + message_index: Some(message_index), + message_id: Some(msg.id.clone()), + message_timestamp: msg.timestamp, + snippet: match_score.snippet, + score, + matched_terms: match_score.matched_terms, + exact_match: match_score.exact_match, + context: build_jcode_context(&session.messages, message_index, options), + }); + } +} + +fn metadata_text(session: &Session) -> String { + let mut fields = vec![ + format!("Session ID: {}", session.id), + format!("Updated: {}", format_datetime(session.updated_at)), + format!("Created: {}", format_datetime(session.created_at)), + ]; + + if let Some(short_name) = &session.short_name { + fields.push(format!("Short name: {short_name}")); + } + if let Some(title) = session.display_title() { + fields.push(format!("Title: {title}")); + } + if let Some(generated_title) = &session.title + && session.custom_title.is_some() + && Some(generated_title.as_str()) != session.display_title() + { + fields.push(format!("Generated title: {generated_title}")); + } + if let Some(working_dir) = &session.working_dir { + fields.push(format!("Working directory: {working_dir}")); + } + if let Some(save_label) = &session.save_label { + fields.push(format!("Save label: {save_label}")); + } + if let Some(provider_key) = &session.provider_key { + fields.push(format!("Provider: {provider_key}")); + } + if let Some(model) = &session.model { + fields.push(format!("Model: {model}")); + } + + fields.join("\n") +} + +fn source_matches_filter(source: &str, options: &SearchOptions) -> bool { + options + .source_filter + .as_deref() + .map(|filter| source.eq_ignore_ascii_case(filter)) + .unwrap_or(true) +} + +fn jcode_session_matches_filters(session: &Session, options: &SearchOptions) -> bool { + if !source_matches_filter("jcode", options) { + return false; + } + if !provider_matches(session.provider_key.as_deref(), "jcode", options) { + return false; + } + if !field_filter_matches(session.model.as_deref(), options.model_filter.as_deref()) { + return false; + } + if options + .saved_filter + .is_some_and(|expected| session.saved != expected) + { + return false; + } + if options + .debug_filter + .is_some_and(|expected| session.is_debug != expected) + { + return false; + } + if options + .canary_filter + .is_some_and(|expected| session.is_canary != expected) + { + return false; + } + true +} + +fn external_session_matches_filters( + session: &ExternalSessionRecord, + options: &SearchOptions, +) -> bool { + if !source_matches_filter(session.source, options) { + return false; + } + if !provider_matches(session.provider_key.as_deref(), session.source, options) { + return false; + } + if !field_filter_matches(session.model.as_deref(), options.model_filter.as_deref()) { + return false; + } + if options.saved_filter == Some(true) + || options.debug_filter == Some(true) + || options.canary_filter == Some(true) + { + return false; + } + true +} + +fn provider_matches(provider_key: Option<&str>, source: &str, options: &SearchOptions) -> bool { + let Some(filter) = options.provider_filter.as_deref() else { + return true; + }; + field_filter_matches(provider_key, Some(filter)) || source.to_ascii_lowercase().contains(filter) +} + +fn role_filter_allows_metadata(options: &SearchOptions) -> bool { + options + .role_filter + .map(|role| role == RoleFilter::Metadata) + .unwrap_or(true) +} + +fn role_filter_allows_message(msg: &StoredMessage, options: &SearchOptions) -> bool { + let Some(role_filter) = options.role_filter else { + return true; + }; + match role_filter { + RoleFilter::User => msg.role == crate::message::Role::User, + RoleFilter::Assistant => msg.role == crate::message::Role::Assistant, + RoleFilter::Metadata => false, + } +} + +fn role_filter_allows_external_message(role: &str, options: &SearchOptions) -> bool { + let Some(role_filter) = options.role_filter else { + return true; + }; + match role_filter { + RoleFilter::User => role.eq_ignore_ascii_case("user"), + RoleFilter::Assistant => role.eq_ignore_ascii_case("assistant"), + RoleFilter::Metadata => false, + } +} + +fn build_jcode_context( + messages: &[StoredMessage], + hit_index: usize, + options: &SearchOptions, +) -> Vec<ResultContextLine> { + if options.context_before == 0 && options.context_after == 0 { + return Vec::new(); + } + let start = hit_index.saturating_sub(options.context_before); + let end = (hit_index + options.context_after + 1).min(messages.len()); + (start..end) + .filter(|&idx| idx != hit_index) + .filter_map(|idx| { + let msg = &messages[idx]; + if !options.include_system && is_system_like_message(msg) { + return None; + } + if !options.include_tools && is_tool_only_message(msg) { + return None; + } + let text = searchable_message_text(msg, options.include_tools); + if text.trim().is_empty() { + return None; + } + Some(ResultContextLine { + message_index: idx, + role: role_label(msg).to_string(), + timestamp: msg.timestamp, + text: truncate_context_text(&text), + }) + }) + .collect() +} + +fn truncate_context_text(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= 320 { + trimmed.to_string() + } else { + format!("{}...", trimmed.chars().take(320).collect::<String>()) + } +} + +fn searchable_message_text(msg: &StoredMessage, include_tools: bool) -> String { + msg.content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.clone()), + ContentBlock::ToolResult { content, .. } if include_tools => Some(content.clone()), + ContentBlock::ToolUse { name, input, .. } if include_tools => { + let input = input.to_string(); + Some(if input == "null" { + format!("[tool call: {name}]") + } else { + format!("[tool call: {name}] {input}") + }) + } + _ => None, + }) + .collect::<Vec<_>>() + .join("\n") +} + +fn is_system_like_message(msg: &StoredMessage) -> bool { + msg.display_role.is_some() + || msg + .content + .iter() + .find_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.trim_start()), + _ => None, + }) + .is_some_and(|text| text.starts_with("<system-reminder>")) +} + +fn is_tool_only_message(msg: &StoredMessage) -> bool { + let mut has_text = false; + let mut has_tool = false; + + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } if !text.trim().is_empty() => has_text = true, + ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => has_tool = true, + _ => {} + } + } + + has_tool && !has_text +} + +fn role_label(msg: &StoredMessage) -> &'static str { + if let Some(display_role) = msg.display_role { + return match display_role { + crate::session::StoredDisplayRole::System => "system", + crate::session::StoredDisplayRole::BackgroundTask => "background", + }; + } + + match msg.role { + crate::message::Role::User => "user", + crate::message::Role::Assistant => "assistant", + } +} + +fn compare_results(a: &SearchResult, b: &SearchResult) -> std::cmp::Ordering { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + .then_with(|| a.session_id.cmp(&b.session_id)) + .then_with(|| a.message_index.cmp(&b.message_index)) +} + +fn group_and_limit_results( + results: Vec<SearchResult>, + options: &SearchOptions, +) -> Vec<SearchResult> { + let mut grouped = Vec::new(); + let mut per_session: HashMap<String, usize> = HashMap::new(); + + for result in results { + let count = per_session.entry(result.session_id.clone()).or_default(); + if *count >= options.max_per_session { + continue; + } + *count += 1; + grouped.push(result); + if grouped.len() >= options.limit { + break; + } + } + + grouped +} + +fn render_options(options: &SearchOptions) -> SessionSearchRenderOptions { + SessionSearchRenderOptions { + include_current: options.include_current, + include_external: options.include_external, + include_tools: options.include_tools, + include_system: options.include_system, + max_per_session: options.max_per_session, + has_working_dir_filter: options.working_dir_filter.is_some(), + } +} + +fn format_results(query: &str, report: &SearchReport, options: &SearchOptions) -> String { + format_session_search_results(query, report, &render_options(options)) +} + +fn no_results_message(query: &str, options: &SearchOptions) -> String { + format_session_search_no_results(query, &render_options(options)) +} + +#[cfg(test)] +#[path = "session_search_tests.rs"] +mod session_search_tests; diff --git a/crates/jcode-app-core/src/tool/session_search_index.rs b/crates/jcode-app-core/src/tool/session_search_index.rs new file mode 100644 index 0000000..430d531 --- /dev/null +++ b/crates/jcode-app-core/src/tool/session_search_index.rs @@ -0,0 +1,657 @@ +//! Incremental hashed-token index used by `session_search` to pre-filter +//! candidate files before any expensive parsing. +//! +//! One index is kept per session store (jcode, codex, claude, ...). Each entry +//! records the file's identity (key, mtime, size) plus a Bloom filter over the +//! FNV-1a hashes of its search tokens. On rebuild, entries whose identity is +//! unchanged are reused as-is, so only new or modified files are re-read and +//! re-tokenized. +//! +//! The index is a *candidate pre-filter*: downstream scoring re-verifies every +//! candidate against the real file contents, so false positives only cost a +//! little extra verification work and can never change results. That makes a +//! Bloom filter the structurally right representation. The previous format +//! stored every token hash exactly (`Arc<[u32]>`, 4 bytes/token), which +//! resident-heap profiling measured at ~32 MB on a real store; at +//! [`BLOOM_BITS_PER_TOKEN`] bits/token the same recall costs ~4x less memory +//! with a ~2-3% false-positive rate, and membership probes are O(k) instead +//! of O(log n). + +use anyhow::{Context, Result, bail}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +/// Tokens longer than this are dropped from the index (they are almost always +/// hashes or base64 blobs). Query terms longer than this match every entry so +/// recall is preserved. +pub const MAX_INDEX_TOKEN_LEN: usize = 32; +/// Entries with more unique tokens than this are marked overflow and always +/// treated as candidates. +pub const MAX_TOKENS_PER_FILE: usize = 200_000; +const MAGIC: &[u8; 4] = b"JSIX"; +/// Version 2 switched per-entry token storage from exact sorted hash sets to +/// Bloom filters. Version-1 files fail to load and are rebuilt from scratch, +/// which is the standard refresh path for this index. +const VERSION: u32 = 2; +const INDEX_THREADS: usize = 8; + +/// Bloom sizing: bits per distinct token. 8 bits/token with [`BLOOM_HASHES`] +/// probes gives a ~2-3% per-term false-positive rate, which is negligible for +/// a verified pre-filter while shrinking resident size 4x vs exact u32 sets. +const BLOOM_BITS_PER_TOKEN: usize = 8; +/// Number of Bloom probe positions per token (k). k=4 is near-optimal for +/// 8 bits/token (k* = ln2 * bits/token ≈ 5.5; 4 keeps probes cheap and the +/// difference is <1% FPR). +const BLOOM_HASHES: u32 = 4; + +static INDEX_CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<TokenHashIndex>>>> = OnceLock::new(); + +/// Snapshot of the resident index cache for memory attribution: +/// `(index_count, entry_count, approx_resident_bytes)`. +pub fn cache_memory_stats() -> (usize, usize, usize) { + let Some(cache) = INDEX_CACHE.get() else { + return (0, 0, 0); + }; + let Ok(guard) = cache.lock() else { + return (0, 0, 0); + }; + let mut entries = 0usize; + let mut bytes = 0usize; + for index in guard.values() { + entries += index.entries.len(); + bytes += index.approx_resident_bytes(); + } + (guard.len(), entries, bytes) +} + +/// Identity of one indexable file (or file group) inside a store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexFileSpec { + /// Stable key, usually the file path or session id. + pub key: String, + pub mtime_ms: u64, + pub size: u64, +} + +#[derive(Debug, Clone)] +struct IndexEntry { + key: String, + mtime_ms: u64, + size: u64, + overflow: bool, + /// Bloom filter over the entry's token hashes; see [`TokenBloom`]. + tokens: TokenBloom, +} + +/// Compact token-membership filter for one indexed file. +/// +/// Sized from the distinct-token count at [`BLOOM_BITS_PER_TOKEN`] bits/token +/// (rounded up to a power-of-two word count so probe positions reduce with a +/// mask instead of a modulo). Shared via `Arc` so incremental rebuilds reuse +/// unchanged entries without copying filter bits. +#[derive(Debug, Clone, Default)] +struct TokenBloom { + /// Number of distinct tokens inserted, kept for size accounting/tests. + token_count: u32, + bits: Arc<[u64]>, +} + +impl TokenBloom { + /// Build a filter from deduplicated token hashes. + fn from_hashes(hashes: &[u32]) -> Self { + if hashes.is_empty() { + return Self::default(); + } + let min_bits = hashes.len().saturating_mul(BLOOM_BITS_PER_TOKEN).max(64); + let words = (min_bits / 64).next_power_of_two(); + let mut bits = vec![0u64; words]; + let mask = (words as u64 * 64) - 1; + for &hash in hashes { + for probe in Self::probe_positions(hash) { + let bit = probe & mask; + bits[(bit / 64) as usize] |= 1u64 << (bit % 64); + } + } + Self { + token_count: hashes.len() as u32, + bits: bits.into(), + } + } + + /// Might `hash` have been inserted? False positives possible, false + /// negatives are not. + fn contains(&self, hash: u32) -> bool { + if self.bits.is_empty() { + return false; + } + let mask = (self.bits.len() as u64 * 64) - 1; + Self::probe_positions(hash).into_iter().all(|probe| { + let bit = probe & mask; + self.bits[(bit / 64) as usize] & (1u64 << (bit % 64)) != 0 + }) + } + + /// Derive [`BLOOM_HASHES`] probe positions from one 32-bit token hash via + /// splitmix64-style mixing (double hashing: g_i = h1 + i*h2). + fn probe_positions(hash: u32) -> [u64; BLOOM_HASHES as usize] { + let mut x = (hash as u64).wrapping_add(0x9e37_79b9_7f4a_7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^= x >> 31; + let h1 = x; + let h2 = (x >> 32) | 1; // odd so strides cover the space + let mut probes = [0u64; BLOOM_HASHES as usize]; + for (i, probe) in probes.iter_mut().enumerate() { + *probe = h1.wrapping_add(h2.wrapping_mul(i as u64)); + } + probes + } +} + +#[derive(Debug, Default)] +pub struct TokenHashIndex { + entries: Vec<IndexEntry>, +} + +/// FNV-1a 32-bit hash over the token bytes. +pub fn hash_token(token: &str) -> u32 { + let mut hash: u32 = 0x811c_9dc5; + for &byte in token.as_bytes() { + hash ^= u32::from(byte); + hash = hash.wrapping_mul(0x0100_0193); + } + hash +} + +/// Tokenize searchable text into sorted unique hashes. Returns `(hashes, +/// overflowed)`. +pub fn hash_tokens_for_text(text: &str) -> (Vec<u32>, bool) { + let tokens = jcode_session_types::tokenize_session_search_query(&text.to_lowercase()); + let mut hashes: Vec<u32> = tokens + .iter() + .filter(|token| token.len() <= MAX_INDEX_TOKEN_LEN) + .map(|token| hash_token(token)) + .collect(); + hashes.sort_unstable(); + hashes.dedup(); + let overflow = hashes.len() > MAX_TOKENS_PER_FILE; + if overflow { + hashes.truncate(MAX_TOKENS_PER_FILE); + } + (hashes, overflow) +} + +impl TokenHashIndex { + /// Return spec indices that plausibly match `terms` under the + /// `min_term_matches` threshold. Overflowed and unreadable entries are + /// always candidates so recall never regresses. + pub fn candidate_slots(&self, terms: &[String], min_term_matches: usize) -> Vec<usize> { + let term_hashes: Vec<Option<u32>> = terms + .iter() + .map(|term| (term.len() <= MAX_INDEX_TOKEN_LEN).then(|| hash_token(term))) + .collect(); + + self.entries + .iter() + .enumerate() + .filter(|(_, entry)| { + if entry.overflow { + return true; + } + let matched = term_hashes + .iter() + .filter(|hash| match hash { + // Query term too long to be indexed: assume present. + None => true, + Some(hash) => entry.tokens.contains(*hash), + }) + .count(); + matched >= min_term_matches.max(1) + }) + .map(|(slot, _)| slot) + .collect() + } + + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.entries.len() + } + + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Approximate resident bytes of this index (entries + filter bits + + /// keys). Used by memory attribution so `server:memory` can explain the + /// session-search cache instead of leaving it as unattributed heap. + pub fn approx_resident_bytes(&self) -> usize { + self.entries + .iter() + .map(|entry| { + std::mem::size_of::<IndexEntry>() + entry.key.len() + entry.tokens.bits.len() * 8 + }) + .sum() + } + + fn matches_specs(&self, specs: &[IndexFileSpec]) -> bool { + self.entries.len() == specs.len() + && self.entries.iter().zip(specs).all(|(entry, spec)| { + entry.key == spec.key && entry.mtime_ms == spec.mtime_ms && entry.size == spec.size + }) + } + + fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut buf = Vec::with_capacity(64 + self.entries.len() * 48); + buf.extend_from_slice(MAGIC); + buf.extend_from_slice(&VERSION.to_le_bytes()); + buf.extend_from_slice(&(self.entries.len() as u32).to_le_bytes()); + for entry in &self.entries { + let key_bytes = entry.key.as_bytes(); + buf.extend_from_slice(&(key_bytes.len() as u16).to_le_bytes()); + buf.extend_from_slice(key_bytes); + buf.extend_from_slice(&entry.mtime_ms.to_le_bytes()); + buf.extend_from_slice(&entry.size.to_le_bytes()); + buf.push(u8::from(entry.overflow)); + buf.extend_from_slice(&entry.tokens.token_count.to_le_bytes()); + buf.extend_from_slice(&(entry.tokens.bits.len() as u32).to_le_bytes()); + } + for entry in &self.entries { + for word in entry.tokens.bits.iter() { + buf.extend_from_slice(&word.to_le_bytes()); + } + } + let tmp = path.with_extension("bin.tmp"); + std::fs::write(&tmp, &buf)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + fn load(path: &Path) -> Result<Self> { + let raw = std::fs::read(path)?; + let mut cursor = Cursor::new(&raw); + if cursor.take(4)? != MAGIC { + bail!("bad magic"); + } + if cursor.read_u32()? != VERSION { + bail!("version mismatch"); + } + let entry_count = cursor.read_u32()? as usize; + let mut metas = Vec::with_capacity(entry_count); + for _ in 0..entry_count { + let key_len = cursor.read_u16()? as usize; + let key = std::str::from_utf8(cursor.take(key_len)?) + .context("invalid key")? + .to_string(); + let mtime_ms = cursor.read_u64()?; + let size = cursor.read_u64()?; + let overflow = cursor.take(1)?[0] != 0; + let token_count = cursor.read_u32()? as usize; + let word_count = cursor.read_u32()? as usize; + metas.push((key, mtime_ms, size, overflow, token_count, word_count)); + } + let mut entries = Vec::with_capacity(entry_count); + for (key, mtime_ms, size, overflow, token_count, word_count) in metas { + let bytes = cursor.take(word_count * 8)?; + let bits: Vec<u64> = bytes + .chunks_exact(8) + .map(|chunk| { + u64::from_le_bytes([ + chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], + chunk[7], + ]) + }) + .collect(); + entries.push(IndexEntry { + key, + mtime_ms, + size, + overflow, + tokens: TokenBloom { + token_count: token_count as u32, + bits: bits.into(), + }, + }); + } + Ok(Self { entries }) + } +} + +struct Cursor<'a> { + raw: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(raw: &'a [u8]) -> Self { + Self { raw, pos: 0 } + } + + fn take(&mut self, len: usize) -> Result<&'a [u8]> { + let end = self + .pos + .checked_add(len) + .filter(|&end| end <= self.raw.len()) + .context("truncated index file")?; + let slice = &self.raw[self.pos..end]; + self.pos = end; + Ok(slice) + } + + fn read_u16(&mut self) -> Result<u16> { + let bytes = self.take(2)?; + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) + } + + fn read_u32(&mut self) -> Result<u32> { + let bytes = self.take(4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_u64(&mut self) -> Result<u64> { + let bytes = self.take(8)?; + Ok(u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) + } +} + +/// Load, incrementally update, and persist the index at `index_path` so it +/// exactly covers `specs` (in order). `read_text` returns the searchable text +/// for one spec slot (content plus any metadata like the path); it must be +/// callable from multiple threads. Returning `None` marks the entry overflow +/// (always-candidate) so unreadable files are never silently dropped. +pub fn build_or_update( + index_path: &Path, + specs: &[IndexFileSpec], + read_text: &(dyn Fn(usize) -> Option<String> + Sync), +) -> Result<Arc<TokenHashIndex>> { + let cache = INDEX_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(guard) = cache.lock() + && let Some(index) = guard.get(index_path) + && index.matches_specs(specs) + { + return Ok(Arc::clone(index)); + } + + let previous = TokenHashIndex::load(index_path).unwrap_or_default(); + let mut previous_by_key: HashMap<&str, &IndexEntry> = HashMap::new(); + for entry in &previous.entries { + previous_by_key.insert(entry.key.as_str(), entry); + } + + // Decide which slots can reuse existing token sets. + let mut reused: Vec<Option<IndexEntry>> = Vec::with_capacity(specs.len()); + let mut stale_slots = Vec::new(); + for (slot, spec) in specs.iter().enumerate() { + match previous_by_key.get(spec.key.as_str()) { + Some(entry) if entry.mtime_ms == spec.mtime_ms && entry.size == spec.size => { + reused.push(Some((*entry).clone())); + } + _ => { + reused.push(None); + stale_slots.push(slot); + } + } + } + + let rebuilt = tokenize_slots_parallel(specs, &stale_slots, read_text); + let mut entries = Vec::with_capacity(specs.len()); + let mut rebuilt_iter = rebuilt.into_iter(); + for reusable in reused { + match reusable { + Some(entry) => entries.push(entry), + // One rebuilt entry exists per stale slot by construction; if the + // invariant ever breaks, skip the slot instead of panicking inside + // the search path (the file is simply re-tokenized next rebuild). + None => { + if let Some(entry) = rebuilt_iter.next() { + entries.push(entry); + } + } + } + } + + let index = TokenHashIndex { entries }; + if (!stale_slots.is_empty() || previous.entries.len() != specs.len()) + && let Err(err) = index.save(index_path) + { + crate::logging::warn(&format!( + "session_search index save failed for {}: {err}", + index_path.display() + )); + } + let index = Arc::new(index); + if let Ok(mut guard) = cache.lock() { + guard.insert(index_path.to_path_buf(), Arc::clone(&index)); + } + Ok(index) +} + +fn tokenize_slots_parallel( + specs: &[IndexFileSpec], + stale_slots: &[usize], + read_text: &(dyn Fn(usize) -> Option<String> + Sync), +) -> Vec<IndexEntry> { + if stale_slots.is_empty() { + return Vec::new(); + } + let thread_count = INDEX_THREADS.min(stale_slots.len()); + let chunk_size = stale_slots.len().div_ceil(thread_count); + + let chunks: Vec<Vec<IndexEntry>> = std::thread::scope(|scope| { + let mut handles = Vec::new(); + for chunk in stale_slots.chunks(chunk_size) { + handles.push(scope.spawn(move || { + chunk + .iter() + .map(|&slot| { + let spec = &specs[slot]; + let (tokens, overflow) = match read_text(slot) { + Some(text) => hash_tokens_for_text(&text), + // Unreadable now: keep it as always-candidate so + // downstream verification decides. + None => (Vec::new(), true), + }; + IndexEntry { + key: spec.key.clone(), + mtime_ms: spec.mtime_ms, + size: spec.size, + overflow, + tokens: TokenBloom::from_hashes(&tokens), + } + }) + .collect() + })); + } + handles + .into_iter() + .map(|handle| handle.join().unwrap_or_default()) + .collect() + }); + + // Rebuild order must match stale_slots order: chunks preserve it. + chunks.into_iter().flatten().collect() +} + +/// Stat helper returning `(mtime_ms, size)`, or zeros when unavailable. +pub fn stat_ms_size(path: &Path) -> (u64, u64) { + match std::fs::metadata(path) { + Ok(meta) => { + let mtime_ms = meta + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + (mtime_ms, meta.len()) + } + Err(_) => (0, 0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(key: &str, mtime_ms: u64, size: u64) -> IndexFileSpec { + IndexFileSpec { + key: key.to_string(), + mtime_ms, + size, + } + } + + #[test] + fn candidates_respect_min_term_matches_and_wildcards() { + let texts = ["alpha beta gamma", "alpha delta", "unrelated words"]; + let specs: Vec<IndexFileSpec> = texts + .iter() + .enumerate() + .map(|(i, text)| spec(&format!("file-{i}"), 1, text.len() as u64)) + .collect(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let index_path = temp.path().join("index.bin"); + let index = build_or_update(&index_path, &specs, &|slot| Some(texts[slot].to_string())) + .expect("build index"); + + let terms = vec!["alpha".to_string(), "beta".to_string()]; + assert_eq!(index.candidate_slots(&terms, 2), vec![0]); + assert_eq!(index.candidate_slots(&terms, 1), vec![0, 1]); + + // Terms longer than the index cap match everything. + let long_term = vec!["x".repeat(MAX_INDEX_TOKEN_LEN + 1)]; + assert_eq!(index.candidate_slots(&long_term, 1), vec![0, 1, 2]); + } + + #[test] + fn incremental_update_reuses_unchanged_entries_and_persists() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let index_path = temp.path().join("index.bin"); + + let specs = vec![spec("a", 1, 10), spec("b", 1, 10)]; + let texts = std::sync::Mutex::new(vec!["needle one", "other two"]); + let reads = std::sync::atomic::AtomicUsize::new(0); + let read = |slot: usize| { + reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(texts.lock().unwrap()[slot].to_string()) + }; + + let index = build_or_update(&index_path, &specs, &read).expect("build"); + assert_eq!(index.len(), 2); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(index.candidate_slots(&["needle".to_string()], 1), vec![0]); + + // Change only file b; a must not be re-read. + texts.lock().unwrap()[1] = "needle three"; + let specs = vec![spec("a", 1, 10), spec("b", 2, 12)]; + let index = build_or_update(&index_path, &specs, &read).expect("update"); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3); + assert_eq!( + index.candidate_slots(&["needle".to_string()], 1), + vec![0, 1] + ); + + // Round-trip through disk (bypass in-memory cache with a fresh load). + let loaded = TokenHashIndex::load(&index_path).expect("load"); + assert!(loaded.matches_specs(&specs)); + assert_eq!( + loaded.candidate_slots(&["needle".to_string()], 1), + vec![0, 1] + ); + } + + #[test] + fn unreadable_files_stay_candidates() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let index_path = temp.path().join("index.bin"); + let specs = vec![spec("gone", 5, 5)]; + let index = build_or_update(&index_path, &specs, &|_| None).expect("build"); + assert_eq!(index.candidate_slots(&["anything".to_string()], 1), vec![0]); + } + + /// Bloom filters must never produce false negatives: every inserted token + /// must be reported present, across a range of set sizes. + #[test] + fn bloom_has_no_false_negatives() { + for count in [1usize, 3, 64, 1000, 20_000] { + let hashes: Vec<u32> = (0..count as u32) + .map(|i| hash_token(&format!("token-{i}"))) + .collect(); + let bloom = TokenBloom::from_hashes(&hashes); + for hash in &hashes { + assert!(bloom.contains(*hash), "false negative at set size {count}"); + } + } + } + + /// The false-positive rate at the configured sizing must stay near the + /// design point (~2-3%), well under 10% even with collision noise. + #[test] + fn bloom_false_positive_rate_is_bounded() { + let hashes: Vec<u32> = (0..10_000u32) + .map(|i| hash_token(&format!("present-{i}"))) + .collect(); + let bloom = TokenBloom::from_hashes(&hashes); + let mut false_positives = 0usize; + const PROBES: usize = 20_000; + for i in 0..PROBES { + if bloom.contains(hash_token(&format!("absent-{i}"))) { + false_positives += 1; + } + } + let rate = false_positives as f64 / PROBES as f64; + assert!( + rate < 0.10, + "bloom false-positive rate {rate:.3} exceeds bound (sizing regression?)" + ); + } + + /// The Bloom representation must be materially smaller than the exact + /// 4-bytes/token encoding it replaced. This is the memory win this change + /// exists for; fail if sizing regresses past half the old cost. + #[test] + fn bloom_is_smaller_than_exact_token_sets() { + let token_count = 50_000usize; + let hashes: Vec<u32> = (0..token_count as u32) + .map(|i| hash_token(&format!("tok-{i}"))) + .collect(); + let bloom = TokenBloom::from_hashes(&hashes); + let bloom_bytes = bloom.bits.len() * 8; + let exact_bytes = hashes.len() * 4; + assert!( + bloom_bytes * 2 <= exact_bytes, + "bloom {bloom_bytes}B should be at most half of exact {exact_bytes}B" + ); + } + + /// approx_resident_bytes must reflect the dominant cost (filter bits). + #[test] + fn approx_resident_bytes_tracks_filter_size() { + let texts = ["alpha beta gamma delta", "tiny"]; + let specs: Vec<IndexFileSpec> = texts + .iter() + .enumerate() + .map(|(i, text)| spec(&format!("f{i}"), 1, text.len() as u64)) + .collect(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let index = build_or_update(&temp.path().join("index.bin"), &specs, &|slot| { + Some(texts[slot].to_string()) + }) + .expect("build"); + let bytes = index.approx_resident_bytes(); + let min_expected: usize = index + .entries + .iter() + .map(|entry| entry.tokens.bits.len() * 8) + .sum(); + assert!( + bytes >= min_expected, + "{bytes} < filter bits {min_expected}" + ); + } +} diff --git a/crates/jcode-app-core/src/tool/session_search_tests.rs b/crates/jcode-app-core/src/tool/session_search_tests.rs new file mode 100644 index 0000000..9bc6f3c --- /dev/null +++ b/crates/jcode-app-core/src/tool/session_search_tests.rs @@ -0,0 +1,670 @@ +use super::*; +use crate::message::{ContentBlock, Role}; +use crate::session::{Session, StoredDisplayRole}; +use chrono::Duration; +use serde_json::json; +use std::path::Path; +use std::time::Instant; + +fn with_temp_home<T>(f: impl FnOnce(&Path) -> T) -> T { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("create temp dir"); + let previous_home = std::env::var("JCODE_HOME").ok(); + crate::env::set_var("JCODE_HOME", temp.path()); + std::fs::create_dir_all(temp.path().join("sessions")).expect("create sessions dir"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(temp.path()))); + + if let Some(previous_home) = previous_home { + crate::env::set_var("JCODE_HOME", previous_home); + } else { + crate::env::remove_var("JCODE_HOME"); + } + + result.unwrap_or_else(|payload| std::panic::resume_unwind(payload)) +} + +fn text(text: &str) -> ContentBlock { + ContentBlock::Text { + text: text.to_string(), + cache_control: None, + } +} + +fn save_test_session(id: &str, messages: Vec<(Role, Vec<ContentBlock>)>) -> Session { + let mut session = Session::create_with_id(id.to_string(), None, None); + session.short_name = Some(format!("short-{id}")); + session.working_dir = Some("/tmp/project".to_string()); + for (role, content) in messages { + session.add_message(role, content); + } + session.save().expect("save test session"); + session +} + +fn run_report(home: &Path, query: &str, options: &SearchOptions) -> SearchReport { + search_sessions_blocking( + &home.join("sessions"), + &QueryProfile::new(query), + options, + "test-log-session", + ) + .expect("search succeeds") +} + +fn run_search(home: &Path, query: &str, options: &SearchOptions) -> Vec<SearchResult> { + run_report(home, query, options).results +} + +#[test] +fn token_overlap_matches_when_exact_phrase_is_absent() { + with_temp_home(|home| { + save_test_session( + "airpods-session", + vec![( + Role::Assistant, + vec![text( + "Try reconnecting your AirPods after the Bluetooth audio drops.", + )], + )], + ); + + let options = SearchOptions::for_test("current-session"); + let results = run_search(home, "airpods reconnect bluetooth", &options); + + assert!(!results.is_empty(), "expected token-overlap match"); + assert!(results[0].snippet.to_lowercase().contains("airpods")); + assert_eq!(results[0].kind, SearchResultKind::Message); + assert_eq!(results[0].message_index, Some(0)); + }); +} + +#[test] +fn tool_use_input_is_hidden_by_default_and_searchable_when_requested() { + with_temp_home(|home| { + save_test_session( + "tool-session", + vec![( + Role::Assistant, + vec![ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "websearch".to_string(), + input: json!({ + "query": "best time post hackernews visibility upvotes" + }), + thought_signature: None, + }], + )], + ); + + let options = SearchOptions::for_test("current-session"); + let hidden_results = run_search(home, "hackernews visibility upvotes", &options); + assert!( + hidden_results.is_empty(), + "tool-only messages should be hidden by default" + ); + + let mut options = SearchOptions::for_test("current-session"); + options.include_tools = true; + let results = run_search(home, "hackernews visibility upvotes", &options); + assert!(!results.is_empty(), "expected tool input match"); + assert!(results[0].snippet.to_lowercase().contains("hackernews")); + }); +} + +#[test] +fn journal_entries_are_searchable() { + with_temp_home(|home| { + let mut session = Session::create_with_id("journal-session".to_string(), None, None); + session.short_name = Some("journal-test".to_string()); + session.working_dir = Some("/tmp/project".to_string()); + session.add_message(Role::User, vec![text("snapshot-only baseline message")]); + session.save().expect("save snapshot"); + session.add_message( + Role::Assistant, + vec![text( + "journal-only-needle appears after the snapshot checkpoint", + )], + ); + session.save().expect("append journal entry"); + + let snapshot = std::fs::read_to_string(home.join("sessions/journal-session.json")) + .expect("read snapshot"); + assert!( + !snapshot.contains("journal-only-needle"), + "test should prove the hit lives only in the journal" + ); + + let options = SearchOptions::for_test("current-session"); + let results = run_search(home, "journal-only-needle", &options); + assert!(!results.is_empty(), "expected journal-backed match"); + assert_eq!(results[0].message_index, Some(1)); + }); +} + +#[test] +fn empty_sessions_dir_returns_no_results_instead_of_panicking() { + with_temp_home(|home| { + let options = SearchOptions::for_test("current-session"); + let results = run_search(home, "anything distinctive", &options); + assert!(results.is_empty()); + }); +} + +#[test] +fn timestamped_session_collection_respects_recent_limit_without_mtime_stat() { + with_temp_home(|home| { + save_test_session( + "session_1760000000000_old", + vec![(Role::User, vec![text("old-needle")])], + ); + save_test_session( + "session_1760000001000_mid", + vec![(Role::User, vec![text("mid-needle")])], + ); + save_test_session( + "session_1760000002000_new", + vec![(Role::User, vec![text("new-needle")])], + ); + + let collection = collect_session_files(&home.join("sessions"), 2).expect("collect files"); + assert!(collection.truncated); + let ids = collection + .files + .iter() + .map(|candidate| candidate.session_id_hint.as_str()) + .collect::<Vec<_>>(); + + assert_eq!(ids.len(), 2); + assert!(ids.contains(&"session_1760000002000_new")); + assert!(ids.contains(&"session_1760000001000_mid")); + assert!(!ids.contains(&"session_1760000000000_old")); + }); +} + +#[test] +#[ignore = "local performance benchmark over the real ~/.jcode session corpus"] +fn bench_real_session_search_corpus() { + if std::env::var("JCODE_SESSION_SEARCH_BENCH_REAL") + .ok() + .as_deref() + != Some("1") + { + eprintln!("set JCODE_SESSION_SEARCH_BENCH_REAL=1 to run against the real session corpus"); + return; + } + + let sessions_dir = crate::storage::jcode_dir() + .expect("jcode dir") + .join("sessions"); + let mut options = SearchOptions::for_test("benchmark-current-session"); + options.include_external = false; + options.max_scan_sessions = 1000; + + for query in ["session_search", "optimization", "nonexistentneedle123"] { + let start = Instant::now(); + let report = search_sessions_blocking( + &sessions_dir, + &QueryProfile::new(query), + &options, + "benchmark-log-session", + ) + .expect("search succeeds"); + eprintln!( + "BENCH query={query} elapsed_ms={} scanned={} candidates={} results={} truncated={}", + start.elapsed().as_millis(), + report.scanned_jcode_sessions, + report.candidate_jcode_sessions, + report.results.len(), + report.truncated + ); + } + + // Repeat with external sources included; on real machines the external + // stores (codex/claude/etc.) are the dominant IO cost. + options.include_external = true; + for query in ["session_search", "nonexistentneedle123"] { + let start = Instant::now(); + let report = search_sessions_blocking( + &sessions_dir, + &QueryProfile::new(query), + &options, + "benchmark-log-session", + ) + .expect("search succeeds"); + eprintln!( + "BENCH_EXTERNAL query={query} elapsed_ms={} scanned_jcode={} scanned_external={} sources={:?} results={} truncated={}", + start.elapsed().as_millis(), + report.scanned_jcode_sessions, + report.scanned_external_sessions, + report.external_sources, + report.results.len(), + report.truncated + ); + } +} + +#[test] +fn stop_word_only_query_is_not_actionable() { + with_temp_home(|home| { + save_test_session( + "generic-session", + vec![( + Role::User, + vec![text("This message should never be returned.")], + )], + ); + + let query = QueryProfile::new("the and of"); + assert!(!query.is_actionable()); + + let options = SearchOptions::for_test("current-session"); + let results = + search_sessions_blocking(&home.join("sessions"), &query, &options, "test-log-session") + .expect("search succeeds"); + assert!(results.results.is_empty()); + }); +} + +#[test] +fn current_session_is_excluded_by_default_but_can_be_included() { + with_temp_home(|home| { + save_test_session( + "current-session", + vec![(Role::User, vec![text("current-only-needle")])], + ); + + let options = SearchOptions::for_test("current-session"); + assert!(run_search(home, "current-only-needle", &options).is_empty()); + + let mut options = SearchOptions::for_test("current-session"); + options.include_current = true; + let results = run_search(home, "current-only-needle", &options); + assert_eq!(results.len(), 1); + assert_eq!(results[0].session_id, "current-session"); + }); +} + +#[test] +fn metadata_is_searchable_and_returned_with_locator() { + with_temp_home(|home| { + let mut session = save_test_session( + "metadata-session", + vec![(Role::User, vec![text("ordinary content without the label")])], + ); + session.short_name = Some("pegasus".to_string()); + session.title = Some("Saved architecture discussion".to_string()); + session.save_label = Some("project-pegasus".to_string()); + session.save().expect("save metadata update"); + + let options = SearchOptions::for_test("current-session"); + let results = run_search(home, "project-pegasus", &options); + assert!(!results.is_empty(), "metadata should be searchable"); + assert_eq!(results[0].kind, SearchResultKind::Metadata); + assert_eq!(results[0].message_index, None); + assert!(results[0].snippet.contains("Save label: project-pegasus")); + }); +} + +#[test] +fn system_reminders_are_hidden_by_default_and_opt_in_searchable() { + with_temp_home(|home| { + let mut session = Session::create_with_id("system-session".to_string(), None, None); + session.working_dir = Some("/tmp/project".to_string()); + session.add_message( + Role::User, + vec![text( + "<system-reminder>\nsecret-system-needle\n</system-reminder>", + )], + ); + session.add_message_with_display_role( + Role::Assistant, + vec![text("display-role-needle")], + Some(StoredDisplayRole::System), + ); + session.save().expect("save system session"); + + let options = SearchOptions::for_test("current-session"); + assert!(run_search(home, "secret-system-needle", &options).is_empty()); + assert!(run_search(home, "display-role-needle", &options).is_empty()); + + let mut options = SearchOptions::for_test("current-session"); + options.include_system = true; + assert!(!run_search(home, "secret-system-needle", &options).is_empty()); + assert!(!run_search(home, "display-role-needle", &options).is_empty()); + }); +} + +#[test] +fn working_dir_filter_is_case_insensitive_and_prefix_based() { + with_temp_home(|home| { + let mut session = save_test_session( + "dir-session", + vec![(Role::Assistant, vec![text("directory-filter-needle")])], + ); + session.working_dir = Some("/tmp/Project/Subdir".to_string()); + session.save().expect("save working dir update"); + + let mut options = SearchOptions::for_test("current-session"); + options.working_dir_filter = Some("/TMP/project".to_string()); + let results = run_search(home, "directory-filter-needle", &options); + assert_eq!(results.len(), 1); + assert_eq!(results[0].session_id, "dir-session"); + }); +} + +#[test] +fn results_are_grouped_by_session_by_default() { + with_temp_home(|home| { + save_test_session( + "many-hit-session", + vec![ + (Role::User, vec![text("duplicate-needle alpha")]), + (Role::Assistant, vec![text("duplicate-needle beta")]), + ], + ); + save_test_session( + "single-hit-session", + vec![(Role::User, vec![text("duplicate-needle gamma")])], + ); + + let mut options = SearchOptions::for_test("current-session"); + options.limit = 10; + let results = run_search(home, "duplicate-needle", &options); + let many_count = results + .iter() + .filter(|result| result.session_id == "many-hit-session") + .count(); + assert_eq!(many_count, 1, "default max_per_session should be 1"); + assert_eq!(results.len(), 2); + }); +} + +#[test] +fn formatter_emits_stable_locators_and_safe_code_fences() { + with_temp_home(|home| { + save_test_session( + "format-session", + vec![( + Role::Assistant, + vec![text("format-needle with a markdown fence ``` inside")], + )], + ); + + let options = SearchOptions::for_test("current-session"); + let report = run_report(home, "format-needle", &options); + let output = format_results("format-needle", &report, &options); + assert!(output.contains("Session ID: `format-session`")); + assert!(output.contains("Match: message #1")); + assert!( + output.contains("````text"), + "fence should grow when snippet contains ```" + ); + }); +} + +#[test] +fn filters_cover_role_provider_model_flags_and_dates() { + with_temp_home(|home| { + let mut session = save_test_session( + "filter-session", + vec![ + (Role::User, vec![text("filterable-needle from the user")]), + ( + Role::Assistant, + vec![text("filterable-needle from the assistant")], + ), + ], + ); + session.provider_key = Some("anthropic".to_string()); + session.model = Some("claude-sonnet-4".to_string()); + session.saved = true; + session.is_debug = true; + session.is_canary = true; + session.save().expect("save filter metadata"); + + let mut options = SearchOptions::for_test("current-session"); + options.limit = 10; + options.max_per_session = 10; + options.role_filter = Some(RoleFilter::User); + let results = run_search(home, "filterable-needle", &options); + assert_eq!(results.len(), 1); + assert_eq!(results[0].role, "user"); + + options.role_filter = Some(RoleFilter::Assistant); + let results = run_search(home, "filterable-needle", &options); + assert_eq!(results.len(), 1); + assert_eq!(results[0].role, "assistant"); + + options.role_filter = None; + options.provider_filter = Some("anthropic".to_string()); + options.model_filter = Some("sonnet".to_string()); + options.saved_filter = Some(true); + options.debug_filter = Some(true); + options.canary_filter = Some(true); + options.before = Some(Utc::now() + Duration::days(1)); + assert!(!run_search(home, "filterable-needle", &options).is_empty()); + + options.model_filter = Some("nonexistent-model".to_string()); + assert!(run_search(home, "filterable-needle", &options).is_empty()); + + options.model_filter = Some("sonnet".to_string()); + options.saved_filter = Some(false); + assert!(run_search(home, "filterable-needle", &options).is_empty()); + + options.saved_filter = Some(true); + options.after = Some(Utc::now() + Duration::days(1)); + assert!(run_search(home, "filterable-needle", &options).is_empty()); + }); +} + +#[test] +fn role_all_searches_user_assistant_and_metadata() { + with_temp_home(|home| { + let mut session = save_test_session( + "role-all-session", + vec![ + (Role::User, vec![text("shared-needle from the user")]), + ( + Role::Assistant, + vec![text("shared-needle from the assistant")], + ), + ], + ); + session.save_label = Some("shared-needle metadata".to_string()); + session.save().expect("save metadata update"); + + let mut options = SearchOptions::for_test("current-session"); + options.limit = 10; + options.max_per_session = 10; + options.role_filter = parse_role_filter(Some("all")).expect("parse all role"); + + let results = run_search(home, "shared-needle", &options); + let roles = results + .iter() + .map(|result| result.role.as_str()) + .collect::<Vec<_>>(); + assert!(roles.contains(&"user"), "all should include user messages"); + assert!( + roles.contains(&"assistant"), + "all should include assistant messages" + ); + assert!(roles.contains(&"metadata"), "all should include metadata"); + + options.role_filter = Some(RoleFilter::User); + let user_results = run_search(home, "shared-needle", &options); + assert_eq!(user_results.len(), 1); + assert_eq!(user_results[0].role, "user"); + }); +} + +#[test] +fn role_parser_accepts_all_as_default_all_roles_filter() { + assert_eq!(parse_role_filter(None).unwrap(), None); + assert_eq!(parse_role_filter(Some("all")).unwrap(), None); + assert_eq!(parse_role_filter(Some(" ALL ")).unwrap(), None); + assert_eq!( + parse_role_filter(Some("assistant")).unwrap(), + Some(RoleFilter::Assistant) + ); + let err = parse_role_filter(Some("browser")).expect_err("invalid role should fail"); + assert!(err.contains("all, user, assistant, or metadata")); +} + +#[test] +fn context_expansion_returns_neighboring_messages_without_matching_hit() { + with_temp_home(|home| { + save_test_session( + "context-session", + vec![ + (Role::User, vec![text("context-before-line")]), + (Role::Assistant, vec![text("context-hit-needle")]), + (Role::User, vec![text("context-after-line")]), + ], + ); + + let mut options = SearchOptions::for_test("current-session"); + options.context_before = 1; + options.context_after = 1; + let results = run_search(home, "context-hit-needle", &options); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].message_index, Some(1)); + assert_eq!(results[0].context.len(), 2); + assert!(results[0].context[0].text.contains("context-before-line")); + assert!(results[0].context[1].text.contains("context-after-line")); + }); +} + +#[test] +fn external_codex_sessions_are_searchable_without_jcode_session_dir() { + with_temp_home(|home| { + let codex_dir = home.join("external/.codex/sessions/2026/05/01"); + std::fs::create_dir_all(&codex_dir).expect("create codex dir"); + let lines = [ + json!({ + "type": "session_meta", + "payload": { + "id": "codex-test", + "timestamp": "2026-05-01T00:00:00Z", + "cwd": "/tmp/external-project" + } + }), + json!({ + "type": "message", + "id": "m1", + "role": "user", + "timestamp": "2026-05-01T00:01:00Z", + "content": [{"type": "input_text", "text": "external before context"}] + }), + json!({ + "type": "message", + "id": "m2", + "role": "assistant", + "timestamp": "2026-05-01T00:02:00Z", + "content": [{"type": "output_text", "text": "external-codex-needle answer"}] + }), + json!({ + "type": "message", + "id": "m3", + "role": "user", + "timestamp": "2026-05-01T00:03:00Z", + "content": [{"type": "input_text", "text": "external after context"}] + }), + ]; + let body = lines + .iter() + .map(|line| serde_json::to_string(line).expect("serialize codex line")) + .collect::<Vec<_>>() + .join("\n"); + std::fs::write(codex_dir.join("codex-test.jsonl"), body).expect("write codex jsonl"); + std::fs::remove_dir_all(home.join("sessions")).expect("remove jcode sessions dir"); + + let mut options = SearchOptions::for_test("current-session"); + options.source_filter = Some("codex".to_string()); + options.context_before = 1; + options.context_after = 1; + let report = run_report(home, "external-codex-needle", &options); + + assert_eq!(report.scanned_jcode_sessions, 0); + assert!(report.scanned_external_sessions >= 1); + assert_eq!(report.external_sources, vec!["codex"]); + assert_eq!(report.results.len(), 1); + let result = &report.results[0]; + assert_eq!(result.source, "codex"); + assert_eq!(result.session_id, "codex:codex-test"); + assert_eq!(result.working_dir.as_deref(), Some("/tmp/external-project")); + assert_eq!(result.message_id.as_deref(), Some("m2")); + assert!( + result + .context + .iter() + .any(|line| line.text.contains("external before context")) + ); + assert!( + result + .context + .iter() + .any(|line| line.text.contains("external after context")) + ); + }); +} + +#[test] +fn external_cursor_sessions_are_searchable_without_jcode_session_dir() { + with_temp_home(|home| { + let session_id = "11111111-2222-3333-4444-555555555555"; + let cursor_dir = home.join(format!( + "external/.cursor/projects/tmp-proj/agent-transcripts/{session_id}" + )); + std::fs::create_dir_all(&cursor_dir).expect("create cursor dir"); + let lines = [ + json!({ + "role": "user", + "message": {"content": [{"type": "text", "text": "cursor before context"}]} + }), + json!({ + "role": "assistant", + "message": {"content": [{"type": "text", "text": "external-cursor-needle answer"}]} + }), + json!({ + "role": "user", + "message": {"content": [{"type": "text", "text": "cursor after context"}]} + }), + ]; + let body = lines + .iter() + .map(|line| serde_json::to_string(line).expect("serialize cursor line")) + .collect::<Vec<_>>() + .join("\n"); + std::fs::write(cursor_dir.join(format!("{session_id}.jsonl")), body) + .expect("write cursor jsonl"); + std::fs::remove_dir_all(home.join("sessions")).expect("remove jcode sessions dir"); + + let mut options = SearchOptions::for_test("current-session"); + options.source_filter = Some("cursor".to_string()); + let report = run_report(home, "external-cursor-needle", &options); + + assert_eq!(report.scanned_jcode_sessions, 0); + assert!(report.scanned_external_sessions >= 1); + assert_eq!(report.external_sources, vec!["cursor"]); + assert_eq!(report.results.len(), 1); + let result = &report.results[0]; + assert_eq!(result.source, "cursor"); + assert_eq!(result.session_id, format!("cursor:{session_id}")); + }); +} + +#[test] +fn limit_validation_reports_friendly_errors() { + assert_eq!( + validate_bounded_usize(Some(3), DEFAULT_LIMIT, 1, MAX_LIMIT, "limit").unwrap(), + 3 + ); + let err = validate_bounded_usize(Some(0), DEFAULT_LIMIT, 1, MAX_LIMIT, "limit") + .expect_err("zero limit should be rejected"); + assert!(err.contains("limit must be between 1")); + let err = validate_bounded_usize(Some(-1), DEFAULT_LIMIT, 1, MAX_LIMIT, "limit") + .expect_err("negative limit should be rejected"); + assert!(err.contains("received -1")); +} diff --git a/crates/jcode-app-core/src/tool/side_panel.rs b/crates/jcode-app-core/src/tool/side_panel.rs new file mode 100644 index 0000000..4e623c6 --- /dev/null +++ b/crates/jcode-app-core/src/tool/side_panel.rs @@ -0,0 +1,210 @@ +#![cfg_attr(test, allow(clippy::await_holding_lock))] + +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, SidePanelUpdated}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::Path; + +pub struct SidePanelTool; + +impl SidePanelTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Deserialize)] +struct SidePanelInput { + action: String, + #[serde(default)] + page_id: Option<String>, + #[serde(default)] + file_path: Option<String>, + #[serde(default)] + title: Option<String>, + #[serde(default)] + content: Option<String>, + #[serde(default)] + focus: Option<bool>, +} + +#[async_trait] +impl Tool for SidePanelTool { + fn name(&self) -> &str { + "side_panel" + } + + fn description(&self) -> &str { + "Manage side panel pages." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["action"], + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["status", "write", "append", "load", "focus", "delete"], + "description": "Action." + }, + "page_id": { + "type": "string", + "description": "Page ID." + }, + "file_path": { + "type": "string", + "description": "File path." + }, + "title": { + "type": "string", + "description": "Page title." + }, + "content": { + "type": "string", + "description": "Page content." + }, + "focus": { + "type": "boolean", + "description": "Focus the page." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: SidePanelInput = serde_json::from_value(input)?; + let action_label = params.action.clone(); + let page_label = params + .page_id + .clone() + .unwrap_or_else(|| "<none>".to_string()); + let file_label = params + .file_path + .clone() + .unwrap_or_else(|| "<none>".to_string()); + let focus = params.focus.unwrap_or(true); + + let snapshot = match params.action.as_str() { + "status" => crate::side_panel::snapshot_for_session(&ctx.session_id)?, + "write" => crate::side_panel::write_markdown_page( + &ctx.session_id, + params + .page_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("page_id is required for write"))?, + params.title.as_deref(), + params + .content + .as_deref() + .ok_or_else(|| anyhow::anyhow!("content is required for write"))?, + focus, + )?, + "append" => crate::side_panel::append_markdown_page( + &ctx.session_id, + params + .page_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("page_id is required for append"))?, + params.title.as_deref(), + params + .content + .as_deref() + .ok_or_else(|| anyhow::anyhow!("content is required for append"))?, + focus, + )?, + "load" => { + let file_path = params + .file_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("file_path is required for load"))?; + let resolved = ctx.resolve_path(Path::new(file_path)); + let page_id = params + .page_id + .clone() + .unwrap_or_else(|| derive_page_id(&resolved)); + let title = params.title.clone().or_else(|| { + resolved + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }); + crate::side_panel::load_markdown_file( + &ctx.session_id, + &page_id, + title.as_deref(), + &resolved, + focus, + )? + } + "focus" => crate::side_panel::focus_page( + &ctx.session_id, + params + .page_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("page_id is required for focus"))?, + )?, + "delete" => crate::side_panel::delete_page( + &ctx.session_id, + params + .page_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("page_id is required for delete"))?, + )?, + other => anyhow::bail!("unknown side_panel action: {}", other), + }; + + if params.action != "status" { + Bus::global().publish(BusEvent::SidePanelUpdated(SidePanelUpdated { + session_id: ctx.session_id.clone(), + snapshot: snapshot.clone(), + })); + } + + Ok(ToolOutput::new(crate::side_panel::status_output(&snapshot)) + .with_title("side_panel") + .with_metadata(serde_json::to_value(&snapshot)?)) + .map_err(|err| { + crate::logging::warn(&format!( + "[tool:side_panel] action failed action={} page_id={} file_path={} session_id={} error={}", + action_label, page_label, file_label, ctx.session_id, err + )); + err + }) + } +} + +fn derive_page_id(path: &Path) -> String { + let raw = path + .file_stem() + .or_else(|| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "page".to_string()); + + let mut page_id = String::new(); + let mut prev_dash = false; + for ch in raw.chars() { + let lower = ch.to_ascii_lowercase(); + if lower.is_ascii_alphanumeric() || matches!(lower, '_' | '.') { + page_id.push(lower); + prev_dash = false; + } else if !prev_dash { + page_id.push('-'); + prev_dash = true; + } + } + + let page_id = page_id.trim_matches('-').trim_matches('.').to_string(); + if page_id.is_empty() { + "page".to_string() + } else { + page_id + } +} + +#[cfg(test)] +#[path = "side_panel_tests.rs"] +mod side_panel_tests; diff --git a/crates/jcode-app-core/src/tool/side_panel_tests.rs b/crates/jcode-app-core/src/tool/side_panel_tests.rs new file mode 100644 index 0000000..70e03c4 --- /dev/null +++ b/crates/jcode-app-core/src/tool/side_panel_tests.rs @@ -0,0 +1,96 @@ +use super::*; + +struct EnvVarGuard { + key: &'static str, + previous: Option<std::ffi::OsString>, +} + +impl EnvVarGuard { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + crate::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + crate::env::set_var(self.key, previous); + } else { + crate::env::remove_var(self.key); + } + } +} + +#[tokio::test] +async fn side_panel_tool_writes_page() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + + let tool = SidePanelTool::new(); + let output = tool + .execute( + json!({ + "action": "write", + "page_id": "notes", + "title": "Notes", + "content": "# Notes" + }), + ToolContext { + session_id: "ses_side_panel_tool".to_string(), + message_id: "msg1".to_string(), + tool_call_id: "tool1".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + }, + ) + .await + .expect("tool execute"); + + assert!(output.output.contains("notes")); +} + +#[tokio::test] +async fn side_panel_tool_loads_file_with_derived_page_id() { + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path()); + let doc_path = temp.path().join("Project Plan.md"); + std::fs::write(&doc_path, "# Plan\n\nInitial").expect("write source file"); + + let tool = SidePanelTool::new(); + let output = tool + .execute( + json!({ + "action": "load", + "file_path": "Project Plan.md" + }), + ToolContext { + session_id: "ses_side_panel_tool_load".to_string(), + message_id: "msg1".to_string(), + tool_call_id: "tool1".to_string(), + working_dir: Some(temp.path().to_path_buf()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::AgentTurn, + }, + ) + .await + .expect("tool execute"); + + assert!(output.output.contains("project-plan")); + let snapshot: crate::side_panel::SidePanelSnapshot = + serde_json::from_value(output.metadata.expect("snapshot metadata")) + .expect("parse side panel metadata"); + let page = snapshot + .pages + .iter() + .find(|page| page.id == "project-plan") + .expect("loaded page"); + assert_eq!(page.title, "Project Plan.md"); + assert_eq!(page.content, "# Plan\n\nInitial"); +} diff --git a/crates/jcode-app-core/src/tool/skill.rs b/crates/jcode-app-core/src/tool/skill.rs new file mode 100644 index 0000000..b38bf29 --- /dev/null +++ b/crates/jcode-app-core/src/tool/skill.rs @@ -0,0 +1,649 @@ +//! Skill tool - load, list, reload, and read skills + +use super::{Tool, ToolContext, ToolOutput}; +use crate::skill::SkillRegistry; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub struct SkillTool { + registry: Arc<RwLock<SkillRegistry>>, +} + +impl SkillTool { + pub fn new(registry: Arc<RwLock<SkillRegistry>>) -> Self { + Self { registry } + } + + /// Effective skill set for this call: shared global registry plus the + /// session's project-local overlay resolved from the tool context working + /// dir (issue #457). The overlay is read fresh from disk so edits are + /// visible without daemon restarts and never enter the shared registry. + async fn effective_registry(&self, working_dir: Option<&std::path::Path>) -> SkillRegistry { + let global = self.registry.read().await; + SkillRegistry::effective_for_working_dir(&global, working_dir) + } +} + +#[derive(Deserialize)] +struct SkillInput { + /// Action to perform: load (default), list, reload, reload_all, read. + /// `list` shows both loaded skills and the jcode-endorsed catalog. + #[serde(default = "default_action")] + action: String, + /// Skill name (required for load, reload, read) + #[serde(alias = "skill")] + #[serde(default)] + name: Option<String>, + /// Optional Claude-compatible Skill wrapper argument. The skill loader only + /// needs to load the prompt, so args are currently accepted and ignored. + #[serde(default)] + args: Option<String>, +} + +fn default_action() -> String { + "load".to_string() +} + +#[async_trait] +impl Tool for SkillTool { + fn name(&self) -> &str { + "skill_manage" + } + + fn description(&self) -> &str { + "Manage skills." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "action": { + "type": "string", + "enum": ["load", "list", "reload", "reload_all", "read"], + "description": "Action." + }, + "name": { + "type": "string", + "description": "Skill name." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result<ToolOutput> { + let params: SkillInput = serde_json::from_value(input)?; + let action_label = params.action.clone(); + let name_label = params.name.clone().unwrap_or_else(|| "<none>".to_string()); + let _args = params.args.as_deref(); + + match params.action.as_str() { + "load" => { + self.load_skill(params.name, ctx.working_dir.as_deref()) + .await + } + "list" => self.list_skills(ctx.working_dir.as_deref()).await, + "reload" => self.reload_skill(params.name).await, + "reload_all" => self.reload_all_skills(ctx.working_dir.as_deref()).await, + "read" => { + self.read_skill(params.name, ctx.working_dir.as_deref()) + .await + } + _ => Ok(ToolOutput::new(format!( + "Unknown action: {}. Use 'load', 'list', 'reload', 'reload_all', or 'read'.", + params.action + ))), + } + .map_err(|err| { + crate::logging::warn(&format!( + "[tool:skill_manage] action failed action={} skill={} session_id={} error={}", + action_label, name_label, ctx.session_id, err + )); + err + }) + } +} + +impl SkillTool { + async fn load_skill( + &self, + name: Option<String>, + working_dir: Option<&std::path::Path>, + ) -> Result<ToolOutput> { + let name = normalize_skill_name(name, "load")?; + + let registry = self.effective_registry(working_dir).await; + let skill = registry.get(&name).ok_or_else(|| { + // Endorsed skills are advertised in `list` but are not bundled; + // a bare "not found" here reads like a bug (issue #445). Point at + // the actual install command instead. + if let Some(endorsed) = crate::skill::endorsed_skills() + .iter() + .find(|endorsed| endorsed.name == name) + { + match endorsed.install { + Some(install) => anyhow::anyhow!( + "Skill '{}' is endorsed but not installed. Install it with `{}`, then run skill_manage reload_all.", + name, + install + ), + None => anyhow::anyhow!( + "Skill '{}' is endorsed but not installed (source: {}). Install it into ~/.jcode/skills/{}/SKILL.md, then run skill_manage reload_all.", + name, + endorsed.source, + name + ), + } + } else { + anyhow::anyhow!("Skill '{}' not found", name) + } + })?; + + let base_dir = skill + .path + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| ".".to_string()); + + Ok(ToolOutput::new(format!( + "## Skill: {}\n\n**Base directory**: {}\n\n{}", + skill.name, + base_dir, + skill.get_prompt() + )) + .with_title(format!("skill: {}", skill.name))) + } + + async fn list_skills(&self, working_dir: Option<&std::path::Path>) -> Result<ToolOutput> { + let registry = self.effective_registry(working_dir).await; + let mut skills = registry.list(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + let installed: std::collections::HashSet<&str> = + skills.iter().map(|s| s.name.as_str()).collect(); + + let mut output = if skills.is_empty() { + "No skills loaded.\n\n\ + Skills are loaded from:\n\ + - ~/.jcode/skills/<skill-name>/SKILL.md (global)\n\ + - ./.jcode/skills/<skill-name>/SKILL.md (project-local)\n\ + - ./.claude/skills/<skill-name>/SKILL.md (compatibility)\n\n\ + Create a SKILL.md file with YAML frontmatter:\n\ + ---\n\ + name: my-skill\n\ + description: What this skill does\n\ + allowed-tools: bash, read, write\n\ + ---\n\n\ + # Skill content here\n" + .to_string() + } else { + let mut output = format!("Loaded skills: {}\n\n", skills.len()); + for skill in &skills { + output.push_str(&format!("## /{}\n", skill.name)); + output.push_str(&format!(" {}\n", skill.description)); + output.push_str(&format!(" Path: {}\n", skill.path.display())); + if let Some(ref tools) = skill.allowed_tools { + output.push_str(&format!(" Tools: {}\n", tools.join(", "))); + } + output.push('\n'); + } + output + }; + + append_endorsed_skills(&mut output, &installed); + + Ok(ToolOutput::new(output).with_title("Skills: List")) + } + + async fn reload_skill(&self, name: Option<String>) -> Result<ToolOutput> { + let name = normalize_skill_name(name, "reload")?; + + let mut registry = self.registry.write().await; + + match registry.reload(&name) { + Ok(true) => { + // Re-read to get updated info + if let Some(skill) = registry.get(&name) { + Ok(ToolOutput::new(format!( + "Reloaded skill '{}'\n\nDescription: {}\nPath: {}", + name, + skill.description, + skill.path.display() + )) + .with_title(format!("Skills: Reloaded {}", name))) + } else { + Ok(ToolOutput::new(format!("Reloaded skill '{}'", name)) + .with_title(format!("Skills: Reloaded {}", name))) + } + } + Ok(false) => Ok(ToolOutput::new(format!( + "Skill '{}' not found or was deleted.\n\nUse 'list' to see available skills.", + name + )) + .with_title("Skills: Not found")), + Err(e) => { + crate::logging::warn(&format!( + "[tool:skill_manage] reload failed skill={} error={}", + name, e + )); + Ok( + ToolOutput::new(format!("Failed to reload skill '{}': {}", name, e)) + .with_title("Skills: Reload failed"), + ) + } + } + } + + async fn reload_all_skills(&self, working_dir: Option<&std::path::Path>) -> Result<ToolOutput> { + // Reload the shared GLOBAL registry only; the project-local overlay is + // session-scoped and re-read from disk on every access, so reloading + // it here would leak this session's project skills to other sessions + // (issue #457). + let reloaded = { + let mut registry = self.registry.write().await; + registry.reload_global() + }; + + match reloaded { + Ok(global_count) => { + let effective = self.effective_registry(working_dir).await; + let skills = effective.list(); + let mut output = format!( + "Reloaded {} global skills ({} effective for this session)\n\n", + global_count, + skills.len() + ); + + for skill in skills { + output.push_str(&format!("- /{}: {}\n", skill.name, skill.description)); + } + + Ok( + ToolOutput::new(output) + .with_title(format!("Skills: Reloaded {}", global_count)), + ) + } + Err(e) => { + crate::logging::warn(&format!( + "[tool:skill_manage] reload_all failed error={}", + e + )); + Ok(ToolOutput::new(format!("Failed to reload skills: {}", e)) + .with_title("Skills: Reload failed")) + } + } + } + + async fn read_skill( + &self, + name: Option<String>, + working_dir: Option<&std::path::Path>, + ) -> Result<ToolOutput> { + let name = normalize_skill_name(name, "read")?; + + let registry = self.effective_registry(working_dir).await; + + if let Some(skill) = registry.get(&name) { + let mut output = format!("# Skill: {}\n\n", skill.name); + output.push_str(&format!("**Description:** {}\n", skill.description)); + output.push_str(&format!("**Path:** {}\n", skill.path.display())); + if let Some(ref tools) = skill.allowed_tools { + output.push_str(&format!("**Allowed tools:** {}\n", tools.join(", "))); + } + output.push_str("\n---\n\n"); + output.push_str(&skill.content); + + Ok(ToolOutput::new(output).with_title(format!("Skills: {}", name))) + } else { + Ok(ToolOutput::new(format!( + "Skill '{}' not found.\n\nUse 'list' to see available skills.", + name + )) + .with_title("Skills: Not found")) + } + } +} + +/// Append the curated jcode-endorsed skill catalog to `output`, grouped by +/// category and marked with installed/not-installed status. `installed` is the +/// set of skill names currently loaded in the registry. +fn append_endorsed_skills(output: &mut String, installed: &std::collections::HashSet<&str>) { + let endorsed = crate::skill::endorsed_skills(); + if endorsed.is_empty() { + return; + } + + output.push_str("\nEndorsed skills (recommended by jcode)\n"); + + // Group by category, preserving first-seen order. + let mut category_order: Vec<&str> = Vec::new(); + for skill in endorsed { + if !category_order.contains(&skill.category) { + category_order.push(skill.category); + } + } + + for category in category_order { + let in_category: Vec<_> = endorsed.iter().filter(|e| e.category == category).collect(); + let installed_count = in_category + .iter() + .filter(|e| installed.contains(e.name)) + .count(); + output.push_str(&format!( + "\n {} ({}/{} installed)\n", + category, + installed_count, + in_category.len() + )); + for skill in in_category { + let is_installed = installed.contains(skill.name); + let status = if is_installed { + "installed" + } else { + "not installed" + }; + output.push_str(&format!(" - /{} [{}]\n", skill.name, status)); + output.push_str(&format!(" {}\n", skill.description)); + output.push_str(&format!(" source: {}\n", skill.source)); + if !is_installed && let Some(install) = skill.install { + output.push_str(&format!(" install: {}\n", install)); + } + } + } + + output.push_str( + "\nActivate a loaded skill by loading it with skill_manage (action=load) or typing its slash command.\n", + ); + output.push_str( + "NVIDIA CUDA-X skills come from the official catalog at https://github.com/NVIDIA/skills.\n", + ); +} + +fn normalize_skill_name(name: Option<String>, action: &str) -> Result<String> { + let name = name.ok_or_else(|| anyhow::anyhow!("'name' is required for {} action", action))?; + let trimmed = name.trim().trim_start_matches('/').to_string(); + if trimmed.is_empty() { + anyhow::bail!("'name' is required for {} action", action); + } + Ok(trimmed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_tool() -> SkillTool { + let registry = Arc::new(RwLock::new(SkillRegistry::default())); + SkillTool::new(registry) + } + + fn create_test_tool_with_skill(name: &str) -> (SkillTool, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().unwrap(); + let skill_dir = temp_dir.path().join(".jcode").join("skills").join(name); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + format!( + "---\nname: {name}\ndescription: Test skill\n---\n\n# Test Skill\n\nUse this test skill." + ), + ) + .unwrap(); + + let registry = SkillRegistry::load_for_working_dir(Some(temp_dir.path())).unwrap(); + let tool = SkillTool::new(Arc::new(RwLock::new(registry))); + (tool, temp_dir) + } + + fn create_test_context() -> ToolContext { + ToolContext { + session_id: "test-session".to_string(), + message_id: "test-message".to_string(), + tool_call_id: "test-tool-call".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: crate::tool::ToolExecutionMode::Direct, + } + } + + #[test] + fn test_tool_name() { + let tool = create_test_tool(); + assert_eq!(tool.name(), "skill_manage"); + } + + #[test] + fn test_tool_description() { + let tool = create_test_tool(); + assert!(tool.description().contains("skill")); + } + + #[test] + fn test_parameters_schema() { + let tool = create_test_tool(); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"]["action"].is_object()); + assert!(schema["properties"]["name"].is_object()); + } + + #[tokio::test] + async fn test_list_empty() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "list"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("No skills loaded")); + // Even with no skills loaded, the endorsed catalog should be listed. + assert!(result.output.contains("Endorsed skills")); + } + + #[tokio::test] + async fn test_list_includes_endorsed_skills() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "list"}); + + let result = tool.execute(input, ctx).await.unwrap(); + // Every endorsed skill should appear with an install-status marker. + for endorsed in crate::skill::endorsed_skills() { + assert!( + result.output.contains(&format!("/{}", endorsed.name)), + "expected endorsed skill /{} in:\n{}", + endorsed.name, + result.output + ); + } + // No skills are loaded in this tool, so they should be "not installed". + assert!(result.output.contains("[not installed]")); + } + + #[tokio::test] + async fn test_load_missing_name() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "load"}); + + let result = tool.execute(input, ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("name")); + } + + #[tokio::test] + async fn test_load_accepts_skill_alias_and_args() { + let (tool, _temp_dir) = create_test_tool_with_skill("optimization"); + let ctx = create_test_context(); + let input = json!({"skill": "optimization", "args": "optimize this"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("## Skill: optimization")); + assert_eq!(result.title.as_deref(), Some("skill: optimization")); + } + + #[tokio::test] + async fn test_load_strips_leading_slash_from_name() { + let (tool, _temp_dir) = create_test_tool_with_skill("optimization"); + let ctx = create_test_context(); + let input = json!({"action": "load", "name": "/optimization"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("## Skill: optimization")); + } + + #[tokio::test] + async fn test_reload_missing_name() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "reload"}); + + let result = tool.execute(input, ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("name")); + } + + #[tokio::test] + async fn test_read_missing_name() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "read"}); + + let result = tool.execute(input, ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("name")); + } + + #[tokio::test] + async fn test_reload_nonexistent() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "reload", "name": "nonexistent"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("not found")); + } + + #[tokio::test] + async fn test_unknown_action() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "invalid"}); + + let result = tool.execute(input, ctx).await.unwrap(); + assert!(result.output.contains("Unknown action")); + } + + #[tokio::test] + async fn test_reload_all() { + let tool = create_test_tool(); + let ctx = create_test_context(); + let input = json!({"action": "reload_all"}); + + let result = tool.execute(input, ctx).await.unwrap(); + // The output format is "Reloaded N skills" where N is any number + // (depends on what skills exist on the system) + assert!( + result.output.contains("Reloaded"), + "Expected 'Reloaded' in output, got: {}", + result.output + ); + assert!( + result.output.contains("skills"), + "Expected 'skills' in output, got: {}", + result.output + ); + } + + fn context_with_working_dir(dir: &std::path::Path) -> ToolContext { + ToolContext { + working_dir: Some(dir.to_path_buf()), + ..create_test_context() + } + } + + fn write_project_skill(root: &std::path::Path, name: &str) { + let skill_dir = root.join(".agents").join("skills").join(name); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: Project skill {name}\n---\n\nBody."), + ) + .unwrap(); + } + + /// Issue #457: project-local skills must be session-scoped. Two contexts + /// with different working dirs share one registry but must each see only + /// their own project skills, immediately and without reload_all. + #[tokio::test] + async fn test_project_skills_are_scoped_to_tool_context_working_dir() { + let tool = create_test_tool(); + let repo_a = tempfile::tempdir().unwrap(); + let repo_b = tempfile::tempdir().unwrap(); + write_project_skill(repo_a.path(), "repo-a-skill"); + write_project_skill(repo_b.path(), "repo-b-skill"); + + // Immediately visible in each session without any reload. + let list_a = tool + .execute( + json!({"action": "list"}), + context_with_working_dir(repo_a.path()), + ) + .await + .unwrap(); + assert!(list_a.output.contains("repo-a-skill")); + assert!( + !list_a.output.contains("repo-b-skill"), + "session A must not see session B's project skills" + ); + + let list_b = tool + .execute( + json!({"action": "list"}), + context_with_working_dir(repo_b.path()), + ) + .await + .unwrap(); + assert!(list_b.output.contains("repo-b-skill")); + assert!(!list_b.output.contains("repo-a-skill")); + + // reload_all in session A must not leak A's project skills into the + // shared registry that session B reads. + tool.execute( + json!({"action": "reload_all"}), + context_with_working_dir(repo_a.path()), + ) + .await + .unwrap(); + let shared = tool.registry.read().await; + assert!( + shared.get("repo-a-skill").is_none(), + "shared registry must stay free of project-local skills" + ); + drop(shared); + + // Skill file edits are visible without any reload/restart. + let skill_md = repo_a.path().join(".agents/skills/repo-a-skill/SKILL.md"); + std::fs::write( + &skill_md, + "---\nname: repo-a-skill\ndescription: Updated description\n---\n\nNew body.", + ) + .unwrap(); + let read = tool + .execute( + json!({"action": "read", "name": "repo-a-skill"}), + context_with_working_dir(repo_a.path()), + ) + .await + .unwrap(); + assert!( + read.output.contains("Updated description"), + "skill edits must be visible without daemon restart, got: {}", + read.output + ); + } +} diff --git a/crates/jcode-app-core/src/tool/testdata/ddg_anomaly.html b/crates/jcode-app-core/src/tool/testdata/ddg_anomaly.html new file mode 100644 index 0000000..998d36a --- /dev/null +++ b/crates/jcode-app-core/src/tool/testdata/ddg_anomaly.html @@ -0,0 +1,176 @@ +<!-- <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> --> +<!DOCTYPE html> +<html lang="en"> + +<head> + <link rel="canonical" href="https://duckduckgo.com/"> + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1"> + <meta name="referrer" content="origin"> + <title> + DuckDuckGo + + + + + + + + + + + + + + + + +
+
+ + DuckDuckGo + +

+ + + +
+
+
+
+
Unfortunately, bots use DuckDuckGo too.
+
Please complete the following challenge to confirm this search was made by a human.
+
Select all squares containing a duck:
+
+
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+
+
+ +

+ + + +
+
+ + + + + + + + + + +
+

+ +

+
+ + + + + + + + + + + diff --git a/crates/jcode-app-core/src/tool/testdata/ddg_results.html b/crates/jcode-app-core/src/tool/testdata/ddg_results.html new file mode 100644 index 0000000..77596c0 --- /dev/null +++ b/crates/jcode-app-core/src/tool/testdata/ddg_results.html @@ -0,0 +1,654 @@ + + + + + + + + + + + + + rust lang at DuckDuckGo + + + + + + + + + + + + + + +
+ +
+ +
+
+ + + + + +
+
+

+ Rust (programming language) +

+ + +
+ + + + + + + Rust is a general-purpose programming language which emphasizes performance, type safety, concurrency, and memory safety. Rust supports multiple programming paradigms. It was influenced by ideas from functional programming, including immutability, higher-order functions, algebraic data types, and pattern matching. It also supports object-oriented programming via structs, enums, traits, and methods. + + More at Wikipedia + +
+ +
+
+ + + + +
+
+ +
+
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/crates/jcode-app-core/src/tool/tests.rs b/crates/jcode-app-core/src/tool/tests.rs new file mode 100644 index 0000000..e0245e1 --- /dev/null +++ b/crates/jcode-app-core/src/tool/tests.rs @@ -0,0 +1,637 @@ +#![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; + +struct MockProvider; + +#[async_trait] +impl Provider for MockProvider { + async fn complete( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _system: &str, + _resume_session_id: Option<&str>, + ) -> anyhow::Result { + Err(anyhow::anyhow!( + "Mock provider should not be used for streaming completions in tool registry tests" + )) + } + + fn name(&self) -> &str { + "mock" + } + + fn fork(&self) -> Arc { + Arc::new(MockProvider) + } +} + +#[tokio::test] +async fn test_tool_definitions_are_sorted() { + // Create registry with mock provider + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + + // Get definitions multiple times and verify they're always in the same order + let defs1 = registry.definitions(None).await; + let defs2 = registry.definitions(None).await; + + // Should have the same order + assert_eq!(defs1.len(), defs2.len()); + for (d1, d2) in defs1.iter().zip(defs2.iter()) { + assert_eq!(d1.name, d2.name); + } + + // Verify they're sorted alphabetically + let names: Vec<&str> = defs1.iter().map(|d| d.name.as_str()).collect(); + let mut sorted_names = names.clone(); + sorted_names.sort(); + assert_eq!( + names, sorted_names, + "Tool definitions should be sorted alphabetically" + ); +} + +#[test] +fn test_resolve_skill_aliases_to_skill_manage() { + assert_eq!(Registry::resolve_tool_name("skill"), "skill_manage"); + assert_eq!(Registry::resolve_tool_name("Skill"), "skill_manage"); + assert_eq!(Registry::resolve_tool_name("skill_manage"), "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. + 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")); + } else { + assert!( + !names.iter().any(|n| n == "discover_tools"), + "discover_tools must not be registered when sponsors are disabled" + ); + } +} + +#[tokio::test] +async fn subagent_tool_is_not_registered() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + + assert!( + !registry + .tool_names() + .await + .iter() + .any(|name| name == "subagent"), + "the deprecated direct subagent tool must not be exposed; use swarm instead" + ); +} + +struct BareSchemaTool; + +#[async_trait] +impl Tool for BareSchemaTool { + fn name(&self) -> &str { + "bare_schema" + } + + fn description(&self) -> &str { + "Test tool without an explicit intent property." + } + + fn parameters_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "required": ["command"], + "properties": { + "command": {"type": "string"} + } + }) + } + + async fn execute(&self, _input: Value, _ctx: ToolContext) -> Result { + Ok(ToolOutput::new("ok")) + } +} + +#[test] +fn tool_definitions_do_not_auto_inject_intent() { + let def = BareSchemaTool.to_definition(); + assert!(def.input_schema["properties"]["intent"].is_null()); +} + +#[tokio::test] +async fn first_party_tool_definitions_include_optional_intent_explicitly() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + registry.register_ambient_tools().await; + registry.register_selfdev_tools().await; + + let defs = registry.definitions(None).await; + assert!(!defs.is_empty()); + + for def in defs { + let schema = &def.input_schema; + if schema["type"] != "object" { + continue; + } + + assert_eq!( + schema["properties"]["intent"]["type"], "string", + "{} should explicitly define optional intent in its schema", + def.name + ); + assert!( + schema["properties"]["intent"]["description"] + .as_str() + .unwrap_or_default() + .contains("display only"), + "{} intent description should say it is 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", + def.name + ); + } +} + +#[test] +fn test_resolve_tool_name_oauth_aliases() { + assert_eq!(Registry::resolve_tool_name("file_read"), "read"); + assert_eq!(Registry::resolve_tool_name("file_write"), "write"); + assert_eq!(Registry::resolve_tool_name("file_edit"), "edit"); + assert_eq!(Registry::resolve_tool_name("shell_exec"), "bash"); + assert_eq!(Registry::resolve_tool_name("shell"), "bash"); + assert_eq!(Registry::resolve_tool_name("read_file"), "read"); + assert_eq!(Registry::resolve_tool_name("write_file"), "write"); + assert_eq!(Registry::resolve_tool_name("edit_file"), "edit"); + assert_eq!(Registry::resolve_tool_name("task_runner"), "subagent"); + assert_eq!(Registry::resolve_tool_name("task"), "subagent"); + assert_eq!(Registry::resolve_tool_name("launch"), "open"); + assert_eq!(Registry::resolve_tool_name("grep"), "agentgrep"); + assert_eq!(Registry::resolve_tool_name("file_grep"), "agentgrep"); + assert_eq!(Registry::resolve_tool_name("todo_read"), "todo"); + assert_eq!(Registry::resolve_tool_name("todo_write"), "todo"); + assert_eq!(Registry::resolve_tool_name("todoread"), "todo"); + assert_eq!(Registry::resolve_tool_name("todowrite"), "todo"); + assert_eq!(Registry::resolve_tool_name("bash"), "bash"); + assert_eq!(Registry::resolve_tool_name("batch"), "batch"); + assert_eq!(Registry::resolve_tool_name("memory"), "memory"); +} + +#[tokio::test] +async fn test_batch_resolves_oauth_names() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let temp_dir = std::env::temp_dir(); + + let ctx = ToolContext { + session_id: "test".to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(temp_dir), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + }; + + let result = registry + .execute("shell_exec", serde_json::json!({"command": "true"}), ctx) + .await; + assert!(result.is_ok(), "shell_exec should resolve to bash tool"); +} + +#[tokio::test] +async fn registry_execute_enforces_session_tool_policy_after_alias_resolution() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let temp_dir = std::env::temp_dir(); + let session_id = "test-policy-deny"; + set_session_tool_policy(session_id, None, HashSet::from(["bash".to_string()])); + + let ctx = ToolContext { + session_id: session_id.to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: Some(temp_dir.clone()), + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + }; + + let result = registry + .execute("shell_exec", serde_json::json!({"command": "true"}), ctx) + .await; + + clear_session_tool_policy(session_id); + assert!(result.is_err(), "deny-list should block aliased bash calls"); + assert!( + result + .unwrap_err() + .to_string() + .contains("Tool 'bash' is disabled") + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn registry_execute_pre_tool_hook_blocks_and_allows() { + use std::os::unix::fs::PermissionsExt; + + let _guard = crate::storage::lock_test_env(); + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let temp = tempfile::TempDir::new().expect("temp dir"); + + // Policy script: block bash calls whose input mentions "secret". + let policy = temp.path().join("policy.sh"); + std::fs::write( + &policy, + "#!/bin/sh\ninput=$(cat)\ncase \"$input\" in\n *secret*) echo \"no secrets\" >&2; exit 2 ;;\nesac\nexit 0\n", + ) + .expect("write policy"); + std::fs::set_permissions(&policy, std::fs::Permissions::from_mode(0o755)) + .expect("chmod policy"); + + let prev = std::env::var_os("JCODE_HOOK_PRE_TOOL"); + crate::env::set_var("JCODE_HOOK_PRE_TOOL", policy.to_string_lossy().to_string()); + // jcode-base is compiled without cfg(test) here, so the config cache only + // re-checks env every 500ms; force a reload so the hook is visible now. + crate::config::invalidate_config_cache(); + + let ctx = || ToolContext { + session_id: "test-pre-tool-hook".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, + }; + + let blocked = registry + .execute( + "bash", + serde_json::json!({ + "command": "echo secret" + }), + ctx(), + ) + .await; + let allowed = registry + .execute( + "bash", + serde_json::json!({ + "command": "true" + }), + ctx(), + ) + .await; + + match prev { + Some(value) => crate::env::set_var("JCODE_HOOK_PRE_TOOL", value), + None => crate::env::remove_var("JCODE_HOOK_PRE_TOOL"), + } + crate::config::invalidate_config_cache(); + + let error = blocked.expect_err("pre_tool hook should block matching input"); + assert!( + error.to_string().contains("no secrets"), + "hook stderr should surface in the error: {error}" + ); + assert!(allowed.is_ok(), "non-matching input should pass the gate"); +} + +#[tokio::test] +async fn test_definitions_keep_batch_schema_generic() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + + let defs = registry.definitions(None).await; + let batch_def = defs + .iter() + .find(|def| def.name == "batch") + .expect("batch definition should exist"); + + assert!(batch_def.input_schema["properties"]["tool_calls"]["items"]["oneOf"].is_null()); + assert!( + batch_def.input_schema["properties"]["tool_calls"]["items"]["required"] + .as_array() + .map(|required| required.iter().any(|value| value == "tool")) + .unwrap_or(false) + ); + assert!( + batch_def.input_schema["properties"]["tool_calls"]["items"]["properties"]["parameters"] + .is_null() + ); +} + +#[test] +fn resolve_tool_name_maps_communicate_to_swarm() { + assert_eq!(Registry::resolve_tool_name("communicate"), "swarm"); +} + +#[tokio::test] +#[ignore] +async fn print_tool_definition_token_report() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let mut defs = registry.definitions(None).await; + defs.sort_by_key(|def| std::cmp::Reverse(def.prompt_token_estimate())); + + println!("name,total_tokens,description_tokens"); + for def in defs { + println!( + "{},{},{}", + def.name, + def.prompt_token_estimate(), + def.description_token_estimate() + ); + } +} + +fn schema_type_includes(schema: &Value, expected: &str) -> bool { + match schema.get("type") { + Some(Value::String(value)) => value == expected, + Some(Value::Array(values)) => values + .iter() + .any(|value| value.as_str().is_some_and(|value| value == expected)), + _ => false, + } +} + +fn collect_schema_errors(schema: &Value, path: &str, errors: &mut Vec) { + match schema { + Value::Object(map) => { + if schema_type_includes(schema, "array") && !map.contains_key("items") { + errors.push(format!("{path}: array schema missing items")); + } + + for keyword in ["anyOf", "oneOf", "allOf"] { + let Some(branches) = map.get(keyword) else { + continue; + }; + let Some(branches) = branches.as_array() else { + errors.push(format!("{path}.{keyword}: must be an array")); + continue; + }; + for (idx, branch) in branches.iter().enumerate() { + let branch_path = format!("{path}.{keyword}[{idx}]"); + match branch { + Value::Object(branch_map) => { + if !branch_map.contains_key("type") { + errors.push(format!("{branch_path}: schema missing type")); + } + } + _ => errors.push(format!("{branch_path}: schema branch must be an object")), + } + } + } + + for (key, value) in map { + collect_schema_errors(value, &format!("{path}.{key}"), errors); + } + } + Value::Array(values) => { + for (idx, value) in values.iter().enumerate() { + collect_schema_errors(value, &format!("{path}[{idx}]"), errors); + } + } + _ => {} + } +} + +#[tokio::test] +async fn test_tool_definitions_do_not_expose_invalid_array_schemas() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + + let defs = registry.definitions(None).await; + let mut errors = Vec::new(); + for def in &defs { + collect_schema_errors( + &def.input_schema, + &format!("tool `{}`", def.name), + &mut errors, + ); + } + + assert!( + errors.is_empty(), + "tool definitions must not expose invalid schemas:\n{}", + errors.join("\n") + ); +} + +#[test] +fn test_schema_validator_rejects_any_of_branches_without_type() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "status_filter": { + "anyOf": [ + { "enum": ["running", "completed"] }, + { "type": "array", "items": { "type": "string" } } + ] + } + } + }); + + let mut errors = Vec::new(); + collect_schema_errors(&schema, "tool `test`", &mut errors); + + assert!( + errors + .iter() + .any(|error| error.contains("status_filter.anyOf[0]: schema missing type")), + "expected missing type error, got: {errors:?}" + ); +} + +#[tokio::test] +async fn test_context_guard_small_output_passes_through() { + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(200_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("small output"); + let result = registry.guard_context_overflow("test", output).await; + assert_eq!(result.output, "small output"); +} + +#[tokio::test] +async fn test_context_guard_truncates_huge_single_output() { + 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, + }; + + // 30% of 1000 = 300 tokens = 1200 chars max for a 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; + assert!( + result.output.len() < big_output.len(), + "Output should be truncated" + ); + assert!( + result.output.contains("TRUNCATED"), + "Should contain truncation warning" + ); +} + +#[tokio::test] +async fn test_context_guard_truncates_when_context_nearly_full() { + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(10_000))); + { + let mut mgr = compaction.write().await; + mgr.update_observed_input_tokens(9500); // 95% full + } + let registry = Registry { + tools: Arc::new(RwLock::new(HashMap::new())), + skills: Arc::new(RwLock::new(crate::skill::SkillRegistry::default())), + compaction, + }; + + // 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; + assert!( + result.output.contains("TRUNCATED") || result.output.contains("CONTEXT LIMIT"), + "Should warn about context limits when nearly full" + ); +} + +#[tokio::test] +async fn test_context_guard_zero_budget_passes_through() { + let compaction = Arc::new(RwLock::new(CompactionManager::new().with_budget(0))); + 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(100_000)); + let result = registry.guard_context_overflow("test", output).await; + assert_eq!( + result.output.len(), + 100_000, + "Zero budget should pass through" + ); +} + +#[tokio::test] +async fn test_request_permission_is_ambient_only() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + + let defs = registry.definitions(None).await; + assert!( + !defs.iter().any(|d| d.name == "request_permission"), + "request_permission should not be available in normal sessions" + ); + + registry.register_ambient_tools().await; + let defs_after = registry.definitions(None).await; + assert!( + defs_after.iter().any(|d| d.name == "request_permission"), + "request_permission should be available after ambient tool registration" + ); +} + +#[test] +fn closest_tool_names_suggests_near_misses() { + let available = ["todo", "end_ambient_cycle", "bash", "read", "write", "edit"]; + // Exact-ish prefix/typo cases the ambient agent hit (#104). + let s = Registry::closest_tool_names("todos", &available); + assert_eq!(s.first().map(String::as_str), Some("todo")); + + let s = Registry::closest_tool_names("end_ambient_cyle", &available); + assert!(s.iter().any(|n| n == "end_ambient_cycle"), "got {s:?}"); + + // Case-insensitive containment. + let s = Registry::closest_tool_names("Bash", &available); + assert_eq!(s.first().map(String::as_str), Some("bash")); + + // A wildly unrelated name should yield no confident suggestion. + let s = Registry::closest_tool_names("xyzzy_quux", &available); + assert!(s.is_empty(), "got {s:?}"); +} + +#[tokio::test] +async fn unknown_tool_error_lists_available_tools_and_suggestions() { + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + registry.register_ambient_tools().await; + + let ctx = ToolContext { + session_id: "test-unknown-tool".to_string(), + message_id: "test".to_string(), + tool_call_id: "test".to_string(), + working_dir: None, + stdin_request_tx: None, + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::Direct, + }; + let err = registry + .execute("ToolSearch", serde_json::json!({}), ctx) + .await + .expect_err("ToolSearch is not a real tool"); + let msg = err.to_string(); + assert!(msg.contains("Unknown tool: ToolSearch"), "got: {msg}"); + assert!( + msg.contains("Available tools:"), + "error must list available tools so the model can recover (#104): {msg}" + ); + assert!( + msg.contains("end_ambient_cycle"), + "available list should include registered ambient tools: {msg}" + ); +} + +#[tokio::test] +async fn gemini_build_tools_from_registry_definitions_omits_const_keywords() { + // Moved from jcode-base/src/provider/gemini_tests.rs: this is the one test + // that needs the upper-layer tool::Registry, so it lives here instead of + // forcing a base -> app-core dev-dependency cycle. + fn schema_contains_key(schema: &serde_json::Value, key: &str) -> bool { + match schema { + serde_json::Value::Object(map) => { + map.contains_key(key) || map.values().any(|value| schema_contains_key(value, key)) + } + serde_json::Value::Array(items) => { + items.iter().any(|value| schema_contains_key(value, key)) + } + _ => false, + } + } + + let provider: Arc = Arc::new(MockProvider); + let registry = Registry::new(provider).await; + let defs = registry.definitions(None).await; + + let built = crate::provider::gemini::build_tools(&defs).expect("gemini tools"); + let parameters = &built[0].function_declarations; + + assert!(!schema_contains_key( + &serde_json::json!(parameters), + "const" + )); +} diff --git a/crates/jcode-app-core/src/tool/todo.rs b/crates/jcode-app-core/src/tool/todo.rs new file mode 100644 index 0000000..6a2bba3 --- /dev/null +++ b/crates/jcode-app-core/src/tool/todo.rs @@ -0,0 +1,665 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, TodoEvent}; +use crate::todo::{ + LOW_HILL_CLIMBABILITY, TODO_QUALITY_CONTINUATION_MESSAGE, TodoGoal, TodoItem, load_goals, + load_todos, newly_completed_groups_have_sufficient_ownership, save_goals, save_todos, +}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashMap; + +pub struct TodoTool; + +impl TodoTool { + pub fn new() -> Self { + Self + } +} + +/// Fold each incoming todo's confidence into its tool-maintained history. +/// +/// The model reports `confidence` (and `completion_confidence` at completion) +/// as scalar fields; this keeps an append-only trail of every distinct value a +/// todo has carried so downstream consumers (auto-poke spike checks, analysis) +/// can distinguish a stepped, evidence-driven rise (75 -> 85 -> 95 -> 100) +/// from a bulk end-of-task stamp (75 -> 100). Model-supplied +/// `confidence_history` is ignored: the tool owns this field. +fn merge_confidence_history(previous: &[TodoItem], incoming: &mut [TodoItem]) { + let prior: HashMap<&str, &TodoItem> = previous + .iter() + .map(|todo| (todo.id.as_str(), todo)) + .collect(); + for todo in incoming.iter_mut() { + let mut history = prior + .get(todo.id.as_str()) + .map(|prev| prev.confidence_history.clone()) + .unwrap_or_default(); + for value in [todo.confidence, todo.completion_confidence] + .into_iter() + .flatten() + { + if history.last() != Some(&value) { + history.push(value); + } + } + todo.confidence_history = history; + } +} + +#[derive(Deserialize)] +struct TodoInput { + todos: Option>, + goals: Option>, +} + +/// Normalize a goal's group label: trimmed, with empty/whitespace collapsed +/// to `None` (the implicit goal of an ungrouped list). +fn goal_group_key(group: Option<&str>) -> Option { + group + .map(str::trim) + .filter(|group| !group.is_empty()) + .map(str::to_string) +} + +/// 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). +fn merge_goals(stored: &[TodoGoal], incoming: Option>) -> Vec { + let Some(incoming) = incoming else { + return stored.to_vec(); + }; + let mut merged: Vec = Vec::new(); + for mut goal in incoming { + goal.group = goal_group_key(goal.group.as_deref()); + if let Some(slot) = merged + .iter_mut() + .find(|existing| existing.group == goal.group) + { + *slot = goal; + } else { + merged.push(goal); + } + } + for prev in stored { + let key = goal_group_key(prev.group.as_deref()); + if !merged.iter().any(|goal| goal.group == key) { + merged.push(prev.clone()); + } + } + merged +} + +/// Reframe nudges for goals that score low on hill-climbability. +/// +/// 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; + } + 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 { + continue; + } + nudges.push(TODO_QUALITY_CONTINUATION_MESSAGE.to_string()); + } + nudges +} + +/// Leniently normalize raw todo-tool arguments before strict deserialization. +/// +/// Some providers (notably Claude tool calling) intermittently emit tool +/// arguments as JSON *strings* instead of native types: the whole `todos` +/// array as one stringified JSON blob, individual items as stringified +/// objects, or numeric fields like `confidence` as `"90"`. Strict +/// `serde_json::from_value` rejects these with `invalid type: string ...`, +/// failing the entire call (issue #357; same provider quirk as #106). +fn normalize_todo_input(mut input: Value) -> Value { + let Some(obj) = input.as_object_mut() else { + return input; + }; + for key in ["todos", "goals"] { + let Some(entries) = obj.get_mut(key) else { + continue; + }; + + // Whole array sent as a stringified JSON blob. + if let Value::String(raw) = entries { + let trimmed = raw.trim(); + if trimmed.is_empty() { + *entries = Value::Null; + } else if let Ok(parsed @ (Value::Array(_) | Value::Null)) = + serde_json::from_str::(trimmed) + { + *entries = parsed; + } + } + + if let Value::Array(items) = entries { + for item in items.iter_mut() { + // Individual item sent as a stringified JSON object. + if let Value::String(raw) = item + && let Ok(parsed @ Value::Object(_)) = serde_json::from_str::(raw.trim()) + { + *item = parsed; + } + let Some(fields) = item.as_object_mut() else { + continue; + }; + for key in [ + "confidence", + "completion_confidence", + "hill_climbability", + "end_to_end_ownership", + ] { + if let Some(value) = fields.get_mut(key) { + coerce_value_to_integer(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); + } + } + _ => {} + } +} + +#[async_trait] +impl Tool for TodoTool { + fn name(&self) -> &str { + "todo" + } + + fn description(&self) -> &str { + // SECURITY/EVAL: This is model-visible calibration text. Keep it + // deliberately handwritten. Never generate it from gate constants or + // interpolate private thresholds, because that would teach the model + // how to target the evaluator instead of reporting an honest assessment. + "Read or update structured todo items and optional goal-level assessments." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "intent": super::intent_schema_property(), + "todos": { + "type": "array", + "description": "Todo list to save.", + "items": { + "type": "object", + "required": ["content", "status", "priority", "id", "confidence"], + "properties": { + "content": { + "type": "string", + "description": "Task." + }, + "status": { + "type": "string", + "description": "Status." + }, + "priority": { + "type": "string", + "description": "Priority." + }, + "id": { + "type": "string", + "description": "ID." + }, + "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." + }, + "confidence": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Self-assessed confidence, 0-100, that this todo can be completed correctly." + }, + "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." + } + } + } + }, + "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.", + "items": { + "type": "object", + "required": ["hill_climbability", "feedback_loop"], + "properties": { + "group": { + "type": "string", + "description": "Group label this goal describes. Omit or null for the ungrouped list." + }, + "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." + }, + "objective": { + "type": "string", + "description": "Optional concise statement of the intended measurable outcome." + }, + "feedback_loop": { + "type": "string", + "description": "Concrete process, observation, or check used to compare progress across iterations." + }, + "end_to_end_ownership": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Self-assessment, 0-100, of how completely the stated goal and its relevant outcomes were handled." + } + } + } + } + } + }) + } + + 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 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) { + anyhow::bail!(TODO_QUALITY_CONTINUATION_MESSAGE); + } + let nudges = take_reframe_nudges(&goals, &todos); + save_todos(&ctx.session_id, &todos)?; + save_goals(&ctx.session_id, &goals)?; + + Bus::global().publish(BusEvent::TodoUpdated(TodoEvent { + session_id: ctx.session_id.clone(), + todos: todos.clone(), + })); + + let remaining = todos.iter().filter(|t| t.status != "completed").count(); + let mut text = serde_json::to_string_pretty(&todos)?; + if !goals.is_empty() { + text.push_str("\n\nGoals:\n"); + text.push_str(&serde_json::to_string_pretty(&goals)?); + } + for nudge in &nudges { + text.push_str("\n\n"); + text.push_str(nudge); + } + Ok(ToolOutput::new(text) + .with_title(format!("{} todos", remaining)) + .with_metadata(json!({"todos": todos, "goals": goals}))) + })() + } else { + (|| { + let todos = load_todos(&ctx.session_id)?; + let goals = load_goals(&ctx.session_id).unwrap_or_default(); + let remaining = todos.iter().filter(|t| t.status != "completed").count(); + let mut text = serde_json::to_string_pretty(&todos)?; + if !goals.is_empty() { + text.push_str("\n\nGoals:\n"); + text.push_str(&serde_json::to_string_pretty(&goals)?); + } + Ok(ToolOutput::new(text) + .with_title(format!("{} todos", remaining)) + .with_metadata(json!({"todos": todos, "goals": goals}))) + })() + }; + result.map_err(|err| { + crate::logging::warn(&format!( + "[tool:todo] operation failed operation={} session_id={} error={}", + operation, ctx.session_id, err + )); + err + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_is_named_todo() { + assert_eq!(TodoTool::new().name(), "todo"); + } + + #[test] + fn schema_advertises_intent_and_todos() { + let schema = TodoTool::new().parameters_schema(); + let props = schema + .get("properties") + .and_then(|v| v.as_object()) + .expect("todo schema should have properties"); + assert_eq!(props.len(), 3); + assert!(props.contains_key("intent")); + assert!(props.contains_key("todos")); + assert!(props.contains_key("goals")); + + let item = props["todos"] + .get("items") + .and_then(|v| v.as_object()) + .expect("todos should describe item objects"); + let required = item + .get("required") + .and_then(|v| v.as_array()) + .expect("todo item should advertise required fields"); + assert!(required.iter().any(|v| v == "confidence")); + let item_props = item + .get("properties") + .and_then(|v| v.as_object()) + .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")); + + let goal_props = props["goals"] + .get("items") + .and_then(|v| v.get("properties")) + .and_then(|v| v.as_object()) + .expect("goals should describe item objects"); + assert!(goal_props.contains_key("group")); + assert!(goal_props.contains_key("hill_climbability")); + assert!(goal_props.contains_key("objective")); + assert!(goal_props.contains_key("feedback_loop")); + assert!(goal_props.contains_key("end_to_end_ownership")); + assert_eq!(goal_props.len(), 5); + + let goal_required = props["goals"]["items"]["required"] + .as_array() + .expect("goals should advertise required fields"); + assert!( + goal_required + .iter() + .any(|value| value == "hill_climbability") + ); + assert!(goal_required.iter().any(|value| value == "feedback_loop")); + + let ownership_description = goal_props["end_to_end_ownership"] + .get("description") + .and_then(Value::as_str) + .expect("ownership should have a neutral description"); + 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"] + .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")); + + let model_visible_schema = serde_json::to_string(&schema) + .expect("todo schema should serialize") + .to_ascii_lowercase(); + for disclosure in [ + "threshold", + "quality gate", + "internal quality check", + "not jump", + "test that passes", + "isn't high enough", + ] { + assert!( + !model_visible_schema.contains(disclosure), + "model-visible todo schema disclosed calibration wording: {disclosure}" + ); + } + } + + fn parse(input: Value) -> Result { + serde_json::from_value(normalize_todo_input(input)) + } + + #[test] + fn accepts_stringified_todos_array() { + let input = json!({ + "todos": "[{\"content\":\"a\",\"status\":\"pending\",\"priority\":\"high\",\"id\":\"1\",\"confidence\":90}]" + }); + let parsed = parse(input).expect("stringified todos array should parse"); + 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)); + } + + #[test] + fn accepts_stringified_todo_items_and_string_confidence() { + let input = json!({ + "todos": [ + "{\"content\":\"b\",\"status\":\"completed\",\"priority\":\"low\",\"id\":\"2\",\"confidence\":\"85\",\"completion_confidence\":\"95\"}", + {"content": "c", "status": "pending", "priority": "high", "id": "3", "confidence": "70"} + ] + }); + 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)); + } + + #[test] + fn accepts_float_confidence_and_empty_string_as_none() { + let input = json!({ + "todos": [ + {"content": "d", "status": "pending", "priority": "high", "id": "4", "confidence": 90.0, "completion_confidence": ""} + ] + }); + 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].completion_confidence, None); + } + + #[test] + fn empty_string_todos_means_read() { + let parsed = parse(json!({"todos": ""})).expect("empty string should parse"); + assert!(parsed.todos.is_none()); + } + + #[test] + fn native_input_still_parses() { + let input = json!({ + "todos": [ + {"content": "e", "status": "pending", "priority": "high", "id": "5", "confidence": 80} + ] + }); + let parsed = parse(input).expect("native input should parse"); + assert_eq!(parsed.todos.expect("todos present")[0].confidence, Some(80)); + } + + #[test] + fn accepts_goals_including_string_coercion() { + let input = json!({ + "goals": [ + {"group": "optimize grep", "hill_climbability": "95", "objective": "p50 under 50ms", "feedback_loop": "run the grep benchmark and compare p50"}, + {"hill_climbability": 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].objective.as_deref(), Some("p50 under 50ms")); + 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].feedback_loop, None); + assert_eq!(goals[1].group, None); + } + + fn goal(group: Option<&str>, score: u8) -> TodoGoal { + TodoGoal { + group: group.map(str::to_string), + hill_climbability: Some(score), + ..Default::default() + } + } + + #[test] + fn merge_goals_retains_unmentioned_goals() { + let stored = vec![goal(Some("a"), 20), goal(Some("b"), 90)]; + // Rewrite goal 'a', leave 'b' alone. + let merged = merge_goals(&stored, Some(vec![goal(Some(" a "), 30)])); + 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[1].group.as_deref(), Some("b")); + // No incoming goals: stored goals unchanged. + assert_eq!(merge_goals(&stored, None).len(), 2); + } + + fn open_todo(group: Option<&str>) -> TodoItem { + TodoItem { + id: "t1".to_string(), + content: "work".to_string(), + status: "in_progress".to_string(), + priority: "high".to_string(), + group: group.map(str::to_string), + ..Default::default() + } + } + + #[test] + fn reframe_nudge_recurs_for_every_low_open_goal_write() { + 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_QUALITY_CONTINUATION_MESSAGE); + assert!(!nudges[0].contains("95")); + assert!(!nudges[0].contains("hill-climbability")); + 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); + } + + #[test] + fn reframe_nudge_skips_closed_goals() { + // Low goal whose todos are all completed: nothing to reframe. + 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()); + } + + #[test] + fn reframe_nudge_covers_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_QUALITY_CONTINUATION_MESSAGE); + } + + #[test] + fn garbage_string_still_errors() { + assert!(parse(json!({"todos": "not json at all"})).is_err()); + } + + fn history_todo(id: &str, confidence: Option, history: Vec) -> TodoItem { + TodoItem { + id: id.to_string(), + content: format!("todo {id}"), + status: "in_progress".to_string(), + priority: "high".to_string(), + confidence, + confidence_history: history, + ..Default::default() + } + } + + #[test] + fn confidence_history_appends_changes_and_skips_repeats() { + let previous = vec![history_todo("1", Some(75), vec![75])]; + // Same confidence again: no new entry. + let mut incoming = vec![history_todo("1", Some(75), Vec::new())]; + merge_confidence_history(&previous, &mut incoming); + assert_eq!(incoming[0].confidence_history, vec![75]); + // Raised confidence: appended. + let mut incoming = vec![history_todo("1", Some(90), Vec::new())]; + merge_confidence_history(&previous, &mut incoming); + assert_eq!(incoming[0].confidence_history, vec![75, 90]); + } + + #[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()); + done.status = "completed".to_string(); + done.completion_confidence = Some(100); + 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]); + } + + #[test] + fn confidence_history_ignores_model_supplied_history_for_new_todos() { + let mut incoming = vec![history_todo("9", Some(80), vec![1, 2, 3])]; + merge_confidence_history(&[], &mut incoming); + assert_eq!(incoming[0].confidence_history, vec![80]); + } +} diff --git a/crates/jcode-app-core/src/tool/webfetch.rs b/crates/jcode-app-core/src/tool/webfetch.rs new file mode 100644 index 0000000..86ea085 --- /dev/null +++ b/crates/jcode-app-core/src/tool/webfetch.rs @@ -0,0 +1,335 @@ +use super::{Tool, ToolContext, ToolOutput}; +use anyhow::Result; +use async_trait::async_trait; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::time::Duration; + +const MAX_SIZE: usize = 5 * 1024 * 1024; // 5MB +const DEFAULT_TIMEOUT: u64 = 30; +const MAX_TIMEOUT: u64 = 120; + +pub struct WebFetchTool { + client: reqwest::Client, +} + +impl WebFetchTool { + pub fn new() -> Self { + Self { + client: crate::provider::shared_http_client(), + } + } +} + +#[derive(Deserialize)] +struct WebFetchInput { + url: String, + #[serde(default)] + format: Option, + #[serde(default)] + timeout: Option, +} + +#[async_trait] +impl Tool for WebFetchTool { + fn name(&self) -> &str { + "webfetch" + } + + fn description(&self) -> &str { + "Fetch a URL." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["url"], + "properties": { + "intent": super::intent_schema_property(), + "url": { + "type": "string", + "description": "URL." + }, + "format": { + "type": "string", + "enum": ["text", "markdown", "html"], + "description": "Output format." + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + let params: WebFetchInput = serde_json::from_value(input)?; + + // Validate URL + if !params.url.starts_with("http://") && !params.url.starts_with("https://") { + return Err(anyhow::anyhow!("URL must start with http:// or https://")); + } + + let timeout = params.timeout.unwrap_or(DEFAULT_TIMEOUT).min(MAX_TIMEOUT); + let format = params.format.as_deref().unwrap_or("markdown"); + + let response = self + .client + .get(¶ms.url) + .header( + reqwest::header::USER_AGENT, + "Mozilla/5.0 (compatible; JCode/1.0)", + ) + .timeout(Duration::from_secs(timeout)) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + return Err(anyhow::anyhow!("HTTP error: {}", status)); + } + + // Check content length + if let Some(len) = response.content_length() + && len as usize > MAX_SIZE + { + return Err(anyhow::anyhow!( + "Response too large: {} bytes (max {} bytes)", + len, + MAX_SIZE + )); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let mut body_bytes = Vec::new(); + let mut truncated = false; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + let remaining = MAX_SIZE.saturating_sub(body_bytes.len()); + if chunk.len() > remaining { + body_bytes.extend_from_slice(&chunk[..remaining]); + truncated = true; + break; + } + body_bytes.extend_from_slice(&chunk); + } + + let mut body = String::from_utf8_lossy(&body_bytes).into_owned(); + if truncated { + body.push_str(&format!( + "...\n\n(truncated, showing first {} bytes)", + MAX_SIZE + )); + } + + // Format output + let output = match format { + "html" => body, + "text" => html_to_text(&body), + "markdown" => { + if content_type.contains("text/html") { + html_to_markdown(&body) + } else { + body + } + } + _ => { + if content_type.contains("text/html") { + html_to_markdown(&body) + } else { + body + } + } + }; + + Ok(ToolOutput::new(format!( + "Fetched {} ({} bytes)\n\n{}", + params.url, + output.len(), + output + ))) + } +} + +mod html_regex { + use regex::Regex; + use std::sync::OnceLock; + + fn compile_regex(pattern: &str, label: &str) -> Option { + match Regex::new(pattern) { + Ok(regex) => Some(regex), + Err(err) => { + crate::logging::warn(&format!( + "webfetch: failed to compile static regex {label}: {}", + err + )); + None + } + } + } + + macro_rules! static_regex { + ($name:ident, $pat:expr_2021) => { + pub fn $name() -> Option<&'static Regex> { + static RE: OnceLock> = OnceLock::new(); + RE.get_or_init(|| compile_regex($pat, stringify!($name))) + .as_ref() + } + }; + } + + static_regex!(script, r"(?is)]*>.*?"); + static_regex!(style, r"(?is)]*>.*?"); + static_regex!(tag, r"<[^>]+>"); + static_regex!(whitespace, r"\n\s*\n\s*\n"); + static_regex!(link, r#"(?i)]*href=["']([^"']+)["'][^>]*>([^<]*)"#); + static_regex!(strong, r"(?i)<(?:strong|b)>([^<]*)"); + static_regex!(em, r"(?i)<(?:em|i)>([^<]*)"); + static_regex!(code, r"(?i)([^<]*)"); + static_regex!(pre_code, r"(?is)]*>]*>(.+?)"); + static_regex!(li, r"(?i)]*>"); + + static H_OPEN: OnceLock> = OnceLock::new(); + static H_CLOSE: OnceLock> = OnceLock::new(); + + pub fn h_open() -> Option<&'static [Regex; 6]> { + H_OPEN + .get_or_init(|| { + let mut compiled = Vec::with_capacity(6); + for i in 0..6 { + let pattern = format!(r"(?i)]*>", i + 1); + compiled.push(compile_regex(&pattern, "heading open")?); + } + compiled.try_into().ok() + }) + .as_ref() + } + + pub fn h_close() -> Option<&'static [Regex; 6]> { + H_CLOSE + .get_or_init(|| { + let mut compiled = Vec::with_capacity(6); + for i in 0..6 { + let pattern = format!(r"(?i)", i + 1); + compiled.push(compile_regex(&pattern, "heading close")?); + } + compiled.try_into().ok() + }) + .as_ref() + } +} + +fn html_to_text(html: &str) -> String { + let mut text = html.to_string(); + + let (Some(script), Some(style), Some(tag), Some(whitespace)) = ( + html_regex::script(), + html_regex::style(), + html_regex::tag(), + html_regex::whitespace(), + ) else { + return html.trim().to_string(); + }; + + text = script.replace_all(&text, "").to_string(); + text = style.replace_all(&text, "").to_string(); + + text = text.replace("
", "\n"); + text = text.replace("
", "\n"); + text = text.replace("
", "\n"); + text = text.replace("

", "\n\n"); + text = text.replace("", "\n"); + text = text.replace("", "\n"); + text = text.replace("", "\n"); + + text = tag.replace_all(&text, "").to_string(); + + text = text.replace(" ", " "); + text = text.replace("<", "<"); + text = text.replace(">", ">"); + text = text.replace("&", "&"); + text = text.replace(""", "\""); + text = text.replace("'", "'"); + + text = whitespace.replace_all(&text, "\n\n").to_string(); + + text.trim().to_string() +} + +fn html_to_markdown(html: &str) -> String { + let mut md = html.to_string(); + + let ( + Some(script), + Some(style), + Some(link), + Some(strong), + Some(em), + Some(code), + Some(pre_code), + Some(li), + Some(tag), + Some(whitespace), + ) = ( + html_regex::script(), + html_regex::style(), + html_regex::link(), + html_regex::strong(), + html_regex::em(), + html_regex::code(), + html_regex::pre_code(), + html_regex::li(), + html_regex::tag(), + html_regex::whitespace(), + ) + else { + return html.trim().to_string(); + }; + + md = script.replace_all(&md, "").to_string(); + md = style.replace_all(&md, "").to_string(); + + if let (Some(h_open), Some(h_close)) = (html_regex::h_open(), html_regex::h_close()) { + for i in 0..6 { + let prefix = "#".repeat(i + 1); + md = h_open[i] + .replace_all(&md, &format!("\n{} ", prefix)) + .to_string(); + md = h_close[i].replace_all(&md, "\n").to_string(); + } + } + + md = link.replace_all(&md, "[$2]($1)").to_string(); + md = strong.replace_all(&md, "**$1**").to_string(); + md = em.replace_all(&md, "*$1*").to_string(); + md = code.replace_all(&md, "`$1`").to_string(); + md = pre_code.replace_all(&md, "\n```\n$1\n```\n").to_string(); + md = li.replace_all(&md, "\n- ").to_string(); + + md = md.replace("
", "\n"); + md = md.replace("
", "\n"); + md = md.replace("
", "\n"); + md = md.replace("

", "\n\n"); + + md = tag.replace_all(&md, "").to_string(); + + md = md.replace(" ", " "); + md = md.replace("<", "<"); + md = md.replace(">", ">"); + md = md.replace("&", "&"); + md = md.replace(""", "\""); + md = md.replace("'", "'"); + + md = whitespace.replace_all(&md, "\n\n").to_string(); + + md.trim().to_string() +} diff --git a/crates/jcode-app-core/src/tool/websearch.rs b/crates/jcode-app-core/src/tool/websearch.rs new file mode 100644 index 0000000..a7d6a20 --- /dev/null +++ b/crates/jcode-app-core/src/tool/websearch.rs @@ -0,0 +1,837 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::config::WebSearchEngine; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +/// Web search using DuckDuckGo or Bing (HTML scraping, with optional Bing API) +pub struct WebSearchTool { + client: reqwest::Client, +} + +impl WebSearchTool { + pub fn new() -> Self { + Self { + client: crate::provider::shared_http_client(), + } + } +} + +#[derive(Deserialize)] +struct WebSearchInput { + query: String, + #[serde(default)] + num_results: Option, + #[serde(default)] + engine: Option, + #[serde(default)] + bing_market: Option, +} + +#[derive(Debug)] +struct SearchResult { + title: String, + url: String, + snippet: String, +} + +#[derive(Clone, Copy)] +struct BingSearchOptions<'a> { + market: &'a str, + configured_api_key: Option<&'a str>, + api_key_env: &'a str, +} + +#[async_trait] +impl Tool for WebSearchTool { + fn name(&self) -> &str { + "websearch" + } + + fn description(&self) -> &str { + "Search the web." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["query"], + "properties": { + "intent": super::intent_schema_property(), + "query": { + "type": "string", + "description": "Search query." + }, + "num_results": { + "type": "integer", + "description": "Max results." + }, + "engine": { + "type": "string", + "enum": ["duckduckgo", "bing", "searxng"], + "description": "Search engine. Defaults to duckduckgo. Bing uses JCODE_BING_API_KEY when set, otherwise Bing HTML scraping. searxng queries a configured SearXNG instance (JCODE_SEARXNG_URL)." + }, + "bing_market": { + "type": "string", + "description": "Optional Bing market, e.g. en-US or zh-CN. Defaults to JCODE_BING_MARKET or en-US." + } + } + }) + } + + async fn execute(&self, input: Value, _ctx: ToolContext) -> Result { + let params: WebSearchInput = serde_json::from_value(input)?; + let num_results = params.num_results.unwrap_or(8).min(20); + + let config = crate::config::config(); + let mut engines = Vec::new(); + engines.push(params.engine.unwrap_or(config.websearch.engine)); + engines.extend(config.websearch.fallback_engines.iter().copied()); + engines.dedup(); + + let market = params + .bing_market + .as_deref() + .unwrap_or(&config.websearch.bing_market); + let mut last_error = None; + let mut results = Vec::new(); + for (index, engine) in engines.into_iter().enumerate() { + let allow_bing_api = index == 0; + match self + .search_with_engine( + engine, + ¶ms.query, + num_results, + BingSearchOptions { + market, + configured_api_key: config.websearch.bing_api_key.as_deref(), + api_key_env: &config.websearch.bing_api_key_env, + }, + allow_bing_api, + ) + .await + { + Ok(found) => { + if !found.is_empty() { + results = found; + break; + } + } + Err(err) => last_error = Some(err), + } + } + + if results.is_empty() + && let Some(err) = last_error + { + return Err(err); + } + + if results.is_empty() { + return Ok(ToolOutput::new(format!( + "No results found for: {}\n\n\ + If results are consistently empty on this machine, the default \ + DuckDuckGo/Bing engines may be blocked here by TLS fingerprinting \ + or IP reputation (common on Linux/servers). Workarounds:\n\ + - Point at a SearXNG instance: set `websearch.searxng_url` (or \ + JCODE_SEARXNG_URL) and use engine \"searxng\".\n\ + - Or provide a Bing Search API key via JCODE_BING_API_KEY.", + params.query + ))); + } + + let mut output = format!("Search results for: {}\n\n", params.query); + + for (i, result) in results.iter().enumerate() { + output.push_str(&format!( + "{}. **{}**\n {}\n {}\n\n", + i + 1, + result.title, + result.url, + result.snippet + )); + } + + Ok(ToolOutput::new(output)) + } +} + +impl WebSearchTool { + async fn search_with_engine( + &self, + engine: WebSearchEngine, + query: &str, + num_results: usize, + bing: BingSearchOptions<'_>, + allow_bing_api: bool, + ) -> Result> { + match engine { + WebSearchEngine::Duckduckgo => self.search_duckduckgo(query, num_results).await, + WebSearchEngine::Bing => { + self.search_bing(query, num_results, bing, allow_bing_api) + .await + } + WebSearchEngine::Searxng => self.search_searxng(query, num_results).await, + } + } + + async fn search_duckduckgo( + &self, + query: &str, + num_results: usize, + ) -> Result> { + // DuckDuckGo's HTML endpoint now serves an anti-bot "anomaly" challenge + // (HTTP 202, no results) for plain GET requests. Submitting the query as + // a POST form, the same way the real HTML page does, still returns the + // standard results markup with a 200. + let response = self + .client + .post("https://html.duckduckgo.com/html/") + .header( + reqwest::header::USER_AGENT, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ) + .header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml") + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .form(&[("q", query), ("kl", "us-en")]) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Search failed with status: {}", + response.status() + )); + } + + let body = response.text().await?; + let results = parse_ddg_results(&body, num_results); + if results.is_empty() + && let Some(reason) = detect_anti_bot_page(&body) + { + return Err(anyhow::anyhow!( + "DuckDuckGo served an anti-bot challenge page ({reason}) instead of \ + results. This is commonly caused by TLS fingerprinting or IP \ + reputation on Linux. Falling back to another engine if configured." + )); + } + + Ok(results) + } + + async fn search_bing( + &self, + query: &str, + num_results: usize, + options: BingSearchOptions<'_>, + allow_api: bool, + ) -> Result> { + if allow_api { + if let Some(api_key) = options + .configured_api_key + .filter(|key| !key.trim().is_empty()) + { + return self + .search_bing_api(query, num_results, options.market, api_key) + .await; + } + if let Ok(api_key) = std::env::var(options.api_key_env) + && !api_key.trim().is_empty() + { + return self + .search_bing_api(query, num_results, options.market, &api_key) + .await; + } + } + + self.search_bing_html(query, num_results, options.market) + .await + } + + async fn search_bing_api( + &self, + query: &str, + num_results: usize, + market: &str, + api_key: &str, + ) -> Result> { + let response = self + .client + .get("https://api.bing.microsoft.com/v7.0/search") + .query(&[ + ("q", query), + ("count", &num_results.to_string()), + ("mkt", market), + ]) + .header("Ocp-Apim-Subscription-Key", api_key) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Bing API search failed with status: {}", + response.status() + )); + } + + Ok(parse_bing_api_results(response.json().await?, num_results)) + } + + async fn search_bing_html( + &self, + query: &str, + num_results: usize, + market: &str, + ) -> Result> { + let url = format!( + "https://www.bing.com/search?q={}&mkt={}", + urlencoding::encode(query), + urlencoding::encode(market) + ); + + let response = self + .client + .get(&url) + .header( + reqwest::header::USER_AGENT, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + ) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Bing search failed with status: {}", + response.status() + )); + } + + let body = response.text().await?; + let results = parse_bing_html_results(&body, num_results); + if results.is_empty() + && let Some(reason) = detect_anti_bot_page(&body) + { + return Err(anyhow::anyhow!( + "Bing served an anti-bot challenge page ({reason}) instead of results." + )); + } + + Ok(results) + } + + /// Query a user-configured SearXNG instance via its JSON API. SearXNG is a + /// self-hostable metasearch engine; because the request goes to an instance + /// the user controls (or a public one they trust), it sidesteps the TLS + /// fingerprinting / IP-reputation blocks that DuckDuckGo and Bing apply to + /// scraped requests on some hosts (see issue #270). + async fn search_searxng(&self, query: &str, num_results: usize) -> Result> { + let config = crate::config::config(); + let base = config + .websearch + .searxng_url + .as_deref() + .filter(|u| !u.trim().is_empty()) + .map(|u| u.to_string()) + .or_else(|| { + std::env::var(&config.websearch.searxng_url_env) + .ok() + .filter(|u| !u.trim().is_empty()) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "SearXNG engine selected but no instance URL configured. Set \ + `websearch.searxng_url` in your config or the {} environment \ + variable to a SearXNG base URL (e.g. https://searx.example.org).", + config.websearch.searxng_url_env + ) + })?; + + let endpoint = format!("{}/search", base.trim_end_matches('/')); + let response = self + .client + .get(&endpoint) + .query(&[("q", query), ("format", "json")]) + .header( + reqwest::header::USER_AGENT, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + ) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "SearXNG search failed with status {} (endpoint: {endpoint}). \ + Ensure the instance has the JSON format enabled in its settings.", + response.status() + )); + } + + let parsed: SearxngResponse = response.json().await.map_err(|err| { + anyhow::anyhow!( + "SearXNG returned a non-JSON response ({err}). The instance may have \ + the JSON format disabled; enable `formats: [html, json]` in its settings." + ) + })?; + + Ok(parse_searxng_results(parsed, num_results)) + } +} + +/// Map a parsed SearXNG JSON response to `SearchResult`s, dropping entries with +/// empty URLs and capping to `num_results`. +fn parse_searxng_results(response: SearxngResponse, num_results: usize) -> Vec { + response + .results + .into_iter() + .filter(|r| !r.url.trim().is_empty()) + .take(num_results) + .map(|r| SearchResult { + title: if r.title.trim().is_empty() { + r.url.clone() + } else { + r.title + }, + url: r.url, + snippet: r.content.unwrap_or_default(), + }) + .collect() +} + +mod search_regex { + use regex::Regex; + use std::sync::OnceLock; + + fn compile_regex(pattern: &str, label: &str) -> Option { + match Regex::new(pattern) { + Ok(regex) => Some(regex), + Err(err) => { + crate::logging::warn(&format!( + "websearch: failed to compile static regex {label}: {}", + err + )); + None + } + } + } + + macro_rules! static_regex { + ($name:ident, $pat:expr_2021) => { + pub fn $name() -> Option<&'static Regex> { + static RE: OnceLock> = OnceLock::new(); + RE.get_or_init(|| compile_regex($pat, stringify!($name))) + .as_ref() + } + }; + } + + static_regex!( + result_link, + r#"(?s)]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)"# + ); + static_regex!( + result_snippet, + r#"(?s)]*class="result__snippet"[^>]*>(.*?)"# + ); + static_regex!(tag, r"<[^>]+>"); + static_regex!( + bing_result_block, + r#"(?s)]*class="[^"]*\bb_algo\b[^"]*"[^>]*>(.*?)"# + ); + static_regex!( + bing_link, + r#"(?s)]*>\s*]*href="([^"]+)"[^>]*>(.*?)\s*"# + ); + static_regex!( + bing_caption, + r#"(?s)]*class="[^"]*\bb_caption\b[^"]*"[^>]*>.*?]*>(.*?)

"# + ); +} + +#[derive(Deserialize)] +struct SearxngResponse { + #[serde(default)] + results: Vec, +} + +#[derive(Deserialize)] +struct SearxngResult { + #[serde(default)] + title: String, + #[serde(default)] + url: String, + #[serde(default)] + content: Option, +} + +#[derive(Deserialize)] +struct BingApiResponse { + #[serde(rename = "webPages")] + web_pages: Option, +} + +#[derive(Deserialize)] +struct BingWebPages { + value: Vec, +} + +#[derive(Deserialize)] +struct BingWebPage { + name: String, + url: String, + #[serde(default)] + snippet: String, +} + +fn parse_bing_api_results(response: BingApiResponse, max_results: usize) -> Vec { + response + .web_pages + .map(|pages| { + pages + .value + .into_iter() + .take(max_results) + .map(|page| SearchResult { + title: page.name, + url: page.url, + snippet: page.snippet, + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_bing_html_results(html: &str, max_results: usize) -> Vec { + let mut results = Vec::new(); + let (Some(block_re), Some(link_re), Some(caption_re), Some(tag_re)) = ( + search_regex::bing_result_block(), + search_regex::bing_link(), + search_regex::bing_caption(), + search_regex::tag(), + ) else { + return results; + }; + + for block in block_re.captures_iter(html) { + if results.len() >= max_results { + break; + } + let Some(link) = link_re.captures(&block[1]) else { + continue; + }; + let url = html_decode(&link[1]); + if !url.starts_with("http") || url.contains("bing.com") { + continue; + } + let title = html_decode(&tag_re.replace_all(&link[2], "")); + let snippet = caption_re + .captures(&block[1]) + .map(|cap| html_decode(&tag_re.replace_all(&cap[1], ""))) + .unwrap_or_default(); + results.push(SearchResult { + title, + url, + snippet, + }); + } + + results +} + +fn parse_ddg_results(html: &str, max_results: usize) -> Vec { + let mut results = Vec::new(); + + let (Some(result_link), Some(result_snippet), Some(tag)) = ( + search_regex::result_link(), + search_regex::result_snippet(), + search_regex::tag(), + ) else { + return results; + }; + + let links: Vec<_> = result_link.captures_iter(html).collect(); + let snippets: Vec<_> = result_snippet.captures_iter(html).collect(); + + for (i, link_cap) in links.iter().enumerate() { + if results.len() >= max_results { + break; + } + + let url = decode_ddg_url(&link_cap[1]); + let title = html_decode(&tag.replace_all(&link_cap[2], "")); + + if !url.starts_with("http") || url.contains("duckduckgo.com") { + continue; + } + + let snippet = if i < snippets.len() { + let raw = &snippets[i][1]; + html_decode(&tag.replace_all(raw, "")) + } else { + String::new() + }; + + results.push(SearchResult { + title, + url, + snippet, + }); + } + + results +} + +/// Detect whether an HTML body is an anti-bot/captcha challenge rather than a +/// real results page. DuckDuckGo (and similar) serve these with HTTP 200, so a +/// successful status plus zero parsed results is ambiguous without this check. +/// +/// Returns a short human-readable reason when a challenge page is detected. +fn detect_anti_bot_page(html: &str) -> Option<&'static str> { + let lowered = html.to_ascii_lowercase(); + const MARKERS: &[(&str, &str)] = &[ + ("anomaly-modal", "anomaly challenge"), + ("anomaly.js", "anomaly challenge"), + ("dpn=1", "anomaly challenge"), + ("captcha", "captcha"), + ("g-recaptcha", "recaptcha"), + ("are you a robot", "bot check"), + ("unusual traffic", "bot check"), + ("verify you are human", "human verification"), + ("challenge-platform", "cloudflare challenge"), + ("cf-challenge", "cloudflare challenge"), + ]; + for (needle, reason) in MARKERS { + if lowered.contains(needle) { + return Some(reason); + } + } + None +} + +fn decode_ddg_url(url: &str) -> String { + // DDG wraps URLs like //duckduckgo.com/l/?uddg=ACTUAL_URL&... + if let Some(uddg_start) = url.find("uddg=") { + let start = uddg_start + 5; + let end = url[start..] + .find('&') + .map(|i| start + i) + .unwrap_or(url.len()); + let encoded = &url[start..end]; + urlencoding::decode(encoded) + .map(|s| s.to_string()) + .unwrap_or_else(|_| encoded.to_string()) + } else { + url.to_string() + } +} + +fn html_decode(s: &str) -> String { + s.replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("'", "'") + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bing_html_results() { + let html = r#" +
  • +

    Rust & Cargo

    +

    A systems language.

    +
  • +
  • ad

  • +
  • +

    Jcode

    +

    Agentic coding.

    +
  • + "#; + + let results = parse_bing_html_results(html, 10); + assert_eq!(results.len(), 2); + assert_eq!(results[0].title, "Rust & Cargo"); + assert_eq!(results[0].url, "https://example.com/rust"); + assert_eq!(results[0].snippet, "A systems language."); + assert_eq!(results[1].title, "Jcode"); + } + + #[test] + fn parses_bing_api_results() { + let response: BingApiResponse = serde_json::from_value(json!({ + "webPages": { + "value": [ + {"name": "One", "url": "https://one.test", "snippet": "first"}, + {"name": "Two", "url": "https://two.test", "snippet": "second"} + ] + } + })) + .unwrap(); + + let results = parse_bing_api_results(response, 1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "One"); + assert_eq!(results[0].url, "https://one.test"); + } + + #[test] + fn parses_ddg_html_results() { + // Mirrors the markup html.duckduckgo.com returns for the POST form, + // where titles and snippets contain inline highlight tags. + let html = r#" + + + "#; + + let results = parse_ddg_results(html, 10); + assert_eq!(results.len(), 2); + assert_eq!(results[0].title, "Rust Language"); + assert_eq!(results[0].url, "https://rust-lang.org/"); + assert_eq!(results[0].snippet, "A systems programming language."); + assert_eq!(results[1].url, "https://en.wikipedia.org/wiki/Rust"); + assert_eq!(results[1].snippet, "Encyclopedia entry."); + } + + #[test] + fn websearch_engine_accepts_aliases() { + assert_eq!( + WebSearchEngine::parse("ddg"), + Some(WebSearchEngine::Duckduckgo) + ); + assert_eq!(WebSearchEngine::parse("bing"), Some(WebSearchEngine::Bing)); + assert_eq!(WebSearchEngine::parse("google"), None); + } + + #[test] + fn detects_ddg_anomaly_challenge_page() { + // Shape of the anti-bot challenge DDG serves (HTTP 200) instead of + // results when a request is flagged (e.g. TLS fingerprint on Linux). + let html = r#" + +
    Unfortunately, bots use DuckDuckGo too.
    + "#; + assert_eq!(detect_anti_bot_page(html), Some("anomaly challenge")); + // And it should parse to zero real results. + assert!(parse_ddg_results(html, 10).is_empty()); + } + + #[test] + fn detects_generic_captcha_page() { + let html = r#"
    + Please verify you are human."#; + assert!(detect_anti_bot_page(html).is_some()); + } + + #[test] + fn real_results_are_not_flagged_as_anti_bot() { + let html = r#" + + "#; + assert_eq!(detect_anti_bot_page(html), None); + assert_eq!(parse_ddg_results(html, 10).len(), 1); + } + + // Captured from a live DuckDuckGo request that was flagged on Linux (GH #270): + // the HTML endpoint returns HTTP 202 with an "anomaly" challenge page and no + // results. These fixtures pin the real-world shapes so the fix stays honest. + #[test] + fn real_captured_ddg_anomaly_fixture_is_detected() { + let html = include_str!("testdata/ddg_anomaly.html"); + // The bug: this page parses to zero real results... + assert!( + parse_ddg_results(html, 10).is_empty(), + "anomaly page should yield no results" + ); + // ...but the fix now recognizes it as a challenge instead of a silent + // "no results found". + assert_eq!(detect_anti_bot_page(html), Some("anomaly challenge")); + } + + #[test] + fn real_captured_ddg_results_fixture_parses() { + let html = include_str!("testdata/ddg_results.html"); + assert_eq!(detect_anti_bot_page(html), None); + assert!( + !parse_ddg_results(html, 10).is_empty(), + "real results page should yield results" + ); + } + + #[test] + fn parses_searxng_json_results() { + // Shape of a real SearXNG /search?format=json response (#270). + let body = serde_json::json!({ + "query": "rust", + "results": [ + { + "url": "https://www.rust-lang.org/", + "title": "Rust Programming Language", + "content": "A language empowering everyone." + }, + { + "url": "https://doc.rust-lang.org/book/", + "title": "The Rust Book", + "content": "Learn Rust." + }, + // Entry with empty url is dropped; missing content tolerated. + { "url": "", "title": "junk" }, + { "url": "https://crates.io", "title": "" } + ] + }); + let parsed: SearxngResponse = serde_json::from_value(body).unwrap(); + let results = parse_searxng_results(parsed, 10); + assert_eq!(results.len(), 3, "empty-url entry should be dropped"); + assert_eq!(results[0].url, "https://www.rust-lang.org/"); + assert_eq!(results[0].title, "Rust Programming Language"); + assert_eq!(results[0].snippet, "A language empowering everyone."); + // Missing title falls back to the URL. + assert_eq!(results[2].title, "https://crates.io"); + assert_eq!(results[2].snippet, ""); + } + + #[test] + fn searxng_results_respect_limit() { + let body = serde_json::json!({ + "results": (0..10) + .map(|i| serde_json::json!({"url": format!("https://x/{i}"), "title": "t"})) + .collect::>() + }); + let parsed: SearxngResponse = serde_json::from_value(body).unwrap(); + assert_eq!(parse_searxng_results(parsed, 3).len(), 3); + } + + #[test] + fn websearch_engine_parses_searxng_aliases() { + assert_eq!( + WebSearchEngine::parse("searxng"), + Some(WebSearchEngine::Searxng) + ); + assert_eq!( + WebSearchEngine::parse("searx"), + Some(WebSearchEngine::Searxng) + ); + assert_eq!(WebSearchEngine::Searxng.as_str(), "searxng"); + } +} diff --git a/crates/jcode-app-core/src/tool/write.rs b/crates/jcode-app-core/src/tool/write.rs new file mode 100644 index 0000000..223b098 --- /dev/null +++ b/crates/jcode-app-core/src/tool/write.rs @@ -0,0 +1,286 @@ +use super::{Tool, ToolContext, ToolOutput}; +use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; +use similar::{ChangeTag, TextDiff}; +use std::path::Path; + +const FILE_TOUCH_PREVIEW_MAX_LINES: usize = 6; +const FILE_TOUCH_PREVIEW_MAX_BYTES: usize = 240; + +pub struct WriteTool; + +impl WriteTool { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct WriteInput { + #[serde(default)] + intent: Option, + file_path: String, + content: String, +} + +#[async_trait] +impl Tool for WriteTool { + fn name(&self) -> &str { + "write" + } + + fn description(&self) -> &str { + "Write a file." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["file_path", "content"], + "properties": { + "intent": super::intent_schema_property(), + "file_path": { + "type": "string", + "description": "File path." + }, + "content": { + "type": "string", + "description": "File content." + } + } + }) + } + + async fn execute(&self, input: Value, ctx: ToolContext) -> Result { + let params: WriteInput = serde_json::from_value(input)?; + + let path = ctx.resolve_path(Path::new(¶ms.file_path)); + + // Create parent directories if needed + if let Some(parent) = path.parent() + && !parent.exists() + { + tokio::fs::create_dir_all(parent).await?; + } + + // Check if file existed before and read old content for diff + let existed = path.exists(); + let old_content = if existed { + tokio::fs::read_to_string(&path).await.ok() + } else { + None + }; + + // Write the file + tokio::fs::write(&path, ¶ms.content).await?; + + let _new_len = params.content.len(); + let line_count = params.content.lines().count(); + let diff = if let Some(old) = old_content.as_deref() { + generate_diff_summary(old, ¶ms.content) + } else { + generate_diff_summary("", ¶ms.content) + }; + let detail = build_file_touch_preview(&diff); + + // Publish file touch event for swarm coordination + Bus::global().publish(BusEvent::FileTouch(FileTouch { + session_id: ctx.session_id.clone(), + path: path.to_path_buf(), + op: FileOp::Write, + intent: params + .intent + .clone() + .filter(|value| !value.trim().is_empty()), + summary: Some(if existed { + format!("overwrote file ({} lines)", line_count) + } else { + format!("created new file ({} lines)", line_count) + }), + detail, + })); + + if existed { + Ok(ToolOutput::new(format!( + "Updated {} ({} lines){}\n{}", + params.file_path, + line_count, + if diff.is_empty() { "" } else { ":" }, + diff + )) + .with_title(params.file_path.clone())) + } else { + // For new files, show all lines as additions + let diff = generate_diff_summary("", ¶ms.content); + Ok(ToolOutput::new(format!( + "Created {} ({} lines):\n{}", + params.file_path, line_count, diff + )) + .with_title(params.file_path.clone())) + } + } +} + +/// Generate a compact diff: "42- old" / "42+ new" (max 20 lines) +fn generate_diff_summary(old: &str, new: &str) -> String { + let diff = TextDiff::from_lines(old, new); + let mut output = String::new(); + let mut lines_shown = 0; + const MAX_LINES: usize = 20; + + let mut old_line = 1usize; + let mut new_line = 1usize; + + for change in diff.iter_all_changes() { + match change.tag() { + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + continue; + } + ChangeTag::Delete => { + let content = change.value().trim(); + old_line += 1; + if content.is_empty() { + continue; + } + if lines_shown >= MAX_LINES { + output.push_str("...\n"); + break; + } + output.push_str(&format!("{}- {}\n", old_line - 1, content)); + lines_shown += 1; + } + ChangeTag::Insert => { + let content = change.value().trim(); + new_line += 1; + if content.is_empty() { + continue; + } + if lines_shown >= MAX_LINES { + output.push_str("...\n"); + break; + } + output.push_str(&format!("{}+ {}\n", new_line - 1, content)); + lines_shown += 1; + } + } + } + + output.trim_end().to_string() +} + +fn build_file_touch_preview(diff: &str) -> Option { + let trimmed = diff.trim(); + if trimmed.is_empty() { + return None; + } + + let mut lines = trimmed.lines(); + let mut preview = lines + .by_ref() + .take(FILE_TOUCH_PREVIEW_MAX_LINES) + .collect::>() + .join("\n"); + let mut truncated = lines.next().is_some(); + + if preview.len() > FILE_TOUCH_PREVIEW_MAX_BYTES { + preview = crate::util::truncate_str(&preview, FILE_TOUCH_PREVIEW_MAX_BYTES) + .trim_end() + .to_string(); + truncated = true; + } + + if truncated { + preview.push_str("\n…"); + } + + Some(preview) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_diff_summary_single_change() { + let old = "hello world"; + let new = "hello rust"; + let diff = generate_diff_summary(old, new); + + // Compact format: "1- content" / "1+ content" + assert!(diff.contains("1- hello world"), "Should show deleted line"); + assert!(diff.contains("1+ hello rust"), "Should show added line"); + } + + #[test] + fn test_generate_diff_summary_multi_line() { + let old = "line one\nline two\nline three"; + let new = "line one\nchanged two\nline three"; + let diff = generate_diff_summary(old, new); + + assert!(diff.contains("2- line two"), "Should show deleted line"); + assert!(diff.contains("2+ changed two"), "Should show added line"); + // Equal lines should not appear + assert!( + !diff.contains("line one"), + "Should not show unchanged lines" + ); + } + + #[test] + fn test_generate_diff_summary_new_file() { + let old = ""; + let new = "line one\nline two\nline three"; + let diff = generate_diff_summary(old, new); + + assert!(diff.contains("1+ line one"), "Should show line 1 added"); + assert!(diff.contains("2+ line two"), "Should show line 2 added"); + assert!(diff.contains("3+ line three"), "Should show line 3 added"); + } + + #[test] + fn test_generate_diff_summary_truncation() { + // Create old and new with more than 20 changed lines + let old = (1..=25) + .map(|i| format!("old line {}", i)) + .collect::>() + .join("\n"); + let new = (1..=25) + .map(|i| format!("new line {}", i)) + .collect::>() + .join("\n"); + let diff = generate_diff_summary(&old, &new); + + assert!(diff.contains("..."), "Should truncate after 20 lines"); + } + + #[test] + fn test_generate_diff_summary_line_number_format() { + let old = "old"; + let new = "new"; + let diff = generate_diff_summary(old, new); + + // Compact format: no padding + assert!( + diff.contains("1- old"), + "Should have line number directly before minus" + ); + assert!( + diff.contains("1+ new"), + "Should have line number directly before plus" + ); + } + + #[test] + fn test_generate_diff_summary_empty_result() { + let old = "same content"; + let new = "same content"; + let diff = generate_diff_summary(old, new); + + assert!(diff.is_empty(), "No changes should produce empty diff"); + } +} diff --git a/crates/jcode-app-core/src/turn_cancel_registry.rs b/crates/jcode-app-core/src/turn_cancel_registry.rs new file mode 100644 index 0000000..8090fff --- /dev/null +++ b/crates/jcode-app-core/src/turn_cancel_registry.rs @@ -0,0 +1,159 @@ +//! Process-global registry of cancel signals for actively running turns. +//! +//! Why this exists (issue #428): a cancel (Esc) is delivered through whatever +//! `SessionControlHandle` the receiving connection happens to hold. That +//! handle's stop signal can be a *different* [`InterruptSignal`] instance from +//! the `graceful_shutdown` signal of the agent actually streaming the turn: +//! +//! - re-attach after a reload or disconnect where cleanup removed the +//! `shutdown_signals` registration, +//! - server-initiated turns (`spawn_tracked_live_turn`, headless recovery, +//! swarm wake delivery) running on an agent object the connection never +//! locked, +//! - headless spawns that never registered a shutdown signal, +//! - the lock-free `cancel_only` fallback built while the agent mutex is busy. +//! +//! Firing a stale instance silently does nothing: the client shows +//! "Interrupting..." while the model keeps generating for minutes and every +//! extra Esc only stacks another `Interrupted` event ("Interrupted [x66]"). +//! +//! Every turn now registers its own `graceful_shutdown` signal here for the +//! duration of the turn, and `SessionControlHandle::request_cancel` fires +//! every registered signal for the session in addition to its own handle, so +//! cancellation reaches the in-flight provider stream no matter which handle +//! instance received the request. + +use jcode_agent_runtime::InterruptSignal; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex}; + +static NEXT_TOKEN: AtomicU64 = AtomicU64::new(1); +/// All interrupt signals registered for one session's in-flight turns. +type SessionTurnSignals = Vec<(u64, InterruptSignal)>; +static ACTIVE_TURNS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// RAII registration for one running turn. Dropping the guard removes the +/// signal from the registry, so signals never outlive the turn that owns them. +/// +/// Dropping also resets the signal: a cancel fired through the registry sets +/// the turn's own `graceful_shutdown` flag, and nothing else ever clears that +/// instance (the server's deferred epoch-guarded reset only touches the +/// control handle's signal). Without this, one interrupt would leave the flag +/// permanently set and instantly abort every subsequent turn on the agent. +pub struct ActiveTurnGuard { + session_id: String, + token: u64, + signal: InterruptSignal, +} + +/// Register `signal` as the cancel signal for a turn running in `session_id`. +/// Call at turn start; keep the guard alive for the duration of the turn. +pub fn register_active_turn(session_id: &str, signal: InterruptSignal) -> ActiveTurnGuard { + let token = NEXT_TOKEN.fetch_add(1, Ordering::Relaxed); + let active = match ACTIVE_TURNS.lock() { + Ok(mut map) => { + let entry = map.entry(session_id.to_string()).or_default(); + entry.push((token, signal.clone())); + entry.len() + } + Err(_) => 0, + }; + crate::logging::info(&format!( + "TURN_CANCEL_REGISTERED session={} active_turns={}", + session_id, active + )); + ActiveTurnGuard { + session_id: session_id.to_string(), + token, + signal, + } +} + +/// All cancel signals currently registered for turns in `session_id`. +pub fn active_turn_signals(session_id: &str) -> Vec { + ACTIVE_TURNS + .lock() + .ok() + .and_then(|map| { + map.get(session_id) + .map(|entries| entries.iter().map(|(_, signal)| signal.clone()).collect()) + }) + .unwrap_or_default() +} + +impl Drop for ActiveTurnGuard { + fn drop(&mut self) { + let remaining = match ACTIVE_TURNS.lock() { + Ok(mut map) => { + let remaining = if let Some(entries) = map.get_mut(&self.session_id) { + entries.retain(|(token, _)| *token != self.token); + entries.len() + } else { + 0 + }; + if remaining == 0 { + map.remove(&self.session_id); + } + remaining + } + Err(_) => 0, + }; + // A turn's cancel flag must never outlive the turn: if a cancel fired + // this signal (possibly through a stale control handle that nothing + // else ever resets), leaving it set would instantly abort the next + // turn on this agent. + self.signal.reset(); + crate::logging::info(&format!( + "TURN_CANCEL_UNREGISTERED session={} active_turns={}", + self.session_id, remaining + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_and_drop_tracks_active_signals() { + let session_id = "turn_cancel_registry_register_drop"; + assert!(active_turn_signals(session_id).is_empty()); + + let signal = InterruptSignal::new(); + let guard = register_active_turn(session_id, signal.clone()); + let registered = active_turn_signals(session_id); + assert_eq!(registered.len(), 1); + assert!(registered[0].same_instance(&signal)); + + drop(guard); + assert!( + active_turn_signals(session_id).is_empty(), + "dropping the guard must remove the registration" + ); + } + + #[test] + fn multiple_turns_for_one_session_are_all_listed() { + let session_id = "turn_cancel_registry_multiple"; + let first = InterruptSignal::new(); + let second = InterruptSignal::new(); + let _guard_first = register_active_turn(session_id, first.clone()); + let guard_second = register_active_turn(session_id, second.clone()); + + let registered = active_turn_signals(session_id); + assert_eq!(registered.len(), 2); + assert!(registered.iter().any(|signal| signal.same_instance(&first))); + assert!( + registered + .iter() + .any(|signal| signal.same_instance(&second)) + ); + + drop(guard_second); + let registered = active_turn_signals(session_id); + assert_eq!(registered.len(), 1); + assert!(registered[0].same_instance(&first)); + } +} diff --git a/crates/jcode-app-core/src/update.rs b/crates/jcode-app-core/src/update.rs new file mode 100644 index 0000000..87ab852 --- /dev/null +++ b/crates/jcode-app-core/src/update.rs @@ -0,0 +1,1715 @@ +use crate::build; +use crate::storage; +use anyhow::{Context, Result}; +use jcode_update_core::{ + BACKGROUND_UPDATE_THRESHOLD, estimate_release_update_duration, estimate_source_update_duration, + format_duration_estimate, get_asset_name, summarize_git_pull_failure, update_estimate, + verify_asset_checksum_text, version_is_newer, +}; +pub use jcode_update_core::{ + DownloadProgress, GIT_PULL_DIVERGED_SUMMARY, GitHubAsset, GitHubRelease, PreparedUpdate, + UpdateCheckResult, UpdateEstimate, format_download_progress_bar, summary_is_divergence, +}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +const GITHUB_REPO: &str = "1jehuang/jcode"; +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60); // minimum gap between checks +const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(5); +/// Time allowed for the initial TCP/TLS connect to the download host. +const DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +/// Total wall-clock budget for a single download *attempt*. +/// +/// This is intentionally a per-attempt budget, not a budget for the whole +/// asset. The old code used a single 120s total timeout for the entire +/// transfer, so on a slow link a multi-megabyte asset could never finish: it +/// was killed mid-stream, the partial bytes were discarded, and every relaunch +/// restarted from zero. We now cap each attempt and resume via HTTP Range, so +/// a slow-but-progressing download completes across several attempts while a +/// genuinely hung connection still gets bounded. +const DOWNLOAD_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(120); +/// How many *consecutive* stalled attempts (attempts that made no forward +/// progress) to tolerate before giving up. Any attempt that downloads new +/// bytes resets this counter, so a slow-but-progressing download keeps +/// resuming via HTTP Range for as long as it needs; only a genuinely stuck +/// connection eventually fails. +const DOWNLOAD_MAX_ATTEMPTS: usize = 10; +const DOWNLOAD_PROGRESS_UPDATE_STEP: u64 = 1_048_576; + +pub fn print_centered(msg: &str) { + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + for line in msg.lines() { + let visible_len = unicode_display_width(line); + if visible_len >= width { + println!("{}", line); + } else { + let pad = (width - visible_len) / 2; + println!("{:>pad$}{}", "", line, pad = pad); + } + } +} + +fn unicode_display_width(s: &str) -> usize { + use unicode_width::UnicodeWidthChar; + let mut w = 0; + let mut in_escape = false; + for c in s.chars() { + if in_escape { + if c == 'm' { + in_escape = false; + } + continue; + } + if c == '\x1b' { + in_escape = true; + continue; + } + w += UnicodeWidthChar::width(c).unwrap_or(0); + } + w +} + +pub fn is_release_build() -> bool { + jcode_build_meta::is_release_build() +} + +fn current_update_semver() -> &'static str { + jcode_build_meta::UPDATE_SEMVER +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateMetadata { + pub last_check: SystemTime, + pub installed_version: Option, + pub installed_from: Option, + #[serde(default)] + pub last_release_update_secs: Option, + #[serde(default)] + pub last_source_update_secs: Option, +} + +impl Default for UpdateMetadata { + fn default() -> Self { + Self { + last_check: SystemTime::UNIX_EPOCH, + installed_version: None, + installed_from: None, + last_release_update_secs: None, + last_source_update_secs: None, + } + } +} + +impl UpdateMetadata { + pub fn load() -> Result { + let path = metadata_path()?; + if path.exists() { + let content = fs::read_to_string(&path)?; + Ok(serde_json::from_str(&content)?) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self) -> Result<()> { + let path = metadata_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(self)?; + fs::write(&path, content)?; + Ok(()) + } + + pub fn should_check(&self) -> bool { + match self.last_check.elapsed() { + Ok(elapsed) => elapsed > UPDATE_CHECK_INTERVAL, + Err(_) => true, + } + } +} + +fn metadata_path() -> Result { + Ok(storage::jcode_dir()?.join("update_metadata.json")) +} + +fn source_build_root() -> Result { + Ok(storage::jcode_dir()?.join("builds").join("source")) +} + +fn source_build_repo_dir() -> Result { + Ok(source_build_root()?.join("jcode")) +} + +fn record_release_update_duration(duration: Duration) { + if let Ok(mut metadata) = UpdateMetadata::load() { + metadata.last_release_update_secs = Some(duration.as_secs_f64()); + let _ = metadata.save(); + } +} + +fn record_source_update_duration(duration: Duration) { + if let Ok(mut metadata) = UpdateMetadata::load() { + metadata.last_source_update_secs = Some(duration.as_secs_f64()); + let _ = metadata.save(); + } +} + +pub fn should_auto_update() -> bool { + if std::env::var("JCODE_NO_AUTO_UPDATE").is_ok() { + return false; + } + + if !is_release_build() { + return false; + } + + if let Ok(exe) = std::env::current_exe() + && is_inside_git_repo(&exe) + { + return false; + } + + true +} + +pub fn run_git_pull_ff_only(repo_dir: &Path, quiet: bool) -> Result<()> { + let mut cmd = std::process::Command::new("git"); + cmd.arg("pull").arg("--ff-only"); + if quiet { + cmd.arg("-q"); + } + let output = cmd + .current_dir(repo_dir) + .output() + .context("Failed to run git pull")?; + + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("{}", summarize_git_pull_failure(&output.stderr)); + } +} + +fn is_inside_git_repo(path: &std::path::Path) -> bool { + let mut dir = if path.is_dir() { + Some(path) + } else { + path.parent() + }; + + while let Some(d) = dir { + if d.join(".git").exists() { + return true; + } + dir = d.parent(); + } + false +} + +pub fn fetch_latest_release_blocking() -> Result { + let url = format!( + "https://api.github.com/repos/{}/releases/latest", + GITHUB_REPO + ); + + let client = reqwest::blocking::Client::builder() + .timeout(UPDATE_CHECK_TIMEOUT) + .user_agent("jcode-updater") + .build()?; + + let response = client + .get(&url) + .send() + .context("Failed to fetch release info")?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + anyhow::bail!("No releases found"); + } + + if !response.status().is_success() { + anyhow::bail!("GitHub API error: {}", response.status()); + } + + let release: GitHubRelease = response.json().context("Failed to parse release info")?; + + Ok(release) +} + +fn latest_main_sha_blocking() -> Result { + let url = format!("https://api.github.com/repos/{}/commits/main", GITHUB_REPO); + let client = reqwest::blocking::Client::builder() + .timeout(UPDATE_CHECK_TIMEOUT) + .user_agent("jcode-updater") + .build()?; + + let response = client + .get(&url) + .send() + .context("Failed to check main branch")?; + if !response.status().is_success() { + anyhow::bail!("GitHub API error checking main: {}", response.status()); + } + + let commit: serde_json::Value = response.json().context("Failed to parse commit info")?; + Ok(commit["sha"] + .as_str() + .unwrap_or("") + .get(..7) + .unwrap_or("") + .to_string()) +} + +fn platform_asset(release: &GitHubRelease) -> Result<&GitHubAsset> { + let asset_name = get_asset_name(); + release + .assets + .iter() + .find(|a| a.name.starts_with(asset_name)) + .ok_or_else(|| anyhow::anyhow!("No asset found for platform: {}", asset_name)) +} + +fn checksum_asset(release: &GitHubRelease) -> Option<&GitHubAsset> { + release.assets.iter().find(|a| a.name == "SHA256SUMS") +} + +fn verify_asset_checksum_if_available( + client: &reqwest::blocking::Client, + release: &GitHubRelease, + asset: &GitHubAsset, + bytes: &[u8], +) -> Result<()> { + let Some(checksum_asset) = checksum_asset(release) else { + crate::logging::info(&format!( + "Release {} does not include SHA256SUMS; skipping checksum verification", + release.tag_name + )); + return Ok(()); + }; + + let response = client + .get(&checksum_asset.browser_download_url) + .send() + .context("Failed to download SHA256SUMS")?; + if !response.status().is_success() { + anyhow::bail!("SHA256SUMS download failed: {}", response.status()); + } + let contents = response.text().context("Failed to read SHA256SUMS")?; + verify_asset_checksum_text(&contents, &asset.name, bytes)?; + crate::logging::info(&format!("Verified SHA256 checksum for {}", asset.name)); + Ok(()) +} + +fn synthetic_main_release(latest_sha: &str) -> GitHubRelease { + GitHubRelease { + tag_name: format!("main-{}", latest_sha), + _name: Some(format!("Built from main ({})", latest_sha)), + _html_url: format!("https://github.com/{}/commit/{}", GITHUB_REPO, latest_sha), + _published_at: None, + assets: vec![], + _target_commitish: latest_sha.to_string(), + } +} + +fn install_main_source_update_blocking(latest_sha: &str) -> Result { + let path = build_from_source()?; + crate::logging::info(&format!( + "Main channel: built successfully at {}", + path.display() + )); + + let mut metadata = UpdateMetadata::load().unwrap_or_default(); + let channel_version = format!("main-{}", latest_sha); + build::install_binary_at_version(&path, &channel_version) + .context("Failed to install built binary")?; + // Carry the long-lived daemon's reload target forward too, but only when it + // was tracking stable. A deliberately-promoted self-dev shared-server build + // is left untouched so the update never silently wipes it out. + if let Err(error) = build::advance_shared_server_if_tracking_stable(&channel_version) { + crate::logging::warn(&format!( + "update: failed to advance shared-server channel to {}: {}", + channel_version, error + )); + } + build::update_stable_symlink(&channel_version)?; + build::update_current_symlink(&channel_version)?; + build::update_launcher_symlink_to_current()?; + + metadata.installed_version = Some(channel_version.clone()); + metadata.installed_from = Some("source".to_string()); + metadata.last_check = SystemTime::now(); + metadata.save()?; + + Ok(path) +} + +fn prepare_stable_update_blocking() -> Result { + let current_version = jcode_build_meta::VERSION; + let current_update_version = current_update_semver(); + let release = fetch_latest_release_blocking()?; + let release_version = release.tag_name.trim_start_matches('v'); + + if release_version == current_update_version.trim_start_matches('v') + || !version_is_newer( + release_version, + current_update_version.trim_start_matches('v'), + ) + { + return Ok(PreparedUpdate::None { + current: current_version.to_string(), + }); + } + + let Ok(asset) = platform_asset(&release) else { + return Ok(PreparedUpdate::None { + current: current_version.to_string(), + }); + }; + let metadata = UpdateMetadata::load().unwrap_or_default(); + let duration = estimate_release_update_duration(asset._size, metadata.last_release_update_secs); + let size_mb = asset._size as f64 / (1024.0 * 1024.0); + let summary = format!( + "Prebuilt update {} → {} (~{:.0} MB, {}). {}", + current_version, + release.tag_name, + size_mb, + format_duration_estimate(duration), + if duration >= BACKGROUND_UPDATE_THRESHOLD { + "Running in the background and will reload when it is ready." + } else { + "This should be quick." + } + ); + + Ok(PreparedUpdate::Stable { + release, + estimate: update_estimate(summary, duration), + }) +} + +fn prepare_main_update_blocking() -> Result { + let current_hash = jcode_build_meta::GIT_HASH; + if current_hash.is_empty() || current_hash == "unknown" { + crate::logging::info("Main channel: no git hash in binary, skipping update check"); + return Ok(PreparedUpdate::None { + current: jcode_build_meta::VERSION.to_string(), + }); + } + + let latest_sha = latest_main_sha_blocking()?; + if latest_sha.is_empty() { + return Ok(PreparedUpdate::None { + current: current_hash.to_string(), + }); + } + + let current_short = if current_hash.len() >= 7 { + ¤t_hash[..7] + } else { + current_hash + }; + + if current_short == latest_sha { + crate::logging::info(&format!("Main channel: up to date ({})", current_short)); + return Ok(PreparedUpdate::None { + current: format!("main-{}", current_short), + }); + } + + crate::logging::info(&format!( + "Main channel: new commit {} -> {}", + current_short, latest_sha + )); + + if has_cargo() { + let repo_dir = source_build_repo_dir()?; + let repo_exists = repo_dir.join(".git").exists(); + let has_previous_build = build::release_binary_path(&repo_dir).exists(); + let metadata = UpdateMetadata::load().unwrap_or_default(); + let duration = estimate_source_update_duration( + repo_exists, + has_previous_build, + metadata.last_source_update_secs, + ); + let action = if repo_exists { + if has_previous_build { + "git pull + cargo build with a warm build cache" + } else { + "git pull + cargo build" + } + } else { + "initial clone + cargo build" + }; + let summary = format!( + "Source update {} → main-{} requires {} ({}). Running in the background and will reload when it is ready.", + current_short, + latest_sha, + action, + format_duration_estimate(duration) + ); + return Ok(PreparedUpdate::MainSource { + latest_sha, + estimate: update_estimate(summary, duration), + }); + } + + crate::logging::info("Main channel: cargo not found, falling back to latest release"); + prepare_stable_update_blocking() +} + +pub fn prepare_update_blocking() -> Result { + let channel = crate::config::config().features.update_channel; + match channel { + crate::config::UpdateChannel::Main => prepare_main_update_blocking(), + crate::config::UpdateChannel::Stable => prepare_stable_update_blocking(), + } +} + +pub fn spawn_background_session_update(session_id: String) { + std::thread::spawn(move || { + use crate::bus::{Bus, BusEvent, ClientMaintenanceAction, SessionUpdateStatus}; + + let action = ClientMaintenanceAction::Update; + + let publish = |status| Bus::global().publish(BusEvent::SessionUpdateStatus(status)); + + match prepare_update_blocking() { + Ok(PreparedUpdate::None { current }) => { + publish(SessionUpdateStatus::NoUpdate { + session_id, + current, + }); + } + Ok(PreparedUpdate::Stable { release, estimate }) => { + publish(SessionUpdateStatus::Status { + session_id: session_id.clone(), + action, + message: estimate.summary, + }); + publish(SessionUpdateStatus::Status { + session_id: session_id.clone(), + action, + message: format!( + "Downloading {} (estimated {})...", + release.tag_name, + format_duration_estimate(estimate.duration) + ), + }); + let progress_session_id = session_id.clone(); + let progress_version = release.tag_name.clone(); + match download_and_install_blocking_with_progress(&release, |progress| { + publish(SessionUpdateStatus::Status { + session_id: progress_session_id.clone(), + action, + message: format!( + "{} {}", + progress_version, + format_download_progress_bar(progress) + ), + }); + }) { + Ok(_) => publish(SessionUpdateStatus::ReadyToReload { + session_id, + action, + version: release.tag_name, + }), + Err(error) => publish(SessionUpdateStatus::Error { + session_id, + action, + message: format!("Update failed: {}", error), + }), + } + } + Ok(PreparedUpdate::MainSource { + latest_sha, + estimate, + }) => { + publish(SessionUpdateStatus::Status { + session_id: session_id.clone(), + action, + message: estimate.summary, + }); + publish(SessionUpdateStatus::Status { + session_id: session_id.clone(), + action, + message: format!( + "Building main-{} in the background (estimated {})...", + latest_sha, + format_duration_estimate(estimate.duration) + ), + }); + match install_main_source_update_blocking(&latest_sha) { + Ok(_) => publish(SessionUpdateStatus::ReadyToReload { + session_id, + action, + version: format!("main-{}", latest_sha), + }), + Err(error) => publish(SessionUpdateStatus::Error { + session_id, + action, + message: format!("Update failed: {}", error), + }), + } + } + Err(error) => publish(SessionUpdateStatus::Error { + session_id, + action, + message: format!("Update check failed: {}", error), + }), + } + }); +} + +pub fn check_for_update_blocking() -> Result> { + let channel = crate::config::config().features.update_channel; + match channel { + crate::config::UpdateChannel::Main => check_for_main_update_blocking(), + crate::config::UpdateChannel::Stable => check_for_stable_update_blocking(), + } +} + +fn check_for_stable_update_blocking() -> Result> { + let current_version = current_update_semver(); + let release = fetch_latest_release_blocking()?; + + let release_version = release.tag_name.trim_start_matches('v'); + if release_version == current_version.trim_start_matches('v') { + return Ok(None); + } + + if version_is_newer(release_version, current_version.trim_start_matches('v')) { + let asset_name = get_asset_name(); + let has_asset = release + .assets + .iter() + .any(|a| a.name.starts_with(asset_name)); + + if has_asset { + return Ok(Some(release)); + } + } + + Ok(None) +} + +/// Check for updates on the main branch (cutting edge channel). +/// Compares the current binary's git hash against the latest commit on main. +/// If a new commit is found: +/// - Tries to build from source if cargo is available +/// - Falls back to latest GitHub Release if not +fn check_for_main_update_blocking() -> Result> { + let current_hash = jcode_build_meta::GIT_HASH; + if current_hash.is_empty() || current_hash == "unknown" { + crate::logging::info("Main channel: no git hash in binary, skipping update check"); + return Ok(None); + } + + let latest_sha = latest_main_sha_blocking()?; + + if latest_sha.is_empty() { + return Ok(None); + } + + // Compare short hashes + let current_short = if current_hash.len() >= 7 { + ¤t_hash[..7] + } else { + current_hash + }; + + if current_short == latest_sha { + crate::logging::info(&format!("Main channel: up to date ({})", current_short)); + return Ok(None); + } + + crate::logging::info(&format!( + "Main channel: new commit {} -> {}", + current_short, latest_sha + )); + + // Try to build from source + if has_cargo() { + crate::logging::info("Main channel: cargo found, attempting build from source"); + match install_main_source_update_blocking(&latest_sha) { + Ok(_) => { + return Ok(Some(synthetic_main_release(&latest_sha))); + } + Err(e) => { + crate::logging::error(&format!("Main channel: build failed: {}", e)); + // Fall through to release fallback + } + } + } else { + crate::logging::info("Main channel: cargo not found, falling back to latest release"); + } + + // Fallback: use latest stable release if available + if let Ok(release) = fetch_latest_release_blocking() { + let asset_name = get_asset_name(); + let has_asset = release + .assets + .iter() + .any(|a| a.name.starts_with(asset_name)); + if has_asset { + let release_version = release.tag_name.trim_start_matches('v'); + let current_version = current_update_semver().trim_start_matches('v'); + if version_is_newer(release_version, current_version) { + return Ok(Some(release)); + } + } + } + + Ok(None) +} + +/// Check if cargo is available on the system +fn has_cargo() -> bool { + std::process::Command::new("cargo") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Build jcode from source by cloning/pulling the repo and running cargo build +fn build_from_source() -> Result { + let started = Instant::now(); + let build_dir = source_build_root()?; + fs::create_dir_all(&build_dir)?; + + let repo_dir = build_dir.join("jcode"); + + if repo_dir.join(".git").exists() { + // Pull latest + crate::logging::info("Main channel: pulling latest from main..."); + let output = std::process::Command::new("git") + .args(["pull", "--ff-only", "origin", "main"]) + .current_dir(&repo_dir) + .output() + .context("Failed to run git pull")?; + + if !output.status.success() { + // If pull fails (e.g. diverged), reset to origin/main + let summary = summarize_git_pull_failure(&output.stderr); + crate::logging::warn(&format!("{}, trying reset", summary)); + let output = std::process::Command::new("git") + .args(["fetch", "origin", "main"]) + .current_dir(&repo_dir) + .output() + .context("Failed to run git fetch")?; + if !output.status.success() { + anyhow::bail!( + "git fetch failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let output = std::process::Command::new("git") + .args(["reset", "--hard", "origin/main"]) + .current_dir(&repo_dir) + .output() + .context("Failed to run git reset")?; + if !output.status.success() { + anyhow::bail!( + "git reset failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + } else { + // Clone + crate::logging::info("Main channel: cloning repository..."); + let clone_url = format!("https://github.com/{}.git", GITHUB_REPO); + let output = std::process::Command::new("git") + .args([ + "clone", "--depth", "1", "--branch", "main", &clone_url, "jcode", + ]) + .current_dir(&build_dir) + .output() + .context("Failed to run git clone")?; + + if !output.status.success() { + anyhow::bail!( + "git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + + // Build + crate::logging::info("Main channel: building with cargo..."); + let output = std::process::Command::new("cargo") + .args(["build", "--release"]) + .current_dir(&repo_dir) + .env("JCODE_RELEASE_BUILD", "1") + .output() + .context("Failed to run cargo build")?; + + if !output.status.success() { + anyhow::bail!( + "cargo build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let binary = build::release_binary_path(&repo_dir); + if !binary.exists() { + anyhow::bail!("Built binary not found at {}", binary.display()); + } + + record_source_update_duration(started.elapsed()); + + Ok(binary) +} + +pub fn download_and_install_blocking(release: &GitHubRelease) -> Result { + download_and_install_blocking_with_progress(release, |_| {}) +} + +/// Download an asset into memory, retrying with HTTP Range resume so a slow or +/// flaky connection recovers instead of restarting from zero. +/// +/// Returns the full asset bytes plus the best-known total size (for callers +/// that want a final size). Progress callbacks are invoked across retries using +/// the cumulative bytes already on disk, so the UI never appears to go +/// backwards when a stalled connection reconnects. +fn download_asset_with_resume( + client: &reqwest::blocking::Client, + download_url: &str, + total_hint: Option, + on_progress: &mut impl FnMut(DownloadProgress), +) -> Result<(Vec, Option)> { + let mut bytes: Vec = + Vec::with_capacity(total_hint.unwrap_or_default().min(usize::MAX as u64) as usize); + let mut total = total_hint; + let mut next_progress_at = 0_u64; + let mut last_error: Option = None; + // Count only *consecutive stalls* (attempts that made no forward progress). + // A slow-but-advancing download resets this and keeps resuming, so it can + // take as long as it needs; only a genuinely stuck connection gives up. + let mut stalls = 0_usize; + let mut attempt = 0_usize; + + on_progress(DownloadProgress { + downloaded: 0, + total, + }); + + while stalls < DOWNLOAD_MAX_ATTEMPTS { + attempt += 1; + let resume_from = bytes.len() as u64; + let mut request = client.get(download_url); + if resume_from > 0 { + // Ask the server to continue where we left off. + request = request.header(reqwest::header::RANGE, format!("bytes={}-", resume_from)); + } + + let response = match request.send() { + Ok(response) => response, + Err(err) => { + last_error = Some(anyhow::anyhow!("Failed to download update: {}", err)); + log_download_retry(attempt, resume_from, &err); + stalls += 1; + continue; + } + }; + + let status = response.status(); + if resume_from > 0 && status == reqwest::StatusCode::OK { + // Server ignored the Range header and is resending from the start; + // discard the partial buffer so we don't corrupt the result. + bytes.clear(); + next_progress_at = 0; + } else if resume_from > 0 && status != reqwest::StatusCode::PARTIAL_CONTENT { + last_error = Some(anyhow::anyhow!( + "Resume request returned unexpected status {}", + status + )); + log_download_retry_status(attempt, resume_from, status); + stalls += 1; + continue; + } else if !status.is_success() { + last_error = Some(anyhow::anyhow!("Download failed: {}", status)); + log_download_retry_status(attempt, resume_from, status); + stalls += 1; + continue; + } + + // Establish the total size. For a 206 response, content-length is the + // remaining bytes, so prefer the original hint / Content-Range total. + if total.is_none() { + total = content_range_total(&response).or_else(|| { + if status == reqwest::StatusCode::PARTIAL_CONTENT { + response + .content_length() + .map(|len| len.saturating_add(bytes.len() as u64)) + } else { + response.content_length() + } + }); + } + + let before = bytes.len() as u64; + let read_result = read_response_into( + response, + &mut bytes, + &mut next_progress_at, + total, + on_progress, + ); + let made_progress = bytes.len() as u64 > before; + + match read_result { + Ok(()) => { + let downloaded = bytes.len() as u64; + // If we know the total and fell short, treat as a stall and retry. + if let Some(total) = total + && downloaded < total + { + last_error = Some(anyhow::anyhow!( + "Download ended early ({} of {} bytes)", + downloaded, + total + )); + log_download_retry_short(attempt, downloaded, total); + stalls = if made_progress { 0 } else { stalls + 1 }; + continue; + } + on_progress(DownloadProgress { downloaded, total }); + return Ok((bytes, total)); + } + Err(err) => { + let downloaded = bytes.len() as u64; + crate::logging::warn(&format!( + "Update download attempt {} stream error at {} bytes: {}; retrying with resume", + attempt, downloaded, err + )); + last_error = Some(err); + stalls = if made_progress { 0 } else { stalls + 1 }; + continue; + } + } + } + + Err(last_error.unwrap_or_else(|| { + anyhow::anyhow!( + "Download stalled with no progress after {} attempts", + DOWNLOAD_MAX_ATTEMPTS + ) + })) +} + +fn read_response_into( + mut response: reqwest::blocking::Response, + bytes: &mut Vec, + next_progress_at: &mut u64, + total: Option, + on_progress: &mut impl FnMut(DownloadProgress), +) -> Result<()> { + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = response + .read(&mut buffer) + .context("Failed to read download")?; + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + let downloaded = bytes.len() as u64; + if downloaded >= *next_progress_at || total.is_some_and(|total| downloaded >= total) { + on_progress(DownloadProgress { downloaded, total }); + *next_progress_at = downloaded.saturating_add(DOWNLOAD_PROGRESS_UPDATE_STEP); + } + } + Ok(()) +} + +fn content_range_total(response: &reqwest::blocking::Response) -> Option { + // Content-Range: bytes 200-1023/1024 + response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.rsplit('/').next()) + .and_then(|total| total.trim().parse::().ok()) +} + +fn log_download_retry(attempt: usize, resume_from: u64, err: &impl std::fmt::Display) { + crate::logging::warn(&format!( + "Update download attempt {}/{} failed at {} bytes: {}; retrying with resume", + attempt, DOWNLOAD_MAX_ATTEMPTS, resume_from, err + )); +} + +fn log_download_retry_status(attempt: usize, resume_from: u64, status: reqwest::StatusCode) { + crate::logging::warn(&format!( + "Update download attempt {}/{} got status {} at {} bytes; retrying with resume", + attempt, DOWNLOAD_MAX_ATTEMPTS, status, resume_from + )); +} + +fn log_download_retry_short(attempt: usize, downloaded: u64, total: u64) { + crate::logging::warn(&format!( + "Update download attempt {}/{} ended early ({} of {} bytes); retrying with resume", + attempt, DOWNLOAD_MAX_ATTEMPTS, downloaded, total + )); +} + +pub fn download_and_install_blocking_with_progress( + release: &GitHubRelease, + mut on_progress: impl FnMut(DownloadProgress), +) -> Result { + let started = Instant::now(); + let asset_name = get_asset_name(); + let asset = release + .assets + .iter() + .find(|a| a.name.starts_with(asset_name)) + .ok_or_else(|| anyhow::anyhow!("No asset found for platform: {}", asset_name))?; + + let download_url = asset.browser_download_url.clone(); + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("jcode-update-{}", std::process::id())); + + // The `timeout` here applies per request. Since each retry below is a + // separate request, this acts as a *per-attempt* budget rather than a cap + // on the whole asset: a slow-but-progressing download resumes via HTTP + // Range across attempts (up to DOWNLOAD_MAX_ATTEMPTS), so it can complete + // even when a single attempt would not finish in time. A genuinely hung + // connection is still bounded by the per-attempt timeout. + let client = reqwest::blocking::Client::builder() + .connect_timeout(DOWNLOAD_CONNECT_TIMEOUT) + .timeout(DOWNLOAD_ATTEMPT_TIMEOUT) + .user_agent("jcode-updater") + .build()?; + + let total_hint = if asset._size > 0 { + Some(asset._size) + } else { + None + }; + let (bytes, _total) = + download_asset_with_resume(&client, &download_url, total_hint, &mut on_progress)?; + + verify_asset_checksum_if_available(&client, release, asset, &bytes)?; + + let mut installed_version_dir: Option = None; + if asset.name.ends_with(".tar.gz") { + let cursor = std::io::Cursor::new(&bytes); + let gz = flate2::read::GzDecoder::new(cursor); + let mut archive = tar::Archive::new(gz); + let extract_dir = temp_path.with_extension("extract"); + if extract_dir.exists() { + let _ = fs::remove_dir_all(&extract_dir); + } + fs::create_dir_all(&extract_dir).context("Failed to create archive extraction dir")?; + let mut extracted_binary: Option = None; + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?.into_owned(); + if entry_path.components().count() != 1 { + continue; + } + let file_name = entry_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if file_name.is_empty() || file_name.ends_with(".tar.gz") { + continue; + } + let dest = extract_dir.join(&file_name); + entry.unpack(&dest)?; + if file_name.starts_with("jcode") && !file_name.ends_with(".bin") { + extracted_binary = Some(dest); + } + } + let Some(extracted_binary) = extracted_binary else { + anyhow::bail!("Could not find jcode binary inside tar.gz archive"); + }; + crate::platform::set_permissions_executable(&extracted_binary)?; + + let version = release.tag_name.trim_start_matches('v'); + let dest_dir = build::builds_dir()?.join("versions").join(version); + fs::create_dir_all(&dest_dir).context("Failed to create version install dir")?; + let mut installed_files = Vec::new(); + for entry in fs::read_dir(&extract_dir).context("Failed to read extracted archive")? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name(); + let name_string = name.to_string_lossy(); + let dest_name = if name_string == get_asset_name() + || name_string == format!("{}.exe", get_asset_name()) + { + build::binary_name().to_string() + } else { + name_string.to_string() + }; + let dest = dest_dir.join(dest_name); + if dest.exists() { + fs::remove_file(&dest)?; + } + fs::copy(entry.path(), &dest) + .with_context(|| format!("Failed to install {}", dest.display()))?; + if dest + .file_name() + .is_some_and(|name| name == build::binary_name()) + || dest.extension().is_some_and(|ext| ext == "bin") + { + crate::platform::set_permissions_executable(&dest)?; + } + installed_files.push(dest); + } + // Give every installed file the same mtime. The wrapper script and the + // `.bin` payload otherwise land with whatever sub-second skew the copy + // loop produced, and any code comparing binary freshness by mtime then + // sees two "different age" files for one logical install. + let install_stamp = SystemTime::now(); + for path in &installed_files { + if let Ok(file) = fs::File::options().write(true).open(path) { + let _ = file.set_modified(install_stamp); + } + } + let _ = fs::remove_dir_all(&extract_dir); + installed_version_dir = Some(dest_dir.join(build::binary_name())); + } else { + fs::write(&temp_path, &bytes).context("Failed to write temp file")?; + } + + let version = release.tag_name.trim_start_matches('v'); + let mut metadata = UpdateMetadata::load().unwrap_or_default(); + + let versioned_path = if let Some(versioned_path) = installed_version_dir { + versioned_path + } else { + crate::platform::set_permissions_executable(&temp_path)?; + let versioned_path = build::install_binary_at_version(&temp_path, version)?; + let _ = fs::remove_file(&temp_path); + versioned_path + }; + if let Err(error) = build::advance_shared_server_if_tracking_stable(version) { + crate::logging::warn(&format!( + "update: failed to advance shared-server channel to {}: {}", + version, error + )); + } + build::update_stable_symlink(version)?; + build::update_current_symlink(version)?; + build::update_launcher_symlink_to_current()?; + + metadata.installed_version = Some(release.tag_name.clone()); + metadata.installed_from = Some(asset.browser_download_url.clone()); + metadata.last_check = SystemTime::now(); + metadata.save()?; + record_release_update_duration(started.elapsed()); + + Ok(versioned_path) +} + +pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult { + use crate::bus::{Bus, BusEvent, UpdateStatus}; + + if !should_auto_update() { + return UpdateCheckResult::NoUpdate; + } + + let metadata = UpdateMetadata::load().unwrap_or_default(); + if !metadata.should_check() { + return UpdateCheckResult::NoUpdate; + } + + Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Checking)); + + match check_for_update_blocking() { + Ok(Some(release)) => { + let current = jcode_build_meta::VERSION.to_string(); + let latest = release.tag_name.clone(); + + Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Available { + current: current.clone(), + latest: latest.clone(), + })); + + if auto_install { + Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Downloading { + version: latest.clone(), + })); + match download_and_install_blocking(&release) { + Ok(path) => { + Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Installed { + version: latest.clone(), + })); + UpdateCheckResult::UpdateInstalled { + version: latest, + path, + } + } + Err(e) => { + let msg = format!("Failed to install: {}", e); + Bus::global() + .publish(BusEvent::UpdateStatus(UpdateStatus::Error(msg.clone()))); + UpdateCheckResult::Error(msg) + } + } + } else { + let mut metadata = UpdateMetadata::load().unwrap_or_default(); + metadata.last_check = SystemTime::now(); + let _ = metadata.save(); + UpdateCheckResult::UpdateAvailable { + current, + latest, + _release: release, + } + } + } + Ok(None) => { + repair_stale_shared_server_after_no_update(); + Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::UpToDate)); + let mut metadata = UpdateMetadata::load().unwrap_or_default(); + metadata.last_check = SystemTime::now(); + let _ = metadata.save(); + UpdateCheckResult::NoUpdate + } + Err(e) => { + let msg = format!("Check failed: {}", e); + Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Error(msg.clone()))); + UpdateCheckResult::Error(msg) + } + } +} + +fn repair_stale_shared_server_after_no_update() { + match build::repair_stale_shared_server_channel() { + Ok(build::SharedServerRepair::Repaired { + previous, + repaired_to, + }) => { + crate::logging::info(&format!( + "update: repaired stale shared-server channel {:?} -> {} after no-op update check", + previous, repaired_to + )); + } + Ok(build::SharedServerRepair::AlreadyCurrent) => {} + Err(error) => { + crate::logging::warn(&format!( + "update: failed to repair stale shared-server channel after no-op update check: {}", + error + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use jcode_update_core::parse_sha256sums; + use sha2::{Digest, Sha256}; + + #[test] + fn test_version_is_newer() { + assert!(version_is_newer("0.1.3", "0.1.2")); + assert!(version_is_newer("0.2.0", "0.1.9")); + assert!(version_is_newer("1.0.0", "0.9.9")); + assert!(!version_is_newer("0.1.2", "0.1.2")); + assert!(!version_is_newer("0.1.1", "0.1.2")); + assert!(!version_is_newer("0.0.9", "0.1.0")); + } + + #[test] + fn test_asset_name() { + let name = get_asset_name(); + assert!(name.starts_with("jcode-")); + } + + #[test] + fn test_format_download_progress_bar_known_total() { + let rendered = format_download_progress_bar(DownloadProgress { + downloaded: 512, + total: Some(1024), + }); + assert!(rendered.contains("50%")); + assert!(rendered.contains("512 B/1.0 KiB")); + assert!(rendered.contains('█')); + assert!(rendered.contains('░')); + } + + #[test] + fn test_format_download_progress_bar_unknown_total() { + let rendered = format_download_progress_bar(DownloadProgress { + downloaded: 2 * 1024 * 1024, + total: None, + }); + assert_eq!(rendered, "Downloading update... 2.0 MiB downloaded"); + } + + #[test] + fn test_parse_sha256sums_accepts_standard_and_binary_lines() { + let digest_a = "a".repeat(64); + let digest_b = "B".repeat(64); + let digest_b_lower = "b".repeat(64); + let contents = format!( + "# generated by release workflow\n{} jcode-linux-x86_64.tar.gz\r\n{} *jcode-windows-x86_64.exe\n", + digest_a, digest_b + ); + let parsed = parse_sha256sums(&contents).unwrap(); + assert_eq!( + parsed.get("jcode-linux-x86_64.tar.gz").map(String::as_str), + Some(digest_a.as_str()) + ); + assert_eq!( + parsed.get("jcode-windows-x86_64.exe").map(String::as_str), + Some(digest_b_lower.as_str()) + ); + } + + #[test] + fn test_verify_asset_checksum_text_accepts_matching_digest() { + let bytes = b"hello update"; + let digest = format!("{:x}", Sha256::digest(bytes)); + let contents = format!("{} jcode-linux-x86_64.tar.gz\n", digest); + verify_asset_checksum_text(&contents, "jcode-linux-x86_64.tar.gz", bytes).unwrap(); + } + + #[test] + fn test_verify_asset_checksum_text_rejects_mismatch() { + let wrong = "0".repeat(64); + let contents = format!("{} jcode-linux-x86_64.tar.gz\n", wrong); + let err = verify_asset_checksum_text(&contents, "jcode-linux-x86_64.tar.gz", b"actual") + .unwrap_err() + .to_string(); + assert!(err.contains("Checksum mismatch")); + } + + #[test] + fn test_verify_asset_checksum_text_requires_asset_entry() { + let digest = "1".repeat(64); + let contents = format!("{} other-asset.tar.gz\n", digest); + let err = verify_asset_checksum_text(&contents, "jcode-linux-x86_64.tar.gz", b"actual") + .unwrap_err() + .to_string(); + assert!(err.contains("does not list")); + } + + #[test] + fn test_parse_sha256sums_rejects_invalid_digest() { + let err = parse_sha256sums("not-a-sha jcode-linux-x86_64.tar.gz\n") + .unwrap_err() + .to_string(); + assert!(err.contains("invalid SHA256 digest")); + } + + #[test] + fn test_is_release_build() { + assert!(!is_release_build()); + } + + #[test] + fn test_should_auto_update_dev_build() { + assert!(!should_auto_update()); + } + + #[test] + fn test_summarize_git_pull_failure_diverged() { + let stderr = b"hint: You have divergent branches and need to specify how to reconcile them.\nfatal: Need to specify how to reconcile divergent branches.\n"; + assert_eq!( + summarize_git_pull_failure(stderr), + jcode_update_core::GIT_PULL_DIVERGED_SUMMARY + ); + assert!(jcode_update_core::summary_is_divergence( + &summarize_git_pull_failure(stderr) + )); + } + + #[test] + fn test_summarize_git_pull_failure_no_tracking_branch() { + let stderr = b"There is no tracking information for the current branch.\n"; + assert_eq!( + summarize_git_pull_failure(stderr), + "git pull failed: current branch has no upstream tracking branch" + ); + } + + #[test] + fn test_summarize_git_pull_failure_uses_first_non_hint_line() { + let stderr = b"hint: test hint\nfatal: repository not found\n"; + assert_eq!( + summarize_git_pull_failure(stderr), + "git pull failed: repository not found" + ); + } + + #[test] + fn test_estimate_release_update_duration_uses_size_buckets() { + assert_eq!( + estimate_release_update_duration(10 * 1024 * 1024, None), + Duration::from_secs(10) + ); + assert_eq!( + estimate_release_update_duration(40 * 1024 * 1024, None), + Duration::from_secs(35) + ); + } + + #[test] + fn test_estimate_source_update_duration_prefers_history() { + assert_eq!( + estimate_source_update_duration(true, true, Some(123.4)), + Duration::from_secs(123) + ); + } + + #[test] + fn test_content_range_total_parses_total() { + use reqwest::header::{HeaderMap, HeaderValue}; + // Build a Response is awkward; test the parser via a header map directly. + let mut headers = HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_RANGE, + HeaderValue::from_static("bytes 200-1023/1024"), + ); + let parsed = headers + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.rsplit('/').next()) + .and_then(|total| total.trim().parse::().ok()); + assert_eq!(parsed, Some(1024)); + } + + #[test] + fn test_content_range_total_unknown_size_is_none() { + use reqwest::header::{HeaderMap, HeaderValue}; + let mut headers = HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_RANGE, + HeaderValue::from_static("bytes 200-1023/*"), + ); + let parsed = headers + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.rsplit('/').next()) + .and_then(|total| total.trim().parse::().ok()); + assert_eq!(parsed, None); + } + + /// End-to-end resume test: a tiny HTTP server serves the first half of the + /// body then drops the connection, and on the resumed Range request serves + /// the rest. The download must recover and return the full payload. + #[test] + fn test_download_asset_with_resume_recovers_from_dropped_connection() { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let payload: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); + let total = payload.len(); + let split = total / 2; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let payload_for_server = payload.clone(); + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_server = Arc::clone(&request_count); + + let handle = std::thread::spawn(move || { + // Serve exactly two connections: first truncated, second resumed. + for _ in 0..2 { + let Ok((mut stream, _)) = listener.accept() else { + break; + }; + let n = request_count_server.fetch_add(1, Ordering::SeqCst); + + // Parse request headers; look for a Range header. + let mut range_start = 0usize; + { + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let trimmed = line.trim_end(); + if let Some(rest) = + trimmed.to_ascii_lowercase().strip_prefix("range: bytes=") + && let Some(start) = rest.split('-').next() + { + range_start = start.trim().parse().unwrap_or(0); + } + if trimmed.is_empty() { + break; + } + } + } + + if n == 0 { + // First attempt: 200 OK, but only send the first half, then + // close mid-stream to simulate a stalled/dropped connection. + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n", + total + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&payload_for_server[..split]); + let _ = stream.flush(); + // Drop connection without finishing the body. + } else { + // Resumed attempt: serve 206 with the remaining bytes. + let remaining = &payload_for_server[range_start..]; + let header = format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n\r\n", + remaining.len(), + range_start, + total - 1, + total + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(remaining); + let _ = stream.flush(); + } + } + }); + + let client = reqwest::blocking::Client::builder() + .build() + .expect("client"); + let url = format!("http://{}/asset", addr); + let (bytes, parsed_total) = + download_asset_with_resume(&client, &url, Some(total as u64), &mut |_| {}) + .expect("download should recover"); + + handle.join().ok(); + + assert_eq!(bytes, payload, "resumed download must reconstruct payload"); + assert_eq!(parsed_total, Some(total as u64)); + assert_eq!( + request_count.load(Ordering::SeqCst), + 2, + "should have made an initial + one resume request" + ); + } + + /// A connection that keeps dropping but always advances a little must still + /// complete: forward progress resets the consecutive-stall budget, so the + /// number of resumes can exceed DOWNLOAD_MAX_ATTEMPTS as long as each one + /// delivers new bytes. + #[test] + fn test_download_asset_with_resume_tolerates_many_progressing_drops() { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let payload: Vec = (0..3000u32).map(|i| (i % 251) as u8).collect(); + let total = payload.len(); + // Each attempt delivers only this many bytes, then drops, so it takes + // many more than DOWNLOAD_MAX_ATTEMPTS attempts to finish. + let chunk = 100usize; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let payload_for_server = payload.clone(); + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_server = Arc::clone(&request_count); + + let handle = std::thread::spawn(move || { + loop { + let Ok((mut stream, _)) = listener.accept() else { + break; + }; + let n = request_count_server.fetch_add(1, Ordering::SeqCst); + + let mut range_start = 0usize; + { + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let trimmed = line.trim_end(); + if let Some(rest) = + trimmed.to_ascii_lowercase().strip_prefix("range: bytes=") + && let Some(start) = rest.split('-').next() + { + range_start = start.trim().parse().unwrap_or(0); + } + if trimmed.is_empty() { + break; + } + } + } + + let end = (range_start + chunk).min(total); + let body = &payload_for_server[range_start..end]; + let header = if n == 0 { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n", + total + ) + } else { + format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n\r\n", + total - range_start, + range_start, + total - 1, + total + ) + }; + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); + // Drop after a partial chunk unless we've reached the end. + if end >= total { + break; + } + } + }); + + let client = reqwest::blocking::Client::builder() + .build() + .expect("client"); + let url = format!("http://{}/asset", addr); + let (bytes, _total) = + download_asset_with_resume(&client, &url, Some(total as u64), &mut |_| {}) + .expect("progressing download should complete"); + + handle.join().ok(); + + assert_eq!(bytes, payload); + assert!( + request_count.load(Ordering::SeqCst) > DOWNLOAD_MAX_ATTEMPTS, + "test should require more than the stall budget of resumes" + ); + } + + /// Mirrors the real #293 shape closely: the caller has *no* size hint + /// (None), a slow connection drops repeatedly, and the size is learned from + /// the server (Content-Length on the initial 200, Content-Range on the 206 + /// resumes, exactly like a GitHub release asset). The download must still + /// complete, learn the correct total, and report progress that never moves + /// backwards across reconnects (so the UI bar can't appear to regress). + #[test] + fn test_download_asset_with_resume_unknown_total_and_monotonic_progress() { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let payload: Vec = (0..5000u32).map(|i| (i % 251) as u8).collect(); + let total = payload.len(); + let chunk = 400usize; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let payload_for_server = payload.clone(); + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_server = Arc::clone(&request_count); + + let handle = std::thread::spawn(move || { + loop { + let Ok((mut stream, _)) = listener.accept() else { + break; + }; + let n = request_count_server.fetch_add(1, Ordering::SeqCst); + + let mut range_start = 0usize; + { + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let trimmed = line.trim_end(); + if let Some(rest) = + trimmed.to_ascii_lowercase().strip_prefix("range: bytes=") + && let Some(start) = rest.split('-').next() + { + range_start = start.trim().parse().unwrap_or(0); + } + if trimmed.is_empty() { + break; + } + } + } + + let end = (range_start + chunk).min(total); + let body = &payload_for_server[range_start..end]; + // Like a real GitHub asset: the initial 200 carries the full + // Content-Length (so a mid-stream drop is detectable), and the + // 206 resumes carry Content-Range. The *caller* still gets no + // size hint, so the total must be learned from these headers. + let header = if n == 0 { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n", + total + ) + } else { + format!( + "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n\r\n", + total - range_start, + range_start, + total - 1, + total + ) + }; + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); + // Drop after a partial chunk unless we've reached the end. + if end >= total { + break; + } + } + }); + + let client = reqwest::blocking::Client::builder() + .build() + .expect("client"); + let url = format!("http://{}/asset", addr); + + let mut progress_points: Vec = Vec::new(); + let mut seen_total: Option = None; + let (bytes, parsed_total) = download_asset_with_resume( + &client, + &url, + None, // no size hint, like a fresh download with no metadata + &mut |p| { + progress_points.push(p.downloaded); + if p.total.is_some() { + seen_total = p.total; + } + }, + ) + .expect("download with unknown caller hint should complete"); + + handle.join().ok(); + + assert_eq!(bytes, payload, "payload must be fully reconstructed"); + assert_eq!( + parsed_total, + Some(total as u64), + "total must be learned from the server headers" + ); + assert_eq!(seen_total, Some(total as u64)); + assert!( + progress_points.windows(2).all(|w| w[1] >= w[0]), + "progress must never go backwards across reconnects: {:?}", + progress_points + ); + assert_eq!( + *progress_points.last().expect("at least one progress point"), + total as u64, + "final progress must reach the full size" + ); + } +} diff --git a/crates/jcode-app-core/src/usage_display.rs b/crates/jcode-app-core/src/usage_display.rs new file mode 100644 index 0000000..5740464 --- /dev/null +++ b/crates/jcode-app-core/src/usage_display.rs @@ -0,0 +1,175 @@ +use super::{ + OpenAIUsageData, PROVIDER_USAGE_CACHE_TTL, ProviderUsage, RATE_LIMIT_BACKOFF, UsageData, +}; +use std::time::Instant; + +pub(super) fn reset_timestamp_passed(timestamp: Option<&str>) -> bool { + usage_reset_passed([timestamp]) +} + +impl UsageData { + /// Returns a display-safe snapshot that avoids showing pre-reset usage after a window rolled over. + pub fn display_snapshot(&self) -> Self { + let mut snapshot = self.clone(); + + if reset_timestamp_passed(self.five_hour_resets_at.as_deref()) { + snapshot.five_hour = 0.0; + snapshot.five_hour_resets_at = None; + } + + if reset_timestamp_passed(self.seven_day_resets_at.as_deref()) { + snapshot.seven_day = 0.0; + snapshot.seven_day_opus = None; + snapshot.seven_day_resets_at = None; + } + + snapshot + } +} + +impl OpenAIUsageData { + /// Returns a display-safe snapshot that avoids showing pre-reset exhaustion after a window rolled over. + pub fn display_snapshot(&self) -> Self { + let mut snapshot = self.clone(); + let mut cleared_any_window = false; + + if let Some(window) = snapshot.five_hour.as_mut() + && reset_timestamp_passed(window.resets_at.as_deref()) + { + window.usage_ratio = 0.0; + window.resets_at = None; + cleared_any_window = true; + } + + if let Some(window) = snapshot.seven_day.as_mut() + && reset_timestamp_passed(window.resets_at.as_deref()) + { + window.usage_ratio = 0.0; + window.resets_at = None; + cleared_any_window = true; + } + + if let Some(window) = snapshot.spark.as_mut() + && reset_timestamp_passed(window.resets_at.as_deref()) + { + window.usage_ratio = 0.0; + window.resets_at = None; + cleared_any_window = true; + } + + if cleared_any_window { + snapshot.hard_limit_reached = false; + } + + snapshot + } +} + +pub(super) fn provider_usage_cache_is_fresh( + now: Instant, + fetched_at: Instant, + report: &ProviderUsage, +) -> bool { + let ttl = if report + .error + .as_ref() + .map(|e| e.contains("429") || e.contains("rate limit") || e.contains("Rate limited")) + .unwrap_or(false) + { + RATE_LIMIT_BACKOFF + } else { + PROVIDER_USAGE_CACHE_TTL + }; + + now.duration_since(fetched_at) < ttl + && !usage_reset_passed(report.limits.iter().map(|limit| limit.resets_at.as_deref())) +} + +pub(super) fn format_token_count(tokens: u64) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + format!("{}", tokens) + } +} + +pub(super) fn humanize_key(key: &str) -> String { + key.replace('_', " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(c) => { + let mut s = c.to_uppercase().to_string(); + s.push_str(&chars.as_str().to_lowercase()); + s + } + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn parse_reset_timestamp(timestamp: &str) -> Option> { + if let Ok(reset) = chrono::DateTime::parse_from_rfc3339(timestamp) { + Some(reset.with_timezone(&chrono::Utc)) + } else if let Ok(reset) = + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ") + { + Some(reset.and_utc()) + } else { + None + } +} + +pub(super) fn usage_reset_passed<'a>( + timestamps: impl IntoIterator>, +) -> bool { + let now = chrono::Utc::now(); + timestamps + .into_iter() + .flatten() + .filter_map(parse_reset_timestamp) + .any(|reset| reset <= now) +} + +pub fn format_reset_time(timestamp: &str) -> String { + if let Some(reset) = parse_reset_timestamp(timestamp) { + let duration = reset.signed_duration_since(chrono::Utc::now()); + if duration.num_seconds() <= 0 { + return "now".to_string(); + } + if duration.num_seconds() < 60 { + return "1m".to_string(); + } + let days = duration.num_days(); + let hours = duration.num_hours() % 24; + let minutes = duration.num_minutes() % 60; + if days > 0 { + if hours > 0 { + format!("{}d {}h", days, hours) + } else if minutes > 0 { + format!("{}d {}m", days, minutes) + } else { + format!("{}d", days) + } + } else if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + } + } else { + timestamp.to_string() + } +} + +pub fn format_usage_bar(percent: f32, width: usize) -> String { + let filled = ((percent / 100.0) * width as f32).round() as usize; + let filled = filled.min(width); + let empty = width.saturating_sub(filled); + let bar: String = "█".repeat(filled) + &"░".repeat(empty); + format!("{} {:.0}%", bar, percent) +} diff --git a/crates/jcode-app-core/src/usage_openai.rs b/crates/jcode-app-core/src/usage_openai.rs new file mode 100644 index 0000000..a96de90 --- /dev/null +++ b/crates/jcode-app-core/src/usage_openai.rs @@ -0,0 +1,408 @@ +use super::display::humanize_key; +use super::{OpenAIUsageData, OpenAIUsageWindow, UsageLimit}; + +#[derive(Debug, Default)] +pub(super) struct ParsedOpenAIUsageReport { + pub(super) limits: Vec, + pub(super) extra_info: Vec<(String, String)>, + pub(super) hard_limit_reached: bool, +} + +fn normalize_ratio_value(raw: f32) -> f32 { + if !raw.is_finite() { + return 0.0; + } + if raw > 1.0 { + (raw / 100.0).clamp(0.0, 1.0) + } else { + raw.clamp(0.0, 1.0) + } +} + +fn normalize_percent(raw: f32) -> f32 { + normalize_ratio_value(raw) * 100.0 +} + +fn clamp_percent(raw: f32) -> f32 { + if raw.is_finite() { + raw.clamp(0.0, 100.0) + } else { + 0.0 + } +} + +pub(super) fn usage_percent_to_ratio(percent: f32) -> f32 { + clamp_percent(percent) / 100.0 +} + +fn normalize_limit_key(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn limit_mentions_five_hour(key: &str) -> bool { + key.contains("5 hour") + || key.contains("5hr") + || key.contains("5 h") + || key.contains("five hour") +} + +fn limit_mentions_weekly(key: &str) -> bool { + key.contains("weekly") + || key.contains("1 week") + || key.contains("1w") + || key.contains("7 day") + || key.contains("seven day") +} + +fn limit_mentions_spark(key: &str) -> bool { + key.contains("spark") +} + +fn to_openai_window(limit: &UsageLimit) -> OpenAIUsageWindow { + OpenAIUsageWindow { + name: limit.name.clone(), + usage_ratio: usage_percent_to_ratio(limit.usage_percent), + resets_at: limit.resets_at.clone(), + } +} + +pub(super) fn classify_openai_limits(limits: &[UsageLimit]) -> OpenAIUsageData { + let mut five_hour: Option = None; + let mut seven_day: Option = None; + let mut spark: Option = None; + let mut generic_non_spark: Vec = Vec::new(); + + for limit in limits { + let key = normalize_limit_key(&limit.name); + let window = to_openai_window(limit); + let is_spark = limit_mentions_spark(&key); + + if is_spark && spark.is_none() { + spark = Some(window.clone()); + } + + if !is_spark { + if limit_mentions_five_hour(&key) && five_hour.is_none() { + five_hour = Some(window.clone()); + } + if limit_mentions_weekly(&key) && seven_day.is_none() { + seven_day = Some(window.clone()); + } + generic_non_spark.push(window); + } + } + + if five_hour.is_none() { + five_hour = generic_non_spark.first().cloned(); + } + if seven_day.is_none() { + seven_day = generic_non_spark + .iter() + .find(|w| { + five_hour + .as_ref() + .map(|f| f.name != w.name || f.resets_at != w.resets_at) + .unwrap_or(true) + }) + .cloned(); + } + + OpenAIUsageData { + five_hour, + seven_day, + spark, + ..Default::default() + } +} + +fn parse_f32_value(value: &serde_json::Value) -> Option { + if let Some(n) = value.as_f64() { + return Some(n as f32); + } + value.as_str().and_then(|s| s.trim().parse::().ok()) +} + +pub(super) fn parse_usage_percent_from_obj( + obj: &serde_json::Map, +) -> Option { + for key in ["usage_percent", "used_percent", "percent_used"] { + if let Some(value) = obj.get(key).and_then(parse_f32_value) { + return Some(clamp_percent(value)); + } + } + + for key in ["usage", "utilization", "usage_ratio", "used_ratio"] { + if let Some(value) = obj.get(key).and_then(parse_f32_value) { + return Some(normalize_percent(value)); + } + } + + let used = obj.get("used").and_then(parse_f32_value); + let remaining = obj.get("remaining").and_then(parse_f32_value); + let limit = obj + .get("limit") + .or_else(|| obj.get("max")) + .and_then(parse_f32_value); + + if let (Some(used), Some(limit)) = (used, limit) + && limit > 0.0 + { + return Some(((used / limit) * 100.0).clamp(0.0, 100.0)); + } + + if let (Some(remaining), Some(limit)) = (remaining, limit) + && limit > 0.0 + { + let used = (limit - remaining).max(0.0); + return Some(((used / limit) * 100.0).clamp(0.0, 100.0)); + } + + None +} + +fn parse_resets_at_from_obj(obj: &serde_json::Map) -> Option { + for key in [ + "resets_at", + "reset_at", + "resetsAt", + "resetAt", + "reset_time", + "resetTime", + ] { + if let Some(value) = obj.get(key).and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn parse_limit_name(entry: &serde_json::Value, fallback: &str) -> String { + entry + .get("name") + .or_else(|| entry.get("label")) + .or_else(|| entry.get("display_name")) + .or_else(|| entry.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(fallback) + .to_string() +} + +fn parse_bool_value(value: &serde_json::Value) -> Option { + if let Some(b) = value.as_bool() { + return Some(b); + } + + value + .as_str() + .and_then(|s| match s.trim().to_ascii_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }) +} + +pub(super) fn parse_openai_hard_limit_reached(json: &serde_json::Value) -> bool { + let Some(obj) = json.as_object() else { + return false; + }; + + if obj.get("limit_reached").and_then(parse_bool_value) == Some(true) + || obj.get("limitReached").and_then(parse_bool_value) == Some(true) + { + return true; + } + + obj.get("rate_limit") + .and_then(|rate_limit| rate_limit.as_object()) + .and_then(|rate_limit| rate_limit.get("allowed")) + .and_then(parse_bool_value) + == Some(false) +} + +fn parse_window_seconds(obj: &serde_json::Map) -> Option { + ["limit_window_seconds", "window_seconds"] + .iter() + .find_map(|key| obj.get(*key).and_then(|value| value.as_u64())) +} + +fn window_duration_label(seconds: u64) -> String { + const HOUR: u64 = 60 * 60; + const DAY: u64 = 24 * HOUR; + + // OpenAI's monthly agentic pool is reported as a calendar-like duration + // (currently 2,628,000 seconds), rather than an exact multiple of days. + if (28 * DAY..=32 * DAY).contains(&seconds) { + return "Monthly window".to_string(); + } + if seconds >= DAY && seconds.is_multiple_of(DAY) { + let days = seconds / DAY; + return format!("{days}-day window"); + } + if seconds >= HOUR && seconds.is_multiple_of(HOUR) { + let hours = seconds / HOUR; + return format!("{hours}-hour window"); + } + + format!("{}-second window", seconds) +} + +fn parse_wham_window(window: &serde_json::Value, fallback_name: &str) -> Option { + let obj = window.as_object()?; + let used_percent = obj + .get("used_percent") + .and_then(parse_f32_value) + .map(clamp_percent)?; + let resets_at = obj.get("reset_at").and_then(parse_f32_value).map(|ts| { + chrono::DateTime::from_timestamp(ts as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", ts as i64)) + }); + Some(UsageLimit { + name: parse_window_seconds(obj) + .map(window_duration_label) + .unwrap_or_else(|| fallback_name.to_string()), + usage_percent: used_percent, + resets_at, + }) +} + +fn parse_wham_rate_limit( + rl: &serde_json::Value, + primary_name: &str, + secondary_name: &str, +) -> Vec { + let mut out = Vec::new(); + if let Some(pw) = rl.get("primary_window") + && let Some(limit) = parse_wham_window(pw, primary_name) + { + out.push(limit); + } + if let Some(sw) = rl.get("secondary_window") + && !sw.is_null() + && let Some(limit) = parse_wham_window(sw, secondary_name) + { + out.push(limit); + } + out +} + +fn qualify_additional_limit(limit_name: &str, mut limit: UsageLimit) -> UsageLimit { + let duration = limit.name.strip_suffix(" window").unwrap_or(&limit.name); + let duration = match duration { + "5-hour" => "5h", + "7-day" => "7d", + other => other, + }; + limit.name = format!("{limit_name} ({duration})"); + limit +} + +pub(super) fn parse_openai_usage_payload(json: &serde_json::Value) -> ParsedOpenAIUsageReport { + let mut parsed = ParsedOpenAIUsageReport { + hard_limit_reached: parse_openai_hard_limit_reached(json), + ..Default::default() + }; + + if let Some(rl) = json.get("rate_limit") { + parsed + .limits + .extend(parse_wham_rate_limit(rl, "5-hour window", "7-day window")); + } + + if let Some(additional) = json + .get("additional_rate_limits") + .and_then(|v| v.as_array()) + { + for entry in additional { + let limit_name = entry + .get("limit_name") + .and_then(|v| v.as_str()) + .unwrap_or("Additional"); + if let Some(rl) = entry.get("rate_limit") { + parsed.limits.extend( + parse_wham_rate_limit(rl, "5-hour window", "7-day window") + .into_iter() + .map(|limit| qualify_additional_limit(limit_name, limit)), + ); + } + } + } + + if parsed.limits.is_empty() + && let Some(rate_limits) = json.get("rate_limits").and_then(|v| v.as_array()) + { + for entry in rate_limits { + if let Some(obj) = entry.as_object() + && let Some(usage_percent) = parse_usage_percent_from_obj(obj) + { + parsed.limits.push(UsageLimit { + name: parse_limit_name(entry, "unknown"), + usage_percent, + resets_at: parse_resets_at_from_obj(obj), + }); + } + } + } + + if parsed.limits.is_empty() + && let Some(obj) = json.as_object() + { + for (key, value) in obj { + if key == "rate_limits" || key == "rate_limit" || key == "additional_rate_limits" { + continue; + } + + if let Some(inner) = value.as_object() { + if let Some(usage_percent) = parse_usage_percent_from_obj(inner) { + parsed.limits.push(UsageLimit { + name: humanize_key(key), + usage_percent, + resets_at: parse_resets_at_from_obj(inner), + }); + continue; + } + + if let Some(windows) = inner.get("rate_limits").and_then(|v| v.as_array()) { + for entry in windows { + if let Some(entry_obj) = entry.as_object() + && let Some(usage_percent) = parse_usage_percent_from_obj(entry_obj) + { + parsed.limits.push(UsageLimit { + name: parse_limit_name(entry, key), + usage_percent, + resets_at: parse_resets_at_from_obj(entry_obj), + }); + } + } + } + } + } + } + + if let Some(plan) = json + .get("plan_type") + .or_else(|| json.get("plan")) + .or_else(|| json.get("subscription_type")) + .and_then(|v| v.as_str()) + { + parsed + .extra_info + .insert(0, ("Plan".to_string(), plan.to_string())); + } + + parsed +} diff --git a/crates/jcode-app-core/src/usage_tests.rs b/crates/jcode-app-core/src/usage_tests.rs new file mode 100644 index 0000000..7b822b1 --- /dev/null +++ b/crates/jcode-app-core/src/usage_tests.rs @@ -0,0 +1,596 @@ +use super::*; + +#[test] +fn test_usage_data_default() { + let data = UsageData::default(); + assert!(data.is_stale()); + assert_eq!(data.five_hour_percent(), "0%"); + assert_eq!(data.seven_day_percent(), "0%"); +} + +#[test] +fn test_usage_percent_format() { + let data = UsageData { + five_hour: 0.42, + seven_day: 0.156, + ..Default::default() + }; + assert_eq!(data.five_hour_percent(), "42%"); + assert_eq!(data.seven_day_percent(), "16%"); +} + +#[test] +fn test_humanize_key() { + assert_eq!(humanize_key("five_hour"), "Five Hour"); + assert_eq!(humanize_key("seven_day_opus"), "Seven Day Opus"); + assert_eq!(humanize_key("plan"), "Plan"); +} + +#[test] +fn test_get_sync_without_runtime_does_not_panic() { + let result = std::panic::catch_unwind(get_sync); + assert!( + result.is_ok(), + "get_sync should not require a Tokio runtime" + ); +} + +#[test] +fn test_get_openai_usage_sync_without_runtime_does_not_panic() { + let result = std::panic::catch_unwind(get_openai_usage_sync); + assert!( + result.is_ok(), + "get_openai_usage_sync should not require a Tokio runtime" + ); +} + +#[test] +fn test_usage_data_becomes_stale_when_reset_time_has_passed() { + let data = UsageData { + five_hour: 0.42, + five_hour_resets_at: Some("2020-01-01T00:00:00Z".to_string()), + fetched_at: Some(Instant::now()), + ..Default::default() + }; + + assert!( + data.is_stale(), + "usage data should refresh once a reset window has passed" + ); +} + +#[test] +fn test_openai_usage_data_becomes_stale_when_reset_time_has_passed() { + let data = OpenAIUsageData { + five_hour: Some(OpenAIUsageWindow { + name: "5-hour".to_string(), + usage_ratio: 0.42, + resets_at: Some("2020-01-01T00:00:00Z".to_string()), + }), + fetched_at: Some(Instant::now()), + ..Default::default() + }; + + assert!( + data.is_stale(), + "OpenAI usage data should refresh once a reset window has passed" + ); +} + +#[test] +fn test_usage_data_display_snapshot_clears_passed_reset_window() { + let data = UsageData { + five_hour: 0.73, + five_hour_resets_at: Some("2020-01-01T00:00:00Z".to_string()), + seven_day: 0.41, + seven_day_resets_at: Some("3020-01-01T00:00:00Z".to_string()), + fetched_at: Some(Instant::now()), + ..Default::default() + }; + + let snapshot = data.display_snapshot(); + assert_eq!(snapshot.five_hour, 0.0); + assert!(snapshot.five_hour_resets_at.is_none()); + assert_eq!(snapshot.seven_day, 0.41); + assert_eq!( + snapshot.seven_day_resets_at.as_deref(), + Some("3020-01-01T00:00:00Z") + ); +} + +#[test] +fn test_openai_usage_data_display_snapshot_clears_passed_reset_window() { + let data = OpenAIUsageData { + five_hour: Some(OpenAIUsageWindow { + name: "5-hour".to_string(), + usage_ratio: 0.88, + resets_at: Some("2020-01-01T00:00:00Z".to_string()), + }), + seven_day: Some(OpenAIUsageWindow { + name: "7-day".to_string(), + usage_ratio: 0.31, + resets_at: Some("3020-01-01T00:00:00Z".to_string()), + }), + hard_limit_reached: true, + fetched_at: Some(Instant::now()), + ..Default::default() + }; + + let snapshot = data.display_snapshot(); + assert_eq!( + snapshot.five_hour.as_ref().map(|w| w.usage_ratio), + Some(0.0) + ); + assert_eq!( + snapshot + .five_hour + .as_ref() + .and_then(|w| w.resets_at.as_deref()), + None + ); + assert_eq!( + snapshot.seven_day.as_ref().map(|w| w.usage_ratio), + Some(0.31) + ); + assert!(!snapshot.hard_limit_reached); +} + +#[test] +fn test_provider_usage_cache_is_not_fresh_after_reset_boundary() { + let report = ProviderUsage { + provider_name: "OpenAI".to_string(), + limits: vec![UsageLimit { + name: "5-hour window".to_string(), + usage_percent: 100.0, + resets_at: Some("2020-01-01T00:00:00Z".to_string()), + }], + ..Default::default() + }; + + assert!(!provider_usage_cache_is_fresh( + Instant::now(), + Instant::now(), + &report, + )); +} + +#[test] +fn test_mask_email_censors_local_part() { + assert_eq!(mask_email("jeremyh1@uw.edu"), "j***1@uw.edu"); + assert_eq!(mask_email("ab@example.com"), "a*@example.com"); +} + +#[test] +fn test_format_usage_bar() { + let bar = format_usage_bar(50.0, 10); + assert!(bar.contains("█████░░░░░")); + assert!(bar.contains("50%")); + + let bar = format_usage_bar(0.0, 10); + assert!(bar.contains("░░░░░░░░░░")); + assert!(bar.contains("0%")); + + let bar = format_usage_bar(100.0, 10); + assert!(bar.contains("██████████")); + assert!(bar.contains("100%")); +} + +#[test] +fn test_format_reset_time_past() { + assert_eq!(format_reset_time("2020-01-01T00:00:00Z"), "now"); +} + +#[test] +fn test_format_reset_time_under_one_minute_rounds_up() { + let timestamp = (chrono::Utc::now() + chrono::TimeDelta::seconds(30)).to_rfc3339(); + assert_eq!(format_reset_time(×tamp), "1m"); +} + +#[test] +fn test_format_reset_time_uses_days_for_long_windows() { + let timestamp = (chrono::Utc::now() + + chrono::TimeDelta::hours(109) + + chrono::TimeDelta::minutes(5)) + .to_rfc3339(); + assert_eq!(format_reset_time(×tamp), "4d 13h"); +} + +#[test] +fn test_classify_openai_limits_recognizes_five_weekly_and_spark() { + let limits = vec![ + UsageLimit { + name: "Codex 5h".to_string(), + usage_percent: 25.0, + resets_at: Some("2026-01-01T00:00:00Z".to_string()), + }, + UsageLimit { + name: "Codex 1w".to_string(), + usage_percent: 50.0, + resets_at: Some("2026-01-07T00:00:00Z".to_string()), + }, + UsageLimit { + name: "Codex Spark".to_string(), + usage_percent: 75.0, + resets_at: Some("2026-01-02T00:00:00Z".to_string()), + }, + ]; + + let classified = openai_helpers::classify_openai_limits(&limits); + + assert_eq!( + classified.five_hour.as_ref().map(|w| w.usage_ratio), + Some(0.25) + ); + assert_eq!( + classified.seven_day.as_ref().map(|w| w.usage_ratio), + Some(0.5) + ); + assert_eq!(classified.spark.as_ref().map(|w| w.usage_ratio), Some(0.75)); +} + +#[test] +fn test_parse_usage_percent_supports_used_limit_shape() { + let mut obj = serde_json::Map::new(); + obj.insert("used".to_string(), serde_json::json!(20)); + obj.insert("limit".to_string(), serde_json::json!(80)); + + let percent = openai_helpers::parse_usage_percent_from_obj(&obj); + assert_eq!(percent, Some(25.0)); +} + +#[test] +fn test_parse_usage_percent_supports_remaining_limit_shape() { + let mut obj = serde_json::Map::new(); + obj.insert("remaining".to_string(), serde_json::json!(60)); + obj.insert("limit".to_string(), serde_json::json!(80)); + + let percent = openai_helpers::parse_usage_percent_from_obj(&obj); + assert_eq!(percent, Some(25.0)); +} + +#[test] +fn test_active_anthropic_usage_report_prefers_marked_account() { + let results = vec![ + ProviderUsage { + provider_name: "Anthropic - work".to_string(), + ..Default::default() + }, + ProviderUsage { + provider_name: "Anthropic - personal ✦".to_string(), + ..Default::default() + }, + ]; + + let active = active_anthropic_usage_report(&results) + .expect("expected active anthropic report to be selected"); + assert_eq!(active.provider_name, "Anthropic - personal ✦"); +} + +#[test] +fn test_usage_data_from_provider_report_maps_limits_and_extra_usage() { + let report = ProviderUsage { + provider_name: "Anthropic (Claude)".to_string(), + limits: vec![ + UsageLimit { + name: "5-hour window".to_string(), + usage_percent: 25.0, + resets_at: Some("2026-01-01T00:00:00Z".to_string()), + }, + UsageLimit { + name: "7-day window".to_string(), + usage_percent: 50.0, + resets_at: Some("2026-01-07T00:00:00Z".to_string()), + }, + UsageLimit { + name: "7-day Opus window".to_string(), + usage_percent: 75.0, + resets_at: Some("2026-01-08T00:00:00Z".to_string()), + }, + ], + extra_info: vec![( + "Extra usage (long context)".to_string(), + "enabled".to_string(), + )], + hard_limit_reached: false, + error: None, + }; + + let usage = usage_data_from_provider_report(&report); + + assert_eq!(usage.five_hour, 0.25); + assert_eq!(usage.seven_day, 0.5); + assert_eq!(usage.seven_day_opus, Some(0.75)); + assert!(usage.extra_usage_enabled); + assert_eq!( + usage.five_hour_resets_at.as_deref(), + Some("2026-01-01T00:00:00Z") + ); +} + +#[test] +fn test_openai_usage_data_from_provider_report_preserves_error() { + let report = ProviderUsage { + provider_name: "OpenAI (ChatGPT)".to_string(), + error: Some("API error (401 Unauthorized)".to_string()), + ..Default::default() + }; + + let usage = openai_usage_data_from_provider_report(&report); + + assert_eq!( + usage.last_error.as_deref(), + Some("API error (401 Unauthorized)") + ); + assert!(usage.five_hour.is_none()); + assert!(usage.seven_day.is_none()); +} + +#[test] +fn test_openai_usage_data_from_provider_report_preserves_hard_limit_flag() { + let report = ProviderUsage { + provider_name: "OpenAI (ChatGPT)".to_string(), + hard_limit_reached: true, + limits: vec![UsageLimit { + name: "5-hour window".to_string(), + usage_percent: 100.0, + resets_at: None, + }], + ..Default::default() + }; + + let usage = openai_usage_data_from_provider_report(&report); + + assert!(usage.hard_limit_reached); +} + +#[test] +fn test_openai_snapshot_treats_hard_limit_flag_as_exhausted() { + let usage = OpenAIUsageData { + hard_limit_reached: true, + five_hour: Some(OpenAIUsageWindow { + name: "5-hour window".to_string(), + usage_ratio: 1.0, + resets_at: Some("2026-01-01T00:00:00Z".to_string()), + }), + ..Default::default() + }; + + let snapshot = openai_snapshot_from_usage( + "work".to_string(), + Some("work@example.com".to_string()), + &usage, + ); + + assert!(snapshot.exhausted); + assert_eq!(snapshot.five_hour_ratio, Some(1.0)); + assert_eq!(snapshot.seven_day_ratio, None); +} + +#[test] +fn test_parse_openai_hard_limit_reached_detects_rate_limit_denials() { + let json = serde_json::json!({ + "plan_type": "free", + "rate_limit": { + "allowed": false, + "primary_window": { + "used_percent": 100.0, + "reset_at": 1_766_000_000 + } + }, + "limit_reached": true + }); + + assert!(openai_helpers::parse_openai_hard_limit_reached(&json)); +} + +#[test] +fn test_parse_openai_hard_limit_reached_ignores_unrelated_allowed_flags() { + let json = serde_json::json!({ + "plan_type": "free", + "features": { + "voice_mode": { + "allowed": false + } + }, + "rate_limit": { + "allowed": true + } + }); + + assert!(!openai_helpers::parse_openai_hard_limit_reached(&json)); +} + +#[test] +fn test_parse_openai_usage_payload_prefers_wham_windows_and_additional_limits() { + let json = serde_json::json!({ + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "primary_window": { + "used_percent": 25.0, + "reset_at": 1_766_000_000 + }, + "secondary_window": { + "used_percent": 50.0, + "reset_at": 1_766_086_400 + } + }, + "additional_rate_limits": [{ + "limit_name": "Codex Spark", + "rate_limit": { + "primary_window": { + "used_percent": 75.0, + "reset_at": 1_766_000_000 + } + } + }] + }); + + let parsed = openai_helpers::parse_openai_usage_payload(&json); + + assert_eq!( + parsed.extra_info.first(), + Some(&("Plan".to_string(), "pro".to_string())) + ); + assert!(!parsed.hard_limit_reached); + assert_eq!(parsed.limits.len(), 3); + assert_eq!(parsed.limits[0].name, "5-hour window"); + assert_eq!(parsed.limits[0].usage_percent, 25.0); + assert_eq!(parsed.limits[1].name, "7-day window"); + assert_eq!(parsed.limits[1].usage_percent, 50.0); + assert_eq!(parsed.limits[2].name, "Codex Spark (5h)"); + assert_eq!(parsed.limits[2].usage_percent, 75.0); +} + +#[test] +fn test_parse_openai_usage_payload_labels_monthly_primary_window() { + let json = serde_json::json!({ + "plan_type": "team", + "rate_limit": { + "allowed": false, + "limit_reached": true, + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 2_628_000, + "reset_at": 1_786_512_910 + }, + "secondary_window": null + } + }); + + let parsed = openai_helpers::parse_openai_usage_payload(&json); + assert_eq!(parsed.limits.len(), 1); + assert_eq!(parsed.limits[0].name, "Monthly window"); + + let usage = openai_usage_data_from_provider_report(&ProviderUsage { + provider_name: "OpenAI (ChatGPT)".to_string(), + limits: parsed.limits, + hard_limit_reached: parsed.hard_limit_reached, + ..Default::default() + }); + assert!(usage.exhausted()); +} + +#[test] +fn test_parse_openai_usage_payload_falls_back_to_nested_rate_limits() { + let json = serde_json::json!({ + "plan": "team", + "codex": { + "rate_limits": [ + { + "name": "Codex 5h", + "used": 20, + "limit": 80, + "resets_at": "2026-01-01T00:00:00Z" + }, + { + "name": "Codex 1w", + "remaining": 60, + "limit": 80, + "resets_at": "2026-01-07T00:00:00Z" + } + ] + } + }); + + let parsed = openai_helpers::parse_openai_usage_payload(&json); + + assert_eq!( + parsed.extra_info.first(), + Some(&("Plan".to_string(), "team".to_string())) + ); + assert_eq!(parsed.limits.len(), 2); + assert_eq!(parsed.limits[0].name, "Codex 5h"); + assert_eq!(parsed.limits[0].usage_percent, 25.0); + assert_eq!(parsed.limits[1].name, "Codex 1w"); + assert_eq!(parsed.limits[1].usage_percent, 25.0); +} + +#[test] +fn test_account_usage_probe_prefers_best_available_alternative() { + let probe = AccountUsageProbe { + provider: MultiAccountProviderKind::OpenAI, + current_label: "work".to_string(), + accounts: vec![ + AccountUsageSnapshot { + label: "work".to_string(), + email: Some("work@example.com".to_string()), + exhausted: true, + primary_label: None, + five_hour_ratio: Some(1.0), + secondary_label: None, + seven_day_ratio: Some(1.0), + resets_at: Some("2026-01-01T00:00:00Z".to_string()), + error: None, + }, + AccountUsageSnapshot { + label: "backup".to_string(), + email: Some("backup@example.com".to_string()), + exhausted: false, + primary_label: None, + five_hour_ratio: Some(0.45), + secondary_label: None, + seven_day_ratio: Some(0.10), + resets_at: Some("2026-01-01T01:00:00Z".to_string()), + error: None, + }, + AccountUsageSnapshot { + label: "secondary".to_string(), + email: Some("secondary@example.com".to_string()), + exhausted: false, + primary_label: None, + five_hour_ratio: Some(0.70), + secondary_label: None, + seven_day_ratio: Some(0.20), + resets_at: Some("2026-01-01T02:00:00Z".to_string()), + error: None, + }, + ], + }; + + let best = probe + .best_available_alternative() + .expect("expected alternative account"); + assert_eq!(best.label, "backup"); + + let guidance = probe.switch_guidance().expect("expected switch guidance"); + assert!(guidance.contains("`backup`")); + assert!(guidance.contains("/account openai switch backup")); +} + +#[test] +fn test_account_usage_probe_detects_all_accounts_exhausted() { + let probe = AccountUsageProbe { + provider: MultiAccountProviderKind::Anthropic, + current_label: "primary".to_string(), + accounts: vec![ + AccountUsageSnapshot { + label: "primary".to_string(), + email: None, + exhausted: true, + primary_label: None, + five_hour_ratio: Some(1.0), + secondary_label: None, + seven_day_ratio: Some(1.0), + resets_at: None, + error: None, + }, + AccountUsageSnapshot { + label: "backup".to_string(), + email: None, + exhausted: true, + primary_label: None, + five_hour_ratio: Some(1.0), + secondary_label: None, + seven_day_ratio: Some(1.0), + resets_at: None, + error: None, + }, + ], + }; + + assert!(probe.current_exhausted()); + assert!(probe.all_accounts_exhausted()); + assert!(probe.best_available_alternative().is_none()); + assert!(probe.switch_guidance().is_none()); +} diff --git a/crates/jcode-auth-types/Cargo.toml b/crates/jcode-auth-types/Cargo.toml new file mode 100644 index 0000000..77a0c76 --- /dev/null +++ b/crates/jcode-auth-types/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "jcode-auth-types" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/crates/jcode-auth-types/src/lib.rs b/crates/jcode-auth-types/src/lib.rs new file mode 100644 index 0000000..730f4dc --- /dev/null +++ b/crates/jcode-auth-types/src/lib.rs @@ -0,0 +1,171 @@ +use serde::{Deserialize, Serialize}; + +/// State of a single auth credential +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AuthState { + /// Credential is available and valid + Available, + /// Partial configuration exists (or OAuth may be expired) + Expired, + /// Credential is not configured + #[default] + NotConfigured, +} + +impl AuthState { + /// Short, stable, log-friendly label for this credential state. + pub fn label(self) -> &'static str { + match self { + Self::Available => "available", + Self::Expired => "expired", + Self::NotConfigured => "not_configured", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthCredentialSource { + #[default] + None, + EnvironmentVariable, + AppConfigFile, + JcodeManagedFile, + TrustedExternalFile, + TrustedExternalAppState, + LocalCliSession, + AzureDefaultCredential, + Mixed, +} + +impl AuthCredentialSource { + pub fn label(self) -> &'static str { + match self { + Self::None => "none", + Self::EnvironmentVariable => "environment variable", + Self::AppConfigFile => "app config file", + Self::JcodeManagedFile => "jcode-managed file", + Self::TrustedExternalFile => "trusted external file", + Self::TrustedExternalAppState => "trusted external app state", + Self::LocalCliSession => "local CLI session", + Self::AzureDefaultCredential => "Azure DefaultAzureCredential", + Self::Mixed => "mixed", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthExpiryConfidence { + #[default] + Unknown, + Exact, + PresenceOnly, + ConfigurationOnly, + NotApplicable, +} + +impl AuthExpiryConfidence { + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Exact => "exact timestamp", + Self::PresenceOnly => "presence only", + Self::ConfigurationOnly => "configuration only", + Self::NotApplicable => "not applicable", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthRefreshSupport { + #[default] + Unknown, + Automatic, + Conditional, + ManualRelogin, + ExternalManaged, + NotApplicable, +} + +impl AuthRefreshSupport { + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Automatic => "automatic", + Self::Conditional => "conditional", + Self::ManualRelogin => "manual re-login", + Self::ExternalManaged => "external/manual", + Self::NotApplicable => "not applicable", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthValidationMethod { + #[default] + Unknown, + PresenceCheck, + TimestampCheck, + ConfigurationCheck, + TrustedImportScan, + CommandProbe, + CompositeProbe, +} + +impl AuthValidationMethod { + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::PresenceCheck => "presence check", + Self::TimestampCheck => "timestamp check", + Self::ConfigurationCheck => "configuration check", + Self::TrustedImportScan => "trusted import scan", + Self::CommandProbe => "command probe", + Self::CompositeProbe => "composite probe", + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthReadinessLevel { + #[default] + None, + CredentialPresent, + Authenticated, + RequestValid, + DeploymentValid, +} + +impl AuthReadinessLevel { + pub fn label(self) -> &'static str { + match self { + Self::None => "not configured", + Self::CredentialPresent => "credential present", + Self::Authenticated => "authenticated", + Self::RequestValid => "request valid", + Self::DeploymentValid => "deployment valid", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderValidationRecord { + pub checked_at_ms: i64, + pub success: bool, + pub provider_smoke_ok: Option, + pub tool_smoke_ok: Option, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderRefreshRecord { + pub last_attempt_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_success_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} diff --git a/crates/jcode-azure-auth/Cargo.toml b/crates/jcode-azure-auth/Cargo.toml new file mode 100644 index 0000000..569e685 --- /dev/null +++ b/crates/jcode-azure-auth/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "jcode-azure-auth" +version = "0.1.0" +edition = "2024" + +[lib] +name = "jcode_azure_auth" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +# Use rustls instead of native-tls so the whole binary stays OpenSSL-free +# (required for the BoringSSL-backed websearch client to link; see GH #270). +# azure 1.0 makes the reqwest TLS backend selectable; 0.24 hardcoded native-tls. +azure_core = { version = "1.0", default-features = false, features = ["reqwest_rustls", "reqwest_deflate", "reqwest_gzip", "tokio"] } +azure_identity = { version = "1.0", default-features = false, features = ["tokio"] } diff --git a/crates/jcode-azure-auth/src/lib.rs b/crates/jcode-azure-auth/src/lib.rs new file mode 100644 index 0000000..4a2697c --- /dev/null +++ b/crates/jcode-azure-auth/src/lib.rs @@ -0,0 +1,8 @@ +use anyhow::Result; +use azure_core::credentials::TokenCredential; + +pub async fn get_bearer_token(scope: &str) -> Result { + let credential = azure_identity::DeveloperToolsCredential::new(None)?; + let token = credential.get_token(&[scope], None).await?; + Ok(token.token.secret().to_string()) +} diff --git a/crates/jcode-background-types/Cargo.toml b/crates/jcode-background-types/Cargo.toml new file mode 100644 index 0000000..2a4336e --- /dev/null +++ b/crates/jcode-background-types/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "jcode-background-types" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/crates/jcode-background-types/src/lib.rs b/crates/jcode-background-types/src/lib.rs new file mode 100644 index 0000000..4971e0b --- /dev/null +++ b/crates/jcode-background-types/src/lib.rs @@ -0,0 +1,153 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Status of a background task. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum BackgroundTaskStatus { + Running, + Completed, + Superseded, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BackgroundTaskProgressKind { + Determinate, + Indeterminate, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BackgroundTaskProgressSource { + Reported, + ParsedOutput, + Heuristic, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BackgroundTaskProgress { + pub kind: BackgroundTaskProgressKind, + pub percent: Option, + pub message: Option, + pub current: Option, + pub total: Option, + pub unit: Option, + pub eta_seconds: Option, + pub updated_at: String, + pub source: BackgroundTaskProgressSource, +} + +impl BackgroundTaskProgress { + pub fn normalize(mut self) -> Self { + if let (Some(current), Some(total)) = (self.current, self.total) + && total > 0 + && self.percent.is_none() + { + let computed = (current as f64 / total as f64) * 100.0; + self.percent = Some(((computed * 100.0).round() / 100.0) as f32); + } + + self.percent = self + .percent + .map(|percent| ((percent.clamp(0.0, 100.0) * 100.0).round()) / 100.0); + + if matches!(self.kind, BackgroundTaskProgressKind::Indeterminate) + && (self.percent.is_some() + || matches!((self.current, self.total), (_, Some(total)) if total > 0)) + { + self.kind = BackgroundTaskProgressKind::Determinate; + } + + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BackgroundTaskProgressEvent { + pub task_id: String, + pub tool_name: String, + pub display_name: Option, + pub session_id: String, + pub progress: BackgroundTaskProgress, +} + +/// Event sent when a background task completes. +#[derive(Debug, Clone)] +pub struct BackgroundTaskCompleted { + pub task_id: String, + pub tool_name: String, + pub display_name: Option, + pub session_id: String, + pub status: BackgroundTaskStatus, + pub exit_code: Option, + pub output_preview: String, + pub output_file: PathBuf, + pub duration_secs: f64, + pub notify: bool, + pub wake: bool, +} + +/// Render a one-line human-readable progress display: an optional progress bar, +/// a textual summary, and the source label (for example `[##--] 50% (reported)`). +pub fn format_progress_display(progress: &BackgroundTaskProgress, width: usize) -> String { + let summary = format_progress_summary(progress); + let source = progress_source_label(&progress.source); + match render_progress_bar(progress, width) { + Some(bar) => format!("{} {} ({})", bar, summary, source), + None => format!("{} ({})", summary, source), + } +} + +/// Build the textual portion of a progress display (percentage/counts/message). +pub fn format_progress_summary(progress: &BackgroundTaskProgress) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(percent) = progress.percent { + parts.push(format!("{:.0}%", percent)); + } else if let (Some(current), Some(total)) = (progress.current, progress.total) { + let mut counts = format!("{}/{}", current, total); + if let Some(unit) = progress.unit.as_deref() { + counts.push(' '); + counts.push_str(unit); + } + parts.push(counts); + } else if let Some(unit) = progress.unit.as_deref() { + parts.push(unit.to_string()); + } + + if let Some(message) = progress.message.as_deref() { + parts.push(message.to_string()); + } + + if parts.is_empty() { + match progress.kind { + BackgroundTaskProgressKind::Determinate => "progress reported".to_string(), + BackgroundTaskProgressKind::Indeterminate => "working".to_string(), + } + } else { + parts.join(" · ") + } +} + +fn progress_source_label(source: &BackgroundTaskProgressSource) -> &'static str { + match source { + BackgroundTaskProgressSource::Reported => "reported", + BackgroundTaskProgressSource::ParsedOutput => "parsed", + BackgroundTaskProgressSource::Heuristic => "estimated", + } +} + +/// Render an ASCII progress bar for a determinate progress value, if known. +pub fn render_progress_bar(progress: &BackgroundTaskProgress, width: usize) -> Option { + let percent = progress.percent?; + let width = width.max(4); + let filled = ((percent / 100.0) * width as f32).round() as usize; + let filled = filled.min(width); + Some(format!( + "[{}{}]", + "#".repeat(filled), + "-".repeat(width.saturating_sub(filled)) + )) +} diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml new file mode 100644 index 0000000..202c1ab --- /dev/null +++ b/crates/jcode-base/Cargo.toml @@ -0,0 +1,152 @@ +[package] +name = "jcode-base" +version = "0.1.0" +edition = "2024" +publish = false + +# Foundational layer of the jcode application core: the downward-closed set of +# modules (provider, auth, config, session, message, memory, telemetry, ...) +# that the server/tool/agent layer depends on. Split out of `jcode-app-core` so +# the two halves compile as separate rustc units, halving peak compile memory. +# `jcode-app-core` re-exports this crate (`pub use jcode_base::*`) so that every +# existing `crate::` path keeps resolving unchanged. + +[lib] +name = "jcode_base" +path = "src/lib.rs" + +[dependencies] +# Memory allocator (reduces fragmentation for long-running server) +tikv-jemalloc-ctl = { version = "0.6", optional = true } +tikv-jemalloc-sys = { version = "0.6", optional = true } + +# Async runtime +tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +futures = "0.3" +async-trait = "0.1" + +# HTTP client +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } +serde_yaml = "0.9" +toml = "0.8" + +# File operations + +# Utilities +dirs = "5" # home directory +anyhow = "1" +libc = "0.2" # Unix system calls (flock) +chrono = { version = "0.4", features = ["serde"] } +regex = "1" +urlencoding = "2" # URL encoding for web search +uuid = { version = "1", features = ["v4", "v5"] } +proctitle = "0.1" + +# Embeddings (local inference) - behind feature flag (163 crates, slow to compile) +jcode-embedding = { path = "../jcode-embedding", optional = true } +jcode-gateway-types = { path = "../jcode-gateway-types" } +jcode-import-core = { path = "../jcode-import-core" } +jcode-logging = { path = "../jcode-logging" } + +# OAuth +base64 = "0.22" +sha2 = "0.10" +rand = "0.9.3" +hex = "0.4" +url = "2" +open = "5" # Open URLs in browser +jcode-auth-types = { path = "../jcode-auth-types" } +jcode-azure-auth = { path = "../jcode-azure-auth" } +jcode-agent-runtime = { path = "../jcode-agent-runtime" } +jcode-provider-metadata = { path = "../jcode-provider-metadata" } +jcode-provider-env = { path = "../jcode-provider-env" } +jcode-provider-core = { path = "../jcode-provider-core" } +# default-features=false so this crate's `bedrock` feature (below) controls the +# AWS SDK stack; the pure model-metadata/credential-detection layer is always on. +jcode-provider-bedrock = { path = "../jcode-provider-bedrock", default-features = false } +jcode-provider-antigravity = { path = "../jcode-provider-antigravity" } +jcode-provider-copilot = { path = "../jcode-provider-copilot" } +jcode-provider-openai = { path = "../jcode-provider-openai" } +jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } +jcode-provider-gemini = { path = "../jcode-provider-gemini" } +# NOTE: this foundation layer intentionally does NOT depend on any `jcode-tui-*` +# crate. The two pure-data/string symbols it used to reach for (`ResumeTarget`, +# `reasoning_line_markup`) were moved to `jcode-session-types` and +# `jcode-render-core` respectively, so the layering inversion (foundation +# depending on presentation) is gone. +jcode-render-core = { path = "../jcode-render-core" } +jcode-terminal-launch = { path = "../jcode-terminal-launch" } +jcode-terminal-image = { path = "../jcode-terminal-image" } +jcode-telemetry-core = { path = "../jcode-telemetry-core" } +jcode-usage-types = { path = "../jcode-usage-types" } + +# Streaming + +# Terminal I/O (event stream for interactive auth/secret prompts). NOTE: the +# heavier presentation deps (ratatui widgets, arboard clipboard) were dropped +# from this foundation layer; they are unused here and live only in `jcode-tui`. +crossterm = { version = "0.29", features = ["event-stream"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Only PNG/JPEG (skip avif/rav1e, exr, gif, tiff, etc) + +# NOTE: PDF text extraction (jcode-pdf) lives in the upper jcode-app-core crate +# (tool/read.rs), not here. +jcode-background-types = { path = "../jcode-background-types" } +jcode-batch-types = { path = "../jcode-batch-types" } +jcode-build-meta = { path = "../jcode-build-meta" } +jcode-compaction-core = { path = "../jcode-compaction-core" } +jcode-config-types = { path = "../jcode-config-types" } +jcode-core = { path = "../jcode-core" } +jcode-memory-types = { path = "../jcode-memory-types" } +jcode-message-types = { path = "../jcode-message-types" } +jcode-plan = { path = "../jcode-plan" } +jcode-protocol = { path = "../jcode-protocol" } +jcode-session-types = { path = "../jcode-session-types" } +jcode-storage = { path = "../jcode-storage" } +jcode-task-types = { path = "../jcode-task-types" } +jcode-tool-core = { path = "../jcode-tool-core" } +jcode-tool-types = { path = "../jcode-tool-types" } +jcode-side-panel-types = { path = "../jcode-side-panel-types" } + +# Gzip decoding (used by provider import/helpers) +tempfile = "3" +qrcode = { version = "0.14.1", default-features = false } + +[features] +default = ["embeddings", "bedrock"] +# Compiles the in-crate test helpers (storage::lock_test_env, +# auth::test_sandbox, bus::reset_models_updated_publish_state_for_tests, ...) as +# `pub` so downstream crates' test targets can reach them. Enabled as a +# dev-dependency feature by jcode-app-core / jcode / etc.; never in normal +# (non-test) builds. +test-support = [] +jemalloc = [ + "dep:tikv-jemalloc-ctl", + "dep:tikv-jemalloc-sys", + "tikv-jemalloc-ctl/stats", +] +jemalloc-prof = [ + "jemalloc", + "tikv-jemalloc-ctl/profiling", +] +embeddings = ["dep:jcode-embedding"] +# Live AWS Bedrock support (the ~20-crate AWS SDK stack) lives in +# jcode-provider-bedrock (aws-sdk); forward there. Without it the Bedrock +# provider still compiles but live calls return an explanatory error. +bedrock = ["jcode-provider-bedrock/aws-sdk"] + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + +[target.'cfg(target_os = "macos")'.dependencies] +global-hotkey = "0.7" + +[dev-dependencies] +# Dev-only cycle (cargo permits dev-dependencies on dependents): base's +# MultiProvider routing tests exercise the real OpenRouter/OpenAI-compatible +# runtime through the composition-root registry, exactly like the binary. +jcode-provider-openrouter-runtime = { path = "../jcode-provider-openrouter-runtime" } diff --git a/crates/jcode-base/src/auth/account_store.rs b/crates/jcode-base/src/auth/account_store.rs new file mode 100644 index 0000000..d33269d --- /dev/null +++ b/crates/jcode-base/src/auth/account_store.rs @@ -0,0 +1,272 @@ +use anyhow::Result; +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +/// Runtime (process-local) active-account overrides, keyed by provider +/// prefix ("claude", "openai", ...). Lets `/account switch