diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a42a3dcc4..5a67549d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -286,6 +286,33 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile + # Build the `codeg-mcp` companion binary for the matrix target and + # stage it as a Tauri sidecar at + # `src-tauri/binaries/codeg-mcp-{.exe}`. `tauri-action` (below) + # then bundles it via the `bundle.externalBin` entry in + # `tauri.conf.json` — Tauri installs it next to the main executable + # (Contents/MacOS on macOS, install root on Linux/Windows) where the + # runtime `locate_codeg_mcp_binary()` finds it via `current_exe` + # sibling lookup. The Linux arm64 cross env vars set above also + # apply to this cargo invocation. + - name: Stage codeg-mcp sidecar for Tauri bundle + shell: bash + run: pnpm tauri:prepare-sidecars --target ${{ matrix.target }} + + - name: Verify codeg-mcp sidecar landed + shell: bash + run: | + ext="" + case "${{ matrix.target }}" in + *windows*) ext=".exe" ;; + esac + file="src-tauri/binaries/codeg-mcp-${{ matrix.target }}${ext}" + if [ ! -f "$file" ]; then + echo "FATAL: sidecar $file missing after prepare-sidecars" + exit 1 + fi + ls -la "$file" + - name: Build and upload to draft release (Linux arm64) if: matrix.target == 'aarch64-unknown-linux-gnu' uses: tauri-apps/tauri-action@v0.6.1 @@ -425,15 +452,24 @@ jobs: pnpm install --frozen-lockfile pnpm build - - name: Build server binary + - name: Build server + codeg-mcp companion working-directory: src-tauri - run: cargo build --release --bin codeg-server --no-default-features --target ${{ matrix.target }} + # `codeg-mcp` is the stdio MCP companion the runtime injects per + # session (see acp/delegation/companion.rs). Built with the same + # `--no-default-features --target` flags as the server so it shares + # the cross-compile env (Linux arm64) without dragging in tauri + # runtime deps. + run: | + cargo build --release --bin codeg-server --no-default-features --target ${{ matrix.target }} + cargo build --release --bin codeg-mcp --no-default-features --target ${{ matrix.target }} - name: Package (Unix) if: runner.os != 'Windows' run: | mkdir -p dist/${{ matrix.artifact }} cp src-tauri/target/${{ matrix.target }}/release/codeg-server dist/${{ matrix.artifact }}/ + cp src-tauri/target/${{ matrix.target }}/release/codeg-mcp dist/${{ matrix.artifact }}/ + chmod +x dist/${{ matrix.artifact }}/codeg-server dist/${{ matrix.artifact }}/codeg-mcp cp -r out dist/${{ matrix.artifact }}/web cd dist && tar czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }} @@ -443,9 +479,42 @@ jobs: run: | New-Item -ItemType Directory -Force -Path dist/${{ matrix.artifact }} Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-server.exe dist/${{ matrix.artifact }}/ + Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-mcp.exe dist/${{ matrix.artifact }}/ Copy-Item -Recurse out dist/${{ matrix.artifact }}/web Compress-Archive -Path dist/${{ matrix.artifact }} -DestinationPath dist/${{ matrix.artifact }}.zip + - name: Smoke test packaged artifact (Unix) + if: runner.os != 'Windows' + run: | + set -euo pipefail + test -x "dist/${{ matrix.artifact }}/codeg-server" + test -x "dist/${{ matrix.artifact }}/codeg-mcp" + # `codeg-mcp --help` exits 0 without flags and prints the usage + # line. Only run it for native targets; cross-compiled arm64 + # binaries on x64 runners can't execute. + if [ "${{ matrix.target }}" = "x86_64-unknown-linux-gnu" ] || \ + [ "${{ matrix.target }}" = "x86_64-apple-darwin" ] || \ + [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then + "dist/${{ matrix.artifact }}/codeg-mcp" --help | grep -F 'codeg-mcp --parent-connection-id' + else + echo "skipping codeg-mcp --help on cross-target ${{ matrix.target }}" + fi + + - name: Smoke test packaged artifact (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + if (-not (Test-Path "dist/${{ matrix.artifact }}/codeg-server.exe")) { + throw "codeg-server.exe missing from packaged artifact" + } + if (-not (Test-Path "dist/${{ matrix.artifact }}/codeg-mcp.exe")) { + throw "codeg-mcp.exe missing from packaged artifact" + } + $help = & "dist/${{ matrix.artifact }}/codeg-mcp.exe" --help + if ($help -notlike "*codeg-mcp --parent-connection-id*") { + throw "codeg-mcp --help output unexpected: $help" + } + - name: Upload artifact for Docker build (Linux only) if: startsWith(matrix.target, 'x86_64-unknown-linux') || startsWith(matrix.target, 'aarch64-unknown-linux') uses: actions/upload-artifact@v4 @@ -492,11 +561,20 @@ jobs: - name: Prepare Docker build context run: | + set -euo pipefail mkdir -p dist/amd64 dist/arm64 cp artifacts/linux-x64/codeg-server dist/amd64/codeg-server + cp artifacts/linux-x64/codeg-mcp dist/amd64/codeg-mcp cp artifacts/linux-arm64/codeg-server dist/arm64/codeg-server + cp artifacts/linux-arm64/codeg-mcp dist/arm64/codeg-mcp cp -r artifacts/linux-x64/web dist/web - chmod +x dist/amd64/codeg-server dist/arm64/codeg-server + chmod +x dist/amd64/codeg-server dist/amd64/codeg-mcp \ + dist/arm64/codeg-server dist/arm64/codeg-mcp + # Fail fast if any companion went missing — Docker would otherwise + # produce an image where delegation silently degrades. + for f in dist/amd64/codeg-mcp dist/arm64/codeg-mcp; do + test -x "$f" || { echo "FATAL: $f missing or non-exec"; exit 1; } + done - name: Set up QEMU (for multi-arch manifest) uses: docker/setup-qemu-action@v3 diff --git a/.gitignore b/.gitignore index 732cc43a5..cd01c54e3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,9 @@ coverage/ # Agent .claude .docs -docs/superpowers \ No newline at end of file +docs/superpowers + +# Tauri sidecar staging (built per-target by prepare-sidecars.mjs). +# The binaries are platform-specific build artifacts; ship them through +# release.yml, not git. +src-tauri/binaries/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 455bea12f..edc3106fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,16 @@ COPY public/ ./public/ COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ RUN pnpm build -# Stage 2: Build Rust server binary +# Stage 2: Build Rust server binary + codeg-mcp companion FROM rust:slim-bookworm AS backend RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app/src-tauri COPY src-tauri/ ./ -RUN cargo build --release --bin codeg-server --no-default-features +# codeg-mcp is the stdio MCP companion the runtime injects per session +# (see acp/delegation/companion.rs). It must ship next to codeg-server so +# `locate_codeg_mcp_binary()` finds it via the exe-sibling lookup. +RUN cargo build --release --bin codeg-server --no-default-features \ + && cargo build --release --bin codeg-mcp --no-default-features # Stage 3: Runtime FROM node:22-bookworm-slim @@ -29,6 +33,7 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* COPY --from=backend /app/src-tauri/target/release/codeg-server /usr/local/bin/codeg-server +COPY --from=backend /app/src-tauri/target/release/codeg-mcp /usr/local/bin/codeg-mcp COPY --from=frontend /app/out /app/web ENV CODEG_STATIC_DIR=/app/web diff --git a/Dockerfile.ci b/Dockerfile.ci index 4391e66d9..9caab9228 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -15,6 +15,10 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* COPY dist/${TARGETARCH}/codeg-server /usr/local/bin/codeg-server +# codeg-mcp is the stdio MCP companion the runtime injects per session +# (see acp/delegation/companion.rs). It must ship next to codeg-server so +# `locate_codeg_mcp_binary()` finds it via the exe-sibling lookup. +COPY dist/${TARGETARCH}/codeg-mcp /usr/local/bin/codeg-mcp COPY dist/web /app/web ENV CODEG_STATIC_DIR=/app/web diff --git a/install.ps1 b/install.ps1 index 12744dd5c..5ef79d64f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -15,9 +15,17 @@ $ErrorActionPreference = "Stop" $Repo = "xintaofei/codeg" $Artifact = "codeg-server-windows-x64" -# Stale codeg-server binaries elsewhere in PATH are removed by default so the -# user's `codeg-server` command always runs the freshly installed binary. Pass -# -NoCleanup (or set CODEG_NO_CLEANUP=1) to disable. +# Names of binaries this installer manages. codeg-server is the user-facing +# entry point; codeg-mcp is the stdio MCP companion that the server's ACP +# layer spawns per session for delegation. Both must live in the same +# directory — `locate_codeg_mcp_binary()` in src-tauri/src/acp/connection.rs +# resolves the companion as a sibling of the running server executable. +$ManagedBins = @("codeg-server", "codeg-mcp") + +# Stale codeg-server / codeg-mcp binaries elsewhere in PATH are removed by +# default so the user's `codeg-server` command always runs the freshly +# installed binary AND the runtime locates the matching companion via the +# exe-sibling lookup. Pass -NoCleanup (or set CODEG_NO_CLEANUP=1) to disable. $Cleanup = -not $NoCleanup if ($env:CODEG_NO_CLEANUP -eq "1") { $Cleanup = $false @@ -87,6 +95,10 @@ $PathConflicts = @() $seenReal = @{} $pathDirs = @() if ($env:Path) { $pathDirs = $env:Path.Split(';') } +# Scan PATH for both managed binaries — a stale `codeg-mcp.exe` in an earlier +# PATH entry would be picked by the runtime's `which` fallback once +# `codeg-server.exe` was upgraded out from under it, breaking delegation in +# subtle ways. Track conflicts uniformly for cleanup. foreach ($dir in $pathDirs) { if (-not $dir) { continue } # Match by canonical path string so the destination is recognized even when @@ -95,13 +107,15 @@ foreach ($dir in $pathDirs) { if ($dirReal -eq $InstallDirReal) { break } - foreach ($leaf in @("codeg-server.exe", "codeg-server")) { - $bin = Join-Path $dir $leaf - if (Test-Path -LiteralPath $bin -PathType Leaf) { - $real = Get-CanonicalPath $bin - if ($seenReal.ContainsKey($real)) { continue } - $seenReal[$real] = $true - $PathConflicts += $bin + foreach ($name in $ManagedBins) { + foreach ($leaf in @("$name.exe", $name)) { + $bin = Join-Path $dir $leaf + if (Test-Path -LiteralPath $bin -PathType Leaf) { + $real = Get-CanonicalPath $bin + if ($seenReal.ContainsKey($real)) { continue } + $seenReal[$real] = $true + $PathConflicts += $bin + } } } } @@ -199,15 +213,25 @@ Write-Host "Extracting..." Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force # ── Install ── +# +# Verify both binaries are present in the archive BEFORE writing anything +# to InstallDir. Without the companion, delegation degrades silently on +# every new ACP session — fail fast instead. New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -$BinarySrc = Join-Path $TmpDir $Artifact "codeg-server.exe" -if (-not (Test-Path $BinarySrc)) { - Write-Error "Binary not found in archive" - exit 1 +foreach ($name in $ManagedBins) { + $src = Join-Path $TmpDir $Artifact "$name.exe" + if (-not (Test-Path $src)) { + Write-Error "$name.exe not found in archive $Artifact.zip — release is incomplete, please report." + exit 1 + } +} +foreach ($name in $ManagedBins) { + $src = Join-Path $TmpDir $Artifact "$name.exe" + $dst = Join-Path $InstallDir "$name.exe" + Copy-Item $src -Destination $dst -Force } -Copy-Item $BinarySrc -Destination (Join-Path $InstallDir "codeg-server.exe") -Force # Re-canonicalize destination now that the file exists. Pre-install canon may # leave the final non-existent component unresolved, which would mis-compare @@ -279,8 +303,21 @@ if (-not $InstalledVer) { $InstalledVer = $TargetVer } Write-Host "" Write-Host "codeg-server installed to $InstallDir\codeg-server.exe" +Write-Host "codeg-mcp installed to $InstallDir\codeg-mcp.exe" Write-Host "Version: $InstalledVer" +# Final smoke: codeg-mcp.exe must exist next to codeg-server.exe so the +# runtime's `locate_codeg_mcp_binary()` exe-sibling lookup hits. A failure +# here means the zip was malformed or a previous Copy-Item was silently +# blocked — surface it loudly rather than ship a half-broken install. +$McpPath = Join-Path $InstallDir "codeg-mcp.exe" +if (-not (Test-Path -LiteralPath $McpPath)) { + Write-Host "" + Write-Host "Error: $McpPath missing after install." + Write-Host " Delegation (sub-agent tooling) will not work. Re-run the installer." + $ExitStatus = 1 +} + # Verify the user's `codeg-server` command actually resolves to the new binary. $ActiveBinAfter = "" $resolvedAfter = Get-Command codeg-server -ErrorAction SilentlyContinue diff --git a/install.sh b/install.sh index 5545340ae..d12140340 100755 --- a/install.sh +++ b/install.sh @@ -11,14 +11,23 @@ set -euo pipefail REPO="xintaofei/codeg" INSTALL_DIR="${CODEG_INSTALL_DIR:-/usr/local/bin}" VERSION="" -# Stale codeg-server binaries elsewhere in PATH are removed by default so the -# user's `codeg-server` command always runs the freshly installed binary. Set -# CODEG_NO_CLEANUP=1 (or pass --no-cleanup) to disable. +# Stale codeg-server / codeg-mcp binaries elsewhere in PATH are removed by +# default so the user's `codeg-server` command always runs the freshly +# installed binary AND the runtime locates the matching companion via the +# exe-sibling lookup. Set CODEG_NO_CLEANUP=1 (or pass --no-cleanup) to +# disable. CLEANUP_CONFLICTS=1 if [ "${CODEG_NO_CLEANUP:-0}" = "1" ]; then CLEANUP_CONFLICTS=0 fi +# Names of binaries this installer manages. `codeg-server` is the user-facing +# entry point; `codeg-mcp` is the stdio MCP companion that the server's ACP +# layer spawns per session for delegation. Both must live in the same +# directory — `locate_codeg_mcp_binary()` in src-tauri/src/acp/connection.rs +# resolves the companion as a sibling of the running server executable. +MANAGED_BINS=(codeg-server codeg-mcp) + # ── Parse arguments ── while [[ $# -gt 0 ]]; do @@ -119,6 +128,10 @@ DEST_BIN="${INSTALL_DIR}/codeg-server" DEST_BIN_REAL="$(canon_path "$DEST_BIN")" INSTALL_DIR_REAL="$(canon_path "$INSTALL_DIR")" +# Scan PATH for both managed binaries — a stale `codeg-mcp` in an earlier +# PATH entry would be picked by the runtime's `which` fallback once +# `codeg-server` was upgraded out from under it, breaking delegation in +# subtle ways. Track conflicts uniformly for cleanup. PATH_CONFLICTS=() DEST_IN_PATH=0 _SEEN_REAL=":" @@ -131,15 +144,17 @@ for _dir in "${_PATH_DIRS[@]}"; do DEST_IN_PATH=1 break fi - _bin="$_dir/codeg-server" - if [ -f "$_bin" ] && [ -x "$_bin" ]; then - _real="$(canon_path "$_bin")" - case "$_SEEN_REAL" in - *":$_real:"*) continue ;; - esac - _SEEN_REAL="${_SEEN_REAL}${_real}:" - PATH_CONFLICTS+=("$_bin") - fi + for _name in "${MANAGED_BINS[@]}"; do + _bin="$_dir/$_name" + if [ -f "$_bin" ] && [ -x "$_bin" ]; then + _real="$(canon_path "$_bin")" + case "$_SEEN_REAL" in + *":$_real:"*) continue ;; + esac + _SEEN_REAL="${_SEEN_REAL}${_real}:" + PATH_CONFLICTS+=("$_bin") + fi + done done # If the destination directory isn't on PATH, nothing "shadows" the install — @@ -249,23 +264,40 @@ fi echo "Extracting..." tar xzf "${TMP_DIR}/${ARTIFACT}.tar.gz" -C "$TMP_DIR" -# ── Install binary ── - -BINARY_SRC="${TMP_DIR}/${ARTIFACT}/codeg-server" -if [ ! -f "$BINARY_SRC" ]; then - echo "Error: binary not found in archive" - exit 1 -fi +# ── Install binaries ── +# +# Verify both binaries are present in the archive BEFORE writing anything +# to INSTALL_DIR. Without the companion, delegation degrades silently on +# every new ACP session — fail fast instead. + +for _name in "${MANAGED_BINS[@]}"; do + if [ ! -f "${TMP_DIR}/${ARTIFACT}/${_name}" ]; then + echo "Error: ${_name} not found in archive ${ARTIFACT}.tar.gz" + echo " This release tarball is incomplete; please report it." + exit 1 + fi +done mkdir -p "$INSTALL_DIR" -if [ -w "$INSTALL_DIR" ]; then - cp "$BINARY_SRC" "${INSTALL_DIR}/codeg-server" - chmod +x "${INSTALL_DIR}/codeg-server" -else +_install_one() { + local name="$1" + local src="${TMP_DIR}/${ARTIFACT}/${name}" + local dst="${INSTALL_DIR}/${name}" + if [ -w "$INSTALL_DIR" ]; then + cp "$src" "$dst" + chmod +x "$dst" + else + sudo cp "$src" "$dst" + sudo chmod +x "$dst" + fi +} + +if [ ! -w "$INSTALL_DIR" ]; then echo "Need sudo to install to ${INSTALL_DIR}" - sudo cp "$BINARY_SRC" "${INSTALL_DIR}/codeg-server" - sudo chmod +x "${INSTALL_DIR}/codeg-server" fi +for _name in "${MANAGED_BINS[@]}"; do + _install_one "$_name" +done # Re-canonicalize destination now that the file exists. Pre-install canon may # leave the final non-existent component unresolved (notably macOS readlink -f), @@ -325,9 +357,21 @@ fi echo "" echo "codeg-server installed to ${INSTALL_DIR}/codeg-server" +echo "codeg-mcp installed to ${INSTALL_DIR}/codeg-mcp" INSTALLED_VER=$("${INSTALL_DIR}/codeg-server" --version 2>/dev/null || echo "${TARGET_VER}") echo "Version: ${INSTALLED_VER}" +# Final smoke: codeg-mcp must exist next to codeg-server so the runtime's +# `locate_codeg_mcp_binary()` exe-sibling lookup hits. A failure here means +# the tarball was malformed or a previous `_install_one` was silently +# blocked — surface it loudly rather than ship a half-broken install. +if [ ! -x "${INSTALL_DIR}/codeg-mcp" ]; then + echo "" + echo "Error: ${INSTALL_DIR}/codeg-mcp missing or not executable after install." + echo " Delegation (sub-agent tooling) will not work. Re-run the installer." + EXIT_STATUS=1 +fi + # Verify the user's `codeg-server` command actually resolves to the new binary. ACTIVE_BIN_AFTER="" if command -v codeg-server >/dev/null 2>&1; then diff --git a/package.json b/package.json index 22f24414f..983781022 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "test:coverage": "vitest run --coverage", "server:build": "cd src-tauri && cargo build --release --bin codeg-server --no-default-features", "server:dev": "cd src-tauri && cargo run --bin codeg-server --no-default-features", + "tauri:prepare-sidecars": "node src-tauri/scripts/prepare-sidecars.mjs", + "tauri:before-dev": "pnpm tauri:prepare-sidecars && pnpm dev", + "tauri:before-build": "pnpm build && pnpm tauri:prepare-sidecars", "postinstall": "node -e \"const fs=require('fs');fs.cpSync('node_modules/monaco-editor/min/vs','public/vs',{recursive:true,force:true});const p='public/vs/loader.js';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/\\n\\/\\/# sourceMappingURL=.*/,''))\"" }, "dependencies": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c233c419d..e83b3d9da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,11 @@ name = "codeg-server" path = "src/bin/codeg_server.rs" required-features = [] +[[bin]] +name = "codeg-mcp" +path = "src/bin/codeg_mcp.rs" +required-features = [] + [build-dependencies] tauri-build = { version = "2", features = [], optional = true } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 651de27f7..09e74bc16 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,72 @@ fn main() { #[cfg(feature = "tauri-runtime")] - tauri_build::build(); + { + ensure_sidecar_placeholder(); + tauri_build::build(); + } +} + +/// Tauri's bundler validates that every `bundle.externalBin` path resolves +/// to an existing file at build.rs time. The real `codeg-mcp` sidecar is +/// produced by `pnpm tauri:prepare-sidecars` (invoked from +/// `beforeBuildCommand` / `beforeDevCommand` and the CI release matrix) — +/// but plain `cargo check --features tauri-runtime` doesn't go through that +/// path, so without a backstop every contributor would hit +/// `resource path ... doesn't exist` on first compile. +/// +/// We write a zero-byte placeholder when the sidecar is missing so +/// `cargo check` / clippy / rust-analyzer succeed. Production paths +/// overwrite the placeholder with the real binary before Tauri bundles it: +/// * `pnpm tauri build` → `beforeBuildCommand` → `prepare-sidecars.mjs` +/// * release.yml → explicit "Stage codeg-mcp sidecar" step +/// * `pnpm tauri dev` → `beforeDevCommand` → `prepare-sidecars.mjs` +/// +/// If you ever bypass those wrappers (e.g. invoking the Tauri CLI directly +/// without beforeBuildCommand) you'd ship the placeholder, so emit a +/// cargo:warning that surfaces in any compile log to make that loud. +#[cfg(feature = "tauri-runtime")] +fn ensure_sidecar_placeholder() { + use std::fs; + use std::path::PathBuf; + + let triple = std::env::var("TARGET").unwrap_or_default(); + if triple.is_empty() { + return; + } + let ext = if triple.contains("windows") { + ".exe" + } else { + "" + }; + let dir = PathBuf::from("binaries"); + let path = dir.join(format!("codeg-mcp-{triple}{ext}")); + + println!("cargo:rerun-if-changed={}", path.display()); + + let needs_placeholder = match fs::metadata(&path) { + Ok(meta) => meta.len() == 0, + Err(_) => true, + }; + + if needs_placeholder { + if let Err(e) = fs::create_dir_all(&dir) { + panic!("failed to create {}: {e}", dir.display()); + } + if let Err(e) = fs::write(&path, b"") { + panic!( + "failed to write sidecar placeholder {}: {e}", + path.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); + } + println!( + "cargo:warning=codeg-mcp sidecar missing at {}; wrote 0-byte placeholder. \ + Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", + path.display() + ); + } } diff --git a/src-tauri/scripts/prepare-sidecars.mjs b/src-tauri/scripts/prepare-sidecars.mjs new file mode 100644 index 000000000..cf255749d --- /dev/null +++ b/src-tauri/scripts/prepare-sidecars.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// +// Prepare Tauri sidecars before `tauri build` / `tauri dev` consume them. +// +// What it does: +// 1. Resolves the target triple — `--target ` arg, or +// `TAURI_TARGET_TRIPLE` env, or the host's `rustc -vV` host triple. +// 2. Runs `cargo build --release --bin codeg-mcp --no-default-features` +// for that triple from `src-tauri/`. +// 3. Copies the produced binary to +// `src-tauri/binaries/codeg-mcp-{.exe}` so Tauri's externalBin +// bundler picks it up under the bare name `codeg-mcp` at install time. +// +// Why a separate script (not inline in beforeBuildCommand / GitHub Actions): +// - Cross-compile in release.yml passes `--target ` so we honour +// the matrix triple rather than rebuilding for the host. +// - Local `pnpm tauri dev` / `pnpm tauri build` invoke it without args and +// get a host-triple build, so the externalBin lookup still finds a file. +// - Skippable: set `CODEG_SKIP_SIDECAR=1` when iterating on the frontend +// and you don't care about delegation. +// +// Intentionally Node-only (no shell): runs identically on macOS, Linux, +// Windows GitHub runners. + +import { execFileSync } from "node:child_process" +import { existsSync, copyFileSync, mkdirSync, chmodSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import process from "node:process" + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const SRC_TAURI = resolve(SCRIPT_DIR, "..") +const BINARIES_DIR = join(SRC_TAURI, "binaries") +const BIN_NAME = "codeg-mcp" + +function log(msg) { + console.log(`[prepare-sidecars] ${msg}`) +} + +function die(msg) { + console.error(`[prepare-sidecars][ERROR] ${msg}`) + process.exit(1) +} + +function parseArgs(argv) { + const args = { target: null } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === "--target" && argv[i + 1]) { + args.target = argv[++i] + } else if (a.startsWith("--target=")) { + args.target = a.slice("--target=".length) + } + } + return args +} + +function resolveHostTriple() { + try { + const out = execFileSync("rustc", ["-vV"], { encoding: "utf8" }) + const line = out.split(/\r?\n/).find((l) => l.startsWith("host:")) + if (!line) throw new Error("rustc -vV missing host: line") + return line.replace(/^host:\s*/, "").trim() + } catch (e) { + die(`cannot determine host triple via rustc -vV: ${e.message}`) + } +} + +function main() { + if (process.env.CODEG_SKIP_SIDECAR === "1") { + log("CODEG_SKIP_SIDECAR=1 — skipping sidecar preparation") + return + } + + const { target: cliTarget } = parseArgs(process.argv.slice(2)) + const target = + cliTarget || process.env.TAURI_TARGET_TRIPLE || resolveHostTriple() + const isWindows = target.includes("windows") + const ext = isWindows ? ".exe" : "" + + log(`target triple: ${target}`) + log(`building ${BIN_NAME} (--release --no-default-features)`) + + // cargo build needs to run from src-tauri so it resolves the local manifest + // and shares the swatinem/rust-cache key with other cargo invocations. + // `--no-default-features` keeps codeg-mcp free of the Tauri runtime deps — + // the bin's required-features is empty, so this just enables cross-compile + // without dragging in macOS-private-api / Linux WebKit / Windows WebView2. + execFileSync( + "cargo", + [ + "build", + "--release", + "--bin", + BIN_NAME, + "--no-default-features", + "--target", + target, + ], + { stdio: "inherit", cwd: SRC_TAURI } + ) + + const built = join( + SRC_TAURI, + "target", + target, + "release", + `${BIN_NAME}${ext}` + ) + if (!existsSync(built)) { + die(`expected ${built} after cargo build, but it does not exist`) + } + + mkdirSync(BINARIES_DIR, { recursive: true }) + const dest = join(BINARIES_DIR, `${BIN_NAME}-${target}${ext}`) + copyFileSync(built, dest) + if (!isWindows) { + // copyFileSync preserves modes on POSIX, but be explicit for tarball + // sources that may strip the +x bit. + chmodSync(dest, 0o755) + } + log(`sidecar staged at ${dest}`) +} + +main() diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 8f9ba2104..aadb46f15 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -377,6 +377,7 @@ pub async fn spawn_agent_connection( connections: Arc>>, preferred_mode_id: Option, preferred_config_values: BTreeMap, + delegation_injection: Option, ) -> Result, AcpError> { // Create the authoritative session state up front. Subsequent emit_with_state // calls write through this state and increment its seq counter so the first @@ -452,6 +453,7 @@ pub async fn spawn_agent_connection( connection_id: cleanup_connection_id, }; + let delegation_for_cleanup = delegation_injection.clone(); let result = run_connection( agent, conn_id.clone(), @@ -464,9 +466,25 @@ pub async fn spawn_agent_connection( terminal_base_env, preferred_mode_id, preferred_config_values, + delegation_injection, ) .await; + // Revoke the per-launch token + cascade cancel any still-pending + // delegations owned by this parent connection. Both are best-effort: + // a missing token entry is a no-op, and `cancel_by_parent` is safe + // to call on an empty pending map. + if let Some(inj) = delegation_for_cleanup { + let token = { + let snap = state_clone.read().await; + snap.delegation_token.clone() + }; + if let Some(tok) = token { + inj.tokens.revoke(&tok).await; + } + inj.broker.cancel_by_parent(&conn_id).await; + } + if let Err(e) = result { let code = e.code().map(String::from); emit_with_state( @@ -815,6 +833,141 @@ fn load_mcp_servers_for_agent(agent_type: AgentType) -> Vec { out } +/// Context the connection layer needs to inject the built-in `codeg-delegate` +/// MCP entry. Built once per `run_connection` from the live AppState pieces +/// (broker config, token registry, UDS path) and passed through. +/// +/// Optional because some test paths spin up `run_connection` without a +/// full delegation stack — those just skip injection. +#[derive(Clone)] +pub struct DelegationInjection { + pub broker: Arc, + pub tokens: Arc, + pub socket_path: PathBuf, +} + +/// Locate the `codeg-mcp` companion binary across the supported deployment +/// shapes: +/// +/// 1. `CODEG_MCP_BIN` env override — explicit absolute path. Lets dev shells, +/// custom installs, and integration tests point at a freshly compiled +/// binary without touching the install layout. +/// 2. Sibling of the running executable — the production layout for every +/// shipping target. Tauri sidecar (`Contents/MacOS/codeg-mcp` on macOS, +/// next to `codeg.exe` on Windows, next to the unix binary on Linux +/// deb/rpm), `install.sh`/`install.ps1` (drops `codeg-mcp` next to +/// `codeg-server`), Docker image (`/usr/local/bin/codeg-mcp` next to +/// `codeg-server`), and `cargo build` dev output +/// (`target//codeg-mcp`). +/// 3. `PATH` lookup — last-resort for atypical layouts where ops moved the +/// two binaries apart but kept both reachable on `PATH`. +/// +/// Returns `None` when no candidate is an executable file. Callers MUST +/// treat `None` as "delegation is unavailable at this site" and skip +/// injection — never paper over with a phantom path, because that fails +/// inside the agent's MCP spawn loop and may take the entire ACP session +/// down on stricter agents. +fn locate_codeg_mcp_binary() -> Option { + let filename = if cfg!(windows) { + "codeg-mcp.exe" + } else { + "codeg-mcp" + }; + + if let Some(raw) = std::env::var_os("CODEG_MCP_BIN") { + let candidate = PathBuf::from(raw); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + + if let Some(dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + { + let candidate = dir.join(filename); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + + which::which(filename) + .ok() + .filter(|p| is_executable_file(p)) +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o111 == 0 { + return false; + } + } + true +} + +/// Append the built-in `codeg-delegate` MCP entry if delegation is enabled +/// AND the companion binary is present on disk. Returns the per-launch token +/// that was registered, or `None` when injection was skipped (disabled by +/// config, or binary missing). +/// +/// When the binary is missing we log a single-line warning and skip +/// injection rather than register the token + emit a phantom McpServerStdio +/// pointing at a non-existent path. Phantom injection would have made every +/// new ACP session ship a guaranteed-to-fail MCP server entry: stricter +/// agents (Claude Code) refuse the whole session; lax agents lose the +/// delegate tool silently. Skipping leaves the agent fully functional minus +/// `delegate_to_agent`, which is the right degradation when codeg-mcp didn't +/// make it into the install. +async fn inject_codeg_delegate_mcp( + servers: &mut Vec, + injection: &DelegationInjection, + parent_connection_id: &str, + working_dir: &Path, +) -> Option { + let cfg = injection.broker.config_snapshot().await; + if !cfg.enabled { + return None; + } + let Some(binary_path) = locate_codeg_mcp_binary() else { + eprintln!( + "[delegation][WARN] codeg-mcp companion binary not found (checked CODEG_MCP_BIN, \ + exe sibling, and PATH); skipping delegate_to_agent tool injection for \ + connection {parent_connection_id}. Reinstall codeg or set CODEG_MCP_BIN to fix." + ); + return None; + }; + let token = uuid::Uuid::new_v4().to_string(); + injection + .tokens + .register( + token.clone(), + crate::acp::delegation::listener::TokenEntry { + parent_connection_id: parent_connection_id.to_string(), + working_dir: working_dir.to_path_buf(), + }, + ) + .await; + let mut server = McpServerStdio::new("codeg-delegate", binary_path); + server = server.args(vec![ + "--parent-connection-id".to_string(), + parent_connection_id.to_string(), + "--socket-path".to_string(), + injection.socket_path.to_string_lossy().to_string(), + "--token".to_string(), + token.clone(), + ]); + servers.push(McpServer::Stdio(server)); + Some(token) +} + /// Resolve an MCP server `command` to an absolute path. /// /// The ACP spec requires `McpServerStdio.command` to be an absolute path. @@ -925,6 +1078,7 @@ async fn run_connection( terminal_base_env: BTreeMap, preferred_mode_id: Option, preferred_config_values: BTreeMap, + delegation_injection: Option, ) -> Result<(), AcpError> { let pending_perms: PendingPermissions = Arc::new(tokio::sync::Mutex::new(HashMap::new())); // `terminal_base_env` already filtered to just the credential helper @@ -1129,7 +1283,7 @@ async fn run_connection( // capabilities the agent just declared. Stdio is mandatory per // ACP spec; HTTP/SSE are gated on `mcp_capabilities.{http,sse}`. let mcp_caps = &init_resp.agent_capabilities.mcp_capabilities; - let mcp_servers: Vec = load_mcp_servers_for_agent(agent_type) + let mut mcp_servers: Vec = load_mcp_servers_for_agent(agent_type) .into_iter() .filter(|s| match s { McpServer::Stdio(_) => true, @@ -1159,6 +1313,20 @@ async fn run_connection( }) .collect(); + // Inject the built-in `codeg-delegate` MCP server. Stdio is + // unconditionally supported by the ACP wire — no `mcp_caps` + // filter needed. The returned token is stashed on the session + // state so connection teardown can revoke it. + let delegate_token = if let Some(inj) = delegation_injection.as_ref() { + inject_codeg_delegate_mcp(&mut mcp_servers, inj, &conn_id, &cwd).await + } else { + None + }; + if let Some(ref tok) = delegate_token { + let mut s = state.write().await; + s.delegation_token = Some(tok.clone()); + } + // Emit fork support capability emit_with_state( &state, @@ -1290,6 +1458,7 @@ async fn run_connection( terminal_runtime.clone(), &cwd_string, supports_fork, + delegation_injection.as_ref(), ) .await; terminal_runtime.release_all_for_session(&sid).await; @@ -1305,6 +1474,7 @@ async fn run_connection( terminal_runtime.clone(), &cwd, &cwd_string, + delegation_injection.as_ref(), ) .await } @@ -1427,6 +1597,7 @@ async fn run_connection( terminal_runtime.clone(), &cwd_string, supports_fork, + delegation_injection.as_ref(), ) .await; terminal_runtime @@ -1444,6 +1615,7 @@ async fn run_connection( terminal_runtime.clone(), &cwd, &cwd_string, + delegation_injection.as_ref(), ) .await } @@ -1499,6 +1671,7 @@ async fn run_connection( terminal_runtime.clone(), &cwd_string, supports_fork, + delegation_injection.as_ref(), ) .await; terminal_runtime.release_all_for_session(&sid).await; @@ -1514,6 +1687,7 @@ async fn run_connection( terminal_runtime.clone(), &cwd, &cwd_string, + delegation_injection.as_ref(), ) .await } @@ -2356,6 +2530,10 @@ async fn handle_fork_or_exit( terminal_runtime: Arc, _cwd: &std::path::Path, cwd_string: &str, + // Threaded through from run_connection so the forked session's + // run_conversation_loop call has the same delegation cascade + // capability as the original. + delegation_injection: Option<&DelegationInjection>, ) -> Result<(), sacp::Error> { let fork_info = match loop_result { Ok(Some(info)) => info, @@ -2419,6 +2597,7 @@ async fn handle_fork_or_exit( terminal_runtime.clone(), cwd_string, true, // fork already succeeded on this process + delegation_injection, ) .await; terminal_runtime.release_all_for_session(&new_sid).await; @@ -2436,6 +2615,7 @@ async fn handle_fork_or_exit( terminal_runtime, _cwd, cwd_string, + delegation_injection, )) .await } @@ -2535,6 +2715,10 @@ async fn run_conversation_loop<'a>( terminal_runtime: Arc, cwd: &str, supports_fork: bool, + // Source of the broker reference used to cascade-cancel pending + // delegations on parent prompt cancel / non-success TurnComplete. + // `None` for test paths that don't wire delegation. + delegation_injection: Option<&DelegationInjection>, ) -> Result, sacp::Error> { // Session-scoped cache for diffing cumulative `raw_output` snapshots // into incremental deltas. Shared across the idle loop and the active @@ -2719,6 +2903,33 @@ async fn run_conversation_loop<'a>( }, ) .await; + // Cascade-cancel any pending delegations + // whenever the parent's turn ended for a + // reason other than clean `end_turn`. The + // `end_turn` path lets the legitimate + // delegation completion drain naturally; + // every other reason (cancelled / refusal / + // max_tokens / max_turn_requests / empty / + // unknown) means the parent will never + // consume the in-flight result, so the + // child must be torn down. + // + // Fire-and-forget: the cascade per child + // does spawner.cancel + spawner.disconnect, + // which can block on slow agents — keep the + // parent's message loop unblocked and rely + // on the broker's idempotent drain so the + // cleanup-guard cascade at run_connection + // exit can't race-double-drain. + if reason_str != "end_turn" { + if let Some(inj) = delegation_injection { + let broker = inj.broker.clone(); + let parent_id = conn_id.to_string(); + tokio::spawn(async move { + broker.cancel_by_parent(&parent_id).await; + }); + } + } break; } _ => {} @@ -2759,6 +2970,21 @@ async fn run_conversation_loop<'a>( }, ) .await; + // Mirror the StopReason-message branch above: + // cascade-cancel on any non-`end_turn` reason + // so in-flight delegations don't dangle when + // the parent's turn ended without consuming + // their result. Fire-and-forget for the same + // reason as the StopReason branch — see above. + if reason_str != "end_turn" { + if let Some(inj) = delegation_injection { + let broker = inj.broker.clone(); + let parent_id = conn_id.to_string(); + tokio::spawn(async move { + broker.cancel_by_parent(&parent_id).await; + }); + } + } break; } _ = terminal_poll_interval.tick(), if !tracked_terminal_tool_calls.is_empty() => { @@ -2862,6 +3088,7 @@ async fn run_conversation_loop<'a>( RequestPermissionOutcome::Cancelled, )); } + drop(locked); // Immediately emit TurnComplete so the frontend // transitions out of "prompting" and the user can // send new messages. Don't wait for the agent -- @@ -2876,6 +3103,27 @@ async fn run_conversation_loop<'a>( }, ) .await; + // Cascade-cancel any in-flight delegations owned by + // this parent connection. Idempotent with the + // cleanup-guard cancel_by_parent at the end of + // run_connection (#1: empty pending → no-op). + // Without this, a user-initiated cancel of a parent + // prompt mid-delegation leaves the child agent + // running indefinitely until broker timeout. + // + // Fire-and-forget so the user-visible Cancel + // path doesn't wait on (potentially slow) + // child agent teardown — the user already + // sees the parent's TurnComplete above and + // the broker's drain-first lock guarantees + // no double DelegationCompleted emit. + if let Some(inj) = delegation_injection { + let broker = inj.broker.clone(); + let parent_id = conn_id.to_string(); + tokio::spawn(async move { + broker.cancel_by_parent(&parent_id).await; + }); + } // Drain the prompt response in the background so // the SACP library doesn't log "receiver dropped" // errors when the agent eventually responds. @@ -2990,6 +3238,22 @@ async fn run_conversation_loop<'a>( RequestPermissionOutcome::Cancelled, )); } + drop(locked); + // Cascade-cancel any pending delegations owned by this parent. + // Reached when Cancel arrives between prompts (idle path); the + // inner Cancel handler covers mid-prompt. Both must trigger + // because the per-prompt cancel path doesn't tear down the + // parent connection, so the cleanup-guard cancel_by_parent + // at run_connection's exit wouldn't fire. + // + // Fire-and-forget: see inner Cancel handler above for rationale. + if let Some(inj) = delegation_injection { + let broker = inj.broker.clone(); + let parent_id = conn_id.to_string(); + tokio::spawn(async move { + broker.cancel_by_parent(&parent_id).await; + }); + } } Some(ConnectionCommand::Fork { reply }) => { if !supports_fork { diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs new file mode 100644 index 000000000..c467ef418 --- /dev/null +++ b/src-tauri/src/acp/delegation/broker.rs @@ -0,0 +1,1951 @@ +//! `DelegationBroker` — the coordination unit for multi-agent delegation. +//! +//! Lifecycle of a single call: +//! +//! 1. `handle_request` is the broker's only entry point. The MCP listener +//! feeds it the LLM-issued `delegate_to_agent` payload. +//! 2. Pre-checks: feature enabled? depth limit ok? Both failures return +//! immediately, no child session created. +//! 3. Spawn the child via [`ConnectionSpawner::spawn`]. +//! 4. Send the delegation task as the first prompt via +//! [`ConnectionSpawner::send_prompt_linked_for_delegation`]. The trailing +//! [`DelegationLink`] carries the parent's `tool_use_id` and a +//! broker-internal `call_id` (UUID) — these get persisted onto the new +//! conversation row. +//! 5. Park a `oneshot::Sender` keyed by `call_id`. The race is between: +//! - the listener calling [`DelegationBroker::complete_call`] on +//! `TurnComplete`, and +//! - the broker's own `tokio::time::timeout`. +//! 6. On any resolution, the child connection is disconnected. v1 is +//! explicitly one-shot — no session reuse. +//! +//! Cancellation cascade: when a parent session goes away (user-initiated +//! cancel, parent disconnect), the lifecycle subscriber calls +//! [`DelegationBroker::cancel_by_parent`] which fans out cancel + disconnect +//! to every pending child of that parent. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use tokio::sync::{oneshot, Mutex}; + +use crate::acp::delegation::event_emitter::{DelegationEventEmitter, NoopEventEmitter}; +use crate::acp::delegation::meta_writer::{ + build_delegation_meta, is_synthetic_parent_tool_use_id, DelegationMetaWriter, NoopMetaWriter, +}; +use crate::acp::delegation::spawner::{ConnectionSpawner, DelegationLink}; +use crate::acp::delegation::types::{DelegationError, DelegationOutcome, DelegationRequest}; +use crate::acp::types::DelegationResultSummary; + +/// Lookup the `parent_id` for a conversation. Abstracted so the broker can be +/// unit-tested against an in-memory chain without touching SeaORM. +#[async_trait] +pub trait ConversationDepthLookup: Send + Sync { + async fn parent_of(&self, conversation_id: i32) -> Result, DelegationError>; +} + +#[derive(Debug, Clone)] +pub struct DelegationConfig { + pub enabled: bool, + /// Max chain depth a *new* delegation may exist at. With `depth_limit = 2` + /// the chain root → child → grandchild is allowed; the grandchild trying + /// to spawn a great-grandchild is rejected. See spec §5. + pub depth_limit: u32, + pub default_timeout: Duration, +} + +impl Default for DelegationConfig { + fn default() -> Self { + Self { + enabled: true, + depth_limit: 2, + default_timeout: Duration::from_secs(600), + } + } +} + +struct PendingCall { + child_connection_id: String, + child_conversation_id: i32, + parent_connection_id: String, + #[allow(dead_code)] // surfaced via accessors and listener payloads in later phases + parent_tool_use_id: String, + tx: oneshot::Sender, +} + +#[derive(Default)] +struct PendingCalls { + inner: Mutex>, +} + +/// FIFO of `tool_call_id`s that the ACP lifecycle observed firing +/// `delegate_to_agent` on a given parent connection but for which the +/// matching broker round-trip has not yet arrived. MCP clients (Codex, +/// Claude Code) generally do NOT populate `_meta.tool_use_id` when +/// invoking an MCP tool, so the broker can't read the LLM-issued +/// `tool_use_id` from the wire — we capture it from the parallel ACP +/// `tool_call` event stream instead. +/// +/// ACP `sessionUpdate(tool_call)` almost always lands ahead of the +/// agent's own MCP `tools/call`, so this LRU resolves the race in +/// practice. Stale entries (if any) get evicted on parent disconnect. +#[derive(Default)] +struct PendingToolCalls { + inner: Mutex>>, +} + +/// The broker is intentionally `Clone` (cheap — only `Arc`s inside) so +/// listener/handler code can hand copies to spawned tasks without lifetime +/// gymnastics. +#[derive(Clone)] +pub struct DelegationBroker { + spawner: Arc, + depth_lookup: Arc, + /// Writer for `meta["codeg.delegation"]` on the parent's active + /// `delegate_to_agent` ToolCallState. Defaults to a no-op so tests + /// that aren't exercising the meta lifecycle don't need to wire + /// anything; production constructs the broker with the + /// `ConnectionManagerMetaWriter` via `with_writers`. + meta_writer: Arc, + /// Emitter for `AcpEvent::DelegationCompleted` against the parent + /// connection's event stream. Same Noop/Mock/Production scheme as + /// the meta writer — production wires `ConnectionManagerEventEmitter` + /// via `with_writers`; tests that don't observe the event lifecycle + /// take the default Noop. + event_emitter: Arc, + pending: Arc, + pending_tool_calls: Arc, + config: Arc>, +} + +impl DelegationBroker { + pub fn new( + spawner: Arc, + depth_lookup: Arc, + ) -> Self { + Self::with_writers( + spawner, + depth_lookup, + Arc::new(NoopMetaWriter) as Arc, + Arc::new(NoopEventEmitter) as Arc, + ) + } + + /// Test-only constructor that injects a meta writer but keeps the + /// default Noop event emitter. Retained so existing meta-focused + /// tests don't have to mention the emitter parameter. New callsites + /// (and production wiring) should prefer `with_writers`. + pub fn with_meta_writer( + spawner: Arc, + depth_lookup: Arc, + meta_writer: Arc, + ) -> Self { + Self::with_writers( + spawner, + depth_lookup, + meta_writer, + Arc::new(NoopEventEmitter) as Arc, + ) + } + + /// Production-grade constructor wiring the broker to both a real + /// meta writer (`ConnectionManagerMetaWriter`) AND an event emitter + /// (`ConnectionManagerEventEmitter`). Tests that observe the full + /// lifecycle (meta writes + DelegationCompleted emits) should use + /// this with `MockMetaWriter` + `MockEventEmitter`. + pub fn with_writers( + spawner: Arc, + depth_lookup: Arc, + meta_writer: Arc, + event_emitter: Arc, + ) -> Self { + Self { + spawner, + depth_lookup, + meta_writer, + event_emitter, + pending: Arc::new(PendingCalls::default()), + pending_tool_calls: Arc::new(PendingToolCalls::default()), + config: Arc::new(Mutex::new(DelegationConfig::default())), + } + } + + /// Record a parent ACP `tool_call_id` whose title indicates the LLM is + /// invoking `delegate_to_agent`. The next broker round-trip from the + /// same `parent_connection_id` will claim this id as its + /// `parent_tool_use_id`. Bounded FIFO per connection. + pub async fn register_pending_tool_call( + &self, + parent_connection_id: &str, + tool_call_id: String, + ) { + let mut map = self.pending_tool_calls.inner.lock().await; + let queue = map + .entry(parent_connection_id.to_string()) + .or_insert_with(VecDeque::new); + // Defensive cap so an agent that fires many delegations without ever + // round-tripping can't grow this map without bound. + if queue.len() >= 32 { + queue.pop_front(); + } + queue.push_back(tool_call_id); + } + + /// Pop the oldest pending `tool_call_id` for the given parent, if any. + pub async fn take_pending_tool_call(&self, parent_connection_id: &str) -> Option { + let mut map = self.pending_tool_calls.inner.lock().await; + let queue = map.get_mut(parent_connection_id)?; + let id = queue.pop_front(); + if queue.is_empty() { + map.remove(parent_connection_id); + } + id + } + + /// `take_pending_tool_call` with a brief poll loop. Used by + /// `handle_request` to absorb the inherent race between two parallel + /// arrival paths for the parent's `delegate_to_agent` invocation: + /// + /// * ACP `session/update(tool_call)` → in-process bus → lifecycle + /// dispatcher → `register_pending_tool_call` (fast) + /// * MCP `tools/call` → stdio round-trip → companion server → + /// `handle_request` (slower, but not by much) + /// + /// In practice the ACP path lands first because it's in-process, but + /// the order is not contractually guaranteed. Without this wait, a + /// faster-than-usual MCP delivery would slip past an empty queue and + /// fall back to the synthetic `delegation-` placeholder — which + /// breaks the parent's UI binding because the frontend keys its + /// `parent_tool_use_id` map by the agent's real `tool_call_id`. + /// + /// 100 ms total polling budget (10 attempts × 10 ms) is generous: the + /// observed gap on local dev is well under 5 ms, but headroom protects + /// against busier hosts or slower MCP transports without delaying the + /// no-ACP-id fallback path materially. + async fn claim_pending_tool_call_with_brief_wait( + &self, + parent_connection_id: &str, + ) -> Option { + if let Some(id) = self.take_pending_tool_call(parent_connection_id).await { + return Some(id); + } + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(10)).await; + if let Some(id) = self.take_pending_tool_call(parent_connection_id).await { + return Some(id); + } + } + None + } + + /// Forget every pending tool_call id for the given parent. Called when + /// the parent connection tears down so stale ids don't bind to a future + /// reuse of the same connection_id (UUIDs make that unlikely but cheap + /// to defend against). + pub async fn drop_pending_tool_calls_for_parent(&self, parent_connection_id: &str) { + self.pending_tool_calls + .inner + .lock() + .await + .remove(parent_connection_id); + } + + pub async fn set_config(&self, cfg: DelegationConfig) { + *self.config.lock().await = cfg; + } + + pub async fn config_snapshot(&self) -> DelegationConfig { + self.config.lock().await.clone() + } + + /// Entry point. Drives the full lifecycle and returns whatever the parent + /// LLM should see as the `delegate_to_agent` tool_result. + pub async fn handle_request(&self, mut req: DelegationRequest) -> DelegationOutcome { + // MCP clients usually don't populate `_meta.tool_use_id`, so the + // listener will pass through an empty string. Best-effort claim the + // most recent ACP-side `tool_call_id` for this parent — with a brief + // poll loop so an MCP round-trip that out-races the in-process ACP + // `session/update` doesn't fall back to a synthetic id (which + // breaks the parent UI's `parent_tool_use_id` binding). Falls back + // to a UUID placeholder only after the wait budget is exhausted. + if req.parent_tool_use_id.is_empty() { + req.parent_tool_use_id = self + .claim_pending_tool_call_with_brief_wait(&req.parent_connection_id) + .await + .unwrap_or_else(|| format!("delegation-{}", uuid::Uuid::new_v4())); + } + let cfg = self.config_snapshot().await; + if !cfg.enabled { + return DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "delegation disabled".into(), + }, + None, + ); + } + + // --- Depth pre-check ---------------------------------------------------- + // We walk up to `limit + 1` so we know whether the *new* child would + // sit at >= limit. Cycles/dead chains saturate at the cap. + let lookup = self.depth_lookup.clone(); + let parent_depth = match crate::acp::delegation::depth::compute_depth( + req.parent_conversation_id, + |id| { + let lookup = lookup.clone(); + async move { lookup.parent_of(id).await } + }, + cfg.depth_limit + 1, + ) + .await + { + Ok(d) => d, + Err(e) => return DelegationOutcome::from_err(e, None), + }; + // The child the broker is about to create would sit at `parent_depth + 1`. + // Reject when the *child* depth would equal or exceed the limit. + if parent_depth + 1 > cfg.depth_limit { + return DelegationOutcome::from_err( + DelegationError::DepthLimitExceeded { + current_depth: parent_depth, + limit: cfg.depth_limit, + }, + None, + ); + } + + let timeout = req + .timeout_seconds + .map(Duration::from_secs) + .unwrap_or(cfg.default_timeout); + let started_at = Instant::now(); + + // --- Spawn child connection -------------------------------------------- + let child_connection_id = match self + .spawner + .spawn( + &req.parent_connection_id, + req.agent_type, + req.working_dir.clone(), + ) + .await + { + Ok(id) => id, + Err(e) => { + return DelegationOutcome::from_err( + DelegationError::SpawnFailed(e.to_string()), + None, + ); + } + }; + + // --- Send linked prompt ------------------------------------------------ + let call_id = uuid::Uuid::new_v4().to_string(); + let link = DelegationLink { + parent_conversation_id: req.parent_conversation_id, + parent_tool_use_id: req.parent_tool_use_id.clone(), + delegation_call_id: call_id.clone(), + }; + let child_conversation_id = match self + .spawner + .send_prompt_linked_for_delegation(&child_connection_id, req.task.clone(), link) + .await + { + Ok(cid) => cid, + Err(e) => { + let _ = self.spawner.disconnect(&child_connection_id).await; + return DelegationOutcome::from_err( + DelegationError::SpawnFailed(e.to_string()), + None, + ); + } + }; + + // --- Mark the parent's tool call as in-flight ------------------------- + // The frontend's DelegationContext seeds its `parent_tool_use_id`-keyed + // binding map from this meta on snapshot replay, so a page refresh + // mid-delegation can reconstruct the child connection / conversation + // ids without depending on the live `delegation_started` event having + // been received. + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "running", + Some(&child_connection_id), + Some(child_conversation_id), + None, + ), + ) + .await; + + // --- Register pending + race timeout vs completion -------------------- + let (tx, rx) = oneshot::channel(); + { + let mut map = self.pending.inner.lock().await; + map.insert( + call_id.clone(), + PendingCall { + child_connection_id: child_connection_id.clone(), + child_conversation_id, + parent_connection_id: req.parent_connection_id.clone(), + parent_tool_use_id: req.parent_tool_use_id.clone(), + tx, + }, + ); + } + + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(outcome)) => { + // complete_call already removed from `pending`, wrote meta, + // emitted DelegationCompleted, and disconnected; this is a + // belt-and-braces idempotent prune in case complete_call + // wasn't reached on this path (it always is in production, + // but the prune is cheap). + self.pending.inner.lock().await.remove(&call_id); + outcome + } + Ok(Err(_)) => { + // The sender was dropped before sending — should not happen in + // practice (complete_call always sends before drop), but be defensive. + // Drain pending FIRST so a racing complete_call (from a late + // lifecycle TurnComplete) finds no entry and silently no-ops + // instead of double-emitting DelegationCompleted. + let _ = self.pending.inner.lock().await.remove(&call_id); + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some("canceled"), + ), + ) + .await; + self.emit_completed_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + &child_connection_id, + child_conversation_id, + DelegationResultSummary::Err { + error_code: "canceled".to_string(), + }, + ) + .await; + let _ = self.spawner.disconnect(&child_connection_id).await; + DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "completion channel dropped".into(), + }, + Some(child_conversation_id), + ) + } + Err(_) => { + // Timeout. Drain pending FIRST so the subsequent + // spawner.disconnect (which fires the child's + // StatusChanged{Disconnected} → forward_disconnect_to_broker → + // cancel_by_child_connection cascade) finds an empty entry + // and doesn't race us into a double DelegationCompleted emit. + let _ = self.pending.inner.lock().await.remove(&call_id); + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some("timeout"), + ), + ) + .await; + self.emit_completed_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + &child_connection_id, + child_conversation_id, + DelegationResultSummary::Err { + error_code: "timeout".to_string(), + }, + ) + .await; + let _ = self.spawner.cancel(&child_connection_id).await; + let _ = self.spawner.disconnect(&child_connection_id).await; + DelegationOutcome::from_err( + DelegationError::Timeout { + elapsed_ms: started_at.elapsed().as_millis() as u64, + }, + Some(child_conversation_id), + ) + } + } + } + + /// Called by the child-session lifecycle subscriber on `TurnComplete` + /// (success path) or by error mappers (failure path). Idempotent — + /// calls on unknown `call_id` are silent no-ops. + pub async fn complete_call(&self, call_id: &str, outcome: DelegationOutcome) { + let entry = self.pending.inner.lock().await.remove(call_id); + if let Some(PendingCall { + child_connection_id, + child_conversation_id, + parent_connection_id, + parent_tool_use_id, + tx, + }) = entry + { + // Mirror the resolution onto the parent's `delegate_to_agent` + // ToolCallState meta so snapshot recovery after refresh shows + // the final state without depending on the broker's live + // `delegation_completed` event having been received. + let meta = match &outcome { + DelegationOutcome::Ok(_) => build_delegation_meta( + "completed", + Some(&child_connection_id), + Some(child_conversation_id), + None, + ), + DelegationOutcome::Err { code, .. } => build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some(code), + ), + }; + self.write_meta_if_real(&parent_connection_id, &parent_tool_use_id, meta) + .await; + self.emit_completed_if_real( + &parent_connection_id, + &parent_tool_use_id, + &child_connection_id, + child_conversation_id, + Self::outcome_to_summary(&outcome), + ) + .await; + // v1 one-shot: always tear down the child. + let _ = self.spawner.disconnect(&child_connection_id).await; + let _ = tx.send(outcome); + } + } + + /// Project a `DelegationOutcome` onto the wire-stable + /// `DelegationResultSummary` carried by `AcpEvent::DelegationCompleted`. + /// Keeps the mapping (and the `error_code` choice) in one place. + fn outcome_to_summary(outcome: &DelegationOutcome) -> DelegationResultSummary { + match outcome { + DelegationOutcome::Ok(ok) => DelegationResultSummary::Ok { + duration_ms: ok.duration_ms, + }, + DelegationOutcome::Err { code, .. } => DelegationResultSummary::Err { + error_code: code.clone(), + }, + } + } + + /// Internal helper — apply the meta write iff the parent's + /// `tool_use_id` refers to a real ACP `tool_call_id`. The + /// broker-synthesized `"delegation-"` placeholder targets no + /// ToolCallState, so emitting a `ToolCallUpdate` against it would be + /// noise that the frontend would route through `apply_tool_call_update` + /// to a non-existent entry. See `meta_writer::is_synthetic_parent_tool_use_id`. + async fn write_meta_if_real( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ) { + if is_synthetic_parent_tool_use_id(parent_tool_use_id) { + return; + } + self.meta_writer + .write_meta(parent_connection_id, parent_tool_use_id, meta) + .await; + } + + /// Internal helper — emit `AcpEvent::DelegationCompleted` on the parent's + /// stream iff the `parent_tool_use_id` refers to a real ACP tool_call. + /// Synthetic ids (the `"delegation-"` UUID fallback) map to no + /// live UI binding, so the emit would be wasted noise — same skip + /// criterion as `write_meta_if_real`. + async fn emit_completed_if_real( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + child_connection_id: &str, + child_conversation_id: i32, + result: DelegationResultSummary, + ) { + if is_synthetic_parent_tool_use_id(parent_tool_use_id) { + return; + } + self.event_emitter + .emit_completed( + parent_connection_id, + parent_tool_use_id, + child_connection_id, + child_conversation_id, + result, + ) + .await; + } + + /// Resolve the pending delegation whose child matches + /// `child_connection_id` with a `canceled` outcome. Used when a child + /// session disconnects or errors out without firing a clean + /// TurnComplete — the parent's `tool_use_id` shouldn't dangle. + /// No-op when no matching entry exists. + pub async fn cancel_by_child_connection(&self, child_connection_id: &str) { + let drained: Vec = { + let mut map = self.pending.inner.lock().await; + let keys: Vec = map + .iter() + .filter(|(_, v)| v.child_connection_id == child_connection_id) + .map(|(k, _)| k.clone()) + .collect(); + keys.into_iter() + .map(|k| map.remove(&k).expect("key just observed")) + .collect() + }; + for entry in drained { + self.write_meta_if_real( + &entry.parent_connection_id, + &entry.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&entry.child_connection_id), + Some(entry.child_conversation_id), + Some("canceled"), + ), + ) + .await; + self.emit_completed_if_real( + &entry.parent_connection_id, + &entry.parent_tool_use_id, + &entry.child_connection_id, + entry.child_conversation_id, + DelegationResultSummary::Err { + error_code: "canceled".to_string(), + }, + ) + .await; + let _ = self.spawner.disconnect(&entry.child_connection_id).await; + let _ = entry.tx.send(DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "child session ended without TurnComplete".into(), + }, + Some(entry.child_conversation_id), + )); + } + } + + /// Cascade-cancel every pending delegation owned by `parent_connection_id`. + /// Used when a parent session disconnects or the user cancels the parent's + /// active prompt. + pub async fn cancel_by_parent(&self, parent_connection_id: &str) { + // Also drain any tool_call ids that were captured ahead of an MCP + // round-trip that never arrived — keeps the map bounded across + // parent reconnects. + self.drop_pending_tool_calls_for_parent(parent_connection_id) + .await; + let drained: Vec = { + let mut map = self.pending.inner.lock().await; + let keys: Vec = map + .iter() + .filter(|(_, v)| v.parent_connection_id == parent_connection_id) + .map(|(k, _)| k.clone()) + .collect(); + keys.into_iter() + .map(|k| map.remove(&k).expect("key just observed")) + .collect() + }; + for entry in drained { + // Best-effort meta patch so a parent-side snapshot post-cancel + // shows the delegation as failed/canceled rather than stuck + // on the prior "running" mark. + self.write_meta_if_real( + &entry.parent_connection_id, + &entry.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&entry.child_connection_id), + Some(entry.child_conversation_id), + Some("canceled"), + ), + ) + .await; + self.emit_completed_if_real( + &entry.parent_connection_id, + &entry.parent_tool_use_id, + &entry.child_connection_id, + entry.child_conversation_id, + DelegationResultSummary::Err { + error_code: "canceled".to_string(), + }, + ) + .await; + let _ = self.spawner.cancel(&entry.child_connection_id).await; + let _ = self.spawner.disconnect(&entry.child_connection_id).await; + let _ = entry.tx.send(DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "parent canceled".into(), + }, + Some(entry.child_conversation_id), + )); + } + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn peek_first_pending_call_id(&self) -> Option { + self.pending.inner.lock().await.keys().next().cloned() + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn pending_count(&self) -> usize { + self.pending.inner.lock().await.len() + } +} + +/// `ConversationDepthLookup` over the live `AppDatabase`. Used by the +/// production wiring; tests use the in-module `MockDepth`. +pub struct DbDepthLookup { + pub db: Arc, +} + +#[async_trait] +impl ConversationDepthLookup for DbDepthLookup { + async fn parent_of(&self, conversation_id: i32) -> Result, DelegationError> { + use sea_orm::EntityTrait; + let row = crate::db::entities::conversation::Entity::find_by_id(conversation_id) + .one(&self.db.conn) + .await + .map_err(|e| DelegationError::SubagentRuntimeError(format!("db: {e}")))?; + Ok(row.and_then(|r| r.parent_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::{mock::MockSpawner, SpawnerError}; + use crate::acp::delegation::types::DelegationSuccess; + use crate::models::AgentType; + + /// Test-only `ConversationDepthLookup` that resolves against a flat + /// (id, parent_id) table. Unknown ids return `Ok(None)` to keep test + /// setup small. + struct MockDepth(Vec<(i32, Option)>); + + #[async_trait] + impl ConversationDepthLookup for MockDepth { + async fn parent_of(&self, id: i32) -> Result, DelegationError> { + Ok(self.0.iter().find(|(c, _)| *c == id).and_then(|(_, p)| *p)) + } + } + + fn shallow_lookup() -> Arc { + // parent conversation is the root — depth = 0, no rejection. + Arc::new(MockDepth(vec![(1, None)])) as Arc + } + + fn request(parent_conv: i32, tool_use: &str) -> DelegationRequest { + DelegationRequest { + parent_connection_id: "parent-conn".into(), + parent_conversation_id: parent_conv, + parent_tool_use_id: tool_use.into(), + agent_type: AgentType::ClaudeCode, + task: "do x".into(), + working_dir: None, + timeout_seconds: Some(30), + } + } + + // -- Task 4.3 ----------------------------------------------------------- + + #[tokio::test] + async fn config_round_trip() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker + .set_config(DelegationConfig { + enabled: false, + depth_limit: 5, + default_timeout: Duration::from_secs(120), + }) + .await; + let got = broker.config_snapshot().await; + assert!(!got.enabled); + assert_eq!(got.depth_limit, 5); + assert_eq!(got.default_timeout, Duration::from_secs(120)); + } + + #[tokio::test] + async fn disabled_returns_canceled_without_touching_spawner() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: false, + depth_limit: 2, + default_timeout: Duration::from_secs(60), + }) + .await; + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "canceled"), + _ => panic!("expected Err"), + } + assert!(mock.disconnects.lock().await.is_empty()); + } + + // -- Task 4.4: happy path ---------------------------------------------- + + #[tokio::test] + async fn happy_path_returns_ok_after_complete_call() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) + }; + + // Spin until the broker has registered the pending call so the test + // doesn't race the spawn/send awaits. + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "4".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 50, + token_usage: None, + }), + ) + .await; + + let outcome = driver.await.unwrap(); + match outcome { + DelegationOutcome::Ok(s) => { + assert_eq!(s.text, "4"); + assert_eq!(s.child_conversation_id, 42); + } + other => panic!("expected Ok, got {other:?}"), + } + assert_eq!(broker.pending_count().await, 0); + // complete_call disconnects the child once. + assert_eq!(mock.disconnects.lock().await.as_slice(), &["child-conn-1"]); + } + + // -- Task 4.5: error paths --------------------------------------------- + + #[tokio::test] + async fn spawn_failure_maps_to_spawn_failed() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Spawn("nope".into()))) + .await; + let broker = DelegationBroker::new(mock as Arc, shallow_lookup()); + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "spawn_failed"), + other => panic!("expected Err, got {other:?}"), + } + } + + #[tokio::test] + async fn send_failure_after_spawn_disconnects_child() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Err(SpawnerError::Send("agent rejected prompt".into()))) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "spawn_failed"), + other => panic!("expected Err, got {other:?}"), + } + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c1"]); + } + + #[tokio::test] + async fn timeout_cancels_and_disconnects() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(99)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + let mut req = request(1, "pt-1"); + req.timeout_seconds = Some(1); + let outcome = broker.handle_request(req).await; + match outcome { + DelegationOutcome::Err { + code, + child_conversation_id, + .. + } => { + assert_eq!(code, "timeout"); + assert_eq!(child_conversation_id, Some(99)); + } + other => panic!("expected Timeout, got {other:?}"), + } + assert_eq!(mock.cancels.lock().await.as_slice(), &["c1"]); + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c1"]); + assert_eq!(broker.pending_count().await, 0); + } + + // -- Task 4.6: parent-cancel cascade ----------------------------------- + + #[tokio::test] + async fn parent_cancel_cancels_all_pending_children() { + let mock = Arc::new(MockSpawner::new()); + for i in 0..3 { + mock.queue_spawn(Ok(format!("c{i}"))).await; + mock.queue_send(Ok(100 + i)).await; + } + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let mut handles = Vec::new(); + for i in 0..3 { + let broker = broker.clone(); + handles.push(tokio::spawn(async move { + broker.handle_request(request(1, &format!("pt-{i}"))).await + })); + } + + // Wait until all three are parked. + while broker.pending_count().await < 3 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + + broker.cancel_by_parent("parent-conn").await; + for h in handles { + let outcome = h.await.unwrap(); + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "canceled"), + other => panic!("expected canceled, got {other:?}"), + } + } + assert_eq!(mock.cancels.lock().await.len(), 3); + // Each child disconnects exactly once via cancel_by_parent. + assert_eq!(mock.disconnects.lock().await.len(), 3); + } + + #[tokio::test] + async fn cancel_by_parent_ignores_other_parents() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(200)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + + broker.cancel_by_parent("other-parent").await; + // No effect — pending entry still there. + assert_eq!(broker.pending_count().await, 1); + + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: 200, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 10, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + // -- Task 4.7: depth limit --------------------------------------------- + + #[tokio::test] + async fn depth_limit_rejects_before_spawn() { + let mock = Arc::new(MockSpawner::new()); + // No queued spawn results — if the broker tries to spawn, it errors loudly. + // chain: 1 (root, None) <- 2 (child of 1) <- 3 (grandchild of 2). + // Parent = grandchild (id 3): parent_depth = 2. With limit = 2, child + // would sit at depth 3 → reject. + let lookup = Arc::new(MockDepth(vec![(1, None), (2, Some(1)), (3, Some(2))])) + as Arc; + let broker = DelegationBroker::new(mock as Arc, lookup); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 2, + default_timeout: Duration::from_secs(60), + }) + .await; + let outcome = broker.handle_request(request(3, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "depth_limit"), + other => panic!("expected depth_limit, got {other:?}"), + } + } + + // -- Pending tool_call_id queue (MCP `_meta.tool_use_id` fallback) ---- + + #[tokio::test] + async fn pending_tool_call_register_and_take_is_fifo() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker.register_pending_tool_call("p1", "tc-a".into()).await; + broker.register_pending_tool_call("p1", "tc-b".into()).await; + assert_eq!( + broker.take_pending_tool_call("p1").await.as_deref(), + Some("tc-a") + ); + assert_eq!( + broker.take_pending_tool_call("p1").await.as_deref(), + Some("tc-b") + ); + assert!(broker.take_pending_tool_call("p1").await.is_none()); + } + + #[tokio::test] + async fn pending_tool_call_is_isolated_per_parent() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker.register_pending_tool_call("p1", "p1-a".into()).await; + broker.register_pending_tool_call("p2", "p2-a".into()).await; + assert_eq!( + broker.take_pending_tool_call("p1").await.as_deref(), + Some("p1-a") + ); + assert_eq!( + broker.take_pending_tool_call("p2").await.as_deref(), + Some("p2-a") + ); + assert!(broker.take_pending_tool_call("p1").await.is_none()); + assert!(broker.take_pending_tool_call("p2").await.is_none()); + } + + #[tokio::test] + async fn empty_parent_tool_use_id_claims_pending_then_completes() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(7)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .register_pending_tool_call("parent-conn", "tu-from-acp".into()) + .await; + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // The captured ACP id was consumed. + assert!(broker.take_pending_tool_call("parent-conn").await.is_none()); + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 7, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + #[tokio::test] + async fn empty_parent_tool_use_id_claims_pending_arriving_late() { + // Regression: when the parent's ACP `session/update(tool_call)` + // lands at the lifecycle dispatcher AFTER `broker.handle_request` + // already entered the claim phase, the brief poll loop must still + // pick it up rather than falling back to the synthetic UUID. + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-late".into())).await; + mock.queue_send(Ok(13)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + + // Give the driver time to enter the claim wait loop on an empty + // queue, then register the ACP id (simulates the dispatcher's + // ToolCall handling landing late). + tokio::time::sleep(Duration::from_millis(30)).await; + broker + .register_pending_tool_call("parent-conn", "tu-late".into()) + .await; + + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // The late-arriving ACP id was consumed by the broker — no leftover + // entry. + assert!(broker.take_pending_tool_call("parent-conn").await.is_none()); + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "late ok".into(), + child_conversation_id: 13, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + #[tokio::test] + async fn empty_parent_tool_use_id_with_no_pending_falls_back_to_uuid() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(11)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "fallback ok".into(), + child_conversation_id: 11, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + #[tokio::test] + async fn cancel_by_parent_also_drops_pending_tool_calls() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker + .register_pending_tool_call("parent-conn", "tu-1".into()) + .await; + broker.cancel_by_parent("parent-conn").await; + assert!(broker.take_pending_tool_call("parent-conn").await.is_none()); + } + + #[tokio::test] + async fn depth_limit_allows_root() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c1".into())).await; + mock.queue_send(Ok(7)).await; + let lookup = Arc::new(MockDepth(vec![(1, None)])) as Arc; + let broker = DelegationBroker::new(mock.clone() as Arc, lookup); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 2, + default_timeout: Duration::from_secs(60), + }) + .await; + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + let call_id = broker.peek_first_pending_call_id().await.unwrap(); + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 7, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Ok(_))); + } + + // -- Meta writer lifecycle -------------------------------------------- + + use crate::acp::delegation::meta_writer::mock::MockMetaWriter; + use crate::acp::delegation::meta_writer::DelegationMetaWriter; + + fn broker_with_meta(mock: Arc, writer: Arc) -> DelegationBroker { + DelegationBroker::with_meta_writer( + mock as Arc, + shallow_lookup(), + writer as Arc, + ) + } + + #[tokio::test] + async fn meta_writer_records_running_then_completed_on_happy_path() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-real")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: 42, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let calls = writer.snapshot().await; + assert_eq!(calls.len(), 2); + // First write: running, with child connection + conversation ids. + let first = &calls[0]; + assert_eq!(first.parent_tool_use_id, "pt-real"); + let inner_first = first + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!( + inner_first.get("status").unwrap().as_str().unwrap(), + "running" + ); + assert_eq!( + inner_first + .get("child_connection_id") + .unwrap() + .as_str() + .unwrap(), + "child-conn-1" + ); + assert_eq!( + inner_first + .get("child_conversation_id") + .unwrap() + .as_i64() + .unwrap(), + 42 + ); + // Second write: completed. + let second = &calls[1]; + let inner_second = second + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!( + inner_second.get("status").unwrap().as_str().unwrap(), + "completed" + ); + } + + #[tokio::test] + async fn meta_writer_records_failed_on_err_outcome() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-2".into())).await; + mock.queue_send(Ok(7)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-err")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::from_err( + DelegationError::Timeout { elapsed_ms: 5_000 }, + Some(7), + ), + ) + .await; + driver.await.unwrap(); + + let calls = writer.snapshot().await; + assert_eq!(calls.len(), 2); + let inner = calls[1] + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "failed"); + assert_eq!( + inner.get("error_code").unwrap().as_str().unwrap(), + "timeout" + ); + } + + #[tokio::test] + async fn meta_writer_records_failed_on_parent_cancel() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-cancel".into())).await; + mock.queue_send(Ok(33)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-pcancel")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + broker.cancel_by_parent("parent-conn").await; + let outcome = driver.await.unwrap(); + assert!(matches!(outcome, DelegationOutcome::Err { .. })); + + let calls = writer.snapshot().await; + // running + canceled + assert_eq!(calls.len(), 2); + let inner = calls[1] + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "failed"); + assert_eq!( + inner.get("error_code").unwrap().as_str().unwrap(), + "canceled" + ); + } + + #[tokio::test] + async fn meta_writer_skipped_for_synthetic_parent_tool_use_id() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-synth".into())).await; + mock.queue_send(Ok(8)).await; + let writer = Arc::new(MockMetaWriter::new()); + let broker = broker_with_meta(mock.clone(), writer.clone()); + + // Empty `parent_tool_use_id` triggers the broker's UUID fallback — + // `"delegation-"` — which the writer must skip because no + // matching ACP tool_call_id exists. + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 8, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let calls = writer.snapshot().await; + assert!( + calls.is_empty(), + "writer should be skipped for synthetic parent_tool_use_id, got {:?}", + calls + ); + } + + // -- Event emitter lifecycle ------------------------------------------ + // + // Issue: `.docs/issues/2026-05-24-delegation-termination-cascade.md`. + // The broker must emit `AcpEvent::DelegationCompleted` once per drained + // pending entry, regardless of which terminal path drained it (happy + // `complete_call`, broker-side timeout, child-disconnect cleanup, or + // parent-cancel cascade). Without these emits the frontend's live + // delegation binding stays at "running" forever — see the issue doc + // for the full path matrix. + + use crate::acp::delegation::event_emitter::mock::MockEventEmitter; + use crate::acp::delegation::event_emitter::DelegationEventEmitter; + use crate::acp::types::DelegationResultSummary; + + fn broker_with_emitter( + mock: Arc, + writer: Arc, + emitter: Arc, + ) -> DelegationBroker { + DelegationBroker::with_writers( + mock as Arc, + shallow_lookup(), + writer as Arc, + emitter as Arc, + ) + } + + #[tokio::test] + async fn emitter_records_ok_on_complete_call_happy_path() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-ok")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: 42, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 73, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let calls = emitter.snapshot().await; + assert_eq!(calls.len(), 1); + let call = &calls[0]; + assert_eq!(call.parent_tool_use_id, "pt-ok"); + assert_eq!(call.child_connection_id, "child-conn-1"); + assert_eq!(call.child_conversation_id, 42); + assert!( + matches!(call.result, DelegationResultSummary::Ok { duration_ms: 73 }), + "expected Ok{{73}}, got {:?}", + call.result + ); + } + + #[tokio::test] + async fn emitter_records_err_on_complete_call_err_outcome() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-err".into())).await; + mock.queue_send(Ok(11)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-err")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::from_err( + DelegationError::SubagentRuntimeError("agent died".into()), + Some(11), + ), + ) + .await; + driver.await.unwrap(); + + let calls = emitter.snapshot().await; + assert_eq!(calls.len(), 1); + match &calls[0].result { + DelegationResultSummary::Err { error_code } => { + assert_eq!(error_code, "subagent_error") + } + other => panic!("expected Err, got {other:?}"), + } + } + + #[tokio::test] + async fn emitter_records_timeout_on_broker_side_timeout() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-to".into())).await; + mock.queue_send(Ok(91)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let mut req = request(1, "pt-timeout"); + req.timeout_seconds = Some(1); + let outcome = broker.handle_request(req).await; + assert!(matches!( + outcome, + DelegationOutcome::Err { ref code, .. } if code == "timeout" + )); + + let calls = emitter.snapshot().await; + assert_eq!(calls.len(), 1, "expected exactly one emit, got {calls:?}"); + let call = &calls[0]; + assert_eq!(call.parent_tool_use_id, "pt-timeout"); + assert_eq!(call.child_connection_id, "child-conn-to"); + assert_eq!(call.child_conversation_id, 91); + match &call.result { + DelegationResultSummary::Err { error_code } => { + assert_eq!(error_code, "timeout") + } + other => panic!("expected Err{{timeout}}, got {other:?}"), + } + } + + #[tokio::test] + async fn emitter_records_canceled_on_cancel_by_child_connection() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-dropped".into())).await; + mock.queue_send(Ok(55)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-cbc")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + broker.cancel_by_child_connection("c-dropped").await; + let outcome = driver.await.unwrap(); + assert!(matches!( + outcome, + DelegationOutcome::Err { ref code, .. } if code == "canceled" + )); + + let calls = emitter.snapshot().await; + assert_eq!(calls.len(), 1); + match &calls[0].result { + DelegationResultSummary::Err { error_code } => { + assert_eq!(error_code, "canceled") + } + other => panic!("expected Err{{canceled}}, got {other:?}"), + } + } + + #[tokio::test] + async fn emitter_records_one_event_per_drained_entry_on_cancel_by_parent() { + let mock = Arc::new(MockSpawner::new()); + for i in 0..3 { + mock.queue_spawn(Ok(format!("c{i}"))).await; + mock.queue_send(Ok(100 + i)).await; + } + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let mut handles = Vec::new(); + for i in 0..3 { + let broker = broker.clone(); + handles.push(tokio::spawn(async move { + broker.handle_request(request(1, &format!("pt-{i}"))).await + })); + } + while broker.pending_count().await < 3 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + broker.cancel_by_parent("parent-conn").await; + for h in handles { + let _ = h.await.unwrap(); + } + + let calls = emitter.snapshot().await; + assert_eq!(calls.len(), 3, "expected 3 emits, got {calls:?}"); + let mut parent_tool_use_ids: Vec = + calls.iter().map(|c| c.parent_tool_use_id.clone()).collect(); + parent_tool_use_ids.sort(); + assert_eq!( + parent_tool_use_ids, + vec!["pt-0".to_string(), "pt-1".to_string(), "pt-2".to_string()] + ); + for call in &calls { + match &call.result { + DelegationResultSummary::Err { error_code } => { + assert_eq!(error_code, "canceled") + } + other => panic!("expected Err{{canceled}}, got {other:?}"), + } + } + } + + #[tokio::test] + async fn emitter_does_not_double_emit_on_repeat_cancel_by_parent() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-once".into())).await; + mock.queue_send(Ok(42)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-idem")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // First call drains the entry + emits one. + broker.cancel_by_parent("parent-conn").await; + // Second call finds the pending map empty — no extra emit. + broker.cancel_by_parent("parent-conn").await; + // Cleanup-guard-style triple call also stays bounded. + broker.cancel_by_parent("parent-conn").await; + let _ = driver.await.unwrap(); + + assert_eq!(emitter.count().await, 1); + } + + #[tokio::test] + async fn emitter_skipped_for_synthetic_parent_tool_use_id() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-synth".into())).await; + mock.queue_send(Ok(8)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 8, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let calls = emitter.snapshot().await; + assert!( + calls.is_empty(), + "emitter must skip synthetic parent_tool_use_id (same rule as meta writer); got {calls:?}" + ); + } + + #[tokio::test] + async fn emitter_records_after_meta_write_on_complete_call() { + // Frontend's snapshot-recovery path reads `meta["codeg.delegation"]` + // first and the live event second; if the emit lands before the + // meta write, a snapshot taken between them would see "running" + // meta paired with a "completed" event. Enforce meta-before-emit + // by checking the MockMetaWriter has at least one call before the + // emitter records. + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-order".into())).await; + mock.queue_send(Ok(7)).await; + let writer = Arc::new(MockMetaWriter::new()); + let emitter = Arc::new(MockEventEmitter::new()); + let broker = broker_with_emitter(mock.clone(), writer.clone(), emitter.clone()); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-order")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "ok".into(), + child_conversation_id: 7, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + driver.await.unwrap(); + + let meta_calls = writer.snapshot().await; + let event_calls = emitter.snapshot().await; + // running (from handle_request) + completed (from complete_call) = + // 2 meta writes. The single event must be the "completed" one, + // and it must land AFTER the running meta — guaranteed structurally + // by complete_call's order (write_meta_if_real then emit). + assert_eq!(meta_calls.len(), 2); + assert_eq!(event_calls.len(), 1); + let inner_second = meta_calls[1] + .meta + .get("codeg.delegation") + .unwrap() + .as_object() + .unwrap(); + assert_eq!( + inner_second.get("status").unwrap().as_str().unwrap(), + "completed" + ); + } + + // -- Production-path fanout coverage ---------------------------------- + // + // Every other emitter test in this module uses `MockEventEmitter`. The + // production wiring goes through `ConnectionManagerEventEmitter`, which + // resolves `(state, emitter)` against the live `ConnectionManager` and + // hands the event to `emit_with_state` so it fans out to (1) the parent + // connection's `ConnectionEventStream` (the WS attach path) and (2) the + // `InternalEventBus` (the lifecycle/pet/chat-channel subscriber path). + // These tests exercise that real fanout end-to-end so a regression in + // `get_state_and_emitter` lookup, `emit_with_state` routing, or the + // `EventEmitter::WebOnly { bus, .. }` wiring is caught here even when + // every mock-backed test stays green. + + #[tokio::test] + async fn real_emitter_fans_out_delegation_completed_to_parent_stream_and_bus() { + use crate::acp::delegation::event_emitter::ConnectionManagerEventEmitter; + use crate::acp::manager::ConnectionManager; + use crate::acp::types::AcpEvent; + use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster}; + + // Real ConnectionManager + fake parent wired to a WebOnly emitter so + // the InternalEventBus gets typed envelopes and we can subscribe to + // verify the lifecycle-path delivery alongside the per-connection + // stream delivery. + let manager = ConnectionManager::new(); + let broadcaster = Arc::new(WebEventBroadcaster::new()); + let parent_emitter = EventEmitter::test_web_only(broadcaster); + let bus = parent_emitter + .acp_event_bus() + .expect("WebOnly emitter must expose an InternalEventBus"); + manager + .insert_test_connection("parent-conn", AgentType::ClaudeCode, None, parent_emitter) + .await; + + // Subscribe BEFORE triggering events — broadcast channels drop + // sends that happen with no receivers registered. + let mut bus_rx = bus.subscribe(); + let (parent_state, _) = manager + .get_state_and_emitter("parent-conn") + .await + .expect("parent just inserted"); + let mut stream_rx = parent_state.read().await.event_stream().subscribe(); + + // Build the broker with the PRODUCTION emitter; meta writer can stay + // noop because this test is asserting the event-fanout invariant. + let mock_spawner = Arc::new(MockSpawner::new()); + mock_spawner.queue_spawn(Ok("child-conn-real".into())).await; + mock_spawner.queue_send(Ok(77)).await; + let real_emitter = Arc::new(ConnectionManagerEventEmitter { + manager: Arc::new(manager.clone_ref()), + }); + let broker = DelegationBroker::with_writers( + mock_spawner.clone() as Arc, + shallow_lookup(), + Arc::new(crate::acp::delegation::meta_writer::NoopMetaWriter) + as Arc, + real_emitter as Arc, + ); + + // Park a pending entry then trigger cancel_by_parent to drive the + // production emit path. `request()` hard-codes parent_connection_id + // = "parent-conn" which matches the insert above. + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-fanout")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + broker.cancel_by_parent("parent-conn").await; + let _ = driver.await.unwrap(); + + // Per-connection stream (WS attach delivery path) must receive the + // envelope tagged with the right connection + payload shape. + let envelope = tokio::time::timeout(Duration::from_millis(500), stream_rx.recv()) + .await + .expect("per-connection stream should receive DelegationCompleted within 500ms") + .expect("envelope recv must not error"); + assert_eq!(envelope.connection_id, "parent-conn"); + match &envelope.payload { + AcpEvent::DelegationCompleted { + parent_tool_use_id, + child_connection_id, + child_conversation_id, + result, + .. + } => { + assert_eq!(parent_tool_use_id, "pt-fanout"); + assert_eq!(child_connection_id, "child-conn-real"); + assert_eq!(*child_conversation_id, 77); + match result { + DelegationResultSummary::Err { error_code } => { + assert_eq!(error_code, "canceled"); + } + other => panic!("expected Err{{canceled}}, got {other:?}"), + } + } + other => panic!("expected DelegationCompleted, got {other:?}"), + } + + // InternalEventBus (lifecycle/pet/chat-channel subscriber path) must + // also receive the same envelope — proves the WebOnly emitter's bus + // arm in `emit_with_state` is reached. + let bus_envelope = tokio::time::timeout(Duration::from_millis(500), bus_rx.recv()) + .await + .expect("InternalEventBus should receive DelegationCompleted within 500ms") + .expect("bus recv must not error"); + assert_eq!(bus_envelope.connection_id, "parent-conn"); + assert!(matches!( + bus_envelope.payload, + AcpEvent::DelegationCompleted { .. } + )); + } + + #[tokio::test] + async fn real_emitter_is_silent_no_op_when_parent_already_detached() { + // Parent torn down mid-delegation: `get_state_and_emitter` returns + // None, the emit silently drops, BUT the broker still drains its + // pending table and surfaces the outcome to the awaiting caller. + // This is the "parent disappeared before terminal" path that the + // mock-backed tests can't observe. + use crate::acp::delegation::event_emitter::ConnectionManagerEventEmitter; + use crate::acp::manager::ConnectionManager; + + let manager = ConnectionManager::new(); + // Intentionally no insert_test_connection — parent is absent. + let real_emitter = Arc::new(ConnectionManagerEventEmitter { + manager: Arc::new(manager.clone_ref()), + }); + let mock_spawner = Arc::new(MockSpawner::new()); + mock_spawner.queue_spawn(Ok("c-orphan".into())).await; + mock_spawner.queue_send(Ok(1)).await; + let broker = DelegationBroker::with_writers( + mock_spawner.clone() as Arc, + shallow_lookup(), + Arc::new(crate::acp::delegation::meta_writer::NoopMetaWriter) + as Arc, + real_emitter as Arc, + ); + + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-orphan")).await }) + }; + while broker.pending_count().await == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + broker.cancel_by_parent("parent-conn").await; + let outcome = driver.await.unwrap(); + + assert!(matches!( + outcome, + DelegationOutcome::Err { ref code, .. } if code == "canceled" + )); + assert_eq!( + broker.pending_count().await, + 0, + "broker must drain pending even when no parent exists to receive the emit" + ); + } +} diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs new file mode 100644 index 000000000..3e1963d5f --- /dev/null +++ b/src-tauri/src/acp/delegation/companion.rs @@ -0,0 +1,312 @@ +//! Companion-side MCP protocol — the bits that live inside the `codeg-mcp` +//! binary but are factored out into the library so they can be unit-tested +//! without spawning the binary. +//! +//! The companion speaks newline-delimited JSON-RPC 2.0 on stdio: +//! one request → one response per line. It exposes exactly one tool — +//! `delegate_to_agent` — whose schema is embedded at compile time from +//! [`tool_schema_json`]. +//! +//! Notifications (id = None) are silently ignored, matching MCP's expectation +//! that `notifications/initialized` etc. produce no response. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::acp::delegation::transport::{client_round_trip, BrokerRequest}; + +/// Static MCP tool schema. Lives next to this module so codeg-mcp ships +/// a single embedded copy — no runtime file IO, no version skew with the +/// broker's [`super::types::DelegationRequest`]. +pub const TOOL_SCHEMA_JSON: &str = include_str!("tool_schema.json"); + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + /// MCP notifications carry no `id`. We dispatch a response only when this + /// is `Some`. + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn ok(id: Value, result: Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: Some(result), + error: None, + } +} + +pub fn err(id: Value, code: i64, message: impl Into) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".into(), + id, + result: None, + error: Some(JsonRpcError { + code, + message: message.into(), + data: None, + }), + } +} + +/// Process arguments threaded through every `tools/call` so the dispatcher +/// can build a [`BrokerRequest`] without re-parsing argv per call. +#[derive(Debug, Clone)] +pub struct CompanionContext { + pub parent_connection_id: String, + pub socket_path: String, + pub token: String, +} + +/// Parse, dispatch, and return the response JSON-RPC envelope, or `None` for +/// notifications. The caller is responsible for writing the line to stdout. +/// +/// Errors that happen before we have an `id` (parse failures with no `id` in +/// the parsed object) get reported with `id = null`, per JSON-RPC 2.0. +pub async fn handle_line(ctx: &CompanionContext, line: &str) -> Option { + let req: JsonRpcRequest = match serde_json::from_str(line) { + Ok(r) => r, + Err(e) => { + return Some(err(Value::Null, -32700, format!("parse error: {e}"))); + } + }; + let id_opt = req.id.clone(); + let response = handle_request(ctx, req).await; + // A response is only sent when the request carried an id (i.e. it was a + // call, not a notification). For notifications we return None even on + // dispatch errors — that's what the MCP spec requires. + match (id_opt, response) { + (Some(_), resp) => resp, + (None, _) => None, + } +} + +async fn handle_request(ctx: &CompanionContext, req: JsonRpcRequest) -> Option { + let id = req.id.unwrap_or(Value::Null); + let resp = match req.method.as_str() { + "initialize" => ok( + id, + json!({ + "protocolVersion": "2024-11-05", + "serverInfo": { + "name": "codeg-mcp", + "version": env!("CARGO_PKG_VERSION"), + }, + "capabilities": { "tools": {} }, + }), + ), + "tools/list" => { + let tool: Value = match serde_json::from_str(TOOL_SCHEMA_JSON) { + Ok(v) => v, + Err(e) => return Some(err(id, -32603, format!("embedded schema invalid: {e}"))), + }; + ok(id, json!({ "tools": [tool] })) + } + "tools/call" => handle_tool_call(ctx, id, req.params).await, + _ => err(id, -32601, format!("method not found: {}", req.method)), + }; + Some(resp) +} + +async fn handle_tool_call(ctx: &CompanionContext, id: Value, params: Value) -> JsonRpcResponse { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name != "delegate_to_agent" { + return err(id, -32602, format!("unknown tool: {name}")); + } + let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); + // MCP clients (Codex / Claude Code) generally do NOT populate + // `_meta.tool_use_id` when calling an MCP server. We still surface it + // when present (it's the most precise binding), but a missing one is + // expected — the broker falls back to claiming the most recent + // `delegate_to_agent` tool_call_id observed on the parent's ACP event + // stream. + let tool_use_id = params + .get("_meta") + .and_then(|m| m.get("tool_use_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let req = BrokerRequest { + token: ctx.token.clone(), + parent_connection_id: ctx.parent_connection_id.clone(), + parent_tool_use_id: tool_use_id, + input: arguments, + }; + match client_round_trip(&ctx.socket_path, &req).await { + Ok(resp) => ok(id, render_tool_result(&resp.outcome)), + Err(e) => err(id, -32603, format!("broker round-trip failed: {e}")), + } +} + +/// Map a serialized [`super::types::DelegationOutcome`] into MCP `tools/call` +/// result content. Kept as a separate function so unit tests can assert the +/// mapping without a real socket. +pub fn render_tool_result(outcome: &Value) -> Value { + let kind = outcome.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + let is_error = kind == "err"; + let text = if is_error { + outcome + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("delegation failed") + .to_string() + } else { + outcome + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error, + "structuredContent": outcome.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> CompanionContext { + CompanionContext { + parent_connection_id: "p1".into(), + socket_path: "/tmp/nope.sock".into(), + token: "tok".into(), + } + } + + #[tokio::test] + async fn initialize_returns_protocol_version() { + let line = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let result = resp.result.unwrap(); + assert_eq!(result["protocolVersion"], "2024-11-05"); + assert_eq!(result["serverInfo"]["name"], "codeg-mcp"); + } + + #[tokio::test] + async fn tools_list_returns_delegate_to_agent() { + let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let result = resp.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0]["name"], "delegate_to_agent"); + // Schema enumerates all 6 agent types. + let agents = tools[0]["inputSchema"]["properties"]["agent_type"]["enum"] + .as_array() + .unwrap(); + assert_eq!(agents.len(), 6); + } + + #[tokio::test] + async fn notification_produces_no_response() { + let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let resp = handle_line(&ctx(), line).await; + assert!(resp.is_none()); + } + + #[tokio::test] + async fn parse_error_returns_null_id_error() { + let line = "not json"; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32700); + assert!(e.message.contains("parse")); + assert_eq!(resp.id, Value::Null); + } + + #[tokio::test] + async fn unknown_method_returns_32601() { + let line = r#"{"jsonrpc":"2.0","id":9,"method":"resources/list"}"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32601); + } + + #[tokio::test] + async fn tools_call_with_unknown_tool_rejected() { + let line = r#"{ + "jsonrpc":"2.0", + "id":3, + "method":"tools/call", + "params": { + "name": "other_tool", + "arguments": {}, + "_meta": {"tool_use_id": "tu1"} + } + }"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("other_tool")); + } + + #[tokio::test] + async fn tools_call_without_tool_use_id_passes_through_to_broker() { + // MCP clients (Codex / Claude Code) generally don't fill + // `_meta.tool_use_id`, so the companion must NOT reject the call — + // it must forward to the broker, which falls back to claiming the + // most recent ACP-side tool_call_id. With a bogus socket path the + // round-trip fails downstream, surfacing as -32603 (NOT -32602). + let line = r#"{ + "jsonrpc":"2.0", + "id":4, + "method":"tools/call", + "params": { + "name": "delegate_to_agent", + "arguments": {"agent_type": "codex", "task": "x"} + } + }"#; + let resp = handle_line(&ctx(), line).await.unwrap(); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32603); + assert!(e.message.contains("broker round-trip")); + } + + #[test] + fn render_tool_result_maps_ok_outcome() { + let outcome = json!({"kind": "ok", "text": "hi", "child_conversation_id": 42}); + let rendered = render_tool_result(&outcome); + assert_eq!(rendered["isError"], false); + assert_eq!(rendered["content"][0]["text"], "hi"); + assert_eq!(rendered["structuredContent"]["child_conversation_id"], 42); + } + + #[test] + fn render_tool_result_maps_err_outcome() { + let outcome = json!({ + "kind": "err", + "code": "timeout", + "message": "timeout after 5000ms" + }); + let rendered = render_tool_result(&outcome); + assert_eq!(rendered["isError"], true); + assert_eq!(rendered["content"][0]["text"], "timeout after 5000ms"); + assert_eq!(rendered["structuredContent"]["code"], "timeout"); + } +} diff --git a/src-tauri/src/acp/delegation/depth.rs b/src-tauri/src/acp/delegation/depth.rs new file mode 100644 index 000000000..e076bb494 --- /dev/null +++ b/src-tauri/src/acp/delegation/depth.rs @@ -0,0 +1,103 @@ +//! Walk the conversation parent chain to compute delegation depth. +//! +//! The walker is generic over an async closure so the broker can plug in a +//! real DB lookup in production and a stub `Vec<(id, parent_id)>` in tests +//! without any extra trait plumbing. +//! +//! `cap` saturates the walk so a corrupted chain (cycle, deep history) can't +//! cause unbounded DB load. Callers pass `depth_limit + 1` — that's all the +//! broker ever needs to decide rejection. + +use std::future::Future; + +use crate::acp::delegation::types::DelegationError; + +pub async fn compute_depth( + start: i32, + mut parent_resolver: F, + cap: u32, +) -> Result +where + F: FnMut(i32) -> Fut, + Fut: Future, DelegationError>>, +{ + let mut current = start; + let mut depth = 0u32; + while depth < cap { + match parent_resolver(current).await? { + None => return Ok(depth), + Some(parent) => { + current = parent; + depth += 1; + } + } + } + Ok(depth) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn fake_chain(n: usize) -> Vec { + (0..n as i32).collect() + } + + fn parent_of(chain: &[i32], id: i32) -> Result, DelegationError> { + let idx = chain + .iter() + .position(|c| *c == id) + .expect("test resolver called with id not in chain"); + if idx == 0 { + Ok(None) + } else { + Ok(Some(chain[idx - 1])) + } + } + + #[tokio::test] + async fn depth_of_root_is_zero() { + let chain = fake_chain(1); + let resolver = |id: i32| { + let chain = chain.clone(); + async move { parent_of(&chain, id) } + }; + let depth = compute_depth(chain[0], resolver, 8).await.unwrap(); + assert_eq!(depth, 0); + } + + #[tokio::test] + async fn depth_of_grandchild_is_two() { + let chain = fake_chain(3); // root -> mid -> leaf + let resolver = |id: i32| { + let chain = chain.clone(); + async move { parent_of(&chain, id) } + }; + let depth = compute_depth(chain[2], resolver, 8).await.unwrap(); + assert_eq!(depth, 2); + } + + #[tokio::test] + async fn saturates_at_cap_without_walking_full_chain() { + let chain = fake_chain(20); + let calls = AtomicU32::new(0); + let resolver = |id: i32| { + calls.fetch_add(1, Ordering::SeqCst); + let chain = chain.clone(); + async move { parent_of(&chain, id) } + }; + let depth = compute_depth(chain[19], resolver, 3).await.unwrap(); + assert_eq!(depth, 3); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn resolver_error_propagates() { + let resolver = |_id: i32| async { + Err::, _>(DelegationError::SubagentRuntimeError("db down".into())) + }; + let err = compute_depth(42, resolver, 8).await.unwrap_err(); + assert!(matches!(err, DelegationError::SubagentRuntimeError(_))); + } +} diff --git a/src-tauri/src/acp/delegation/event_emitter.rs b/src-tauri/src/acp/delegation/event_emitter.rs new file mode 100644 index 000000000..a8bb7fac3 --- /dev/null +++ b/src-tauri/src/acp/delegation/event_emitter.rs @@ -0,0 +1,176 @@ +//! `DelegationEventEmitter` — broker capability for surfacing +//! `AcpEvent::DelegationCompleted` to the parent's event stream. +//! +//! Parallel to [`crate::acp::delegation::meta_writer::DelegationMetaWriter`]: +//! both abstract over the broker's access to the parent connection's +//! `(state, emitter)` pair so the broker can be unit-tested without spinning +//! up a `ConnectionManager`. Together they form the broker's two-output +//! capability surface — meta writes patch the persisted `ToolCallState`, +//! event emits drive the live frontend `DelegationContext`. +//! +//! The broker calls this from every terminal path: +//! +//! 1. `complete_call` — happy path (kind=ok) and error completions +//! (kind=err) propagated by the listener/lifecycle. +//! 2. `handle_request` broker-side timeout — emits `Err{error_code: "timeout"}`. +//! 3. `handle_request` completion-channel-dropped — emits `Err{error_code: "canceled"}`. +//! 4. `cancel_by_child_connection` — emits `Err{error_code: "canceled"}` for +//! every drained pending entry whose child matches. +//! 5. `cancel_by_parent` — emits `Err{error_code: "canceled"}` for every +//! drained pending entry whose parent matches. +//! +//! Emits are skipped when the broker is operating on a synthetic +//! `parent_tool_use_id` (the `"delegation-*"` UUID fallback) because no +//! `tool_call_id`-keyed UI exists to receive them — same guard as the meta +//! writer. The frontend's snapshot path will still recover state from the +//! broker's meta write. + +use async_trait::async_trait; +use std::sync::Arc; + +use crate::acp::manager::ConnectionManager; +use crate::acp::types::{AcpEvent, DelegationResultSummary}; +use crate::web::event_bridge::emit_with_state; + +/// Capability the broker uses to publish `AcpEvent::DelegationCompleted` +/// against the parent connection's event stream. +/// +/// Errors are swallowed at the impl boundary — same rationale as +/// `DelegationMetaWriter`. The broker must finish its pending-table +/// cleanup regardless of whether the parent connection is still around to +/// observe the event. +#[async_trait] +pub trait DelegationEventEmitter: Send + Sync { + async fn emit_completed( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + child_connection_id: &str, + child_conversation_id: i32, + result: DelegationResultSummary, + ); +} + +/// Default emitter used when the broker is constructed via the short-form +/// `DelegationBroker::new`. Silently drops every emit — most broker tests +/// observe behavior via outcomes + pending accounting + meta writes, not +/// event fanout. Tests that DO assert on the event lifecycle wire +/// `MockEventEmitter` via `with_writers`. +#[derive(Default, Clone)] +pub struct NoopEventEmitter; + +#[async_trait] +impl DelegationEventEmitter for NoopEventEmitter { + async fn emit_completed( + &self, + _parent_connection_id: &str, + _parent_tool_use_id: &str, + _child_connection_id: &str, + _child_conversation_id: i32, + _result: DelegationResultSummary, + ) { + } +} + +/// Production impl backed by `ConnectionManager`. Resolves the parent +/// connection's `(state, emitter)` and routes the `DelegationCompleted` +/// event through `emit_with_state` so it lands on the same fanout path +/// as every other ACP event from that connection. +/// +/// A missing parent connection (user disconnected mid-delegation, parent +/// already torn down by another path) becomes a silent no-op — the broker +/// still needs to drain its pending table even when no one is listening. +#[derive(Clone)] +pub struct ConnectionManagerEventEmitter { + pub manager: Arc, +} + +#[async_trait] +impl DelegationEventEmitter for ConnectionManagerEventEmitter { + async fn emit_completed( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + child_connection_id: &str, + child_conversation_id: i32, + result: DelegationResultSummary, + ) { + let Some((state_arc, emitter)) = self + .manager + .get_state_and_emitter(parent_connection_id) + .await + else { + return; + }; + emit_with_state( + &state_arc, + &emitter, + AcpEvent::DelegationCompleted { + parent_connection_id: parent_connection_id.to_string(), + parent_tool_use_id: parent_tool_use_id.to_string(), + child_connection_id: child_connection_id.to_string(), + child_conversation_id, + result, + }, + ) + .await; + } +} + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock { + use super::*; + use tokio::sync::Mutex; + + /// Records every emit so broker tests can assert the event lifecycle + /// (one emit per drained pending entry, never doubled, correct + /// `result_summary` per terminal path). No-op on the publishing side — + /// the broker is the unit under test, not the event fanout. + #[derive(Default)] + pub struct MockEventEmitter { + pub calls: Mutex>, + } + + #[derive(Debug, Clone)] + pub struct EmitCall { + pub parent_connection_id: String, + pub parent_tool_use_id: String, + pub child_connection_id: String, + pub child_conversation_id: i32, + pub result: DelegationResultSummary, + } + + impl MockEventEmitter { + pub fn new() -> Self { + Self::default() + } + + pub async fn snapshot(&self) -> Vec { + self.calls.lock().await.clone() + } + + pub async fn count(&self) -> usize { + self.calls.lock().await.len() + } + } + + #[async_trait] + impl DelegationEventEmitter for MockEventEmitter { + async fn emit_completed( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + child_connection_id: &str, + child_conversation_id: i32, + result: DelegationResultSummary, + ) { + self.calls.lock().await.push(EmitCall { + parent_connection_id: parent_connection_id.to_string(), + parent_tool_use_id: parent_tool_use_id.to_string(), + child_connection_id: child_connection_id.to_string(), + child_conversation_id, + result, + }); + } + } +} diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs new file mode 100644 index 000000000..32930ef39 --- /dev/null +++ b/src-tauri/src/acp/delegation/listener.rs @@ -0,0 +1,536 @@ +//! Main-process side of the `codeg-mcp` round-trip: accept UDS / named-pipe +//! connections from companion processes, validate the per-launch token, +//! resolve the parent's current conversation, and hand off to the broker. +//! +//! The listener is intentionally tiny — most of the work (depth checking, +//! spawn lifecycle, timeout, cancellation) happens inside +//! [`DelegationBroker`]. The listener is the boundary between the wire and +//! the broker, plus the place where the per-launch token policy is enforced. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::RwLock; + +use crate::acp::delegation::broker::DelegationBroker; +use crate::acp::delegation::transport::{read_frame, write_frame, BrokerRequest, BrokerResponse}; +use crate::acp::delegation::types::{DelegationOutcome, DelegationRequest}; +use crate::models::AgentType; + +/// Pluggable "what conversation is this parent currently in?" lookup. The +/// production impl wraps `ConnectionManager.get_state`; tests use an +/// in-memory map. +/// +/// Kept as a trait so the listener can be unit-tested without spinning up a +/// real `ConnectionManager` or RwLock. +#[async_trait] +pub trait ParentSessionLookup: Send + Sync { + async fn current_conversation_id(&self, parent_connection_id: &str) -> Option; +} + +/// Per-launch token entry. Bound at MCP injection time and revoked on parent +/// connection teardown. +#[derive(Debug, Clone)] +pub struct TokenEntry { + pub parent_connection_id: String, + pub working_dir: PathBuf, +} + +#[derive(Default)] +pub struct TokenRegistry { + inner: RwLock>, +} + +impl TokenRegistry { + pub async fn register(&self, token: String, entry: TokenEntry) { + self.inner.write().await.insert(token, entry); + } + + pub async fn revoke(&self, token: &str) { + self.inner.write().await.remove(token); + } + + pub async fn lookup(&self, token: &str) -> Option { + self.inner.read().await.get(token).cloned() + } + + /// Drop every token whose `parent_connection_id` matches. Used on parent + /// connection teardown so a leaked token can't be reused. + pub async fn revoke_by_parent(&self, parent_connection_id: &str) { + let mut map = self.inner.write().await; + map.retain(|_, entry| entry.parent_connection_id != parent_connection_id); + } +} + +pub struct DelegationListener { + pub broker: Arc, + pub tokens: Arc, + pub parent_lookup: Arc, +} + +impl DelegationListener { + pub fn new( + broker: Arc, + tokens: Arc, + parent_lookup: Arc, + ) -> Arc { + Arc::new(Self { + broker, + tokens, + parent_lookup, + }) + } + + /// Run the accept loop until the socket is unbound. Errors on accept are + /// logged and the loop continues — a single bad connection can't bring + /// down the listener. + #[cfg(unix)] + pub async fn run(self: Arc, socket_path: PathBuf) -> std::io::Result<()> { + let _ = tokio::fs::remove_file(&socket_path).await; + if let Some(parent) = socket_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let listener = tokio::net::UnixListener::bind(&socket_path)?; + eprintln!("[delegation] listening on UDS {}", socket_path.display()); + loop { + match listener.accept().await { + Ok((mut conn, _)) => { + let me = Arc::clone(&self); + tokio::spawn(async move { + if let Err(e) = me.serve_one(&mut conn).await { + eprintln!("[delegation] connection failed: {e}"); + } + }); + } + Err(e) => { + eprintln!("[delegation] accept failed: {e}"); + // Brief backoff so a persistent accept error doesn't pin a core. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + } + + /// Windows variant: accept once, re-create the named pipe instance, repeat. + /// Tokio's named-pipe API exposes one server instance at a time and + /// requires re-binding after each connection. + #[cfg(windows)] + pub async fn run(self: Arc, socket_path: PathBuf) -> std::io::Result<()> { + use tokio::net::windows::named_pipe::ServerOptions; + let path_str = socket_path.to_string_lossy().to_string(); + loop { + let mut server = ServerOptions::new().create(&path_str)?; + if let Err(e) = server.connect().await { + eprintln!("[delegation] connect failed: {e}"); + continue; + } + let me = Arc::clone(&self); + tokio::spawn(async move { + if let Err(e) = me.serve_one(&mut server).await { + eprintln!("[delegation] connection failed: {e}"); + } + }); + } + } + + /// Stream-generic per-connection handler. Exposed so unit tests can drive + /// it over `tokio::io::duplex` instead of a real socket. + pub async fn serve_one(&self, conn: &mut C) -> std::io::Result<()> + where + C: AsyncReadExt + AsyncWriteExt + Unpin, + { + let req: BrokerRequest = read_frame(conn).await?; + let outcome = self.process(req).await; + let resp = BrokerResponse { + outcome: serde_json::to_value(&outcome).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) + })?, + }; + write_frame(conn, &resp).await?; + Ok(()) + } + + async fn process(&self, req: BrokerRequest) -> DelegationOutcome { + // 1. Token + parent_connection_id consistency check. Treat both as + // "canceled" since the LLM can't usefully react to either — + // the parent has either been torn down or is impersonating. + let entry = match self.tokens.lookup(&req.token).await { + Some(e) => e, + None => return cancel("invalid token"), + }; + if entry.parent_connection_id != req.parent_connection_id { + return cancel("token does not match parent connection"); + } + + // 2. Resolve the parent's current conversation. Without one the + // broker can't link the child row to the parent. + let parent_conversation_id = match self + .parent_lookup + .current_conversation_id(&req.parent_connection_id) + .await + { + Some(id) => id, + None => return cancel("parent has no active conversation"), + }; + + // 3. Parse the delegate_to_agent arguments. Schema validation lives + // on the LLM side; we only enforce what the broker can't. + let agent_type = match req.input.get("agent_type").and_then(|v| v.as_str()) { + Some(raw) => match parse_agent_type(raw) { + Some(t) => t, + None => return invalid_agent_type(raw), + }, + None => return invalid_agent_type(""), + }; + let task = match req.input.get("task").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => { + return DelegationOutcome::Err { + code: "invalid_working_dir".into(), + message: "missing or empty task".into(), + child_conversation_id: None, + } + } + }; + let working_dir = req + .input + .get("working_dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); + let timeout_seconds = req.input.get("timeout_seconds").and_then(|v| v.as_u64()); + + let delegation_req = DelegationRequest { + parent_connection_id: req.parent_connection_id, + parent_conversation_id, + parent_tool_use_id: req.parent_tool_use_id, + agent_type, + task, + working_dir, + timeout_seconds, + }; + self.broker.handle_request(delegation_req).await + } +} + +fn cancel(message: &str) -> DelegationOutcome { + DelegationOutcome::Err { + code: "canceled".into(), + message: message.into(), + child_conversation_id: None, + } +} + +fn invalid_agent_type(raw: &str) -> DelegationOutcome { + DelegationOutcome::Err { + code: "invalid_agent_type".into(), + message: if raw.is_empty() { + "missing agent_type".into() + } else { + format!("invalid agent_type: {raw}") + }, + child_conversation_id: None, + } +} + +fn parse_agent_type(raw: &str) -> Option { + serde_json::from_value(serde_json::Value::String(raw.to_string())).ok() +} + +/// Default socket path for the running process, scoped to PID so multiple +/// codeg instances on the same machine don't collide. +pub fn default_socket_path(temp_dir: &Path) -> PathBuf { + temp_dir.join(format!("codeg-delegation-{}.sock", std::process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::broker::{ConversationDepthLookup, DelegationConfig}; + use crate::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner, SpawnerError}; + use crate::acp::delegation::types::{DelegationError, DelegationSuccess}; + use serde_json::json; + use std::time::Duration; + use tokio::io::duplex; + + struct AlwaysRootLookup; + #[async_trait] + impl ConversationDepthLookup for AlwaysRootLookup { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } + } + + struct StaticParentLookup(Option); + #[async_trait] + impl ParentSessionLookup for StaticParentLookup { + async fn current_conversation_id(&self, _parent_connection_id: &str) -> Option { + self.0 + } + } + + fn make_broker(mock: Arc) -> Arc { + Arc::new(DelegationBroker::new( + mock as Arc, + Arc::new(AlwaysRootLookup) as Arc, + )) + } + + fn make_listener( + broker: Arc, + tokens: Arc, + parent_conversation: Option, + ) -> Arc { + DelegationListener::new( + broker, + tokens, + Arc::new(StaticParentLookup(parent_conversation)), + ) + } + + async fn make_request(input: serde_json::Value) -> BrokerRequest { + BrokerRequest { + token: "tok".into(), + parent_connection_id: "parent-conn".into(), + parent_tool_use_id: "pt-1".into(), + input, + } + } + + #[tokio::test] + async fn invalid_token_rejected() { + let listener = make_listener( + make_broker(Arc::new(MockSpawner::new())), + Arc::new(TokenRegistry::default()), + Some(1), + ); + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, message, .. } => { + assert_eq!(code, "canceled"); + assert!(message.contains("invalid token")); + } + _ => panic!("expected canceled"), + } + } + + #[tokio::test] + async fn token_parent_mismatch_rejected() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "other-parent".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(make_broker(Arc::new(MockSpawner::new())), tokens, Some(1)); + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, message, .. } => { + assert_eq!(code, "canceled"); + assert!(message.contains("does not match")); + } + _ => panic!("expected canceled"), + } + } + + #[tokio::test] + async fn missing_parent_conversation_rejected() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + // parent_conversation = None: parent has no live conversation. + let listener = make_listener(make_broker(Arc::new(MockSpawner::new())), tokens, None); + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, message, .. } => { + assert_eq!(code, "canceled"); + assert!(message.contains("no active conversation")); + } + _ => panic!("expected canceled"), + } + } + + #[tokio::test] + async fn invalid_agent_type_rejected() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(make_broker(Arc::new(MockSpawner::new())), tokens, Some(1)); + let outcome = listener + .process(make_request(json!({"agent_type": "garbage", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "invalid_agent_type"), + _ => panic!("expected invalid_agent_type"), + } + } + + #[tokio::test] + async fn happy_path_via_duplex_stream() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn".into())).await; + mock.queue_send(Ok(42)).await; + let broker = make_broker(mock.clone()); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(broker.clone(), tokens, Some(1)); + + // Make broker resolve from another task once the call lands. + let completer = { + let broker = broker.clone(); + tokio::spawn(async move { + loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + broker + .complete_call( + &id, + DelegationOutcome::Ok(DelegationSuccess { + text: "result-text".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + }; + + // Drive the listener over a duplex pair. + let (mut client, mut server) = duplex(16 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "parent-conn".into(), + parent_tool_use_id: "pt-1".into(), + input: json!({"agent_type": "codex", "task": "do x"}), + }; + write_frame(&mut client, &req).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + completer.await.unwrap(); + server_task.await.unwrap(); + + assert_eq!(resp.outcome["kind"], "ok"); + assert_eq!(resp.outcome["text"], "result-text"); + assert_eq!(resp.outcome["child_conversation_id"], 42); + } + + #[tokio::test] + async fn token_registry_revoke_and_revoke_by_parent() { + let registry = TokenRegistry::default(); + registry + .register( + "t1".into(), + TokenEntry { + parent_connection_id: "p1".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + registry + .register( + "t2".into(), + TokenEntry { + parent_connection_id: "p1".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + registry + .register( + "t3".into(), + TokenEntry { + parent_connection_id: "p2".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + + registry.revoke("t1").await; + assert!(registry.lookup("t1").await.is_none()); + assert!(registry.lookup("t2").await.is_some()); + + registry.revoke_by_parent("p1").await; + assert!(registry.lookup("t2").await.is_none()); + assert!(registry.lookup("t3").await.is_some()); + } + + // Sanity: spawn failure surfaces as spawn_failed when the listener path + // is exercised. Exercises the full process() → broker.handle_request chain. + #[tokio::test] + async fn spawn_failure_surfaces_through_listener() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Err(SpawnerError::Spawn("agent missing".into()))) + .await; + let broker = make_broker(mock); + broker + .set_config(DelegationConfig { + enabled: true, + depth_limit: 8, + default_timeout: Duration::from_secs(5), + }) + .await; + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(broker, tokens, Some(1)); + + let outcome = listener + .process(make_request(json!({"agent_type": "codex", "task": "x"})).await) + .await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "spawn_failed"), + _ => panic!("expected spawn_failed"), + } + } +} diff --git a/src-tauri/src/acp/delegation/meta_writer.rs b/src-tauri/src/acp/delegation/meta_writer.rs new file mode 100644 index 000000000..3040825aa --- /dev/null +++ b/src-tauri/src/acp/delegation/meta_writer.rs @@ -0,0 +1,254 @@ +//! `DelegationMetaWriter` — broker capability that attaches the live +//! delegation state onto the parent's active `delegate_to_agent` +//! tool-call. The shape written under `meta["codeg.delegation"]` +//! follows the convention documented at +//! [`crate::acp::session_state::ToolCallState::meta`]. +//! +//! The broker calls this at three lifecycle points: +//! +//! 1. After `send_prompt_linked_for_delegation` returns Ok — sets +//! `status: "running"` with the child's connection / conversation ids. +//! 2. In `complete_call` — sets `status: "completed"` (ok branch) or +//! `status: "failed"` + `error_code` (err branch). +//! 3. In `cancel_by_parent` / `cancel_by_child_connection` — sets +//! `status: "failed"` + `error_code: "canceled"`. +//! +//! Writes are skipped when the broker is operating on a synthetic +//! `parent_tool_use_id` (the `"delegation-*"` UUID fallback) because +//! there's no matching ACP `tool_call_id` to attach meta to. The +//! frontend's snapshot path will still recover via `parseInput(input)`. + +use async_trait::async_trait; +use std::sync::Arc; + +use crate::acp::manager::ConnectionManager; +use crate::acp::types::AcpEvent; +use crate::web::event_bridge::emit_with_state; + +/// Top-level key under which delegation state lives on a tool call's +/// `meta` object. Single source of truth — both the writer and the +/// frontend reader must spell it the same way. +pub const DELEGATION_META_KEY: &str = "codeg.delegation"; + +/// Capability the broker uses to patch `meta["codeg.delegation"]` on +/// the parent connection's active `delegate_to_agent` tool call. +/// +/// Errors are swallowed at the impl boundary: a missing parent +/// connection (e.g. user disconnected mid-delegation) or a stale +/// tool_use_id (e.g. parent turn already wrapped up) must not derail +/// the rest of the broker lifecycle, which still has to disconnect the +/// child and resolve the pending call. +#[async_trait] +pub trait DelegationMetaWriter: Send + Sync { + async fn write_meta( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ); +} + +/// Default writer used when the broker is constructed via the +/// short-form `DelegationBroker::new` (most test callsites). Silently +/// drops every write — the broker's correctness is observable through +/// its outcomes and pending-call accounting, not through meta emits. +#[derive(Default, Clone)] +pub struct NoopMetaWriter; + +#[async_trait] +impl DelegationMetaWriter for NoopMetaWriter { + async fn write_meta( + &self, + _parent_connection_id: &str, + _parent_tool_use_id: &str, + _meta: serde_json::Value, + ) { + } +} + +/// Production impl backed by `ConnectionManager`. Emits an +/// `AcpEvent::ToolCallUpdate` carrying only the `meta` field so the +/// existing `apply_tool_call_update` merge path (partial-update +/// preservation of locations / images / content / etc.) is reused +/// without duplicating the patch logic. +#[derive(Clone)] +pub struct ConnectionManagerMetaWriter { + pub manager: Arc, +} + +#[async_trait] +impl DelegationMetaWriter for ConnectionManagerMetaWriter { + async fn write_meta( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ) { + let Some((state_arc, emitter)) = self + .manager + .get_state_and_emitter(parent_connection_id) + .await + else { + return; + }; + emit_with_state( + &state_arc, + &emitter, + AcpEvent::ToolCallUpdate { + tool_call_id: parent_tool_use_id.to_string(), + title: None, + status: None, + content: None, + raw_input: None, + raw_output: None, + raw_output_append: None, + locations: None, + meta: Some(meta), + images: None, + }, + ) + .await; + } +} + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock { + use super::*; + use tokio::sync::Mutex; + + /// Records every call so broker tests can assert the meta lifecycle + /// (running → completed/failed) was driven correctly. No-op on the + /// emit side — the broker is the unit under test, not the event + /// fanout. + #[derive(Default)] + pub struct MockMetaWriter { + pub calls: Mutex>, + } + + #[derive(Debug, Clone)] + pub struct MetaWriteCall { + pub parent_connection_id: String, + pub parent_tool_use_id: String, + pub meta: serde_json::Value, + } + + impl MockMetaWriter { + pub fn new() -> Self { + Self::default() + } + + pub async fn snapshot(&self) -> Vec { + self.calls.lock().await.clone() + } + } + + #[async_trait] + impl DelegationMetaWriter for MockMetaWriter { + async fn write_meta( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + meta: serde_json::Value, + ) { + self.calls.lock().await.push(MetaWriteCall { + parent_connection_id: parent_connection_id.to_string(), + parent_tool_use_id: parent_tool_use_id.to_string(), + meta, + }); + } + } +} + +/// Helper to construct the canonical `meta["codeg.delegation"]` value. +/// Keeps the schema in one place so the writer impls and the broker +/// callsites can't drift apart on field naming. +pub fn build_delegation_meta( + status: &str, + child_connection_id: Option<&str>, + child_conversation_id: Option, + error_code: Option<&str>, +) -> serde_json::Value { + let mut inner = serde_json::Map::new(); + inner.insert( + "status".to_string(), + serde_json::Value::String(status.to_string()), + ); + if let Some(id) = child_connection_id { + inner.insert( + "child_connection_id".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + if let Some(id) = child_conversation_id { + inner.insert( + "child_conversation_id".to_string(), + serde_json::Value::Number(serde_json::Number::from(id)), + ); + } + if let Some(code) = error_code { + inner.insert( + "error_code".to_string(), + serde_json::Value::String(code.to_string()), + ); + } + let mut outer = serde_json::Map::new(); + outer.insert( + DELEGATION_META_KEY.to_string(), + serde_json::Value::Object(inner), + ); + serde_json::Value::Object(outer) +} + +/// True when the broker handed out a synthetic placeholder +/// `parent_tool_use_id` (no matching ACP tool_call_id exists). Skipping +/// meta writes for these avoids spamming `ToolCallUpdate` events with a +/// tool_call_id that no live `ToolCallState` will ever match. +pub fn is_synthetic_parent_tool_use_id(id: &str) -> bool { + id.starts_with("delegation-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_meta_includes_provided_fields() { + let v = build_delegation_meta("running", Some("conn-1"), Some(42), None); + let inner = v.get(DELEGATION_META_KEY).unwrap().as_object().unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "running"); + assert_eq!( + inner.get("child_connection_id").unwrap().as_str().unwrap(), + "conn-1" + ); + assert_eq!( + inner + .get("child_conversation_id") + .unwrap() + .as_i64() + .unwrap(), + 42 + ); + assert!(inner.get("error_code").is_none()); + } + + #[test] + fn build_meta_with_error_code() { + let v = build_delegation_meta("failed", None, Some(7), Some("timeout")); + let inner = v.get(DELEGATION_META_KEY).unwrap().as_object().unwrap(); + assert_eq!(inner.get("status").unwrap().as_str().unwrap(), "failed"); + assert_eq!( + inner.get("error_code").unwrap().as_str().unwrap(), + "timeout" + ); + assert!(inner.get("child_connection_id").is_none()); + } + + #[test] + fn synthetic_id_detection() { + assert!(is_synthetic_parent_tool_use_id( + "delegation-3b4a5c6d-7e8f-90ab-cdef-1234567890ab" + )); + assert!(!is_synthetic_parent_tool_use_id("tu_real_acp_id")); + assert!(!is_synthetic_parent_tool_use_id("")); + } +} diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs new file mode 100644 index 000000000..3101a1b89 --- /dev/null +++ b/src-tauri/src/acp/delegation/mod.rs @@ -0,0 +1,41 @@ +//! Multi-agent delegation: the parent agent's LLM can call the built-in MCP +//! tool `delegate_to_agent` to spawn a fresh ACP session of any (possibly +//! different) agent type, wait for its first turn to finish, and receive the +//! sub-agent's final assistant text as the MCP tool_result. +//! +//! The high-level wiring is: +//! +//! ```text +//! parent LLM ─┐ +//! │ ToolUse(delegate_to_agent, ...) +//! ▼ +//! parent CLI ──stdio──► codeg-mcp (per-launch companion binary) +//! │ +//! │ UDS / named pipe (token-authed) +//! ▼ +//! DelegationBroker (this module) +//! │ +//! │ ConnectionSpawner trait +//! ▼ +//! ConnectionManager.spawn_agent / send_prompt_linked +//! │ +//! ▼ +//! child ACP session ── TurnComplete ──┐ +//! │ +//! parent LLM ◄── MCP tool_result ◄── DelegationOutcome ◄───┘ +//! ``` +//! +//! v1 is one-shot (function-call semantics): after the child's first +//! `TurnComplete`, the broker resolves the pending call, sends `disconnect` +//! to the child, and returns. v2 will introduce `continue_with_session` / +//! `close_session` tools without protocol breakage. + +pub mod broker; +pub mod companion; +pub mod depth; +pub mod event_emitter; +pub mod listener; +pub mod meta_writer; +pub mod spawner; +pub mod transport; +pub mod types; diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs new file mode 100644 index 000000000..1f4399008 --- /dev/null +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -0,0 +1,206 @@ +//! `ConnectionSpawner` trait — the subset of `ConnectionManager` capabilities +//! that the delegation broker needs. Defined as a trait so: +//! +//! 1. The broker can be unit-tested with a `MockSpawner` (no real ACP +//! processes, no DB writes). +//! 2. Future cross-host / remote-agent work (v3+) can plug in a different +//! backend without touching the broker. +//! +//! The concrete impl on `Arc` lives in +//! `acp::manager` next to the existing `ConnectionManager` methods to keep +//! the manager's surface area contiguous. + +use async_trait::async_trait; + +use crate::models::agent::AgentType; + +/// Identifies a delegation call across the broker, the ACP layer, and the DB. +/// +/// `parent_conversation_id` is the **DB** id (i32) of the parent's conversation +/// row, not the ACP-side external session id. The child's new conversation +/// row will carry this as `parent_id` plus `parent_tool_use_id` (the MCP +/// tool_use_id from the parent's LLM-issued ToolUse) and `delegation_call_id` +/// (broker-internal UUID). +#[derive(Debug, Clone)] +pub struct DelegationLink { + pub parent_conversation_id: i32, + pub parent_tool_use_id: String, + pub delegation_call_id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SpawnerError { + #[error("spawn failed: {0}")] + Spawn(String), + #[error("send prompt failed: {0}")] + Send(String), + #[error("disconnect failed: {0}")] + Disconnect(String), + #[error("cancel failed: {0}")] + Cancel(String), +} + +/// Capabilities the delegation broker needs from whatever owns the ACP +/// connections. v1 production impl is `Arc` (see +/// `acp/manager.rs`); tests use `mock::MockSpawner`. +/// +/// All methods are `async` because the production impl drives a Tokio runtime +/// and DB; the mock returns immediately. +#[async_trait] +pub trait ConnectionSpawner: Send + Sync { + /// Spawn a fresh child ACP connection of `agent_type` in `working_dir`. + /// No session resume, no preferred mode, no special env — delegation + /// children are always brand-new sessions. + /// + /// `parent_connection_id` identifies the parent ACP connection so the + /// production impl can inherit the parent's `EventEmitter` and + /// `owner_window_label` (both required by `ConnectionManager::spawn_agent`) + /// without leaking those types into the broker. If `working_dir` is + /// `None`, the impl may fall back to the parent connection's `working_dir`. + /// + /// Returns the new connection id (codeg-internal UUID, not the ACP + /// session id assigned by the agent). + async fn spawn( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + ) -> Result; + + /// Send the delegation task as the child's first prompt. The + /// `DelegationLink` is persisted onto the new conversation row so the + /// lifecycle subscriber can later notify the broker on `TurnComplete`. + /// + /// Returns the new child conversation row id (i32). + async fn send_prompt_linked_for_delegation( + &self, + conn_id: &str, + task: String, + link: DelegationLink, + ) -> Result; + + /// Cancel any in-flight prompt on the child connection. Idempotent: + /// calling on a connection with nothing in flight is a no-op success. + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError>; + + /// Tear down the child connection. Always called after the broker has + /// resolved (or failed) the pending call, to enforce v1's one-shot + /// semantics. + async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError>; +} + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock { + use super::*; + use std::collections::VecDeque; + use tokio::sync::Mutex; + + /// In-memory spawner that returns pre-queued results and records every + /// `cancel` / `disconnect` it sees. Use `queue_spawn` / `queue_send` to + /// stage the next return value; calls without queued results fail loudly. + #[derive(Default)] + pub struct MockSpawner { + pub spawn_results: Mutex>>, + pub send_results: Mutex>>, + pub cancels: Mutex>, + pub disconnects: Mutex>, + } + + impl MockSpawner { + pub fn new() -> Self { + Self::default() + } + + pub async fn queue_spawn(&self, r: Result) { + self.spawn_results.lock().await.push_back(r); + } + + pub async fn queue_send(&self, r: Result) { + self.send_results.lock().await.push_back(r); + } + } + + #[async_trait] + impl ConnectionSpawner for MockSpawner { + async fn spawn( + &self, + _parent_connection_id: &str, + _agent_type: AgentType, + _working_dir: Option, + ) -> Result { + self.spawn_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Spawn("no queued spawn result".into()))) + } + + async fn send_prompt_linked_for_delegation( + &self, + _conn_id: &str, + _task: String, + _link: DelegationLink, + ) -> Result { + self.send_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Send("no queued send result".into()))) + } + + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.cancels.lock().await.push(conn_id.to_string()); + Ok(()) + } + + async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.disconnects.lock().await.push(conn_id.to_string()); + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[tokio::test] + async fn mock_records_cancel_and_disconnect() { + let m = MockSpawner::new(); + m.cancel("c1").await.unwrap(); + m.disconnect("c2").await.unwrap(); + assert_eq!(m.cancels.lock().await.as_slice(), &["c1".to_string()]); + assert_eq!(m.disconnects.lock().await.as_slice(), &["c2".to_string()]); + } + + #[tokio::test] + async fn mock_consumes_queued_spawn_results() { + let m = MockSpawner::new(); + m.queue_spawn(Ok("child-1".into())).await; + m.queue_spawn(Err(SpawnerError::Spawn("oh no".into()))) + .await; + let r1 = m + .spawn("parent-1", AgentType::ClaudeCode, Some("/tmp".into())) + .await + .unwrap(); + assert_eq!(r1, "child-1"); + let r2 = m + .spawn("parent-1", AgentType::Codex, None) + .await + .unwrap_err(); + assert!(matches!(r2, SpawnerError::Spawn(_))); + } + + #[tokio::test] + async fn mock_unqueued_spawn_fails_loudly() { + let m = MockSpawner::new(); + let r = m + .spawn("parent-1", AgentType::ClaudeCode, None) + .await + .unwrap_err(); + match r { + SpawnerError::Spawn(msg) => assert!(msg.contains("no queued")), + other => panic!("expected SpawnerError::Spawn, got {other:?}"), + } + } + } +} diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json new file mode 100644 index 000000000..893ad657d --- /dev/null +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -0,0 +1,37 @@ +{ + "name": "delegate_to_agent", + "description": "Delegate a self-contained task to another local AI coding agent and wait for its result. The sub-agent runs as an independent session with no access to this conversation's history; pass everything it needs in `task`. Use sparingly — each call spawns a full agent process and may take minutes.", + "inputSchema": { + "type": "object", + "required": ["agent_type", "task"], + "properties": { + "agent_type": { + "type": "string", + "enum": [ + "claude_code", + "codex", + "open_code", + "gemini", + "cline", + "open_claw" + ], + "description": "Which agent type to spawn for this sub-task." + }, + "task": { + "type": "string", + "description": "Complete, self-contained task description. The sub-agent does NOT see this conversation's prior messages." + }, + "working_dir": { + "type": "string", + "description": "Optional absolute path. Defaults to the parent session's working directory." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 30, + "maximum": 3600, + "default": 600, + "description": "Per-call timeout. Defaults to 600 seconds." + } + } + } +} diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs new file mode 100644 index 000000000..19c2ae0aa --- /dev/null +++ b/src-tauri/src/acp/delegation/transport.rs @@ -0,0 +1,186 @@ +//! Wire format for `codeg-mcp` companion ↔ main process round-trip over UDS +//! (Unix) or named pipe (Windows). +//! +//! The frame is dead simple: a little-endian `u32` byte length followed by +//! that many bytes of UTF-8 JSON. One request, one response — the companion +//! reopens the socket per `tools/call`. This trades a few extra connects for +//! a wire that's trivial to test and that doesn't need multiplexing +//! (a parent makes at most one delegation call at a time from the LLM's +//! perspective — the broker handles concurrency at a higher level). +//! +//! Why length-prefix instead of newline-delimited JSON? The LLM-issued +//! `task` arguments can contain newlines, and we'd rather avoid escaping +//! them into a single line. JSON-RPC over stdio uses newlines because +//! Content-Length headers add complexity; for an internal UDS we can do +//! better. + +use std::io; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// One delegation call's worth of input forwarded from the companion to the +/// main process. The main process re-validates `token` and maps +/// `parent_connection_id` to the live ACP connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerRequest { + /// Shared secret minted by the main process when it spawned the agent CLI; + /// the agent passes it through to the companion via `--token`. Rejects + /// anything else. + pub token: String, + /// codeg-internal ACP connection UUID for the parent session. + pub parent_connection_id: String, + /// The MCP `tool_use_id` for the LLM-issued `delegate_to_agent` call. + /// Used to bind the eventual child outcome back to the parent's + /// tool_use_id in the UI / DB. + pub parent_tool_use_id: String, + /// Raw `arguments` JSON from the MCP `tools/call` request, schema-shaped + /// per [`super::tool_schema_json`]. The main process re-parses into + /// [`super::types::DelegationRequest`]. + pub input: Value, +} + +/// The wrapped outcome the main process returns over the same socket. +/// `outcome` is a serialized [`super::types::DelegationOutcome`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerResponse { + pub outcome: Value, +} + +/// Maximum allowed frame size, 16 MiB. Guards against a misbehaving peer +/// allocating gigabytes when reading the length prefix. +pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// Write one length-prefixed JSON frame. +pub async fn write_frame(stream: &mut W, value: &T) -> io::Result<()> +where + W: AsyncWriteExt + Unpin, + T: Serialize, +{ + let bytes = serde_json::to_vec(value) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("encode: {e}")))?; + let len: u32 = bytes + .len() + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame > u32::MAX"))?; + stream.write_all(&len.to_le_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +/// Read one length-prefixed JSON frame. Rejects frames larger than +/// [`MAX_FRAME_BYTES`]. +pub async fn read_frame(stream: &mut R) -> io::Result +where + R: AsyncReadExt + Unpin, + T: for<'de> Deserialize<'de>, +{ + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf) as usize; + if len > MAX_FRAME_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("frame {len} bytes exceeds cap {MAX_FRAME_BYTES}"), + )); + } + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await?; + serde_json::from_slice(&body) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}"))) +} + +/// One-shot client round-trip: connect, write the request, read the response, +/// drop the connection. +#[cfg(unix)] +pub async fn client_round_trip( + socket_path: &str, + req: &BrokerRequest, +) -> io::Result { + use tokio::net::UnixStream; + let mut stream = UnixStream::connect(socket_path).await?; + write_frame(&mut stream, req).await?; + read_frame(&mut stream).await +} + +/// Windows path uses named pipes; the address format is `\\.\pipe\`. +#[cfg(windows)] +pub async fn client_round_trip( + socket_path: &str, + req: &BrokerRequest, +) -> io::Result { + use tokio::net::windows::named_pipe::ClientOptions; + let mut stream = ClientOptions::new() + .open(socket_path) + .map_err(|e| io::Error::other(format!("open pipe: {e}")))?; + write_frame(&mut stream, req).await?; + read_frame(&mut stream).await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tokio::io::duplex; + + #[tokio::test] + async fn frame_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: "pt1".into(), + input: json!({"agent_type": "codex", "task": "hi"}), + }; + write_frame(&mut a, &req).await.unwrap(); + let got: BrokerRequest = read_frame(&mut b).await.unwrap(); + assert_eq!(got.token, "tok"); + assert_eq!(got.input["agent_type"], "codex"); + } + + #[tokio::test] + async fn rejects_oversized_frame() { + let (mut a, mut b) = duplex(8); + // Write a length prefix larger than the cap, no body. + let bad_len: u32 = (MAX_FRAME_BYTES as u32) + 1; + a.write_all(&bad_len.to_le_bytes()).await.unwrap(); + a.flush().await.unwrap(); + let result: io::Result = read_frame(&mut b).await; + let err = result.expect_err("expected oversized frame to be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[cfg(unix)] + #[tokio::test] + async fn uds_round_trip() { + use tokio::net::UnixListener; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("codeg-mcp.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let server_path = path.to_string_lossy().to_string(); + + let server = tokio::spawn(async move { + let (mut conn, _) = listener.accept().await.unwrap(); + let req: BrokerRequest = read_frame(&mut conn).await.unwrap(); + assert_eq!(req.token, "tok"); + let resp = BrokerResponse { + outcome: json!({"kind": "ok", "text": "hello"}), + }; + write_frame(&mut conn, &resp).await.unwrap(); + }); + + let req = BrokerRequest { + token: "tok".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: "pt1".into(), + input: json!({"agent_type": "codex", "task": "do x"}), + }; + let resp = client_round_trip(&server_path, &req).await.unwrap(); + assert_eq!(resp.outcome["kind"], "ok"); + assert_eq!(resp.outcome["text"], "hello"); + server.await.unwrap(); + } +} diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs new file mode 100644 index 000000000..e0c652ba7 --- /dev/null +++ b/src-tauri/src/acp/delegation/types.rs @@ -0,0 +1,132 @@ +//! Broker-facing request / outcome types. +//! +//! These cross two boundaries: +//! 1. The MCP companion serializes `DelegationRequest` → JSON-RPC params and +//! deserializes `DelegationOutcome` → MCP `tool_result`. +//! 2. The broker emits a structured outcome the listener can persist and +//! forward to the parent's tool_use_id. +//! +//! DB ids are `i32` to match the actual `conversation.id` / `conversation.parent_id` +//! column types — keeping them strongly typed here saves us a parse-or-die step +//! at every DB boundary. + +use serde::{Deserialize, Serialize}; + +use crate::models::AgentType; + +/// Everything the broker needs to dispatch a single delegation call. +/// +/// `parent_connection_id` is the codeg-internal ACP connection UUID for the +/// parent session (NOT the agent-assigned ACP session id). The broker uses it +/// to inherit the parent's EventEmitter/working_dir and to scope +/// `cancel_by_parent`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationRequest { + pub parent_connection_id: String, + pub parent_conversation_id: i32, + pub parent_tool_use_id: String, + pub agent_type: AgentType, + pub task: String, + pub working_dir: Option, + pub timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenUsage { + pub input: u64, + pub output: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationSuccess { + pub text: String, + pub child_conversation_id: i32, + pub child_agent_type: AgentType, + pub turn_count: u32, + pub duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_usage: Option, +} + +/// Broker-internal failure modes. Serialized via the wrapping +/// [`DelegationOutcome::Err`] variant — the broker maps each into a stable +/// `code` string so the frontend / MCP consumer can pattern-match without +/// caring about the inner shape. +#[derive(Debug, Clone, thiserror::Error, Serialize, Deserialize)] +#[serde(tag = "code", content = "detail", rename_all = "snake_case")] +pub enum DelegationError { + #[error("depth limit exceeded ({current_depth} >= {limit})")] + DepthLimitExceeded { current_depth: u32, limit: u32 }, + #[error("invalid agent type")] + InvalidAgentType, + #[error("invalid working dir: {0}")] + InvalidWorkingDir(String), + #[error("spawn failed: {0}")] + SpawnFailed(String), + #[error("subagent runtime error: {0}")] + SubagentRuntimeError(String), + /// Child agent ended its turn via `refusal`. Often a backend / gateway + /// error masquerading as a refusal per the ACP spec gap. + #[error("subagent refused to continue")] + ChildRefusal, + #[error("subagent reached max token budget")] + ChildMaxTokens, + #[error("subagent reached max turn request budget")] + ChildMaxTurnRequests, + /// Child reported `end_turn` without producing any output (synthesized + /// as `empty` by the connection loop's "silent EndTurn" guard). + #[error("subagent produced no output")] + ChildEmpty, + #[error("subagent ended with unrecognized stop reason: {0}")] + ChildUnknown(String), + #[error("timeout after {elapsed_ms}ms")] + Timeout { elapsed_ms: u64 }, + #[error("canceled: {reason}")] + Canceled { reason: String }, + #[error("parent session is gone")] + ParentSessionGone, +} + +/// The single value the broker hands back to the listener / MCP companion. +/// `child_conversation_id` on the `Err` arm is best-effort — it's `Some` once +/// the broker successfully created the child DB row, even if the run later +/// fails or times out. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DelegationOutcome { + Ok(DelegationSuccess), + Err { + code: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + child_conversation_id: Option, + }, +} + +impl DelegationOutcome { + /// Project a `DelegationError` onto the wire-stable `code` string used by + /// the frontend and MCP companion. Keep these strings stable — they ship + /// to LLM context. + pub fn from_err(err: DelegationError, child_conversation_id: Option) -> Self { + let code = match &err { + DelegationError::DepthLimitExceeded { .. } => "depth_limit", + DelegationError::InvalidAgentType => "invalid_agent_type", + DelegationError::InvalidWorkingDir(_) => "invalid_working_dir", + DelegationError::SpawnFailed(_) => "spawn_failed", + DelegationError::SubagentRuntimeError(_) => "subagent_error", + DelegationError::ChildRefusal => "child_refusal", + DelegationError::ChildMaxTokens => "child_max_tokens", + DelegationError::ChildMaxTurnRequests => "child_max_turn_requests", + DelegationError::ChildEmpty => "child_empty", + DelegationError::ChildUnknown(_) => "child_unknown", + DelegationError::Timeout { .. } => "timeout", + DelegationError::Canceled { .. } => "canceled", + DelegationError::ParentSessionGone => "canceled", + }; + DelegationOutcome::Err { + code: code.to_string(), + message: err.to_string(), + child_conversation_id, + } + } +} diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 66a41a02a..a897addb1 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -18,6 +18,8 @@ use std::time::Duration; use sea_orm::DatabaseConnection; use tokio::sync::{broadcast, mpsc}; +use crate::acp::delegation::broker::DelegationBroker; +use crate::acp::delegation::types::{DelegationError, DelegationOutcome, DelegationSuccess}; use crate::acp::internal_bus::InternalEventBus; use crate::acp::manager::ConnectionManager; use crate::acp::session_state::SessionState; @@ -44,12 +46,18 @@ const WORKER_QUEUE_CAPACITY: usize = 64; /// uninteresting events) means ContentDelta floods can't crowd out a /// TurnComplete in the worker mailbox: only events that may write the DB /// or update the per-connection cache enter the queue. +/// +/// `ToolCall` is in the accept list because the worker's ToolCall arm +/// captures `delegate_to_agent` invocations for the broker's pending +/// tool_call_id queue. ToolCall fires a handful of times per turn (not +/// per-token like ContentDelta), so the queue pressure is bounded. fn is_lifecycle_relevant(event: &AcpEvent) -> bool { matches!( event, AcpEvent::SessionStarted { .. } | AcpEvent::TurnComplete { .. } | AcpEvent::ConversationLinked { .. } + | AcpEvent::ToolCall { .. } | AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected } @@ -91,8 +99,9 @@ async fn handle_event_with_retry( db_conn: &DatabaseConnection, manager: &ConnectionManager, envelope: &EventEnvelope, + broker: Option<&Arc>, ) { - match handle_event(db_conn, manager, envelope).await { + match handle_event(db_conn, manager, envelope, broker).await { Ok(()) => return, Err(e) => { eprintln!( @@ -103,7 +112,7 @@ async fn handle_event_with_retry( } for (attempt, backoff) in HANDLE_EVENT_RETRY_BACKOFFS.iter().enumerate() { tokio::time::sleep(*backoff).await; - match handle_event(db_conn, manager, envelope).await { + match handle_event(db_conn, manager, envelope, broker).await { Ok(()) => return, Err(e) => { let attempt_num = attempt + 2; @@ -128,8 +137,34 @@ pub(crate) async fn handle_event( db_conn: &DatabaseConnection, manager: &ConnectionManager, envelope: &EventEnvelope, + broker: Option<&Arc>, ) -> Result<(), DbError> { match &envelope.payload { + AcpEvent::ToolCall { + tool_call_id, + title, + raw_input, + .. + } => { + // MCP clients don't reliably populate `_meta.tool_use_id`, so we + // capture every parent-side `delegate_to_agent` tool_call_id + // here. The broker pops the most recent one when the matching + // MCP round-trip arrives. See [`DelegationBroker::register_pending_tool_call`]. + // + // ACP `title` is a free-form human-readable string the agent + // composes from the tool name (Codex emits the bare MCP method, + // Claude Code emits "Run ", others phrase it as + // "Delegate to "). Pair the title match with a raw_input + // shape check so we don't miss a delegation just because the + // host re-phrased the title. + if let Some(b) = broker { + if is_delegation_invocation(title, raw_input.as_deref()) { + b.register_pending_tool_call(&envelope.connection_id, tool_call_id.clone()) + .await; + } + } + Ok(()) + } AcpEvent::SessionStarted { session_id } => { // Look up conversation_id from the live state. let Some(state_arc) = manager.get_state(&envelope.connection_id).await else { @@ -164,38 +199,57 @@ pub(crate) async fn handle_event( // we leave it alone here. `completed` transitions remain // frontend-driven. let target_status = match stop_reason.as_str() { - "end_turn" => ConversationStatus::PendingReview, + "end_turn" => Some(ConversationStatus::PendingReview), "refusal" | "max_tokens" | "max_turn_requests" | "unknown" | "empty" => { - ConversationStatus::Cancelled + Some(ConversationStatus::Cancelled) } // `cancelled` and any future reason: don't write here. - _ => return Ok(()), + _ => None, }; let Some((state_arc, emitter)) = manager.get_state_and_emitter(&envelope.connection_id).await else { return Ok(()); }; - let conversation_id = state_arc.read().await.conversation_id; + let (conversation_id, last_text) = { + let snap = state_arc.read().await; + (snap.conversation_id, snap.last_assistant_text.clone()) + }; // No conversation row bound (defensive — should never happen in // practice since `send_prompt_linked` runs before TurnComplete can // fire). Nothing to update. let Some(cid) = conversation_id else { return Ok(()); }; - // DB write before emit so any downstream subscriber that observes - // the ConversationStatusChanged event can assume the row is - // already at the target status. - conversation_service::update_status(db_conn, cid, target_status.clone()).await?; - emit_with_state( - &state_arc, - &emitter, - AcpEvent::ConversationStatusChanged { - conversation_id: cid, - status: target_status, - }, - ) - .await; + if let Some(ts) = target_status.clone() { + // DB write before emit so any downstream subscriber that observes + // the ConversationStatusChanged event can assume the row is + // already at the target status. + conversation_service::update_status(db_conn, cid, ts.clone()).await?; + emit_with_state( + &state_arc, + &emitter, + AcpEvent::ConversationStatusChanged { + conversation_id: cid, + status: ts, + }, + ) + .await; + } + + // If this conversation was spawned by a delegation, resolve the + // pending broker call. The broker maps the outcome onto the + // parent's `tool_use_id` via the registered `call_id`. + if let Some(b) = broker { + forward_turn_complete_to_broker( + db_conn, + b.as_ref(), + cid, + stop_reason.as_str(), + last_text, + ) + .await; + } Ok(()) } // Other events don't need cross-connection DB persistence today; extend @@ -204,6 +258,81 @@ pub(crate) async fn handle_event( } } +/// On TurnComplete for a delegation child, resolve the pending broker call +/// and let the broker drive the rest of the lifecycle (meta write, the +/// `AcpEvent::DelegationCompleted` emit against the parent stream, child +/// disconnect, tx.send). Keeping the emit responsibility inside +/// `broker.complete_call` is what guarantees the broker's other terminal +/// paths (`timeout` / `cancel_by_child_connection` / `cancel_by_parent`) +/// also surface the event — see +/// `.docs/issues/2026-05-24-delegation-termination-cascade.md`. +async fn forward_turn_complete_to_broker( + db_conn: &DatabaseConnection, + broker: &DelegationBroker, + conversation_id: i32, + stop_reason: &str, + last_text: Option, +) { + let row = match conversation_service::get_by_id(db_conn, conversation_id).await { + Ok(r) => r, + Err(e) => { + eprintln!( + "[delegation][lifecycle] couldn't fetch child conversation \ + {conversation_id} for outcome routing: {e}" + ); + return; + } + }; + let call_id = match row.delegation_call_id.clone() { + Some(id) => id, + None => return, // not a delegation child; nothing to do. + }; + if row.parent_tool_use_id.is_none() { + eprintln!( + "[delegation][lifecycle] conversation {conversation_id} has \ + delegation_call_id but no parent_tool_use_id; dropping" + ); + return; + } + let agent_type = row.agent_type; + let outcome = match stop_reason { + "end_turn" => DelegationOutcome::Ok(DelegationSuccess { + text: last_text.unwrap_or_default(), + child_conversation_id: conversation_id, + child_agent_type: agent_type, + turn_count: 1, + duration_ms: 0, + token_usage: None, + }), + "cancelled" => DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "child session was cancelled".into(), + }, + Some(conversation_id), + ), + // Each child turn-failure reason gets a distinct wire code so the + // parent UI can show a more useful error label than a generic + // "subagent error". Mirrors the parent's own + // `turn_failure_error_event` mapping in `connection.rs`. + "refusal" => { + DelegationOutcome::from_err(DelegationError::ChildRefusal, Some(conversation_id)) + } + "max_tokens" => { + DelegationOutcome::from_err(DelegationError::ChildMaxTokens, Some(conversation_id)) + } + "max_turn_requests" => DelegationOutcome::from_err( + DelegationError::ChildMaxTurnRequests, + Some(conversation_id), + ), + "empty" => DelegationOutcome::from_err(DelegationError::ChildEmpty, Some(conversation_id)), + other => DelegationOutcome::from_err( + DelegationError::ChildUnknown(other.to_string()), + Some(conversation_id), + ), + }; + broker.complete_call(&call_id, outcome).await; +} + /// Snapshot the connection's `(state, emitter)` into the lifecycle cache when /// `ConversationLinked` arrives. Idempotent on repeat calls (re-link on the /// already-bound path is a no-op so we don't churn the cached refs). @@ -276,6 +405,90 @@ async fn handle_terminal_event( Ok(()) } +/// On a non-TurnComplete terminal event (Disconnected / Error) for a +/// delegation child, surface a `canceled` outcome to the broker. The +/// child's DB row may already be marked `Cancelled` by `handle_terminal_event` +/// above; this separately wakes the parent's pending `delegate_to_agent` +/// tool_use_id. Match-by-`child_connection_id` is O(pending), bounded by +/// active delegations. +async fn forward_disconnect_to_broker(broker: &DelegationBroker, connection_id: &str) { + broker.cancel_by_child_connection(connection_id).await; +} + +/// True when the ACP `tool_call` smells like an invocation of the +/// `delegate_to_agent` MCP tool. Defensive on both inputs because the host +/// agent gets to decide both fields: +/// +/// * `title` is a free-form human-readable string the host composes. Some +/// hosts copy the MCP method verbatim (`mcp__codeg-delegate__delegate_to_agent`), +/// some prefix it with a verb (`Run mcp__…__delegate_to_agent`), some +/// rephrase it (`Delegate to codex`). We match by substring so any +/// form containing `delegate_to_agent` is captured. +/// * `raw_input` is the JSON arg blob the agent sent to the MCP server. The +/// `delegate_to_agent` schema requires `agent_type` AND `task`; presence +/// of both is a near-zero false-positive shape check that catches any +/// host that mangles the title beyond recognition. +fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { + let normalized_title = title.to_ascii_lowercase().replace([' ', '-'], "_"); + if normalized_title.contains("delegate_to_agent") { + return true; + } + if let Some(raw) = raw_input { + if let Ok(v) = serde_json::from_str::(raw) { + if let Some(obj) = v.as_object() { + let has_task = obj.get("task").and_then(|t| t.as_str()).is_some(); + let has_agent_type = obj.get("agent_type").and_then(|a| a.as_str()).is_some(); + if has_task && has_agent_type { + return true; + } + } + } + } + false +} + +#[cfg(test)] +mod delegation_title_tests { + use super::is_delegation_invocation; + + #[test] + fn matches_bare_method_in_title() { + assert!(is_delegation_invocation("delegate_to_agent", None)); + assert!(is_delegation_invocation("Delegate To Agent", None)); + assert!(is_delegation_invocation("delegate-to-agent", None)); + } + + #[test] + fn matches_mcp_prefixed_method_in_title() { + assert!(is_delegation_invocation( + "mcp__codeg-delegate__delegate_to_agent", + None + )); + assert!(is_delegation_invocation( + "Run mcp__codeg__delegate_to_agent", + None + )); + } + + #[test] + fn matches_via_raw_input_shape_when_title_is_unrecognized() { + let raw = r#"{"agent_type":"codex","task":"smoke test"}"#; + assert!(is_delegation_invocation("Delegate to codex", Some(raw))); + assert!(is_delegation_invocation("anything", Some(raw))); + } + + #[test] + fn rejects_unrelated_tools() { + assert!(!is_delegation_invocation("write", None)); + assert!(!is_delegation_invocation("agent", None)); + assert!(!is_delegation_invocation("delegate_other_thing", None)); + assert!(!is_delegation_invocation( + "write", + Some(r#"{"path":"/tmp/x","content":"y"}"#) + )); + } +} + /// Per-connection worker that owns the cache for one connection and /// serializes its DB writes. Multiple connections run in parallel; within a /// connection, ordering is preserved by the mpsc FIFO. Decouples the bus @@ -286,6 +499,7 @@ async fn connection_worker_loop( connection_id: String, db: DatabaseConnection, manager: ConnectionManager, + broker: Option>, mut rx: mpsc::Receiver>, ) { // 1-entry HashMap so we can reuse `handle_terminal_event` (also keeps the @@ -306,9 +520,15 @@ async fn connection_worker_loop( if let Err(e) = handle_terminal_event(&db, &mut cache, &connection_id).await { eprintln!("[lifecycle][ERROR] terminal event for {connection_id}: {e}"); } + // If this connection owned a delegation child, surface a + // terminal outcome to the broker so the parent's pending + // tool_use_id doesn't dangle. + if let Some(b) = broker.as_ref() { + forward_disconnect_to_broker(b.as_ref(), &connection_id).await; + } } _ => { - handle_event_with_retry(&db, &manager, envelope).await; + handle_event_with_retry(&db, &manager, envelope, broker.as_ref()).await; } } } @@ -337,6 +557,7 @@ pub fn lifecycle_subscriber_task( db_conn: DatabaseConnection, manager: ConnectionManager, bus: Arc, + broker: Option>, ) -> impl Future + Send + 'static { let mut rx = bus.subscribe(); let metrics = Arc::clone(bus.metrics()); @@ -369,9 +590,14 @@ pub fn lifecycle_subscriber_task( mpsc::channel::>(WORKER_QUEUE_CAPACITY); let db_clone = db_conn.clone(); let mgr_clone = manager.clone_ref(); + let broker_clone = broker.clone(); let id_clone = conn_id.clone(); tokio::spawn(connection_worker_loop( - id_clone, db_clone, mgr_clone, worker_rx, + id_clone, + db_clone, + mgr_clone, + broker_clone, + worker_rx, )); tx }); @@ -490,7 +716,7 @@ mod tests { session_id: "ext-99".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); let reloaded = conversation_service::get_by_id(&db.conn, conv.id) .await .unwrap(); @@ -519,7 +745,7 @@ mod tests { session_id: "should-not-write".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); // Sentinel row must still have no external_id — dispatcher correctly // skipped the write because the connection had no conversation_id. @@ -581,7 +807,7 @@ mod tests { agent_type: "claude_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, conv.id).await, ConversationStatus::PendingReview @@ -627,7 +853,7 @@ mod tests { agent_type: "open_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, conv.id).await, ConversationStatus::Cancelled, @@ -665,7 +891,7 @@ mod tests { agent_type: "claude_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, conv.id).await, ConversationStatus::InProgress, @@ -699,7 +925,7 @@ mod tests { agent_type: "claude_code".into(), }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); assert_eq!( read_row_status(&db, sentinel.id).await, ConversationStatus::InProgress, @@ -916,7 +1142,7 @@ mod tests { connection_id: "c1".to_string(), payload: AcpEvent::ContentDelta { text: "hi".into() }, }; - handle_event(&db.conn, &mgr, &env).await.unwrap(); + handle_event(&db.conn, &mgr, &env, None).await.unwrap(); let reloaded = conversation_service::get_by_id(&db.conn, conv.id) .await @@ -953,6 +1179,23 @@ mod tests { assert!(is_lifecycle_relevant(&AcpEvent::ConversationLinked { conversation_id: 1, folder_id: 1, + parent_conversation_id: None, + parent_tool_use_id: None, + })); + // ToolCall must enter the queue so the delegation broker's + // pending tool_call_id capture (see `handle_event`'s ToolCall + // arm) actually runs. + assert!(is_lifecycle_relevant(&AcpEvent::ToolCall { + tool_call_id: "tc-1".into(), + title: "delegate_to_agent".into(), + kind: "other".into(), + status: "pending".into(), + content: None, + raw_input: None, + raw_output: None, + locations: None, + meta: None, + images: None, })); assert!(is_lifecycle_relevant(&AcpEvent::StatusChanged { status: ConnectionStatus::Disconnected, @@ -1042,6 +1285,7 @@ mod tests { db.conn.clone(), mgr.clone_ref(), bus.clone(), + None, )); // Subscribe AFTER spawn would race; the bus's broadcast channel @@ -1105,6 +1349,7 @@ mod tests { db.conn.clone(), mgr.clone_ref(), bus.clone(), + None, )); bus.send(Arc::new(EventEnvelope { @@ -1177,6 +1422,7 @@ mod tests { db.conn.clone(), mgr.clone_ref(), bus.clone(), + None, )); // Burst of 200 SessionStarted events (each writes external_id). diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 0efd1cd7e..452519c71 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -105,6 +105,13 @@ pub struct ConnectionManager { /// tests; in production initialized from env via /// `spawn_handshake_timeout_from_env`. spawn_handshake_timeout: Duration, + /// Delegation broker + token registry + UDS path installed during app + /// bootstrap (`install_delegation`). When present, `spawn_agent` propagates + /// the injection to `spawn_agent_connection`, which makes + /// `codeg-delegate` appear in the agent's MCP server list during ACP + /// init. `Arc` so the inner `Self` cloned from `clone_ref` sees + /// the install too — the lock is set once at startup and never mutated. + delegation_injection: Arc>, } impl Default for ConnectionManager { @@ -119,6 +126,7 @@ impl ConnectionManager { connections: Arc::new(Mutex::new(HashMap::new())), spawn_locks: Arc::new(Mutex::new(HashMap::new())), spawn_handshake_timeout: spawn_handshake_timeout_from_env(), + delegation_injection: Arc::new(std::sync::OnceLock::new()), } } @@ -128,9 +136,21 @@ impl ConnectionManager { connections: self.connections.clone(), spawn_locks: self.spawn_locks.clone(), spawn_handshake_timeout: self.spawn_handshake_timeout, + delegation_injection: self.delegation_injection.clone(), } } + /// Set the delegation injection context exactly once during bootstrap. + /// Calling twice is a no-op — protects against accidental re-init in + /// the unlikely event a second `build_delegation_stack` runs. + pub fn install_delegation(&self, injection: crate::acp::connection::DelegationInjection) { + let _ = self.delegation_injection.set(injection); + } + + fn delegation_snapshot(&self) -> Option { + self.delegation_injection.get().cloned() + } + /// Test-only constructor that overrides the spawn-handshake timeout. /// Production code should use `new()`. #[cfg(test)] @@ -139,6 +159,7 @@ impl ConnectionManager { connections: Arc::new(Mutex::new(HashMap::new())), spawn_locks: Arc::new(Mutex::new(HashMap::new())), spawn_handshake_timeout: timeout, + delegation_injection: Arc::new(std::sync::OnceLock::new()), } } @@ -265,6 +286,7 @@ impl ConnectionManager { self.connections.clone(), preferred_mode_id, preferred_config_values, + self.delegation_snapshot(), ) .await?; @@ -480,7 +502,8 @@ impl ConnectionManager { blocks: Vec, folder_id: Option, conversation_id: Option, - ) -> Result<(), AcpError> { + delegation: Option, + ) -> Result, AcpError> { // Caller-supplied conversation_id requires folder_id (we include it in // the emitted ConversationLinked event so subscribers don't have to // re-query the DB). Validate before touching any state. @@ -489,6 +512,15 @@ impl ConnectionManager { "conversation_id provided without folder_id".to_string(), )); } + // Delegation is only meaningful on the create-new-row branch — adopting + // an existing caller-supplied row already has its own (or no) parent + // linkage. Reject the combination loudly so a misuse from the broker + // doesn't silently drop the linkage. + if delegation.is_some() && conversation_id.is_some() { + return Err(AcpError::protocol( + "delegation link is incompatible with caller-supplied conversation_id".to_string(), + )); + } // Acquire the per-connection prompt lock for the entire link-check // + DB write + emit + cmd_tx.send sequence. Two concurrent prompts @@ -529,6 +561,8 @@ impl ConnectionManager { AcpEvent::ConversationLinked { conversation_id: caller_conv_id, folder_id: caller_folder_id, + parent_conversation_id: None, + parent_tool_use_id: None, }, ) .await; @@ -543,19 +577,56 @@ impl ConnectionManager { // silent fallback to working_dir-based find-or-create masked // contract violations. (None, Some(folder_id)) => { - let row = - conversation_service::create(&db.conn, folder_id, agent_type, None, None) - .await - .map_err(|e| AcpError::protocol(e.to_string()))?; + // Snapshot the delegation link before move-into-create: we + // still need the parent ids for the ConversationLinked + // event payload. + let parent_conversation_id_for_event = + delegation.as_ref().map(|d| d.parent_conversation_id); + let parent_tool_use_id_for_event = + delegation.as_ref().map(|d| d.parent_tool_use_id.clone()); + let row = conversation_service::create_with_delegation( + &db.conn, + folder_id, + agent_type, + None, + None, + delegation.clone(), + ) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; emit_with_state( &state_arc, &emitter, AcpEvent::ConversationLinked { conversation_id: row.id, folder_id, + parent_conversation_id: parent_conversation_id_for_event, + parent_tool_use_id: parent_tool_use_id_for_event, }, ) .await; + + // Surface DelegationStarted on the child's stream so the + // frontend can paint "Delegating to …" against the + // parent's tool_use_id while the child's first turn runs. + // The parent_connection_id isn't on the DelegationLink + // payload — derive it via reverse lookup. (For v1 we leave + // it empty; Phase 8's frontend grouper uses parent_tool_use_id + // as the primary key.) + if let Some(link) = delegation.as_ref() { + emit_with_state( + &state_arc, + &emitter, + AcpEvent::DelegationStarted { + parent_connection_id: String::new(), + parent_tool_use_id: link.parent_tool_use_id.clone(), + child_connection_id: conn_id.to_string(), + child_conversation_id: row.id, + agent_type, + }, + ) + .await; + } } (None, None) => { return Err(AcpError::protocol( @@ -623,7 +694,7 @@ impl ConnectionManager { // PendingReview write also never fires — the row would be stuck // until a follow-up `send_prompt_linked` happened to re-flip it. match self.send_prompt_inner(conn_id, blocks).await { - Ok(()) => Ok(()), + Ok(()) => Ok(conversation_id_for_status), Err(send_err) => { if let Some(cid) = conversation_id_for_status { match conversation_service::update_status( @@ -878,6 +949,8 @@ impl ConnectionManager { git_branch: Set(git_branch), external_id: Set(Some(original_for_tx)), parent_id: Set(None), + parent_tool_use_id: Set(None), + delegation_call_id: Set(None), message_count: Set(0), created_at: Set(now), updated_at: Set(now), @@ -1009,6 +1082,156 @@ impl ConnectionManager { } } +/// Production impl of `ConnectionSpawner` used by `DelegationBroker`. +/// +/// Bundles `Arc` with `Arc` because +/// `cancel` writes the cancelled status onto the conversation row, which +/// happens inside `ConnectionManager::cancel`. The wrapper exists so the +/// broker can depend on a small `dyn`-able interface instead of pulling +/// in the full `AppState` graph. +#[derive(Clone)] +pub struct ConnectionManagerSpawner { + pub manager: Arc, + pub db: Arc, +} + +#[async_trait::async_trait] +impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpawner { + async fn spawn( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + ) -> Result { + use crate::acp::delegation::spawner::SpawnerError; + // Resolve the parent connection so we can inherit its emitter and + // owner_window. Falling back is not safe: a child whose emitter is + // wired to a different broadcaster would emit events the frontend + // never sees. + let (emitter, owner_window, parent_working_dir) = { + let conns = self.manager.connections.lock().await; + let parent = conns.get(parent_connection_id).ok_or_else(|| { + SpawnerError::Spawn(format!( + "parent connection {parent_connection_id} not found" + )) + })?; + let pwd = { + let s = parent.state.read().await; + s.working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + }; + ( + parent.emitter.clone(), + parent.owner_window_label.clone(), + pwd, + ) + }; + let effective_working_dir = working_dir.or(parent_working_dir); + self.manager + .spawn_agent( + agent_type, + effective_working_dir, + None, + BTreeMap::new(), + owner_window, + emitter, + None, + BTreeMap::new(), + ) + .await + .map_err(|e| SpawnerError::Spawn(e.to_string())) + } + + async fn send_prompt_linked_for_delegation( + &self, + conn_id: &str, + task: String, + link: crate::acp::delegation::spawner::DelegationLink, + ) -> Result { + use crate::acp::delegation::spawner::SpawnerError; + // The child has no caller-supplied conversation_id (it's brand new). + // folder_id must be None too — the manager's create-new-row branch + // requires folder_id, which we resolve from the child's working_dir + // via folder_service. Do that lookup here so the trait stays small. + let working_dir_pathbuf = { + let conns = self.manager.connections.lock().await; + let conn = conns + .get(conn_id) + .ok_or_else(|| SpawnerError::Send(format!("child {conn_id} not found")))?; + let s = conn.state.read().await; + s.working_dir.clone() + }; + let folder_path = working_dir_pathbuf + .ok_or_else(|| { + SpawnerError::Send( + "child connection has no working_dir; cannot derive folder_id".into(), + ) + })? + .to_string_lossy() + .to_string(); + let folder = crate::db::service::folder_service::add_folder(&self.db.conn, &folder_path) + .await + .map_err(|e| SpawnerError::Send(format!("add_folder: {e}")))?; + + let result = self + .manager + .send_prompt_linked( + &self.db, + conn_id, + vec![PromptInputBlock::Text { text: task }], + Some(folder.id), + None, + Some(link), + ) + .await + .map_err(|e| SpawnerError::Send(e.to_string()))?; + result.ok_or_else(|| { + SpawnerError::Send( + "send_prompt_linked succeeded but no conversation_id was bound".into(), + ) + }) + } + + async fn cancel( + &self, + conn_id: &str, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + self.manager + .cancel(&self.db.conn, conn_id) + .await + .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Cancel(e.to_string())) + } + + async fn disconnect( + &self, + conn_id: &str, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + self.manager + .disconnect(conn_id) + .await + .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Disconnect(e.to_string())) + } +} + +/// Production impl of `ParentSessionLookup` for the delegation listener. +/// Resolves the parent's current `conversation_id` by reading its +/// `SessionState`. Bundled with `ConnectionManagerSpawner` here so the +/// concrete wiring lives next to the manager it depends on. +#[derive(Clone)] +pub struct ConnectionManagerParentLookup { + pub manager: Arc, +} + +#[async_trait::async_trait] +impl crate::acp::delegation::listener::ParentSessionLookup for ConnectionManagerParentLookup { + async fn current_conversation_id(&self, parent_connection_id: &str) -> Option { + let state = self.manager.get_state(parent_connection_id).await?; + let snapshot = state.read().await; + snapshot.conversation_id + } +} + #[cfg(test)] mod tests { use super::*; @@ -1148,7 +1371,7 @@ mod tests { // First call: creates conversation row, sets state.conversation_id. // The mpsc send error after linking is expected and ignored here. let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let snap = mgr .get_state(conn_id) @@ -1166,7 +1389,7 @@ mod tests { // Second call: ignores folder_id, does NOT create another row. let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let snap2 = mgr .get_state(conn_id) @@ -1189,7 +1412,7 @@ mod tests { map.insert(conn_id.into(), fake_connection(conn_id, None)); } let result = mgr - .send_prompt_linked(&db, conn_id, vec![], None, None) + .send_prompt_linked(&db, conn_id, vec![], None, None, None) .await; assert!( result.is_err(), @@ -1243,7 +1466,14 @@ mod tests { // Send with caller-supplied conversation_id + folder_id. let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre_existing.id)) + .send_prompt_linked( + &db, + conn_id, + vec![], + Some(folder_id), + Some(pre_existing.id), + None, + ) .await; // No new conversation row was created. @@ -1260,6 +1490,7 @@ mod tests { AcpEvent::ConversationLinked { conversation_id, folder_id: emitted_folder, + .. } => { assert_eq!(conversation_id, pre_existing.id); assert_eq!(emitted_folder, folder_id); @@ -1285,7 +1516,7 @@ mod tests { .await; let err = mgr - .send_prompt_linked(&db, conn_id, vec![], None, Some(42)) + .send_prompt_linked(&db, conn_id, vec![], None, Some(42), None) .await .expect_err("should reject conversation_id without folder_id"); assert!(matches!(err, AcpError::Protocol(_))); @@ -1321,7 +1552,7 @@ mod tests { let before = count_conversation_rows(&db).await; let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre.id)) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), Some(pre.id), None) .await; let after = count_conversation_rows(&db).await; assert_eq!(after, before); @@ -1391,7 +1622,7 @@ mod tests { // 2. ConversationStatusChanged(InProgress) [pre-send write] // 3. ConversationStatusChanged(Cancelled) [rollback after send failure] let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let env1 = recv_first_acp_event(&mut rx).await; @@ -1399,6 +1630,7 @@ mod tests { AcpEvent::ConversationLinked { conversation_id, folder_id: emitted_folder, + .. } => { assert_eq!(emitted_folder, folder_id); conversation_id @@ -1459,7 +1691,7 @@ mod tests { .unwrap(); let _ = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; let env4 = recv_first_acp_event(&mut rx).await; @@ -1810,12 +2042,12 @@ mod tests { tokio::join!( async { let _ = mgr_ref - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; }, async { let _ = mgr_ref - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; }, ); @@ -1936,7 +2168,7 @@ mod tests { let mut rx = subscribe_conn_stream(&mgr, conn_id).await; let result = mgr - .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None) + .send_prompt_linked(&db, conn_id, vec![], Some(folder_id), None, None) .await; assert!( matches!(result, Err(AcpError::ProcessExited)), diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 85fb935c6..8cb90ae45 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -1,5 +1,6 @@ pub mod binary_cache; pub mod connection; +pub mod delegation; pub mod error; pub mod event_stream; pub mod file_system_runtime; diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index 42b25f98c..e63876911 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -130,34 +130,34 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "Codex CLI", description: "ACP adapter for OpenAI's coding assistant", distribution: AgentDistribution::Binary { - version: "0.14.0", + version: "0.15.0", cmd: "codex-acp", args: &[], env: &[], platforms: &[ PlatformBinary { platform: "darwin-aarch64", - url: "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-aarch64-apple-darwin.tar.gz", + url: "https://github.com/zed-industries/codex-acp/releases/download/v0.15.0/codex-acp-0.15.0-aarch64-apple-darwin.tar.gz", }, PlatformBinary { platform: "darwin-x86_64", - url: "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-x86_64-apple-darwin.tar.gz", + url: "https://github.com/zed-industries/codex-acp/releases/download/v0.15.0/codex-acp-0.15.0-x86_64-apple-darwin.tar.gz", }, PlatformBinary { platform: "linux-aarch64", - url: "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-aarch64-unknown-linux-gnu.tar.gz", + url: "https://github.com/zed-industries/codex-acp/releases/download/v0.15.0/codex-acp-0.15.0-aarch64-unknown-linux-gnu.tar.gz", }, PlatformBinary { platform: "linux-x86_64", - url: "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-x86_64-unknown-linux-gnu.tar.gz", + url: "https://github.com/zed-industries/codex-acp/releases/download/v0.15.0/codex-acp-0.15.0-x86_64-unknown-linux-gnu.tar.gz", }, PlatformBinary { platform: "windows-aarch64", - url: "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-aarch64-pc-windows-msvc.zip", + url: "https://github.com/zed-industries/codex-acp/releases/download/v0.15.0/codex-acp-0.15.0-aarch64-pc-windows-msvc.zip", }, PlatformBinary { platform: "windows-x86_64", - url: "https://github.com/zed-industries/codex-acp/releases/download/v0.14.0/codex-acp-0.14.0-x86_64-pc-windows-msvc.zip", + url: "https://github.com/zed-industries/codex-acp/releases/download/v0.15.0/codex-acp-0.15.0-x86_64-pc-windows-msvc.zip", }, ], }, @@ -206,34 +206,34 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { name: "OpenCode", description: "The open source coding agent", distribution: AgentDistribution::Binary { - version: "1.15.7", + version: "1.15.10", cmd: "opencode", args: &["acp"], env: &[], platforms: &[ PlatformBinary { platform: "darwin-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-arm64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip", }, PlatformBinary { platform: "darwin-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-darwin-x64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip", }, PlatformBinary { platform: "linux-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-arm64.tar.gz", + url: "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz", }, PlatformBinary { platform: "linux-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-linux-x64.tar.gz", + url: "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz", }, PlatformBinary { platform: "windows-aarch64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-arm64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-arm64.zip", }, PlatformBinary { platform: "windows-x86_64", - url: "https://github.com/anomalyco/opencode/releases/download/v1.15.7/opencode-windows-x64.zip", + url: "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip", }, ], }, @@ -270,6 +270,33 @@ mod tests { } } + fn assert_binary_version( + agent_type: AgentType, + expected_version: &str, + expected_release_path: &str, + ) { + let meta = get_agent_meta(agent_type); + match meta.distribution { + AgentDistribution::Binary { + version, platforms, .. + } => { + assert_eq!(version, expected_version); + assert_eq!(meta.registry_version(), Some(expected_version)); + for platform in platforms { + assert!( + platform.url.contains(expected_release_path), + "{} URL did not use {expected_release_path}: {}", + platform.platform, + platform.url + ); + } + } + AgentDistribution::Npx { .. } => { + panic!("expected binary distribution for {agent_type:?}"); + } + } + } + #[test] fn registry_pins_current_acp_agent_versions() { assert_npx_version( @@ -285,26 +312,11 @@ mod tests { Some("22.19.0"), ); assert_npx_version(AgentType::Cline, "3.0.9", "cline@3.0.9", None); - - let meta = get_agent_meta(AgentType::OpenCode); - match meta.distribution { - AgentDistribution::Binary { - version, platforms, .. - } => { - assert_eq!(version, "1.15.7"); - assert_eq!(meta.registry_version(), Some("1.15.7")); - for platform in platforms { - assert!( - platform.url.contains("/releases/download/v1.15.7/"), - "{} URL did not use v1.15.7: {}", - platform.platform, - platform.url - ); - } - } - AgentDistribution::Npx { .. } => { - panic!("expected binary distribution for OpenCode"); - } - } + assert_binary_version(AgentType::Codex, "0.15.0", "/releases/download/v0.15.0/"); + assert_binary_version( + AgentType::OpenCode, + "1.15.10", + "/releases/download/v1.15.10/", + ); } } diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 126f5a4a4..0e3339c4a 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -57,6 +57,22 @@ pub struct ToolCallState { /// ACP extensibility metadata. Used by frontend Phase 1 parent /// extraction. `None` if the agent didn't supply it. Same partial-update /// preservation semantic as `locations`. + /// + /// Convention used by codeg's multi-agent delegation (the `delegate_to_agent` + /// MCP tool) — `DelegationBroker` writes the following object under + /// `meta["codeg.delegation"]` on the parent's active tool call: + /// + /// ```jsonc + /// { + /// "child_connection_id": "", + /// "child_conversation_id": , + /// "status": "pending" | "running" | "completed" | "failed" + /// } + /// ``` + /// + /// The frontend reads this to render "Delegating to …" on the live + /// tool-call, and to anchor the inline `` to the + /// correct child conversation. pub meta: Option, /// Latest images attached to this tool call (e.g. codex-acp v0.14+ /// image generation). Replace-on-update semantics matching `content`: @@ -193,6 +209,18 @@ pub struct SessionState { /// read lock to decide between sending a snapshot or a batched replay. /// See `event_stream` module for size limits. pub(crate) recent_events: RecentEventsBuffer, + + /// Per-launch token registered with the delegation broker's + /// `TokenRegistry` when `codeg-delegate` is injected at init. + /// Revoked when the connection tears down so a leaked binary can't + /// keep round-tripping after the parent session ends. + pub delegation_token: Option, + + /// Concatenated text content of the just-completed turn's assistant + /// message. Captured at TurnComplete (just before live_message is + /// cleared) so the lifecycle subscriber can surface it as the + /// `delegation_call_id`-bound child outcome. Cleared on the next prompt. + pub last_assistant_text: Option, } impl SessionState { @@ -228,6 +256,8 @@ impl SessionState { last_activity_at: Utc::now(), event_stream: Arc::new(ConnectionEventStream::new()), recent_events: RecentEventsBuffer::new(), + delegation_token: None, + last_assistant_text: None, } } @@ -422,6 +452,25 @@ impl SessionState { } } AcpEvent::TurnComplete { .. } => { + // Snapshot the assistant text from the just-finished turn so + // the delegation subscriber can surface it as the child + // outcome. Concatenate all Text blocks in order; skip + // Thinking/ToolCallRef/Plan — they're either non-final or + // structurally separate. + if let Some(live) = self.live_message.as_ref() { + let assembled: String = live + .content + .iter() + .filter_map(|b| match b { + LiveContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(""); + if !assembled.is_empty() { + self.last_assistant_text = Some(assembled); + } + } self.live_message = None; self.active_tool_calls.clear(); self.pending_permission = None; @@ -430,6 +479,7 @@ impl SessionState { AcpEvent::ConversationLinked { conversation_id, folder_id, + .. } => { self.conversation_id = Some(*conversation_id); self.folder_id = Some(*folder_id); @@ -464,8 +514,15 @@ impl SessionState { } AcpEvent::ClaudeSdkMessage { .. } | AcpEvent::Error { .. } - | AcpEvent::SessionLoadFailed { .. } => { + | AcpEvent::SessionLoadFailed { .. } + | AcpEvent::DelegationStarted { .. } + | AcpEvent::DelegationCompleted { .. } => { // 这些事件不直接修改 SessionState 的可见字段。 + // Delegation events: parent/child bookkeeping happens in + // DelegationBroker; SessionState only mirrors the in-flight + // `delegate_to_agent` tool call through `ToolCallState.meta` + // (key `codeg.delegation`), updated by the ToolCall / + // ToolCallUpdate handlers above. } } self.last_activity_at = Utc::now(); @@ -789,6 +846,8 @@ mod tests { s.apply_event(&AcpEvent::ConversationLinked { conversation_id: 7, folder_id: 3, + parent_conversation_id: None, + parent_tool_use_id: None, }); let before = s.to_snapshot(); let before_status = s.status.clone(); @@ -828,6 +887,8 @@ mod tests { s.apply_event(&AcpEvent::ConversationLinked { conversation_id: 42, folder_id: 7, + parent_conversation_id: None, + parent_tool_use_id: None, }); assert_eq!(s.conversation_id, Some(42)); assert_eq!(s.folder_id, Some(7)); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 26a663de7..9ebcc84be 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -134,9 +134,17 @@ pub enum AcpEvent { /// once per connection lifetime, on first prompt that creates the row. /// Frontend uses this to associate the connection_id with conversation_id /// without polling the DB. + /// + /// `parent_conversation_id` / `parent_tool_use_id` are set when the row was + /// created as a delegation child (see `DelegationLink` in + /// `acp::delegation`); they are `None` for normal top-level conversations. ConversationLinked { conversation_id: i32, folder_id: i32, + #[serde(skip_serializing_if = "Option::is_none", default)] + parent_conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + parent_tool_use_id: Option, }, /// Backend has transitioned the conversation row's `status` column. /// Emitted by `send_prompt_linked` (`InProgress`) and the lifecycle @@ -192,6 +200,33 @@ pub enum AcpEvent { AvailableCommands { commands: Vec }, /// Session usage/context window updated during conversation UsageUpdate { used: u64, size: u64 }, + /// A `delegate_to_agent` MCP tool call from the parent agent has spawned a + /// child sub-session and the child's prompt is in flight. Emitted as soon + /// as the broker registers the pending call. The frontend uses this to + /// build the parent ↔ child mapping for inline rendering. + DelegationStarted { + parent_connection_id: String, + parent_tool_use_id: String, + child_connection_id: String, + child_conversation_id: i32, + agent_type: crate::models::agent::AgentType, + }, + /// The child sub-session has finished (or errored / timed out / been + /// canceled). The MCP tool_result has been delivered to the parent agent. + DelegationCompleted { + parent_connection_id: String, + parent_tool_use_id: String, + child_connection_id: String, + child_conversation_id: i32, + result: DelegationResultSummary, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum DelegationResultSummary { + Ok { duration_ms: u64 }, + Err { error_code: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 07cd23070..cee78bb4f 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; use std::sync::Arc; +use crate::acp::delegation::broker::DelegationBroker; +use crate::acp::delegation::listener::TokenRegistry; use crate::acp::manager::ConnectionManager; use crate::acp::InternalEventBus; use crate::chat_channel::manager::ChatChannelManager; @@ -30,6 +32,18 @@ pub struct AppState { /// Read by `pet_get_current_state` so a freshly-opened pet window can /// pick up the current state without waiting for the next transition. pub pet_state: PetStateHandle, + /// Multi-agent delegation broker. Spawned in both desktop and server + /// mode at startup; the UDS listener task forwards incoming companion + /// requests here. v1 uses the default `DelegationConfig`; settings UI + /// hot-swaps via `delegation_broker.set_config`. + pub delegation_broker: Arc, + /// Per-launch ephemeral tokens identifying parent ACP connections. + /// Registered when `load_mcp_servers_for_agent` injects the + /// `codeg-delegate` MCP entry, revoked on parent teardown. + pub delegation_tokens: Arc, + /// Absolute path of the UDS / named pipe the companion connects to. + /// PID-scoped so multiple codeg processes on the same host don't fight. + pub delegation_socket_path: PathBuf, } pub fn default_connection_manager() -> ConnectionManager { @@ -44,6 +58,61 @@ pub fn default_chat_channel_manager() -> ChatChannelManager { ChatChannelManager::new() } +/// Build the delegation broker + token registry + per-process UDS socket +/// path. Shared between codeg-server bootstrap and the Tauri `setup` block +/// so both modes apply identical depth limit + timeout defaults. +/// +/// The listener task is _not_ spawned here — callers spawn it after they +/// own an `Arc` (or the relevant pieces) so the listener can +/// borrow the long-lived state without circular Arc shenanigans. +pub fn build_delegation_stack( + connection_manager: &ConnectionManager, + db_conn: sea_orm::DatabaseConnection, +) -> (Arc, Arc, PathBuf) { + use crate::acp::connection::DelegationInjection; + use crate::acp::delegation::broker::{ConversationDepthLookup, DbDepthLookup}; + use crate::acp::delegation::event_emitter::{ + ConnectionManagerEventEmitter, DelegationEventEmitter, + }; + use crate::acp::delegation::listener::default_socket_path; + use crate::acp::delegation::meta_writer::{ConnectionManagerMetaWriter, DelegationMetaWriter}; + use crate::acp::delegation::spawner::ConnectionSpawner; + use crate::acp::manager::ConnectionManagerSpawner; + + let cm_arc = Arc::new(connection_manager.clone_ref()); + let db_arc = Arc::new(AppDatabase { + conn: db_conn.clone(), + }); + let spawner = Arc::new(ConnectionManagerSpawner { + manager: cm_arc.clone(), + db: db_arc.clone(), + }) as Arc; + let depth_lookup = Arc::new(DbDepthLookup { db: db_arc }) as Arc; + let meta_writer = Arc::new(ConnectionManagerMetaWriter { + manager: cm_arc.clone(), + }) as Arc; + let event_emitter = Arc::new(ConnectionManagerEventEmitter { manager: cm_arc }) + as Arc; + let broker = Arc::new(DelegationBroker::with_writers( + spawner, + depth_lookup, + meta_writer, + event_emitter, + )); + let tokens = Arc::new(TokenRegistry::default()); + let socket_path = default_socket_path(&std::env::temp_dir()); + + // Install the injection on the manager so spawn_agent picks it up + // without an extra parameter at every call site. + connection_manager.install_delegation(DelegationInjection { + broker: broker.clone(), + tokens: tokens.clone(), + socket_path: socket_path.clone(), + }); + + (broker, tokens, socket_path) +} + impl AppState { /// Test-only constructor: build an `AppState` wired to an in-memory /// database and a `WebOnly` event emitter. Suitable for axum-test driven @@ -61,9 +130,13 @@ impl AppState { let acp_event_bus = Arc::new(InternalEventBus::new(metrics)); let emitter = EventEmitter::web_only(broadcaster.clone(), acp_event_bus.clone()); + let connection_manager = default_connection_manager(); + let (delegation_broker, delegation_tokens, delegation_socket_path) = + build_delegation_stack(&connection_manager, db.conn.clone()); + Self { db, - connection_manager: default_connection_manager(), + connection_manager, terminal_manager: default_terminal_manager(), event_broadcaster: broadcaster, acp_event_bus, @@ -77,6 +150,9 @@ impl AppState { ), ), pet_state: crate::pet_state_mapper::new_pet_state_handle(), + delegation_broker, + delegation_tokens, + delegation_socket_path, } } } diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs new file mode 100644 index 000000000..c23784680 --- /dev/null +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -0,0 +1,128 @@ +//! `codeg-mcp` — the per-launch stdio MCP companion that an agent CLI runs +//! to surface the `delegate_to_agent` tool to its LLM. +//! +//! The agent's MCP config (injected by codeg via `load_mcp_servers_for_agent`) +//! spawns this binary with three required flags: +//! +//! codeg-mcp \ +//! --parent-connection-id \ +//! --socket-path \ +//! --token +//! +//! All three are required and the binary exits early if any is missing. +//! Everything heavyweight — JSON-RPC dispatch, UDS round-trip, MCP tool +//! schema — lives in `codeg_lib::acp::delegation::{companion, transport}` +//! so it's unit-testable without spawning a process. + +use std::io::Write; +use std::process::ExitCode; + +use codeg_lib::acp::delegation::companion::{handle_line, CompanionContext}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +struct Args { + parent_connection_id: String, + socket_path: String, + token: String, +} + +fn parse_args() -> Result { + let mut parent_connection_id = None; + let mut socket_path = None; + let mut token = None; + + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--parent-connection-id" => { + parent_connection_id = Some( + iter.next() + .ok_or_else(|| "--parent-connection-id requires a value".to_string())?, + ); + } + "--socket-path" => { + socket_path = Some( + iter.next() + .ok_or_else(|| "--socket-path requires a value".to_string())?, + ); + } + "--token" => { + token = Some( + iter.next() + .ok_or_else(|| "--token requires a value".to_string())?, + ); + } + "--help" | "-h" => { + println!( + "codeg-mcp --parent-connection-id --socket-path --token " + ); + std::process::exit(0); + } + other => return Err(format!("unknown arg: {other}")), + } + } + Ok(Args { + parent_connection_id: parent_connection_id + .ok_or_else(|| "missing --parent-connection-id".to_string())?, + socket_path: socket_path.ok_or_else(|| "missing --socket-path".to_string())?, + token: token.ok_or_else(|| "missing --token".to_string())?, + }) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + let _ = writeln!(std::io::stderr(), "codeg-mcp: {e}"); + return ExitCode::from(2); + } + }; + let ctx = CompanionContext { + parent_connection_id: args.parent_connection_id, + socket_path: args.socket_path, + token: args.token, + }; + + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut lines = BufReader::new(stdin).lines(); + + loop { + let line = match lines.next_line().await { + Ok(Some(l)) => l, + Ok(None) => break, // parent closed stdin → graceful exit + Err(e) => { + let _ = writeln!(std::io::stderr(), "codeg-mcp: read stdin: {e}"); + return ExitCode::from(1); + } + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(resp) = handle_line(&ctx, line).await { + match serde_json::to_string(&resp) { + Ok(serialized) => { + if let Err(e) = stdout.write_all(serialized.as_bytes()).await { + let _ = writeln!(std::io::stderr(), "codeg-mcp: write stdout: {e}"); + return ExitCode::from(1); + } + if let Err(e) = stdout.write_all(b"\n").await { + let _ = writeln!(std::io::stderr(), "codeg-mcp: write stdout: {e}"); + return ExitCode::from(1); + } + if let Err(e) = stdout.flush().await { + let _ = writeln!(std::io::stderr(), "codeg-mcp: flush stdout: {e}"); + return ExitCode::from(1); + } + } + Err(e) => { + let _ = writeln!(std::io::stderr(), "codeg-mcp: encode response: {e}"); + return ExitCode::from(1); + } + } + } + } + ExitCode::SUCCESS +} diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 4bcb68922..9d89a7a02 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -149,9 +149,12 @@ async fn async_main() { // Build AppState let pet_state_handle = codeg_lib::pet_state_mapper::new_pet_state_handle(); + let connection_manager = codeg_lib::app_state::default_connection_manager(); + let (delegation_broker, delegation_tokens, delegation_socket_path) = + codeg_lib::app_state::build_delegation_stack(&connection_manager, db.conn.clone()); let state = Arc::new(AppState { db, - connection_manager: codeg_lib::app_state::default_connection_manager(), + connection_manager, terminal_manager: codeg_lib::app_state::default_terminal_manager(), event_broadcaster: broadcaster, acp_event_bus: acp_event_bus.clone(), @@ -163,8 +166,36 @@ async fn async_main() { codeg_lib::workspace_transfer::WorkspaceTransferManager::new_from_env(), ), pet_state: pet_state_handle.clone(), + delegation_broker: delegation_broker.clone(), + delegation_tokens: delegation_tokens.clone(), + delegation_socket_path: delegation_socket_path.clone(), }); + // Apply persisted delegation settings (depth, timeout, enabled) before + // the listener starts accepting so even the first companion request + // sees the operator's configured behavior. + codeg_lib::commands::delegation::apply_persisted_config(&state.db.conn, &delegation_broker) + .await; + + // Spawn the delegation listener so companion processes can round-trip + // through the broker. Path is PID-scoped, so the listener owns it for + // the lifetime of the process. + { + let listener = codeg_lib::acp::delegation::listener::DelegationListener::new( + delegation_broker, + delegation_tokens, + Arc::new(codeg_lib::acp::manager::ConnectionManagerParentLookup { + manager: Arc::new(state.connection_manager.clone_ref()), + }), + ); + let socket = delegation_socket_path.clone(); + tokio::spawn(async move { + if let Err(e) = listener.run(socket).await { + eprintln!("[delegation] listener exited: {e}"); + } + }); + } + // Install bundled expert skills into the central store // (`~/.codeg/skills/`). Runs in the background; failures are logged // but non-fatal. @@ -198,11 +229,15 @@ async fn async_main() { ) .await; - // Spawn the LifecycleSubscriber for cross-connection DB writes. + // Spawn the LifecycleSubscriber for cross-connection DB writes. The + // broker is supplied so TurnComplete on a delegation child resolves the + // parent's pending `delegate_to_agent` tool_use_id and emits + // `DelegationCompleted`. tokio::spawn(codeg_lib::lifecycle_subscriber_task( state.db.conn.clone(), state.connection_manager.clone_ref(), state.acp_event_bus.clone(), + Some(state.delegation_broker.clone()), )); // Spawn the desktop pet state mapper so server-mode browsers viewing diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs index a5eba2c93..0f55a9c2d 100644 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -164,6 +164,18 @@ async fn handle_acp_envelope( raw_input, .. } => { + // Emit a "delegation started" placeholder to the channel so + // remote users see something happen as soon as the parent agent + // fires `delegate_to_agent`, not only when the child wraps up. + let delegation_announce = if is_delegation_title(title) { + raw_input + .as_deref() + .and_then(extract_agent_type) + .map(|agent| format!("🤖 Delegating to {agent}…")) + } else { + None + }; + let mut guard = bridge.lock().await; if let Some(session) = guard.get_mut(connection_id) { // Store title for progress indicator; store raw_input for later @@ -173,6 +185,12 @@ async fn handle_acp_envelope( .tool_call_inputs .insert(tool_call_id.clone(), input.to_string()); } + if let Some(text) = delegation_announce { + let channel_id = session.channel_id; + drop(guard); + let msg = RichMessage::info(text); + let _ = manager.send_to_channel(channel_id, &msg).await; + } } } @@ -181,6 +199,7 @@ async fn handle_acp_envelope( title, status, raw_input, + raw_output, .. } => { let mut guard = bridge.lock().await; @@ -196,11 +215,20 @@ async fn handle_acp_envelope( let stored_input = session.tool_call_inputs.remove(tool_call_id); let effective_title = title.as_deref().unwrap_or("tool"); let input_ref = stored_input.as_deref().or(raw_input.as_deref()); - let detail = format_tool_call_detail(effective_title, input_ref); let channel_id = session.channel_id; + + let body = if is_delegation_title(effective_title) + || input_ref + .map(|s| extract_agent_type(s).is_some()) + .unwrap_or(false) + { + format_delegation_outcome(input_ref, raw_output.as_deref()) + } else { + format!(">> {}", format_tool_call_detail(effective_title, input_ref)) + }; drop(guard); - let msg = RichMessage::info(format!(">> {detail}")); + let msg = RichMessage::info(body); let _ = manager.send_to_channel(channel_id, &msg).await; } } @@ -692,3 +720,135 @@ fn truncate_str(s: &str, max: usize) -> String { format!("{truncated}...") } } + +/// Title-side match for `delegate_to_agent`. Title is free-form text the +/// host agent composes; some hosts copy the bare MCP method, some prefix +/// it with `mcp____`, some rephrase it. Match by substring so any +/// of those forms get the delegation-announcement path. The completion- +/// side callsite already pairs this with a raw_input shape check, so a +/// rare false-positive here just sends one announce message that gets +/// overwritten by the completion's actual outcome. +fn is_delegation_title(title: &str) -> bool { + let normalized = title.to_lowercase().replace([' ', '-'], "_"); + normalized.contains("delegate_to_agent") +} + +/// Pull `agent_type` out of the raw_input JSON (e.g. `{"agent_type":"codex", +/// "task":"..."}`). Returns the canonical string the agent supplied so the +/// announce message matches what the user wrote, not a re-mapped label. +fn extract_agent_type(raw_input: &str) -> Option { + let parsed: serde_json::Value = serde_json::from_str(raw_input).ok()?; + parsed + .get("agent_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Build the chat-channel summary for a finished `delegate_to_agent` call. +/// Receives the broker's wire payload (already a JSON-serialized +/// `DelegationOutcome`) and renders a compact ✅/❌ line plus the short +/// preview text the user can act on. +fn format_delegation_outcome(raw_input: Option<&str>, raw_output: Option<&str>) -> String { + let agent = raw_input + .and_then(extract_agent_type) + .unwrap_or_else(|| "agent".to_string()); + + // Try to parse the MCP-style structured output Phase 5 emits: + // `{ "kind": "ok", "text": "…", … }` or `{ "kind": "err", "code": "…" }`. + // Fall back to the plain text body if the agent already collapsed it. + if let Some(out) = raw_output { + if let Ok(value) = serde_json::from_str::(out) { + let kind = value.get("kind").and_then(|v| v.as_str()); + match kind { + Some("ok") => { + let text = value + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if text.is_empty() { + return format!("✅ {agent} done"); + } + let preview = truncate_str(text, 200); + return format!("✅ {agent}: {preview}"); + } + Some("err") => { + let code = value.get("code").and_then(|v| v.as_str()).unwrap_or("err"); + return format!("❌ {agent} failed ({code})"); + } + _ => {} + } + } + let preview = truncate_str(out.trim(), 200); + if !preview.is_empty() { + return format!("✅ {agent}: {preview}"); + } + } + format!("✅ {agent} done") +} + +#[cfg(test)] +mod delegation_relay_tests { + use super::*; + + #[test] + fn is_delegation_title_matches_variants() { + assert!(is_delegation_title("delegate_to_agent")); + assert!(is_delegation_title("Delegate To Agent")); + assert!(is_delegation_title("delegate-to-agent")); + assert!(is_delegation_title( + "mcp__codeg-delegate__delegate_to_agent" + )); + assert!(is_delegation_title("Run mcp__codeg__delegate_to_agent")); + assert!(!is_delegation_title("agent")); + assert!(!is_delegation_title("write")); + } + + #[test] + fn extract_agent_type_pulls_canonical_string() { + assert_eq!( + extract_agent_type(r#"{"agent_type":"codex","task":"x"}"#), + Some("codex".into()) + ); + assert_eq!(extract_agent_type(r#"{"task":"x"}"#), None); + assert_eq!(extract_agent_type("not json"), None); + } + + #[test] + fn format_delegation_outcome_renders_ok_with_preview() { + let out = r#"{"kind":"ok","text":" hello world "}"#; + let body = format_delegation_outcome(Some(r#"{"agent_type":"codex"}"#), Some(out)); + assert_eq!(body, "✅ codex: hello world"); + } + + #[test] + fn format_delegation_outcome_renders_err_with_code() { + let out = r#"{"kind":"err","code":"timeout"}"#; + let body = format_delegation_outcome(Some(r#"{"agent_type":"gemini"}"#), Some(out)); + assert_eq!(body, "❌ gemini failed (timeout)"); + } + + #[test] + fn format_delegation_outcome_falls_back_to_plain_text() { + let body = + format_delegation_outcome(Some(r#"{"agent_type":"cline"}"#), Some("plain reply body")); + assert_eq!(body, "✅ cline: plain reply body"); + } + + #[test] + fn format_delegation_outcome_empty_output_marks_done() { + let body = format_delegation_outcome(Some(r#"{"agent_type":"open_code"}"#), None); + assert_eq!(body, "✅ open_code done"); + } + + #[test] + fn format_delegation_outcome_truncates_long_ok_text() { + let long_text = "x".repeat(400); + let out = format!(r#"{{"kind":"ok","text":"{long_text}"}}"#); + let body = format_delegation_outcome(Some(r#"{"agent_type":"codex"}"#), Some(&out)); + // 200-char cap + "..." + assert!(body.len() < 300); + assert!(body.starts_with("✅ codex: ")); + assert!(body.ends_with("...")); + } +} diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 36d6b8f8b..f713fcaeb 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -2466,8 +2466,16 @@ pub async fn acp_prompt( manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager - .send_prompt_linked(&db, &connection_id, blocks, folder_id, conversation_id) + .send_prompt_linked( + &db, + &connection_id, + blocks, + folder_id, + conversation_id, + None, + ) .await + .map(|_| ()) } #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index d9249fa9f..99736bd5a 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -21,10 +21,19 @@ pub async fn list_all_conversations_core( search: Option, sort_by: Option, status: Option, + include_children: bool, ) -> Result, AppCommandError> { - conversation_service::list_all(conn, folder_ids, agent_type, search, sort_by, status) - .await - .map_err(AppCommandError::from) + conversation_service::list_all( + conn, + folder_ids, + agent_type, + search, + sort_by, + status, + include_children, + ) + .await + .map_err(AppCommandError::from) } #[cfg(feature = "tauri-runtime")] @@ -36,8 +45,36 @@ pub async fn list_all_conversations( search: Option, sort_by: Option, status: Option, + include_children: Option, ) -> Result, AppCommandError> { - list_all_conversations_core(&db.conn, folder_ids, agent_type, search, sort_by, status).await + list_all_conversations_core( + &db.conn, + folder_ids, + agent_type, + search, + sort_by, + status, + include_children.unwrap_or(false), + ) + .await +} + +pub async fn list_child_conversations_core( + conn: &sea_orm::DatabaseConnection, + parent_conversation_id: i32, +) -> Result, AppCommandError> { + conversation_service::list_children(conn, parent_conversation_id) + .await + .map_err(AppCommandError::from) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_child_conversations( + db: tauri::State<'_, AppDatabase>, + parent_conversation_id: i32, +) -> Result, AppCommandError> { + list_child_conversations_core(&db.conn, parent_conversation_id).await } pub async fn list_opened_tabs_core( @@ -665,7 +702,7 @@ mod tests { #[tokio::test] async fn list_all_conversations_core_empty_db_returns_empty() { let db = fresh_in_memory_db().await; - let rows = list_all_conversations_core(&db.conn, None, None, None, None, None) + let rows = list_all_conversations_core(&db.conn, None, None, None, None, None, false) .await .expect("list"); assert!(rows.is_empty(), "fresh db must have zero conversations"); @@ -770,7 +807,7 @@ mod tests { .await .expect("delete"); // After soft delete the row should no longer show up in list_all. - let remaining = list_all_conversations_core(&db.conn, None, None, None, None, None) + let remaining = list_all_conversations_core(&db.conn, None, None, None, None, None, false) .await .expect("list"); assert!( @@ -778,4 +815,64 @@ mod tests { "soft-deleted conversation must not appear in list_all" ); } + + // ────────────────────────────────────────────────────────────────────── + // Phase 7 — delegation list filter + child lookup wrappers. + // ────────────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn list_child_conversations_core_returns_empty_for_no_parent() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-list-children-empty").await; + let parent_id = create_conversation_core(&db.conn, folder_id, AgentType::Codex, None) + .await + .expect("create parent"); + let rows = list_child_conversations_core(&db.conn, parent_id) + .await + .expect("list"); + assert!(rows.is_empty()); + } + + #[tokio::test] + async fn list_child_conversations_core_returns_only_matching_children() { + use crate::acp::delegation::spawner::DelegationLink; + use crate::db::service::conversation_service; + + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-list-children-match").await; + let parent_id = create_conversation_core(&db.conn, folder_id, AgentType::ClaudeCode, None) + .await + .expect("create parent"); + + // Two delegation children — both should come back, oldest-first. + for (i, tool_use) in ["tu-A", "tu-B"].iter().enumerate() { + let link = DelegationLink { + parent_conversation_id: parent_id, + parent_tool_use_id: (*tool_use).into(), + delegation_call_id: format!("call-{i}"), + }; + conversation_service::create_with_delegation( + &db.conn, + folder_id, + AgentType::Codex, + Some(format!("child-{i}")), + None, + Some(link), + ) + .await + .expect("create child"); + } + // Sibling root conversation that must NOT appear. + let _other = create_conversation_core(&db.conn, folder_id, AgentType::Gemini, None) + .await + .expect("unrelated root"); + + let rows = list_child_conversations_core(&db.conn, parent_id) + .await + .expect("list"); + assert_eq!(rows.len(), 2, "expected 2 children, got {}", rows.len()); + assert!(rows.iter().all(|r| r.parent_id == Some(parent_id))); + // Oldest-first ordering (created_at ascending). + assert!(rows[0].created_at <= rows[1].created_at); + } } diff --git a/src-tauri/src/commands/delegation.rs b/src-tauri/src/commands/delegation.rs new file mode 100644 index 000000000..460150da8 --- /dev/null +++ b/src-tauri/src/commands/delegation.rs @@ -0,0 +1,266 @@ +//! Delegation settings persistence + Tauri/HTTP command surface. +//! +//! Three knobs survive across restarts: +//! * `delegation.enabled` — feature kill switch (default true) +//! * `delegation.depth_limit` — max chain depth a child is allowed to sit at +//! * `delegation.default_timeout_seconds` — broker fallback when the LLM +//! omits `timeout_seconds` +//! +//! On startup `apply_persisted_config` reads all three keys from +//! `app_metadata` and pushes them into the live `DelegationBroker`. On UI +//! save, `set_delegation_settings_core` writes the three keys and +//! immediately re-applies — the broker has no concept of "pending config", +//! it just owns the current `DelegationConfig`. + +use std::path::PathBuf; +#[cfg(any(test, feature = "tauri-runtime"))] +use std::sync::Arc; +use std::time::Duration; + +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use crate::acp::delegation::broker::{DelegationBroker, DelegationConfig}; +use crate::app_error::AppCommandError; +use crate::db::service::app_metadata_service; + +pub const KEY_DELEGATION_ENABLED: &str = "delegation.enabled"; +pub const KEY_DELEGATION_DEPTH: &str = "delegation.depth_limit"; +pub const KEY_DELEGATION_TIMEOUT: &str = "delegation.default_timeout_seconds"; + +pub const DEPTH_MIN: u32 = 1; +pub const DEPTH_MAX: u32 = 8; +pub const TIMEOUT_MIN_SECS: u64 = 30; +pub const TIMEOUT_MAX_SECS: u64 = 3600; + +/// Newtype so the Tauri managed-state lookup can distinguish the delegation +/// UDS path from other `PathBuf`s in the state graph. +#[derive(Clone)] +pub struct DelegationSocketPath(pub PathBuf); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationSettings { + pub enabled: bool, + pub depth_limit: u32, + pub default_timeout_seconds: u64, +} + +impl Default for DelegationSettings { + fn default() -> Self { + Self { + enabled: true, + depth_limit: 2, + default_timeout_seconds: 600, + } + } +} + +impl DelegationSettings { + fn clamped(self) -> Self { + Self { + enabled: self.enabled, + depth_limit: self.depth_limit.clamp(DEPTH_MIN, DEPTH_MAX), + default_timeout_seconds: self + .default_timeout_seconds + .clamp(TIMEOUT_MIN_SECS, TIMEOUT_MAX_SECS), + } + } + + fn into_broker_config(self) -> DelegationConfig { + DelegationConfig { + enabled: self.enabled, + depth_limit: self.depth_limit, + default_timeout: Duration::from_secs(self.default_timeout_seconds), + } + } +} + +/// Read all three keys from `app_metadata`, falling back to defaults for any +/// missing or malformed value. Never errors hard — corrupt persistence is +/// treated as "no preference yet." +pub async fn load_delegation_settings(conn: &DatabaseConnection) -> DelegationSettings { + let mut settings = DelegationSettings::default(); + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_ENABLED).await { + if let Ok(v) = raw.parse::() { + settings.enabled = v; + } + } + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_DEPTH).await { + if let Ok(v) = raw.parse::() { + settings.depth_limit = v; + } + } + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_TIMEOUT).await { + if let Ok(v) = raw.parse::() { + settings.default_timeout_seconds = v; + } + } + settings.clamped() +} + +/// Pull settings from the DB and push the resulting `DelegationConfig` onto +/// the broker. Idempotent — safe to call on startup, after settings save, or +/// after any external write to `app_metadata`. +pub async fn apply_persisted_config(conn: &DatabaseConnection, broker: &DelegationBroker) { + let settings = load_delegation_settings(conn).await; + broker.set_config(settings.into_broker_config()).await; +} + +/// Persist + apply. Used by both the Tauri command and the HTTP handler so +/// the clamp / re-apply chain is in exactly one place. +pub async fn set_delegation_settings_core( + conn: &DatabaseConnection, + broker: &DelegationBroker, + desired: DelegationSettings, +) -> Result { + let clamped = desired.clamped(); + app_metadata_service::upsert_value(conn, KEY_DELEGATION_ENABLED, &clamped.enabled.to_string()) + .await + .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value( + conn, + KEY_DELEGATION_DEPTH, + &clamped.depth_limit.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value( + conn, + KEY_DELEGATION_TIMEOUT, + &clamped.default_timeout_seconds.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + broker + .set_config(clamped.clone().into_broker_config()) + .await; + Ok(clamped) +} + +// -------- Tauri commands ----------------------------------------------------- + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_delegation_settings( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + Ok(load_delegation_settings(&db.conn).await) + } + #[cfg(not(feature = "tauri-runtime"))] + { + // Server mode reaches this via the web handler, not this command. + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn set_delegation_settings( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] broker: tauri::State<'_, Arc>, + settings: DelegationSettings, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + set_delegation_settings_core(&db.conn, broker.inner(), settings).await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = settings; + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::broker::{ConversationDepthLookup, DelegationBroker}; + use crate::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner}; + use crate::acp::delegation::types::DelegationError; + use async_trait::async_trait; + + struct EmptyLookup; + #[async_trait] + impl ConversationDepthLookup for EmptyLookup { + async fn parent_of(&self, _id: i32) -> Result, DelegationError> { + Ok(None) + } + } + + fn make_broker() -> DelegationBroker { + DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + Arc::new(EmptyLookup) as Arc, + ) + } + + #[test] + fn settings_clamp_to_safe_range() { + let s = DelegationSettings { + enabled: true, + depth_limit: 99, + default_timeout_seconds: 10, + } + .clamped(); + assert_eq!(s.depth_limit, DEPTH_MAX); + assert_eq!(s.default_timeout_seconds, TIMEOUT_MIN_SECS); + } + + #[tokio::test] + async fn load_returns_defaults_when_unset() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let settings = load_delegation_settings(&db.conn).await; + assert!(settings.enabled); + assert_eq!(settings.depth_limit, 2); + assert_eq!(settings.default_timeout_seconds, 600); + } + + #[tokio::test] + async fn set_then_load_round_trip_and_broker_applied() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let broker = make_broker(); + let desired = DelegationSettings { + enabled: false, + depth_limit: 3, + default_timeout_seconds: 120, + }; + let saved = set_delegation_settings_core(&db.conn, &broker, desired) + .await + .unwrap(); + assert!(!saved.enabled); + assert_eq!(saved.depth_limit, 3); + assert_eq!(saved.default_timeout_seconds, 120); + + let loaded = load_delegation_settings(&db.conn).await; + assert_eq!(loaded.enabled, saved.enabled); + assert_eq!(loaded.depth_limit, saved.depth_limit); + assert_eq!( + loaded.default_timeout_seconds, + saved.default_timeout_seconds + ); + + let cfg = broker.config_snapshot().await; + assert!(!cfg.enabled); + assert_eq!(cfg.depth_limit, 3); + assert_eq!(cfg.default_timeout, Duration::from_secs(120)); + } + + #[tokio::test] + async fn set_clamps_out_of_range_values() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let broker = make_broker(); + let saved = set_delegation_settings_core( + &db.conn, + &broker, + DelegationSettings { + enabled: true, + depth_limit: 999, + default_timeout_seconds: 10, + }, + ) + .await + .unwrap(); + assert_eq!(saved.depth_limit, DEPTH_MAX); + assert_eq!(saved.default_timeout_seconds, TIMEOUT_MIN_SECS); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e04dd0254..8c4c93607 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod acp; pub mod chat_channel; pub mod conversations; +pub mod delegation; pub mod experts; #[cfg(feature = "tauri-runtime")] pub mod file_io; diff --git a/src-tauri/src/commands/system_settings.rs b/src-tauri/src/commands/system_settings.rs index 3be5fe4cd..1ecf48af1 100644 --- a/src-tauri/src/commands/system_settings.rs +++ b/src-tauri/src/commands/system_settings.rs @@ -139,7 +139,7 @@ pub(crate) fn normalize_terminal_settings( /// Build the per-platform option list shown in the "default shell" picker. /// The frontend renders these verbatim, looking each `label_key` up under its -/// `SystemSettings` namespace — so adding a new shell here requires zero +/// `GeneralSettings` namespace — so adding a new shell here requires zero /// frontend code changes (only a new translation key). pub(crate) fn build_available_terminal_shells() -> AvailableTerminalShells { let mut options: Vec = Vec::new(); diff --git a/src-tauri/src/db/entities/conversation.rs b/src-tauri/src/db/entities/conversation.rs index d38fa0ecc..054f25994 100644 --- a/src-tauri/src/db/entities/conversation.rs +++ b/src-tauri/src/db/entities/conversation.rs @@ -28,6 +28,8 @@ pub struct Model { pub git_branch: Option, pub external_id: Option, pub parent_id: Option, + pub parent_tool_use_id: Option, + pub delegation_call_id: Option, pub message_count: i32, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, diff --git a/src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs b/src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs new file mode 100644 index 000000000..194ca76eb --- /dev/null +++ b/src-tauri/src/db/migration/m20260522_000001_delegation_columns.rs @@ -0,0 +1,104 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const IDX_PARENT_TOOL_USE_ID: &str = "idx_conversation_parent_tool_use_id"; +const IDX_DELEGATION_CALL_ID: &str = "idx_conversation_delegation_call_id"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .add_column(ColumnDef::new(Conversation::ParentToolUseId).text().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .add_column(ColumnDef::new(Conversation::DelegationCallId).text().null()) + .to_owned(), + ) + .await?; + + // Both columns are queried as filter predicates from the conversation + // list path (`include_children` filter + `list_child_conversations`) + // once delegation starts producing sub-sessions. Without indexes SQLite + // falls back to a full table scan over conversation, which grows + // linearly with session history. + manager + .create_index( + Index::create() + .if_not_exists() + .name(IDX_PARENT_TOOL_USE_ID) + .table(Conversation::Table) + .col(Conversation::ParentToolUseId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name(IDX_DELEGATION_CALL_ID) + .table(Conversation::Table) + .col(Conversation::DelegationCallId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .if_exists() + .name(IDX_DELEGATION_CALL_ID) + .table(Conversation::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .if_exists() + .name(IDX_PARENT_TOOL_USE_ID) + .table(Conversation::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .drop_column(Conversation::DelegationCallId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Conversation::Table) + .drop_column(Conversation::ParentToolUseId) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Conversation { + Table, + ParentToolUseId, + DelegationCallId, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 49825c781..86b92d417 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -16,6 +16,7 @@ mod m20260424_000001_folder_color; mod m20260424_000002_quick_message; mod m20260513_000001_remote_workspace_connection; mod m20260518_000001_model_provider_single_type_and_model; +mod m20260522_000001_delegation_columns; pub struct Migrator; #[async_trait::async_trait] @@ -38,6 +39,7 @@ impl MigratorTrait for Migrator { Box::new(m20260424_000002_quick_message::Migration), Box::new(m20260513_000001_remote_workspace_connection::Migration), Box::new(m20260518_000001_model_provider_single_type_and_model::Migration), + Box::new(m20260522_000001_delegation_columns::Migration), ] } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 7056905c9..8383a4062 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -14,12 +14,36 @@ pub async fn create( agent_type: AgentType, title: Option, git_branch: Option, +) -> Result { + create_with_delegation(conn, folder_id, agent_type, title, git_branch, None).await +} + +/// Mirror of [`create`] plus optional delegation linkage. Used by the +/// multi-agent broker when spawning a child sub-session — populates +/// `parent_id` / `parent_tool_use_id` / `delegation_call_id` so the lifecycle +/// subscriber and frontend can rebuild the parent ↔ child binding without +/// inspecting the live broker state. +pub async fn create_with_delegation( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + git_branch: Option, + delegation: Option, ) -> Result { let at_str = serde_json::to_value(agent_type) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); let now = Utc::now(); + let (parent_id, parent_tool_use_id, delegation_call_id) = match delegation { + Some(link) => ( + Some(link.parent_conversation_id), + Some(link.parent_tool_use_id), + Some(link.delegation_call_id), + ), + None => (None, None, None), + }; let model = conversation::ActiveModel { id: NotSet, folder_id: Set(folder_id), @@ -29,7 +53,9 @@ pub async fn create( model: Set(None), git_branch: Set(git_branch), external_id: Set(None), - parent_id: Set(None), + parent_id: Set(parent_id), + parent_tool_use_id: Set(parent_tool_use_id), + delegation_call_id: Set(delegation_call_id), message_count: Set(0), created_at: Set(now), updated_at: Set(now), @@ -152,6 +178,9 @@ fn conv_to_summary(r: conversation::Model) -> DbConversationSummary { message_count: r.message_count as u32, created_at: r.created_at, updated_at: r.updated_at, + parent_id: r.parent_id, + parent_tool_use_id: r.parent_tool_use_id, + delegation_call_id: r.delegation_call_id, } } @@ -221,6 +250,11 @@ pub async fn list_by_folder( /// List conversations across folders. When `folder_ids` is `None`, queries all /// When `folder_ids` is provided, results are scoped to that set. Otherwise /// returns conversations across every non-deleted folder (open or not). +/// +/// `include_children` controls visibility of delegation sub-sessions. When +/// `false` (the default for the top-level list), rows whose `parent_id` is +/// non-null are filtered out — they belong to their parent's tool-call view, +/// not the workspace conversation list. pub async fn list_all( conn: &DatabaseConnection, folder_ids: Option>, @@ -228,9 +262,14 @@ pub async fn list_all( search: Option, sort_by: Option, status: Option, + include_children: bool, ) -> Result, DbError> { let mut query = conversation::Entity::find().filter(conversation::Column::DeletedAt.is_null()); + if !include_children { + query = query.filter(conversation::Column::ParentId.is_null()); + } + match folder_ids { Some(ids) if !ids.is_empty() => { query = query.filter(conversation::Column::FolderId.is_in(ids)); @@ -281,3 +320,124 @@ pub async fn list_all( let rows = query.all(conn).await?; Ok(rows.into_iter().map(conv_to_summary).collect()) } + +/// List delegation children of a single parent conversation, oldest first. +/// Returns rows where `parent_id == parent_conversation_id`. Soft-deleted +/// children are filtered out so a removed sub-session stays hidden in the +/// parent's tool-call view too. +pub async fn list_children( + conn: &DatabaseConnection, + parent_conversation_id: i32, +) -> Result, DbError> { + let rows = conversation::Entity::find() + .filter(conversation::Column::ParentId.eq(parent_conversation_id)) + .filter(conversation::Column::DeletedAt.is_null()) + .order_by_asc(conversation::Column::CreatedAt) + .all(conn) + .await?; + Ok(rows.into_iter().map(conv_to_summary).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::DelegationLink; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + + /// Build a parent + a delegation child for filter assertions. + async fn seed_parent_with_child(conn: &DatabaseConnection, folder_id: i32) -> (i32, i32) { + let parent = create( + conn, + folder_id, + AgentType::ClaudeCode, + Some("P".into()), + None, + ) + .await + .expect("parent"); + let link = DelegationLink { + parent_conversation_id: parent.id, + parent_tool_use_id: "tu-1".into(), + delegation_call_id: "call-1".into(), + }; + let child = create_with_delegation( + conn, + folder_id, + AgentType::Codex, + Some("C".into()), + None, + Some(link), + ) + .await + .expect("child"); + (parent.id, child.id) + } + + #[tokio::test] + async fn list_all_excludes_children_by_default() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-default").await; + let (parent, _child) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_all(&db.conn, None, None, None, None, None, false) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!(ids.contains(&parent), "parent must remain visible: {ids:?}"); + assert_eq!( + rows.len(), + 1, + "expected only the parent, got {} rows: {ids:?}", + rows.len() + ); + } + + #[tokio::test] + async fn list_all_includes_children_when_requested() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-on").await; + let (parent, child) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_all(&db.conn, None, None, None, None, None, true) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!( + ids.contains(&parent) && ids.contains(&child), + "both parent + child must appear when include_children=true, got: {ids:?}", + ); + } + + #[tokio::test] + async fn list_children_returns_only_matching_parent() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-only").await; + let (parent_a, child_a) = seed_parent_with_child(&db.conn, folder).await; + let (_parent_b, _child_b) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_children(&db.conn, parent_a).await.expect("list"); + assert_eq!( + rows.len(), + 1, + "expected 1 child of parent_a, got {}", + rows.len() + ); + assert_eq!(rows[0].id, child_a); + assert_eq!(rows[0].parent_id, Some(parent_a)); + } + + #[tokio::test] + async fn list_children_excludes_soft_deleted() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-list-children-soft-del").await; + let (parent, child) = seed_parent_with_child(&db.conn, folder).await; + + soft_delete(&db.conn, child).await.expect("soft delete"); + + let rows = list_children(&db.conn, parent).await.expect("list"); + assert!( + rows.is_empty(), + "soft-deleted child must not appear: {rows:?}" + ); + } +} diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index c9cbc49ff..967101b08 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -89,6 +89,8 @@ pub async fn import_local_conversations( git_branch: Set(summary.git_branch.clone()), external_id: Set(Some(summary.id.clone())), parent_id: Set(None), + parent_tool_use_id: Set(None), + delegation_call_id: Set(None), message_count: Set(summary.message_count as i32), created_at: Set(created_at), updated_at: Set(updated_at), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 570c77103..1803e560a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,11 +41,11 @@ mod tauri_app { use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ acp as acp_commands, chat_channel as chat_channel_commands, conversations, - experts as experts_commands, file_io, folder_commands, folders, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, - quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, system_settings, - terminal as terminal_commands, version_control, windows, + delegation as delegation_commands, experts as experts_commands, file_io, folder_commands, + folders, mcp as mcp_commands, model_provider as model_provider_commands, notification, + pet as pet_commands, project_boot, quick_messages as quick_messages_commands, + remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, + system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; @@ -363,6 +363,52 @@ mod tauri_app { ); } + // Delegation broker + UDS listener. Built from the managed + // ConnectionManager + DB so spawn / depth-lookup work against + // live state. Managed alongside the existing per-resource + // states so commands (Tauri + web) can resolve them by type. + // MUST run before the LifecycleSubscriber spawn below so the + // broker handle is available to it. + let broker_for_lifecycle = { + let cm_state = app.state::(); + let db_conn = app.state::().conn.clone(); + let (broker, tokens, socket_path) = + crate::app_state::build_delegation_stack(&cm_state, db_conn.clone()); + app.manage(broker.clone()); + app.manage(tokens.clone()); + app.manage(crate::commands::delegation::DelegationSocketPath( + socket_path.clone(), + )); + + // Push persisted settings into the broker before listener accept. + let broker_for_init = broker.clone(); + let db_for_init = db_conn.clone(); + tauri::async_runtime::block_on(async move { + delegation_commands::apply_persisted_config( + &db_for_init, + &broker_for_init, + ) + .await; + }); + + let listener_broker = broker.clone(); + let listener = crate::acp::delegation::listener::DelegationListener::new( + listener_broker, + tokens, + std::sync::Arc::new( + crate::acp::manager::ConnectionManagerParentLookup { + manager: std::sync::Arc::new(cm_state.clone_ref()), + }, + ), + ); + tauri::async_runtime::spawn(async move { + if let Err(e) = listener.run(socket_path).await { + eprintln!("[delegation] listener exited: {e}"); + } + }); + broker + }; + // Spawn the LifecycleSubscriber: persists cross-connection DB state // (currently `external_id` on conversation rows when SessionStarted fires) // off the emit hot path. `subscribe()` runs synchronously inside @@ -377,7 +423,10 @@ mod tauri_app { .inner() .clone(); tauri::async_runtime::spawn(crate::acp::lifecycle_subscriber_task( - db_conn, cm, bus, + db_conn, + cm, + bus, + Some(broker_for_lifecycle), )); } @@ -607,6 +656,7 @@ mod tauri_app { conversations::list_conversations, conversations::get_conversation, conversations::list_all_conversations, + conversations::list_child_conversations, conversations::list_opened_tabs, conversations::save_opened_tabs, conversations::import_local_conversations, @@ -754,6 +804,8 @@ mod tauri_app { system_settings::probe_terminal_shell_path, system_settings::get_system_rendering_settings, system_settings::update_system_rendering_settings, + delegation_commands::get_delegation_settings, + delegation_commands::set_delegation_settings, version_control::detect_git, version_control::test_git_path, version_control::get_git_settings, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index b1c5112bf..50909d0c1 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -16,6 +16,12 @@ pub struct ConversationSummary { pub message_count: u32, pub model: Option, pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delegation_call_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -31,6 +37,12 @@ pub struct DbConversationSummary { pub message_count: u32, pub created_at: DateTime, pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delegation_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/models/message.rs b/src-tauri/src/models/message.rs index c9d3a6a86..23e7df504 100644 --- a/src-tauri/src/models/message.rs +++ b/src-tauri/src/models/message.rs @@ -103,6 +103,19 @@ pub enum ContentBlock { tool_use_id: Option, tool_name: String, input_preview: Option, + /// ACP extensibility metadata associated with the tool call. The + /// `delegate_to_agent` lifecycle writes + /// `meta["codeg.delegation"] = { status, child_connection_id, + /// child_conversation_id, error_code? }` here so a snapshot or DB + /// re-fetch can re-bind the parent UI to the child conversation + /// without depending on the live event stream having survived. + /// + /// `None` for tool uses without any meta (the agent didn't emit + /// one, or the field predates the meta-on-ToolUse schema change). + /// The shape is intentionally opaque — `serde_json::Value` — + /// because the convention is agent-defined and may grow. + #[serde(default, skip_serializing_if = "Option::is_none")] + meta: Option, }, ToolResult { tool_use_id: Option, diff --git a/src-tauri/src/models/system.rs b/src-tauri/src/models/system.rs index dd3ebba1c..4842ac6d2 100644 --- a/src-tauri/src/models/system.rs +++ b/src-tauri/src/models/system.rs @@ -46,12 +46,12 @@ pub struct SystemTerminalSettings { /// One row in the "default shell" picker. Backend owns the option list so the /// frontend doesn't have to know which shells are available on which platform. /// Labels are not localized server-side: `label_key` points at a frontend i18n -/// key under `SystemSettings.*`. +/// key under `GeneralSettings.*`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TerminalShellOption { /// Stable identifier the dropdown uses as its